2 Commits
Author SHA1 Message Date
b30a3ae55c MUL-6164 feat(runtime): take an unusable agent CLI offline and refuse its triggers with the fix (#6966)
* fix(daemon): take a runtime offline when its CLI cannot be executed (MUL-6164)

A CLI that cannot exec was classified as a transient probe failure, so the
runtime stayed online and every task it claimed died at fork/exec. The user in
MUL-6164 hit the same error once per task with no signal that the runtime was
the problem — an npm placeholder stub passes exec.LookPath, so nothing else the
daemon checks can tell it apart from a working install.

ENOEXEC (and its Windows spelling, ERROR_BAD_EXE_FORMAT) is a fact about the
bytes on disk, not a hiccup: it will reproduce until someone reinstalls. Give it
its own probe verdict and route it down the track below-minimum already uses —
claim barrier, seq-stamped hold, deregistration — so the provider goes offline
with its reason instead of collecting failed tasks. Recovery needs no new
machinery: a demoted provider has no runtime, which puts it back in
providersMissingRuntimes, and the first healthy probe clears the hold and lets
converge register it again, with no restart.

One thing the below-minimum track did not need: a confirmation window. The
repair we tell users to run overwrites the bin entry in place, so a probe landing
mid-copy sees a truncated file. Demotion therefore requires two sightings a
window apart; a genuinely broken install pays one extra probe round, and a
spurious verdict would in any case recover on its own.

detectBuiltinRuntimes' second return value is now "providers condemned this
round, with the evidence against them" rather than "below-minimum versions" —
same shape, both kinds, and the value is the reason /health already shows.

Co-authored-by: multica-agent <[email protected]>

* feat(runtime): refuse triggers for an unusable agent CLI instead of queueing them silently (MUL-6164)

Taking the runtime offline stopped the per-task failures, but it replaced them
with silence: the next @mention or assignment queued as if the machine were
merely asleep, and nothing ran for two hours until queued_expired. Waiting only
makes sense when waiting is a plan — a sleeping laptop comes back on its own; a
CLI that cannot be executed does not.

dispatch/reason.go already draws exactly that line between runtime_offline
("bring the machine back, queued work waits") and agent_runtime_required
("nothing will ever claim this"), and warns that collapsing the two sends users
to fix the wrong thing. This adds the third member of that family,
runtime_unusable, and routes the new verdict to it end to end:

- The daemon reports WHY on deregistration — a stable code plus the repair
  command read from the package's own manifest, since only that side knows the
  path. Stored on agent_runtime.metadata, which registration overwrites
  wholesale, so a repaired runtime drops the explanation structurally.
- AgentReadiness now returns a verdict (available / waitable / blocked) instead
  of a bool plus prose, and the direct-agent trigger paths start consulting it —
  blocking ONLY on the blocked verdict, so an offline machine still queues.
  agentReadinessReasonCode, which re-derived the same code from agent state, is
  gone: one source, no drift.
- Every path that refuses without a human watching the response leaves the
  reason on the issue: an assignment, or another agent's @mention. The
  interactive surfaces already carry the code back to the composer, which now
  localizes it in all four languages.

Below-minimum keeps today's behaviour deliberately. It is equally futile to wait
on, but changing when THAT refuses a trigger is a separate product decision.

Co-authored-by: multica-agent <[email protected]>

* fix(runtime): keep the unusable verdict through a late register, and repair Windows (MUL-6164 review)

Two ways the refusal path failed to reach the user.

The verdict could be erased by an older answer. A register whose request was
still in flight across the demotion upserts the runtime row afterwards, and that
upsert overwrites metadata wholesale — including the reason just stored. The
local hold refuses the response correctly, but the cleanup that took the revived
row offline again sent no reason, so the server ended up "offline, cause
unknown". Every admission path reads that as "the machine will come back" and
queues, which is exactly the behaviour this change exists to remove. The
demotion record now keeps the structured cause and the revived-row cleanup
re-attaches it per row. The regression test drives the real interleave — a
register held inside the server handler across the verdict — and fails without
the fix.

The repair command was POSIX-only. Windows had just gained detection
(ERROR_BAD_EXE_FORMAT), so a Windows user was correctly refused and then handed
`cd 'C:\...' && node install.cjs`: cmd.exe treats the quotes literally and
PowerShell 5.1 has no `&&`. The command is now rendered for the shell of the
machine that reported it — Set-Location on two lines for PowerShell, with
PowerShell's own quote escaping — and carries that shell so the notice fences it
truthfully instead of labelling everything bash.

Also corrected the notice's own docs: it is written for every author, not only
unattended ones. The chip and toast carry the reason code but not the repair
command, so a human needs the durable copy too. The behaviour was already this;
the comment claimed otherwise.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-14 18:56:40 +08:00
f51ee62b53 MUL-6164 fix(agent): diagnose an unrunnable agent CLI instead of leaking "exec format error" (#6963)
* fix(agent): diagnose an unrunnable agent CLI instead of leaking "exec format error" (MUL-6164)

npm-distributed agent CLIs ship a placeholder text stub at their bin entry
point and rely on a postinstall script to overwrite it with the platform-native
binary. npm 12 blocks install scripts unless allowlisted, pnpm 10 needs
`pnpm approve-builds`, and --ignore-scripts / --omit=optional do the same — all
of them leave the stub in place with its executable bit set. exec.LookPath
accepts it, execve rejects it, so the CLI reads as installed everywhere the
daemon looks and only fails at launch with a bare
"start claude: fork/exec <path>: exec format error".

Add ExplainExecError: on ENOEXEC it names the path, the owning npm package, and
the exact repair command read out of that package's own scripts.postinstall.
The executable path is recovered from the error itself, so this is one
provider-agnostic call at each boundary — the shared version probe and the
daemon's single backend.Execute site — rather than a wrap in every backend.
claude and opencode ship this layout today; a CLI added later is covered
without a code change.

Also classify "exec format error" as runtime_missing_executable: a stub that
cannot exec means this host has no usable CLI, and that reason already tells
the daemon a fresh-session retry cannot help.

Co-authored-by: multica-agent <[email protected]>

* fix(agent): keep the ENOEXEC diagnosis to one copy (MUL-6164 review)

The openclaw preflight explained the error, then returned it through
openclawBackend.Execute into the daemon's launch boundary, which explained it
again — %w keeps ENOEXEC reachable, so the second pass fired and the user got
the same paragraph twice.

Drop the openclaw wrap: its error already travels through the daemon boundary,
so the extra site bought nothing. Make ExplainExecError idempotent as well, so
the next nested boundary cannot reintroduce the same regression silently.

Also quote the package root properly in the repair command — it is meant to be
pasted into a shell, and a path holding a quote or a space has to survive that.

The chat copy for runtime_missing_executable said the CLI "couldn't be found /
check it's on PATH", which is the opposite of this failure: the file is right
there on PATH and cannot run. Reworded to cover both across all four locales;
the compact labels ("Runner CLI not installed") are left for the follow-up that
reworks the reason's presentation.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-14 15:31:24 +08:00