Skip to content

feat(cli): install the engine as the setup executor on the daemon path (waired#835 §11) - #115

Merged
gen16k merged 1 commit into
mainfrom
feat/835-executor-engine-install
Jul 20, 2026
Merged

gen16k merged 1 commit into
mainfrom
feat/835-executor-engine-install

Conversation

@gen16k

@gen16k gen16k commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Motivation

On the daemon-mediated path, waired init returns early at runInitViaDaemon (cmd/waired/main.go:325-329) and never reaches the standalone engine block at main.go:535. So on that path nothing could install an engine, and the wizard's first step could only ever report permission_denied — the daemon cannot install unprivileged, and no elevated process was doing it either.

That matters more after waired#874: §11.2 flips the installers to start the daemon before waired init, which makes the daemon path the default journey. This PR is the prerequisite that decision named — the ordering flip must not merge before this, or a fresh install gets no engine at all.

The daemon declares where to install

installOllama on Linux roots at <stateDir>/runtimes/ollama, and the daemon's bundledOllamaBin (cmd/waired-agent/inference.go:260) joins the same path. They must agree exactly.

But runInitViaDaemon (cmd/waired/login_client.go:41) receives only a control URL, a device name and four flags — no state dir. Recomputing it CLI-side with defaultStateDir() works on a default host and silently diverges from a daemon started with --state-dir or $WAIRED_STATE_DIR. Divergence fails in the worst available way: the install succeeds, the daemon looks elsewhere, engine_install spins forever and the operator sees a stuck wizard with no error.

So GET /waired/v1/setup/state gains state_dir, and the executor obeys it rather than guessing. An empty value means do not install. Spec side: waired-ai/waired#876 (amends the frozen §11.1 table and reconciles it with §17.1 — that rule governs values crossing the CP trust boundary; this is a daemon's own value returned to a co-local process that could already compute it).

Changes

file what
internal/management/setup_handlers.go StateDir on SetupStateResponse, served only alongside a desired engine
cmd/waired-agent/setup_desired.go setupStateDir() on setupProvider; adapter returns agentInferenceProvider.stateDir
cmd/waired/setup_install.go (new) runSetupEngineInstall — claim, decide, install, report
cmd/waired/login_client.go call it once a browser setup is active, before waitForBundledModel
docs-site/** (+ ja/) the line the terminal now prints

main.go is deliberately untouched. Routing the state dir through setupProvider instead of a sixth newSetupReconciler parameter avoids cmd/waired-agent/main.go:1093 — the one line that collides with open PR #113 (the Public Share usage batcher rewires daemon wiring there).

Ordering inside runSetupEngineInstall is load-bearing: the lease is claimed (phase=installing) before the multi-GB download starts, so a second executor cannot start a parallel elevated install. TestSetupEngineInstallClaimsBeforeInstalling pins it.

Reuse, per §11.1 ("新しいインストーラを書かない"): the same engineInstallDecision, installOllama and handStateToServiceUser interactive init uses. The #484 ownership handoff is included — the tarball is extracted as root and the unprivileged daemon cannot otherwise read what was just installed.

Two judgement calls

OllamaSourceBundled instead of the interactive prompt's answer. There is no terminal question on this path. We only reach the decision when the daemon reports no engine installed at all, so there is nothing to reuse — a host that already has one returns at EngineInstalled.

WAIRED_NO_OLLAMA opt-out reports failed with a specific error_detail, not a ninth error code. The operator just asked for an engine in the browser while the host is configured never to install one; that is a genuine conflict and must be visible. Of the eight frozen codes, permission_denied ("this device will not do it") is closest, and the detail carries the real reason. Recorded in waired#876's decision entry.

Windows

Not special-cased, on purpose. installOllama already exists on Windows with the same signature and runs fine from an elevated prompt; an unelevated executor gets the honest permission_denied plus elevation.HintFor("windows", …), which is exactly the recovery copy the wizard should show. Gating the code off on Windows would be strictly worse.

What §20.3 defers is narrower — whether a browser-initiated flow can obtain an elevated executor on Windows without a sudo equivalent — and that is subordinate to waired#759's two-stage elevation session model. Filing it as an OS-titled deferral issue separately, since it is a session-model question rather than anything in this diff.

Tests

New (cmd/waired/setup_install_test.go): happy path (install called with the daemon's state dir + ownership handoff + done), claim-before-install ordering, failure detail propagation, 5 skip conditions (inactive / no desire / already installed / claimed by another / vllm), refusal without a state dir, and a 7-case cross-OS table covering elevated and unelevated Linux/Windows, macOS unelevated-installs and macOS-already-present, plus opt-out.

The decision tree takes goos/elevated as parameters (setupEngineInstall) per the repo's cross-OS rule. Without that split CI could never exercise the install arm at all — runners are unprivileged, so elevation.IsElevated() is always false and every case would collapse to SkipNotElevated.

Also: state_dir projection + omission on the agent side, state_dir on the wire in the handler test, and fakeSetupDaemon now mirrors the daemon's lease-bound install latch so executor tests see the claim they would see in production.

Verification

gofmt -l .          # clean
go vet ./...        # clean
golangci-lint run   # 0 issues
go test ./... -timeout 10m   # all pass
make verify-cross   # 4 GOOS/GOARCH combinations clean
cd docs-site && npm ci && npm run build   # 29 pages

Not verified locally: the real install itself (a ~GB download behind an elevation gate). The seam is the same installOllama the interactive path has shipped with, and the 3-OS installtest legs cover it end to end.

Refs

  • Refs waired-ai/waired#835 (§11 / §11.1)
  • Spec side: waired-ai/waired#876
  • Blocks the §11.2 installer ordering flip

🤖 Generated with Claude Code

…h (waired#835 §11)

On the daemon-mediated path `waired init` returns early at main.go's
runInitViaDaemon branch and never reaches the standalone engine block, so
nothing could install an engine there. The wizard's first step could only
ever report permission_denied: the daemon cannot install unprivileged, and
no elevated process was doing it either. This is the piece §11 calls
"executor 経由のエンジン導入".

The daemon declares where to install; the executor obeys:

- internal/management: add `state_dir` to SetupStateResponse. The CLI has
  no state dir on this path (runInitViaDaemon takes only a control URL,
  device name and four flags), and recomputing it with defaultStateDir()
  diverges silently from a daemon started with --state-dir or
  $WAIRED_STATE_DIR. Divergence fails silently in the worst way: the
  install succeeds, the daemon looks elsewhere, engine_install spins
  forever. Published only alongside a desired engine.
- cmd/waired-agent: `setupStateDir()` on setupProvider, served from
  agentInferenceProvider.stateDir — the same value bundledOllamaBin joins
  against, so the two paths agree by construction. Routed through the
  provider rather than a sixth newSetupReconciler parameter so main.go is
  untouched.
- cmd/waired/setup_install.go: runSetupEngineInstall claims the lease
  (phase=installing) BEFORE the download starts, runs the SAME
  engineInstallDecision as interactive init, calls installOllama and
  handStateToServiceUser, then reports done/failed. Every skip reason
  reports through the lease instead of dying quietly.
- login_client.go: run it once a browser setup is actually active, before
  waitForBundledModel — a pull has nothing to pull with until an engine
  exists.

Notes on two judgement calls, both recorded in the PR body:

- OllamaSourceBundled is passed instead of the interactive prompt's
  answer. There is no terminal question here, and we only reach this code
  when the daemon reports no engine at all, so there is nothing to reuse.
- WAIRED_NO_OLLAMA opt-out reports failed with a specific error_detail
  rather than growing a ninth error code. permission_denied is the closest
  of the eight ("this device will not do it") and the detail carries the
  real reason.

Windows is NOT special-cased: an executor started from an elevated prompt
installs fine, and an unelevated one gets the honest permission_denied
with the Administrator hint. What §20.3 defers is the elevation session
model for a browser-initiated flow, which is a separate issue.

The decision tree takes goos/elevated as parameters (setupEngineInstall),
per the repo's cross-OS rule, so all three OSes are table-tested from an
unprivileged runner — otherwise CI could never reach the install arm.

docs-site: show the line the terminal now prints during the browser
setup. The surrounding text already said this step exists.

Refs: waired-ai/waired#835

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: gen16k <gen16k@gmail.com>
@github-actions

Copy link
Copy Markdown

📘 Docs preview for this PR: https://waired-docs--pr-115-4572fefz.web.app

Rebuilt on each push; the preview channel auto-expires in 7 days.

@gen16k
gen16k merged commit 124b4cd into main Jul 20, 2026
14 checks passed
gen16k added a commit that referenced this pull request Jul 20, 2026
…ard (waired#835 §11) (#117)

waired#835 #115 gave the daemon path an executor-driven engine install,
but gated it on setupActive — a browser wizard actually driving. Every
terminal-only daemon-path install therefore still finishes with no
engine: --non-interactive, --no-browser, no TTY, pressing Enter to take
the terminal back, or simply not touching the browser.

This is not a future problem. macOS reaches the daemon path on its
DEFAULT install today:

    darwin_register_agent "$state_dir"   # install.sh:1150 - launchctl bootstrap
    darwin_maybe_init "$state_dir"       # install.sh:1153 - init runs here

The plist that registration installs has RunAtLoad=true (service_darwin.go:
"the agent starts the moment launchctl bootstrap finishes"), so the daemon
is up before init runs and daemonReachable is true. The installer also
stopped pre-installing the engine. So the default macOS install has been
completing with no engine while its closing banner says "installed by
sign-in when local inference is on".

installtest cannot see it: every enroll leg forces the standalone branch
via --bypass-mode, --google-sa-login, or an explicit service stop, so the
daemon path has never been exercised there at all.

Changes:

- ensureDaemonPathEngine: install when the HOST WANTS INFERENCE, not when
  a wizard is driving. The condition is read from the daemon's own
  inference subsystem state, so it reflects what the agent decided rather
  than what a flag claimed: only "no_engine" installs; "disabled" and
  "stopped" are deliberate operator states and anything else means an
  engine already exists. Bounded by engineWaitForStatus so a silent
  daemon cannot hang init, and it never installs on a guess.
- installEngineAsExecutor: extracted from the wizard path so both entry
  points share one claim-decide-install-report core, with only the
  narration differing (saying "for the setup in your browser" on a
  terminal-only install is confusing).
- SetupState now serves state_dir unconditionally. #115 served it only
  alongside a desired engine, reasoning there was nothing to install
  otherwise; that reasoning is what this commit falsifies. Withholding
  the destination is exactly what would keep a terminal-only install
  engine-less.
- applyDaemonInitInference: re-apply --inference-enabled,
  --share-with-mesh and --inference-bundled-model-id after a daemon-path
  login. LoginStartRequest carries only a control URL and a device name,
  so these were accepted and silently dropped — install.ps1 has been
  passing --inference-enabled on Windows to no effect (§11.2). Applied
  through the management routes that already own these three controls;
  no new wire. Nil means "not passed", so absence never overwrites what
  the host already decided, and a model is not requested on a host that
  just turned inference off. Every failure warns rather than failing a
  login that already succeeded.

Ordering is load-bearing: the flags are re-applied before
awaitBrowserSetup (so nothing races desired_model_id) and before
waitForBundledModel (so the terminal does not wait on a download the
operator asked to skip, or download the auto-selected model and only
then switch).

This lands before the §11.2 installer ordering flip, which puts Linux
and Windows onto the same path macOS is already on.

Refs: waired-ai/waired#835

Signed-off-by: gen16k <gen16k@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
gen16k added a commit that referenced this pull request Jul 27, 2026
`waired init` on a host that already has an identity was the last input
that selected the standalone enrollment implementation. The daemon could
not renew credentials for a device it had already enrolled -- Start
returned its idempotent no-op -- so re-auth had nowhere else to go.

The control plane needed nothing: EnrollDevice already matches an
existing Device on the machine key and renews the row (renewDeviceTx,
"#115 Phase C"), and the renewal path is exempt from the device cap. So
this is agent wiring only, no proto change and no CP deploy.

LoginStartRequest gains `reauth`. The daemon honours it by skipping the
already-active short-circuit, and by rebuilding the live session
afterwards rather than publishing a second one: the running session is
using the credentials that were just replaced, and activate refuses to
publish over a current session anyway. That rebuild is the same
teardown-then-activate the node-key rotator has always done, so it is now
one function sharing one mutex with the rotator instead of two copies
that could race.

chooseEnrollRoute therefore loses routeLocal and, with it, every fact
that used to steer: no input picks an implementation any more, because
there is only one. What remains is "is the agent there", and the table
test is now the full input space rather than a sample of it.

The code behind the removed route is unreachable from this commit but
still present; #175's next PR deletes it together with the `waired init`
flags that exist only to steer it. Splitting it that way keeps this
change reviewable as a behaviour change.

Two defects fixed on the way:

* `--auth-key` on an already-enrolled host used to print a successful
  sign-in for a run that renewed nothing (routeDaemon -> the daemon's
  no-op). It now re-authenticates.
* waitForBenchmark read `disabled` and `stopped` as "engine is up, a
  download must be in flight" and waited out the full ten-minute
  deadline before giving up -- on a host with no model and no intention
  of getting one. waitForBundledModel has always treated those two as
  terminal (init_pull.go:97); now both do. Measured cost: ten minutes
  per installtest leg, three legs, every PR. Found because #290's
  harness migration made the daemon path the one CI takes.

An agent predating `reauth` ignores the field and answers with the
no-op status. The CLI names that skew ("the background service is too
old to renew that sign-in") instead of reporting the protocol symptom or,
worse, success.

Refs #175

Signed-off-by: gen16k <gen16k@gmail.com>
gen16k added a commit that referenced this pull request Jul 27, 2026
`waired init` on a host that already has an identity was the last input
that selected the standalone enrollment implementation. The daemon could
not renew credentials for a device it had already enrolled -- Start
returned its idempotent no-op -- so re-auth had nowhere else to go.

The control plane needed nothing: EnrollDevice already matches an
existing Device on the machine key and renews the row (renewDeviceTx,
"#115 Phase C"), and the renewal path is exempt from the device cap. So
this is agent wiring only, no proto change and no CP deploy.

LoginStartRequest gains `reauth`. The daemon honours it by skipping the
already-active short-circuit, and by rebuilding the live session
afterwards rather than publishing a second one: the running session is
using the credentials that were just replaced, and activate refuses to
publish over a current session anyway. That rebuild is the same
teardown-then-activate the node-key rotator has always done, so it is now
one function sharing one mutex with the rotator instead of two copies
that could race.

chooseEnrollRoute therefore loses routeLocal and, with it, every fact
that used to steer: no input picks an implementation any more, because
there is only one. What remains is "is the agent there", and the table
test is now the full input space rather than a sample of it.

The code behind the removed route is unreachable from this commit but
still present; #175's next PR deletes it together with the `waired init`
flags that exist only to steer it. Splitting it that way keeps this
change reviewable as a behaviour change.

Two defects fixed on the way:

* `--auth-key` on an already-enrolled host used to print a successful
  sign-in for a run that renewed nothing (routeDaemon -> the daemon's
  no-op). It now re-authenticates.
* waitForBenchmark read `disabled` and `stopped` as "engine is up, a
  download must be in flight" and waited out the full ten-minute
  deadline before giving up -- on a host with no model and no intention
  of getting one. waitForBundledModel has always treated those two as
  terminal (init_pull.go:97); now both do. Measured cost: ten minutes
  per installtest leg, three legs, every PR. Found because #290's
  harness migration made the daemon path the one CI takes.

An agent predating `reauth` ignores the field and answers with the
no-op status. The CLI names that skew ("the background service is too
old to renew that sign-in") instead of reporting the protocol symptom or,
worse, success.

Refs #175

Signed-off-by: gen16k <gen16k@gmail.com>
gen16k added a commit that referenced this pull request Jul 28, 2026
`waired init` on a host that already has an identity was the last input
that selected the standalone enrollment implementation. The daemon could
not renew credentials for a device it had already enrolled -- Start
returned its idempotent no-op -- so re-auth had nowhere else to go.

The control plane needed nothing: EnrollDevice already matches an
existing Device on the machine key and renews the row (renewDeviceTx,
"#115 Phase C"), and the renewal path is exempt from the device cap. So
this is agent wiring only, no proto change and no CP deploy.

LoginStartRequest gains `reauth`. The daemon honours it by skipping the
already-active short-circuit, and by rebuilding the live session
afterwards rather than publishing a second one: the running session is
using the credentials that were just replaced, and activate refuses to
publish over a current session anyway. That rebuild is the same
teardown-then-activate the node-key rotator has always done, so it is now
one function sharing one mutex with the rotator instead of two copies
that could race.

chooseEnrollRoute therefore loses routeLocal and, with it, every fact
that used to steer: no input picks an implementation any more, because
there is only one. What remains is "is the agent there", and the table
test is now the full input space rather than a sample of it.

The code behind the removed route is unreachable from this commit but
still present; #175's next PR deletes it together with the `waired init`
flags that exist only to steer it. Splitting it that way keeps this
change reviewable as a behaviour change.

Two defects fixed on the way:

* `--auth-key` on an already-enrolled host used to print a successful
  sign-in for a run that renewed nothing (routeDaemon -> the daemon's
  no-op). It now re-authenticates.
* waitForBenchmark read `disabled` and `stopped` as "engine is up, a
  download must be in flight" and waited out the full ten-minute
  deadline before giving up -- on a host with no model and no intention
  of getting one. waitForBundledModel has always treated those two as
  terminal (init_pull.go:97); now both do. Measured cost: ten minutes
  per installtest leg, three legs, every PR. Found because #290's
  harness migration made the daemon path the one CI takes.

An agent predating `reauth` ignores the field and answers with the
no-op status. The CLI names that skew ("the background service is too
old to renew that sign-in") instead of reporting the protocol symptom or,
worse, success.

Refs #175

Signed-off-by: gen16k <gen16k@gmail.com>
gen16k added a commit that referenced this pull request Jul 28, 2026
…#296)

`waired init` on a host that already has an identity was the last input
that selected the standalone enrollment implementation. The daemon could
not renew credentials for a device it had already enrolled -- Start
returned its idempotent no-op -- so re-auth had nowhere else to go.

The control plane needed nothing: EnrollDevice already matches an
existing Device on the machine key and renews the row (renewDeviceTx,
"#115 Phase C"), and the renewal path is exempt from the device cap. So
this is agent wiring only, no proto change and no CP deploy.

LoginStartRequest gains `reauth`. The daemon honours it by skipping the
already-active short-circuit, and by rebuilding the live session
afterwards rather than publishing a second one: the running session is
using the credentials that were just replaced, and activate refuses to
publish over a current session anyway. That rebuild is the same
teardown-then-activate the node-key rotator has always done, so it is now
one function sharing one mutex with the rotator instead of two copies
that could race.

chooseEnrollRoute therefore loses routeLocal and, with it, every fact
that used to steer: no input picks an implementation any more, because
there is only one. What remains is "is the agent there", and the table
test is now the full input space rather than a sample of it.

The code behind the removed route is unreachable from this commit but
still present; #175's next PR deletes it together with the `waired init`
flags that exist only to steer it. Splitting it that way keeps this
change reviewable as a behaviour change.

Two defects fixed on the way:

* `--auth-key` on an already-enrolled host used to print a successful
  sign-in for a run that renewed nothing (routeDaemon -> the daemon's
  no-op). It now re-authenticates.
* waitForBenchmark read `disabled` and `stopped` as "engine is up, a
  download must be in flight" and waited out the full ten-minute
  deadline before giving up -- on a host with no model and no intention
  of getting one. waitForBundledModel has always treated those two as
  terminal (init_pull.go:97); now both do. Measured cost: ten minutes
  per installtest leg, three legs, every PR. Found because #290's
  harness migration made the daemon path the one CI takes.

An agent predating `reauth` ignores the field and answers with the
no-op status. The CLI names that skew ("the background service is too
old to renew that sign-in") instead of reporting the protocol symptom or,
worse, success.

Refs #175

Signed-off-by: gen16k <gen16k@gmail.com>
@gen16k
gen16k deleted the feat/835-executor-engine-install branch August 3, 2026 19:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant