Skip to content

Commands find the daemon by folder, and a gateway-only command sent to the worker says to add --gateway #709

Closed

Description

@V3RON

Part of #700.

Scope

After this PR, a machine with one daemon never needs --gateway. Without the flag, the CLI and simlock mcp reach the gateway when a gateway is present (#708's gatewayPresent: <root>/gateway/instance.json or <root>/gateway/config.json exists) and <root>/instance.json does not; otherwise they reach the worker, as after #708. When a worker refuses a gateway-only command and a gateway is present, the CLI adds a hint to use --gateway. A global --local flag (this machine's worker; named so it cannot clash with component install --worker <id>), the mirror of --gateway, always reaches (and auto-starts) the worker in <root>, so a machine that got its gateway first can still start a worker: simlock --local daemon start. After that first start <root>/instance.json exists and plain commands reach the worker again.

In short

On a machine with only a gateway, simlock status, simlock daemon start and every other command reach it without a flag, and never start an empty worker. On a machine with both, simlock worker drain <id> without the flag prints the worker's refusal followed by A gateway also runs on this machine: run the command again with --gateway.

flowchart TD
  A["simlock &lt;command&gt;"] --> B{--gateway?}
  B -- yes --> G["&lt;root&gt;/gateway"]
  B -- no --> C{"(changed) a gateway is present<br/>and &lt;root&gt;/instance.json does not exist?"}
  C -- yes --> G
  C -- no --> W["&lt;root&gt; worker"]
  W --> D{"(changed) UNSUPPORTED_IN_WORKER_MODE<br/>and a gateway is present?"}
  D -- yes --> E["message + hint"]
Loading
# a machine with only a gateway, before (after #708)
simlock status --json | jq -r .daemon.mode
worker                         # an empty worker was auto-started in ~/.simlock

# after
simlock status --json | jq -r .daemon.mode
gateway

# a machine with both, after
simlock worker drain 3f2a…
{"error":{"code":"UNSUPPORTED_IN_WORKER_MODE","message":"worker.drain acts on a gateway's workers, and this daemon is not a gateway. A gateway also runs on this machine: run the command again with --gateway."}}

No --gateway flag:

<root>/instance.json gateway present After #708 After this task
no no worker worker
yes no worker worker
no yes worker gateway
yes yes worker worker, with the hint on a gateway-only refusal
no no (<root>/gateway/ exists, empty) worker worker
no <root>/gateway/instance.json only (started with defaults) worker gateway
no stat fails with EACCES error, no command runs (#708) error, no command runs

With --gateway, every row reaches the gateway, as after #708. With --local, every row reaches the worker in <root> and auto-starts it; on a gateway-only machine that is how a worker is added:

simlock --local daemon start
Daemon running
simlock status --json | jq -r .daemon.mode     # <root>/instance.json now exists
worker
``` The hint is never added when `--gateway` was given or when no gateway is present.

If this goes wrong, a user on a gateway-only machine sees `"mode": "worker"` and an empty catalog, or a user with both gets the gateway's answer to a plain command.

## Technical spec

### Facts

Commit `e27dc327`. #708 adds `resolveDaemonHome`, `--gateway` and `context.gateway` (feature Shared values).

| #  | Fact | Source |
| -- | ---- | ------ |
| F1 | A worker writes `<data dir>/instance.json` on its first start, before driver discovery, with an exclusive create; it is never regenerated. | `src/daemon/main.ts:244-249`; `src/core/instance-identity.ts:38-53` |
| F2 | `config set` writes `<data dir>/config.json` (and creates the folder) without any daemon; it never writes `instance.json`. | `src/cli/environment.ts:191-197`; `src/cli/commands/config.ts:41-53` |
| F3 | The launcher creates `daemon.log` before spawning; so `daemon.log` can exist with no daemon ever having run. | `src/ports/daemon-launcher.ts:38-43` |
| F4 | `Filesystem.exists` returns `false` only for a missing path; any other `stat` error is thrown. | `src/ports/filesystem.ts:232-242` |
| F5 | A worker answers `worker.drain`, `worker.undrain`, `worker.remove`, `worker.install-component` with `UNSUPPORTED_IN_WORKER_MODE`, message `<operation> acts on a gateway's workers, and this daemon is not a gateway` (`worker.install-component` has its own message, also with no final period). | `src/daemon/dispatcher.ts:301-306`, `:1066-1072` |
| F6 | `UNSUPPORTED_IN_WORKER_MODE` has `cliExitCode: 2`, `httpStatus: 501`. | `src/contract/errors.ts:357-362` |
| F7 | `runCli` catches every error and prints one JSON line `{"error":{"code","message"}}` through `writeError`; the exit code comes from `errorExitCode`. | `src/cli/index.ts:126-129`; `src/cli/errors.ts:22-42` |
| F8 | Commands that never connect read files from the data dir: `events --since` (`events.jsonl`), `daemon logs` (`daemon.log`), `config set` (`config.json`). | `src/cli/environment.ts:182-185`, `:217`, `:228-230`; `src/cli/commands/events.ts:50-55` |
| F9 | MCP's tool-call path auto-launches; its renew path never does. Both use the same socket path. | `src/mcp/main.ts:170-199`; `src/mcp/connect.ts:48-56`, `:87-95` |
| F10 | `e2e/single-host-fleet.test.ts:82` asserts `worker drain` against a worker exits 2 with `UNSUPPORTED_IN_WORKER_MODE`. | `e2e/single-host-fleet.test.ts:82-91` |
| F11 | `e2e/daemon-lifecycle.test.ts:8` asserts the first CLI call auto-starts a worker (`daemon.mode` `worker`). | `e2e/daemon-lifecycle.test.ts:8-31` |
| F12 | `e2e/helpers/cli.ts` `cli(args, env)` runs the real CLI with a given env and no added flags. | `e2e/helpers/cli.ts:34-40` |

### Modules touched

Searches: `rg "resolveDaemonHome"` (callers after #708: `src/cli/index.ts`, `src/mcp/main.ts`; the daemon uses `daemonDataDirectory`), `rg "UNSUPPORTED_IN_WORKER_MODE" src/cli` (none today).

```mermaid
flowchart LR
  CLI["cli/index: folder choice, hint (changed)"] --> PORTS["ports/paths: resolveDaemonHome (changed)"]
  MCP["mcp/main (changed)"] --> PORTS
  PORTS --> FS["ports/filesystem (unchanged)"]
  DAEMON["daemon/main (unchanged: role from flag only)"]
sequenceDiagram
  participant U as simlock worker drain x
  participant C as CLI
  participant W as worker (<root>)
  U->>C: argv, no --gateway
  C->>C: resolveDaemonHome: instance.json yes → worker; gatewayPresent true
  C->>W: worker.drain
  W-->>C: UNSUPPORTED_IN_WORKER_MODE "<msg>"
  C-->>U: {"error":{"code":"UNSUPPORTED_IN_WORKER_MODE","message":"<msg>. A gateway also runs on this machine: run the command again with --gateway."}} exit 2
Loading

Changes:

  1. src/ports/paths.ts: resolveDaemonHome({ root, flag, filesystem }), flag: "gateway" | "local" | undefined. "gateway": as The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708. "local": role worker. No flag: role "gateway" when gatewayPresent and not exists(<root>/instance.json), else "worker". instance.json is read only when no flag is given and a gateway is present. ENOENT/ENOTDIR mean absent, any other error propagates (as The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708). dataDirectory is daemonDataDirectory(root, role). The daemon does not call it (The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708 step 3).
  2. src/cli/index.ts: the global-flag pass also strips --local (bare, anywhere; --local=<x> → UsageError("--local takes no value"); both --gateway and --local → UsageError("--gateway and --local cannot be used together")). CommandContext (src/cli/tree.ts:10) carries the flag in place of The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708's boolean. The USAGE text gains: Pass --local anywhere on the command line to reach this machine's worker even when only a gateway has run here. component install's own --worker <id> is untouched. The launcher gets --gateway whenever the chosen role is gateway, flag or not. The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708's GATEWAY_NOT_PRESENT check applies only when --gateway was given. runCli's catch: when the error's code is UNSUPPORTED_IN_WORKER_MODE, the chosen role is worker, --gateway was not given and gatewayPresent is true, the printed message is the original message, . , then the hint (Shared values). Code and exit code unchanged. An error thrown before the environment exists gets no hint.
  3. src/cli/types.ts, src/cli/commands/mcp.ts, src/mcp/main.ts: the MCP launcher takes the flag ("gateway" | "local" | undefined) in place of The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708's boolean and passes it to resolveDaemonHome. MCP adds no hint (no MCP tool is gateway-only).
  4. Docs and manuals: src/ports/AGENTS.md (the resolveDaemonHome choice), and docs/CLI.md gains a short "Which daemon a command reaches" section with the table above, --local, the order rule (a worker added after the gateway starts with simlock --local daemon start) and the hint; the --gateway entry from The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708 links to it. src/cli/AGENTS.md and src/mcp/AGENTS.md describe the choice.

Contract and event changes

None. Searched src/contract for UNSUPPORTED_IN_WORKER_MODE: the code and its table entry are unchanged; the hint is CLI output only.

Other code on the same state

State: <root>/instance.json, <root>/gateway/instance.json, <root>/gateway/config.json. Searches: rg "instance.json" src (writers: src/daemon/main.ts:249, :707, both through loadInstanceId), rg '"config.json"' src (writers: src/cli/environment.ts:191-197, read by src/daemon/main.ts:156).

Failure modes

Call Outcome What happens Limit, counted from
exists(<root>/instance.json) true / false as the table none
exists throws (e.g. EACCES) the command does not run; the CLI prints the error (INTERNAL, exit 1); MCP fails its first tool call none
worker reply UNSUPPORTED_IN_WORKER_MODE, gateway present, no flag message + hint, exit 2 none
worker reply UNSUPPORTED_IN_WORKER_MODE, no gateway present or --gateway given message unchanged, exit 2 none
worker reply any other error unchanged none
auto-start of the chosen daemon as after #708 20 s from the launch DAEMON_STARTUP_TIMEOUT_MS

This task writes no state. Two commands at once read the same two files and choose the same folder.

Rules in play

  • Architecture: "one place enforces a rule": the folder choice stays in resolveDaemonHome; CLI and MCP call it, neither repeats it.
  • Architecture: "bounded cross-process waits": the two exists calls are local stats; the auto-start keeps its 20 s bound.
  • Safety: "wire input is a claim, not a fact": the hint is added only for the one error code, the reply is not otherwise reinterpreted.
  • ADR 0034 §3: the marker, the choice and the hint on the worker's reply.

Tests

Seam: the real CLI against real daemons on the fake driver through the e2e harness (model: e2e/single-host-fleet.test.ts, e2e/daemon-lifecycle.test.ts); cli(args, env) from e2e/helpers/cli.ts sends a command without --gateway. src/ports/paths.test.ts covers the choice itself.

  • src/ports/paths.test.ts: "resolveDaemonHome without a flag picks the gateway only when a gateway is present and instance.json does not exist" (cases: neither; worker instance only; gateway config only → gateway; gateway instance only → gateway; worker instance and gateway config → worker; root config.json and gateway config, no root instance.json → gateway; empty gateway folder → worker). Extends The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708's test.
  • src/ports/paths.test.ts: "resolveDaemonHome with --local picks the worker and with --gateway the gateway, whatever the folders hold" (cases: each flag × gateway-only folders).
  • src/ports/paths.test.ts: "resolveDaemonHome rethrows an error reading instance.json and chooses nothing" (gateway config present, MemoryFilesystem failing stat of <root>/instance.json with EACCES).
  • src/cli/index.test.ts: "--local is taken from anywhere in the argv; --local= and --local with --gateway are usage errors".
  • e2e e2e/daemon-folders.test.ts: "on a machine that got its gateway first, simlock --local daemon start starts a worker in , and plain status reaches it afterwards" (a worker env that has started nothing yet; the test creates the gateway with a free http.port, and stops the gateway and the worker in finally).
  • src/cli/index.test.ts: "component install ios 26.4 --worker a --worker b still passes both worker ids to the install" (extend the test at src/cli/index.test.ts:2912).
  • e2e/daemon-folders.test.ts: "simlock --local mcp on a machine with only a present gateway lists the worker's catalog".
  • e2e/daemon-folders.test.ts: "with a worker and a present gateway, a worker error of another code carries no hint" (release of an unknown lease id → UNKNOWN_LEASE, message unchanged).
  • e2e e2e/daemon-folders.test.ts (The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708's file): "on a machine with only a present gateway, plain daemon start and plain status reach the gateway and start no worker" (both commands; daemon.mode gateway; <root>/instance.json still absent).
  • same file: "on a machine with only a gateway, plain daemon logs and events --since read the gateway's files".
  • same file: "with a worker and a present gateway, a plain status reaches the worker".
  • e2e/single-host-fleet.test.ts: extend F10's test: "without a gateway on the machine, worker drain exits 2 with UNSUPPORTED_IN_WORKER_MODE and no hint".
  • e2e/daemon-folders.test.ts: "with a worker and a present gateway, a plain worker drain exits 2 with the worker's message and the --gateway hint; with --gateway the gateway answers UNKNOWN_WORKER (exit 12) for the worker's id" (the gateway knows no worker yet). Every test that starts a gateway in a worker env first sets it a free http.port with --gateway config set and stops it with --gateway daemon stop in its own finally: the env's teardown stops only the worker.
  • e2e/daemon-lifecycle.test.ts: extend F11's test to assert <root>/gateway is not created by a plain auto-start.
  • e2e/daemon-folders.test.ts: "plain simlock mcp on a machine with only a present gateway lists the gateway's catalog".

Done when

  • pnpm test and the e2e fast lane pass.
  • docs/CLI.md has the "Which daemon a command reaches" section with the table and the exact hint text.
  • src/cli/AGENTS.md, src/mcp/AGENTS.md, src/ports/AGENTS.md describe the folder choice.

Out of scope

  • Starting a worker on a gateway machine without a flag.

  • A hint in MCP replies.

  • A static list of gateway-only commands in the CLI.

  • Locks across OS users.

Depends on

Written by an agent.

Activity

  1. added
    task:draftScope written; technical spec or its spec check missing.
    on Oct 19, 2026
  2. added
    task:readySpec checked. An agent may implement it once its Depends on allow.
    and removed
    task:draftScope written; technical spec or its spec check missing.
    on Oct 19, 2026
  3. self-assigned this
    on Oct 20, 2026
  4. added a commit that references this issue on Oct 20, 2026
    074e773
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

task:readySpec checked. An agent may implement it once its Depends on allow.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions