105 Commits
Author SHA1 Message Date
22a05d832b MUL-8423 chore(ui-lab): remove the UI Lab design workbench (#9746)
Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-10-31 14:10:13 +08:00
802f0894e0 refactor(attachments): generate text preview whitelist (MUL-8248) (#9527)
* refactor(attachments): generate text preview whitelist

Co-authored-by: Cursor <[email protected]>

* fix(attachments): gate text previews on shared whitelist (MUL-8248)

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

---------

Co-authored-by: Cursor <[email protected]>
Co-authored-by: Multica Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-10-26 13:38:42 +08:00
76b274e898 MUL-8117: fix(wecom): preserve streaming media on Windows (#9395)
* fix(wecom): preserve streaming media on Windows

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

* test(wecom): check closed media files portably

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-10-23 14:26:53 +08:00
ce5a2ba472 ci: upgrade Go build toolchains to 1.27.3 (#9420)
Co-authored-by: Yushen <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-10-22 13:14:26 +08:00
Danila Katalshov f66237fbdf fix(scripts): preserve check failures in exit cleanup (#9032) 2026-10-20 12:47:43 +08:00
3d021489f4 MUL-7697: fix(daemon): keep host TMP/TEMP for every Windows task (#9197)
* fix(daemon): keep host TMP/TEMP for every Windows task

Git Bash's /tmp usertemp mount is shared by all live MSYS processes of a
user, whichever runtime started them. #8985 stopped Windows Claude tasks
from seeding it with a task-private dir, but any other provider still
could, and later tasks then lost /tmp once that dir was deleted.

Apply the rule to every provider on Windows: TMP/TEMP inherit the host
temp, TMPDIR stays task-private, and Claude keeps its CLAUDE_CODE_TMPDIR
default. The Git Bash integration test now alternates providers.

Closes #8824

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

* test(daemon): expect inherited TMP/TEMP on Windows in env test

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

---------

Co-authored-by: multica-eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-10-12 15:56:39 +08:00
makesomethingshit eb59a69722 fix(daemon): preserve host temp for Windows Claude Git Bash (#8985) 2026-10-12 15:08:18 +08:00
Bohan Jiangandmultica-agent e5d4acfaef fix(ci): require Go 1.26.9 security patches (#9143)
Co-authored-by: multica-agent <[email protected]>
2026-10-08 20:22:31 -07:00
Bohan Jiangandmultica-agent e31da86c90 MUL-7801 fix(daemon): follow directory junctions when authorizing repo checkout workdirs (#8958)
* fix(daemon): follow directory junctions when authorizing repo checkout workdirs (#8946)

On Windows, a workspaces root moved to another drive and left behind as a
directory junction put a junction in the middle of every task path.
filepath.EvalSymlinks fails with ENOTDIR on such a path under the winsymlink
semantics Go 1.23+ selects, so the repo checkout authorization added in
v0.4.30 refused every checkout as "not owned by the active task", and the
workdir-reuse check silently declined every follow-up.

Add util.ResolveSymlinks, the strict counterpart of
ResolveSymlinksBestEffort: it follows symlinks and junctions and fails on
any missing component. On Unix it is filepath.EvalSymlinks; on Windows a
junction-free path keeps the spelling EvalSymlinks gives it. Use it in the
checkout authorization and both workdir-reuse checks, and log the real
reason for a 403 and include it in the response.

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

* fix(util): walk extended-length paths in ResolveSymlinks, refuse device-namespace ones

evalPath returns a `\\?\` input unwalked and joins the rest of a path
lexically onto a volume-GUID link target. That is the fail-closed answer for
the best-effort resolver, but ResolveSymlinks feeds a containment check that
can compare two device-namespace spellings with each other: with the active
workdir spelled `\\?\C:\task\workdir`, `\\?\C:\task\workdir\escape\sub` read
as inside it although escape is a junction to another directory, and
canonicalSpelling's fallback kept that unwalked string.

The strict path now converts an extended-length path with a Win32 spelling
and walks it component by component, and returns ErrUnresolvablePath for any
input or result that stays in the device namespace.

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

---------

Co-authored-by: multica-agent <[email protected]>
2026-09-29 06:55:33 -07:00
cf2b84708d fix(release): replace assets on same-tag reruns (MUL-7683) (#8812)
* fix(release): replace assets on same-tag reruns

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

* fix(release): allow late desktop asset replacement

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-24 18:25:01 +08:00
ZIceandmultica-agent 7a3f29b4be fix(installer): preserve Windows PowerShell 5.1 disk parsing (#8663)
Co-authored-by: multica-agent <[email protected]>
2026-09-22 15:49:49 +08:00
VvVandweiweiwei 2b62b2b52e fix(cli): compare workdir paths in one namespace (MUL-4252, MUL-7193) (#8028)
* fix(cli): compare workdir paths in one namespace (MUL-4252)

The containment guard behind --content-file, --description-file and --attachment
canonicalizes the workdir but could not canonicalize a candidate path that does
not exist yet, and compared the two sides in different namespaces. That is wrong
in three ways, and only the first is merely a wrong answer.

## A path inside the workdir read as outside

filepath.EvalSymlinks fails outright when any component is missing. The fallback
resolved one level up (filepath.Dir) and otherwise called filepath.Clean, which
does not follow symlinks at all.

os.Getwd() returns the LOGICAL working directory whenever $PWD names the current
directory, and everything that writes $PWD carries the path as typed: a shell's
`cd`, and the PWD=<workdir> this repo exports to agent processes
(pkg/agent/opencode.go, deveco.go, codearts.go). So on a host whose workdir path
crosses a symlink, the base was /private/var/... while the candidate stayed
/var/..., and `--content-file reports/summary.md` was rejected as a guardrail
violation when the real problem was that `reports/` did not exist. The only
remedy the error offers is --allow-external-file, so the caller's next move is to
disable the guard and collect an unrelated not-found error on the retry.

## A path outside the workdir read as inside

With an already-canonical workdir the same fallback inverts. `escape/sub/x.md`,
where `escape` is a symlink pointing out of the workdir, read as INSIDE it: the
unresolvable tail fell back to a lexical clean that never sees the symlink.

## ".." was applied to the string, not to the followed path

filepath.Join and filepath.Clean collapse "escape/.." before any symlink is
followed, while the kernel follows `escape` out of the workdir first and then
goes up. So the guard judged a different path than os.ReadFile then opened:

  guard says within=true ; os.ReadFile got "another run's file"

That is the MUL-4252 failure this guard exists to prevent — reading a file left
behind by another run — reachable with a file that exists and no race.

## A path on another volume read as an internal failure

Windows only: filepath.Rel cannot relate two absolute paths on different volumes
and returns an error, which surfaced as `Rel: can't make Z:\report.md relative to
C:\Users\...\workdir`. Two absolute paths Rel cannot relate are on different
volumes, which is as far outside the workdir as a path gets, so the guard now
answers that instead.

## The fix

util.ResolveSymlinksBestEffort canonicalizes as far as the filesystem allows: it
resolves the path as given first — the only step that can see ".." the way the
kernel does — then walks up to the deepest existing ancestor, resolves that, and
re-attaches the missing tail lexically. A mismatch inside the missing tail is
inert: a path whose intermediate directories do not exist cannot be opened at
all. Both sides of the comparison go through it.

internal/daemon/execenv already had this algorithm as resolvePathBestEffort,
unexported and documented as Python's Path.resolve(strict=False). It now
delegates, so there is one owner, and the ".." handling makes it more faithful to
that documented contract rather than less.

## Coverage

TestFileWithinWorkingDir runs its table under two working-directory shapes, and
both are load-bearing: under a symlinked workdir an unresolved candidate always
looks outside, so the symlink-escape rows can only fail under a canonical
workdir. Reverting one part of the mechanism at a time:

  candidate unresolved (Clean)        5 tests / 15 subtests fail
  one-level fallback                  3 tests /  8 subtests fail
  base side left unresolved           5 tests /  9 subtests fail
  clean before resolve ("..")         2 tests /  5 subtests fail
  helper walks only one level          4 tests /  9 subtests fail

The lint side is covered too: classifyLocalPathTarget uses the same predicate,
and there the same false negative flips direction — a workdir-local path with a
missing intermediate directory matched neither of its signals, so the link was
published instead of blocked.

cmd/multica has no Windows job, so the guard's Windows-only behaviour ran
nowhere: a candidate on another volume, and a directory junction, which
filepath.EvalSymlinks resolves to its own name rather than descending through it
(internal/daemon/config_windows_test.go asserts that refusal directly). A
junction inside the workdir pointing out of it therefore still reads as inside,
before this change and after it. Failing closed on a component that exists but
cannot be canonicalized would reject every path under a pnpm-style node_modules
junction, and on a host whose workdir is itself reached through a junction it
would reject every --content-file and --attachment on that host — a larger break
than the one it prevents. The windows-tagged test pins the gap with the
observations it rests on and fails with instructions if the platform starts
resolving junctions. Its CI step is scoped and runs with -v so a silent skip is
visible rather than passing as "ok".

* fix(cli): lead with the missing file when a rejected path does not exist (MUL-4252)

The workdir containment guard answers one question and reports it as if it
answered two. A path outside the workdir that does not exist got the same error as
a stale file sitting in /tmp:

  --content-file path "/tmp/desc.md" resolves outside the current working
  directory; write agent temp files inside the task workdir ... Pass
  --allow-external-file to override.

The only remedy that error offers is the flag, so the caller's reasonable next
move is to disable the guard and retry — earning a second, unrelated "no such
file or directory". The diagnosis points away from the actual problem, and the
lesson it teaches an agent is to turn the guard off.

MUL-4252 was about reading a file that exists and belongs to another run. When
the path does not exist there is no such file to protect anyone from, so the
missing file leads, with the containment problem kept as the second clause since
the caller still needs to know the flag would not help.

pathExists treats any stat error other than "not exists" as "exists", so a
permission wall or a broken mount keeps the guard's own wording and stays
fail-closed rather than being reported as a missing file.

Both halves are pinned by mutation: dropping the branch fails
TestExternalFileErrorLeadsWithMissingFile on the missing-file assertion, and
making pathExists report everything as missing fails the same test on the
assertion that an existing external file must still be told about
--allow-external-file.

* fix(cli): preserve Windows path kinds and resolve junctions in the containment guard (MUL-4252)

Addresses the two blockers in the review on 3ec1781e:

1. absNoClean prefixed the working directory onto every non-IsAbs path,
   but on Windows a root-relative path (\tmp\desc.md) resolves against
   the volume ROOT and a drive-relative path (C:tmp\desc.md) against the
   current directory on that drive. The guard inspected
   C:\task\workdir\tmp\desc.md while os.ReadFile opened C:\tmp\desc.md,
   so an in-workdir shadow could pass an outside read. absNoClean now
   splits per platform (path_other.go / path_windows.go) and preserves
   the kinds; a drive-relative path on an unobservable drive is handed
   back uncanonicalized and fails closed through the Rel branch.

2. The junction case in workdir_guard_windows_test.go pinned a known
   contract violation (EvalSymlinks refuses to descend through junctions
   under the winsymlink semantics this module ships with). Resolution is
   now component-wise on Windows (evalPath) and follows junctions via
   os.Readlink — which answers under both winsymlink settings — with a
   bounded follow budget; reparse points Readlink cannot speak for
   (OneDrive placeholders) stay opaque. A junction out of the workdir is
   rejected, one into it (the pnpm shape) is still admitted, and the
   known-gap test is replaced by cases asserting both directions plus a
   chain and a dead target.

The shared walk in ResolveSymlinksBestEffort is now component-wise on
both platforms: it applies ".." to the resolved prefix even when the
tail is missing (previously the second phase started from filepath.Abs,
which cleans first), and it never depends on what EvalSymlinks does at
a filesystem root. Unix behaviour is unchanged for every shape the
existing tests cover; the new dot-dot-in-the-walk case pins the
difference.

* fix(cli): classify link targets with the full Windows path grammar and resolve unknown reparse points by handle (MUL-4252)

Addresses the two blockers in the review on d3d370377:

1. Link targets were spliced onto the link's parent unless
   filepath.IsAbs said otherwise, but the kernel applies the full path
   grammar to targets: a root-relative target (\outside) resolves from
   the root of the volume the link sits on, and a volume-GUID target
   (\\?\Volume{GUID}\...) is absolute in the device namespace. The
   resolver now classifies targets through classifyTarget, which reuses
   the same grammar as absNoClean; device-namespace targets without a
   Win32 spelling are kept verbatim so the containment comparison fails
   closed across the namespace instead of splicing a mangled relative
   path (the prefix rules mirror internal/daemon's #6883 handling).

2. An os.Readlink failure on a ModeIrregular reparse point no longer
   means "does not redirect". Readlink decodes only the symlink and
   mount-point tags; other name-surrogates (e.g. container-isolation
   links) redirect through a filter while the lexical location stays
   behind. Such components are now resolved through
   GetFinalPathNameByHandle (zero desired access, so no hydration, and
   FILE_FLAG_BACKUP_SEMANTICS for directories); if the handle names the
   same path the component is opaque, otherwise the walk continues from
   the final path. When even the handle cannot name the location the
   resolver returns the new util.ErrUnresolvablePath instead of
   guessing: fileWithinWorkingDir treats it as outside (fail closed)
   and execenv's delegate falls back to the input, which its
   containment comparison rejects.

ResolveSymlinksBestEffort now returns an error so the fail-closed case
is explicit rather than a look-alike path. Negative tests cover a
root-relative symlink target with an in-workdir shadow (the kernel's
target is verified by content before asserting rejection) and a
volume-GUID junction target with the prefix-stripped shadow.

* fix(cli): classify drive-absolute link targets before the volume check (MUL-4252)

classifyTarget asked VolumeName before IsAbs, but VolumeName is
non-empty for a drive-absolute path (C:\dir) too. A junction's absolute
target was therefore misread as drive-relative and resolved against the
working directory — a nonexistent spelling — which errored the
resolution and left the escape to be judged lexically inside the
workdir. The IsAbs branch is asked first, and the load-bearing ordering
is pinned by TestClassifyTarget, a fixture-free table over the target
grammar that would have caught this without a filesystem.

* ci: run the util link-target classification table on Windows

TestClassifyTarget pins the branch ordering classifyTarget's link-target
grammar depends on, but windows-tagged tests in internal/util had no
execution point: the windows-execenv job only ran the guard tests in
cmd/multica. Scoped to the table by name, matching the job's pattern.

* test(cli): correct classifyTarget expectations for root and base shapes

The walk base is the volume root and the working directory's components
live in the segments for a drive-relative target, and the plain-relative
branch returns the link's directory verbatim without a trailing
separator. The expectations said otherwise; the code was right.

---------

Co-authored-by: weiweiwei <[email protected]>
2026-09-21 16:25:13 +08:00
Bohan Jiangandmultica-agent 6dbcc257df MUL-7547 fix(ci): prune the Go build cache to the run's entries and deflake telegram lease tests (#8624)
* MUL-7547 ci(backend): prune the Go build cache to the run's entries before saving

Each main push restored the newest go-build-race entry and saved the whole
cache back, so every save carried the previous entry plus the commit's new
objects. Go only trims entries unused for five days, so the entry held every
recent main commit's race objects: 277 MB on 9/11, 3.5 GB by 9/21. Restoring
it took ~2 min and saving it 1-4.5 min, which put backend-tests back at 5-11
minutes, and two such entries filled 7 GB of the repository's 10 GB cache.

Mark an epoch an hour before the first compile, and before the save drop every
`-a`/`-d` entry no newer than it. Go refreshes the mtime of any entry it uses
once that mtime is an hour old, so those entries are exactly the ones this run
did not touch; the rule is Go's own trim with the run as the window.

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

* MUL-7547 test(telegram): give lease-takeover cases a call budget Postgres can meet

Six delivery cases shorten the lease to 100ms so they can really wait out its
expiry. callContext sizes each provider call's budget at a third of the lease,
and the send claim, a real Postgres write, runs inside that budget: 33ms. On a
slow -race CI runner the write took longer, the claim timed out, the reply
ended before its first chunk, and TestAuditReplayAfterInterruptionBetweenChunks
failed on main.

Share one test lease of 600ms (a 200ms budget) and one lapse wait across the
cases. Injecting 40ms into the claim reproduces the CI failure with the old
lease and passes with the new one.

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

---------

Co-authored-by: multica-agent <[email protected]>
2026-09-21 15:00:35 +08:00
Bohan Jiangandmultica-agent 53578958bc fix(selfhost): pass MULTICA_DINGTALK_SECRET_KEY through and guard integration keys (#8617)
The backend service environment in docker-compose.selfhost.yml is an
explicit allowlist, and MULTICA_DINGTALK_SECRET_KEY was never on it:
Docker self-hosters who set the key in .env still got a disabled DingTalk
integration. Telegram had the same gap until #8611.

- Map MULTICA_DINGTALK_SECRET_KEY into the backend service.
- Document MULTICA_SLACK_SECRET_KEY and MULTICA_TELEGRAM_SECRET_KEY in
  .env.example next to the other channel keys.
- Extend selfhost-config.test.sh: every key the server loads through
  secretbox.LoadKey must be mapped in docker-compose.selfhost.yml and
  listed in .env.example. The list comes from the server, not the docs,
  because Telegram's key was missing from both.
- Run script-checks when server/cmd/server/router.go changes, where the
  integration gates live, so a new channel key trips the guard in the PR
  that adds it.

Co-authored-by: multica-agent <[email protected]>
2026-09-21 12:07:23 +08:00
leilei3167 f9f5e3b81f MUL-6734 fix(daemon): resolve Windows set-path overrides via LookPath (#7623)
`multica runtime profile set-path` was accepted on Windows but the daemon never
used the override: the gate required a Unix executable bit, which Go never sets
on Windows files (0666/0444 for regular files; 0111 only on directories). Every
override — including a plain .exe — read as not executable, so the daemon fell
back to PATH: either failing registration with a message that blamed the user's
filesystem, or silently registering a different install than the pinned one.

appendProfileRuntimes now resolves the override through resolveAgentExecutablePath
(exec.LookPath) — the same contract agent launches use — and records the resolved
path rather than the raw override, so Windows PATHEXT completion picks up .cmd
shims and extension-less pins. Unix exec-bit checks are unchanged, and a stale or
mistyped override still falls back to PATH with the existing warning.

Regression coverage runs on the real Windows runner via the existing
windows-execenv job.

Fixes #7613
2026-09-17 17:39:48 +08:00
0cb64a77aa feat(ui-lab): add design system workbench (MUL-7425) (#8388)
* feat(ui-lab): add shared component design workbench

* feat(ui-lab): support English and Chinese

* feat(ui-lab): scaffold design system catalog

* fix(ui-lab): cover narrow headers and CI path filtering

* feat(ui-lab): add dialog motion and component usage rules

* feat(ui-lab): add semantic color workbench

* fix(ui): update brand color to #0070E3

* feat(ui-lab): add color usage and live contrast checks

* feat(ui-lab): support collapsible sidebars

* fix(ui-lab): place collapse controls in sidebar headers

* fix(ui-lab): avoid repeating page titles in breadcrumbs

* test(agent): synchronize Cursor background finalization fixture

* fix(ui-lab): preserve contrast and list scroll restoration (MUL-7425)

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-16 14:39:55 +08:00
d18cec01c3 MUL-7415 ci: retire the standalone Cursor lifecycle job (#8465)
* MUL-7415 ci: retire the standalone Cursor lifecycle job

cursor-background-lifecycle ran a macos-latest and a windows-latest runner
on the runtime scope, i.e. on nearly every backend change, to cover one
integration's background shell. Neither leg needed to be its own job.

Windows: windows-execenv is already a windows-latest job on the same
runtime scope testing ./pkg/agent process-tree ownership. The Cursor
patterns move there as a step, so a backend PR spins up one Windows
runner instead of two. The ./internal/daemon watchdog command does not
come along: every test it selected is untagged except
cursor_uncaptured_watchdog_test.go, which is `linux || darwin` and never
built on Windows, so the ubuntu backend suite already runs all of them
under -race.

macOS: nothing else in this workflow runs Go on macOS, and
cursor_background_process_darwin.go is 208 lines of XNU identity
signalling whose two tests are `//go:build darwin`. Deleting outright
would leave it with no coverage anywhere, so it becomes macos-runtime,
gated on `full` — the daily cron and workflow_dispatch that #8451 added
for exactly this trade. Its single-count finalization step is dropped as
dead: `full` is now always true here, so the -count=5 stress pass that
was already the full-run variant is the only one left.

The backend gate tracks both moves; ci-scope's production gate test
parses ci.yml, so it verifies the new needs/if/scope wiring directly.

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

* MUL-7415 test(agent): stop building the Cursor late-descendant test on Windows

TestCaptureCursorBackgroundLateDescendant failed twice in the last 43 main
pushes on windows-latest and zero times in 40 on ubuntu. The two Windows
failures reported different errors -- `Access is denied.` and `not an owned
descendant` -- from the same captureCursorBackgroundProcess call, which is
one race wearing two faces: cursorWindowsProcessParents snapshots every
PID->PPID pair on the machine, Windows keeps a child's recorded PPID after
the parent exits, and that PID is then free to be reused, so the ancestry
walk can reach a PID someone else now owns.

Narrowing the tag to `linux || darwin` keeps the Linux and Darwin arms,
which have never flaked and still cover the whole contract: a descendant
forked after capture is owned and terminated, the leader's death does not
orphan it, and an unrelated process is never signalled. Windows keeps
TestCaptureCursorBackgroundProcessRejectsForeignJob for Job membership.

This is deliberately a mute, not a fix. MUL-7417 carries the race itself
and restores the tag.

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

* MUL-7415 ci: run the Windows Cursor step last in windows-execenv

Folding the Cursor job into windows-execenv means Cursor's failures now
truncate windows-execenv's own steps, which the separate job could never
do. Eight Windows-only checks -- bounded output collection, the Pi session
lock, the OpenClaw npm shim, junction resolution -- sat after it and would
be skipped by a single Cursor failure.

Moving the step behind `Build Windows CLI helper entrypoint` keeps the
one-runner saving while leaving the pre-existing coverage first in line.
Also adds the -timeout=5m this job's other test steps carry.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-16 01:13:55 +08:00
d7f8c3f2b6 MUL-7404: ci: scope validation and remove duplicate lifecycle runs (#8451)
* ci: scope validation and remove duplicate lifecycle runs

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

* fix(migrations): renumber the Triage validate migration to 489

#8449 and #8436 merged minutes apart and both took 485, so
TestMigrationNumericPrefixesAreUnique fails on main and every PR branched from
it. #8436 owns 485-488, so the single validate file moves to 489.

Renaming an applied migration makes databases that already recorded
485_issue_triage_state_validate run it once more under the new version string.
That is safe here: VALIDATE CONSTRAINT on an already-validated constraint is a
no-op, verified against PostgreSQL 17.

Co-authored-by: multica-agent <[email protected]>
(cherry picked from commit 09140a04aea06e8ca1cf008433cad3600d851cef)

* fix(ci): gate installers and reuse frontend quality setup

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

* docs(ci): remove redundant CI overview

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

* refactor(ci): remove unused output and duplicate test fixtures

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Co-authored-by: J <[email protected]>
2026-09-15 18:40:14 +08:00
Dante 8b7a4fb68a test(daemon): isolate local skill homes on Windows (#8338) 2026-09-13 17:27:27 +08:00
b54e665295 MUL-7280: ci(backend): cache race builds per commit and shard pkg/agent onto its own runner (#8302)
* MUL-7280: ci(backend): cache race builds per commit and shard pkg/agent onto its own runner

backend-tests never warmed its Go build cache: setup-go shares one go.sum-keyed entry across every Linux job, go-vulnerability-scan saved a 95 MB one first, and the test job race-compiled the whole module from scratch on every run. Own the caches explicitly (modules by go.sum, race build objects by commit SHA with prefix fallback, published from main only), compile build and vet in race mode so the objects are shared, and pin -count=1 so a rolling cache cannot replay results against a changed schema.

pkg/agent ran as a 139s serial tail at -parallel 2; it is timer-bound and reads no database, so it moves to its own runner without service containers. test-go.sh gains --only regular|agent for that split; the default still runs both halves.

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

* MUL-7280: ci(backend): make backend-tests the sole Go cache writer and key build objects by image

backend-agent-tests restored the module cache through the combined actions/cache action with the same go.sum key as backend-tests. Being faster, it saved first with only pkg/agent's slice of the module graph, and backend-tests then failed to reserve the immutable key: the same first-writer-wins problem this change set out to fix. The agent job now restores both caches read-only, leaving backend-tests as the only writer.

The build-cache key also gains ImageOS. Cached cgo and race objects are linked against the runner image's glibc, so an entry must not cross an Ubuntu release when ubuntu-latest rolls forward; setup-go keys on ImageOS for the same reason (actions/setup-go#368). ImageOS is a runner environment variable rather than a context value, so each job resolves the key prefix once in a shell step and the restore and save steps reuse it.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-11 15:57:26 +08:00
6af2268bd9 MUL-7227 test(perf): measure comment typing while agent runs stream (#8276)
* test(perf): measure comment typing while runs stream (MUL-7227)

The regression that made typing a comment take tens of seconds was a summary
animation replacing a DOM node per streamed message, and nothing we had could
see it. The component suite runs in jsdom, which has no style recalculation, no
layout and no main-thread contention, so several thousand passing tests said
nothing about the thing users felt. #8260 pinned the DOM node reuse; this pins
what the user actually waits for.

One browser scenario, on the real page: app shell, shared CSS, the ProseMirror
composer, inline runs, a production Next.js build. Only HTTP and the WebSocket
are synthetic, so no API server, database, daemon, agent or account is
involved. Nothing disables animations or trims the DOM — the cost being
measured lives in exactly that machinery.

Fixed workload, identical for both builds under comparison: 80 comments with
prose and code, three running agents, 1000 seeded transcript messages each, 60
ticks of live messages 100ms apart, and 172 keystrokes at 25ms. Messages are
scheduled from Node rather than the page, so a stalling build cannot quietly
measure less work than the one it is being compared against.

`typing_elapsed_ms` is what the user waits for: first keystroke to the editor
holding the whole string, main-thread queueing included. Style recalculation,
layout, task time and long tasks say where it went. An empty page types very
fast, so a run whose messages never reached the UI, or whose composer did not
end up holding every character, is reported as `invalid` or `timeout` and never
as a duration — including when the scenario blows its budget, which is the case
whose numbers matter most.

Reports rather than gates. `scripts/perf-compare.mjs` builds and measures two
refs in sequence on one machine, each installed against its own lockfile, with
the spec, fixture and browser always taken from the running tree so the product
is the only difference. The workflow is separate and not required: one sample
per ref cannot separate a small regression from machine noise, and the noise
floor has not been calibrated on CI hardware yet.

Verified against the known regression: restoring the pre-#8260 summary
animation moves style recalculation from ~130ms to 1577ms and typing from
~4.9s to 6.2s, against a spread of ~1% across three runs of the fixed build.
Disconnecting the message injection reports `timeout: run 0 end marker`, and
typing fewer characters reports `timeout: editor content` — neither can pass as
fast. #8260's DOM reuse test is untouched and still green.

Tests and run configuration only: no product change.

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

* test(perf): always build for real when comparing two refs (MUL-7227)

The comparison reports a build time, and turbo's cache made it read as ~1.7s
when the real build is ~60s — understating what this costs CI by a factor of
thirty. Worse, a run where one ref hit the cache and the other missed would put
that difference in the timing split as if it belonged to the products.

`--force` on both sides: two real builds, two comparable numbers.

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

* test(perf): report cached builds instead of forcing real ones (MUL-7227)

Forcing a real build on both sides cost minutes on every local run to remove an
ambiguity that a single line of output removes instead. A restored build is
byte-identical to the one that produced it, so the cache cannot move the numbers
this script collects — only the build time it reports, which is now labelled
when it came from the cache.

It buys nothing on CI either: the runner is cold every time, so both builds are
real whether or not they are forced. Locally a repeat comparison drops from
roughly three minutes to under one.

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

* test(perf): run the comparison on demand, and let it exit (MUL-7227)

The comparison never exited on success. The frontend server is spawned
detached, and its teardown hung off `process.on("exit")` — but a live child
keeps the event loop running, so `exit` never fired and the child was never
killed. The report was written and the process then waited on the server it
was supposed to stop. On CI that ran until the job was cancelled at sixty
minutes; locally it left the servers running after being killed. The failure
path only terminated because it happened to call `process.exit(1)`.

Teardown is explicit now. Each side stops its server, waits until the process
has exited and the port has stopped answering, and removes its worktree before
the next side starts — so the second build is also measured on a machine with
nothing of the first one running. The script then exits explicitly, and the
`exit` and signal handlers remain only as a last resort for cancellation.

Checked on every path, with a harness that records the gap between the report
and the exit and looks for anything left behind: success, a scenario failure
with a server running, an unknown ref, and SIGTERM mid-run all exit promptly
with no server, worktree or temp directory remaining. Run through the same
harness, the previous script wrote its report, then was still running 247s
later and left three servers and two worktrees behind.

The workflow is manual only, capped at fifteen minutes. On CI one comparison
took 340s — 292s of it the two production builds, 26s the two measurements —
which is a lot to spend on every frontend PR for a report that gates nothing.
The routine guard stays the component test from #8260, which pins the exact
failure mode and runs with the normal suite; this is for changes that touch
summary animation, long lists, transcript rendering or global styles.

No change to the scenario or its metrics.

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

* test(perf): only trust this run's report, and wait for the whole server (MUL-7227)

Two ways the comparison could report something that was not true.

A report left in the output directory by an earlier run was read back when
this run's scenario failed before writing one, so two failed scenarios showed
the previous run's timings, were marked a usable comparison, and exited 0. And
a scenario that wrote `ok` and then failed was passed through too: the exit
code was recorded as `spec_failed` and then ignored. Each side's report is now
deleted before the scenario runs, the files this script writes are cleared up
front, and a failing scenario process overrides an `ok` report — whatever
failed after the numbers were written is exactly what nobody has looked at.

And a server that stopped answering was taken for one that had stopped. Any
fetch error counted as a closed port, a timeout included, and the server was
dropped from the last-resort cleanup before anything had been confirmed. With
the pnpm wrapper gone but a child still holding the port, the script exited 0
and left two servers running. Teardown now waits for the whole process group to
exit — escalating to SIGKILL if it outlives SIGTERM — keeps the server
registered until then, and treats the port as free only when a connection is
refused, since a hung server still completes the handshake.

`scripts/perf-compare.test.sh` drives the real script with a fake pnpm — no
build, no browser, 9 seconds — through success, a scenario that writes nothing,
a stale report, `ok` followed by failure, a child that ignores SIGTERM, and an
unknown ref. Each of the three new cases fails against the previous script for
the reason above. It runs in `frontend-build` like the other script tests;
the browser comparison itself stays manual.

`PERF_STOP_GRACE_MS` sets the SIGTERM grace (default 10s) so the test can
exercise the SIGKILL path without waiting on it.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-11 13:30:21 +08:00
378e3bd24b MUL-7154: unify the global radius system (#8175)
* fix(ui): unify global radius tokens

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

* fix(ui): reduce global radius scale

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

* fix(ui): address radius review findings

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-10 21:45:46 +08:00
8661e586f6 MUL-7063 fix(cursor): bound stalled runs after background shell launch (#8050)
* fix(agent): supervise Cursor background shells with a liveness guard

Cursor's stream-json reports a launched background shell as the
`isBackground` boolean at the root of its completed tool-call result, and
the adapter acted on none of it: a top-level execution that went quiet
after launching one kept holding its runtime slot until the execution
deadline (upstream #7833).

Bound that shape instead of condemning it. The structural observation now
arms a Cursor-private liveness guard: a rolling semantic-progress
deadline (10 minutes in production, overridable per backend so tests do
not have to wait a minute-scale timeout) while execution continues
normally. Only a supervised run that stops progressing and never emits
its terminal result is ended, and it fails closed with a dedicated,
content-free reason rather than a generic cancellation.

Precedence is decided when the cancellation happens, not guessed at
finalization: fire() declines to claim a run the execution deadline or
the caller had already ended, so `timeout` and `aborted` keep their
classifications, while a cancellation the guard provoked is never
rewritten into "execution cancelled". A valid terminal Cursor result
outranks a latched background observation and still completes.

Detection is structural only — no reading of the command string, no
substring of captured output, no assistant text — and malformed or
non-object result metadata leaves the call foreground, so it cannot arm
the guard or fail the rest of the tool parse. Raw result bytes are
forwarded unchanged. The guard owns one goroutine and one timer per
execution, started on the first background observation and waited for
before the reader hands over its last channel; the overwhelmingly common
foreground run never arms it and pays nothing.

Tests run cross-platform by re-executing the test binary as a fake
cursor-agent, so the pathological stream stays alive and only the daemon
can end it. Test A is RED on unpatched main, where the same run is
bounded solely by the 6s execution deadline with status "timeout".

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

* fix(agent): keep the Cursor liveness guard on its own evidence

Cross-verification of 15605057e found three places where the guard acted on
weaker evidence than the spec allows, plus one helper that did not do what it
documented.

- cursorSemanticProgressEvent believed every `tool_call` subtype. Execute
  handles `started` and `completed` and counts anything else as unhandled, so
  a stream of subtypes it drops could extend the deadline forever. Progress is
  now limited to the subtypes that are actually handled.
- The lifecycle observation read `isBackground` off any completed tool result.
  The flag the spec names lives on the shell payload, and another tool
  reporting it is not evidence that Cursor detached a process, so the read is
  gated on the `shellToolCall` key. An unrecognised payload key now leaves the
  run unsupervised, which is the pre-existing behaviour, not a new failure.
- Precedence between a native terminal result and the guard is now decided when
  the result is observed. A result that lands after the guard ended the run is
  late evidence: it no longer rewrites the failure to `completed`, its text is
  not exposed as output, and the run keeps the guard's own reason. A cause the
  guard declined to claim — a real deadline or a caller cancellation — still
  leaves the result authoritative.
- cursorBackgroundGuard.Stop closes its channels through sync.Once, matching
  the idempotency its doc claims, and returns the guard's cause as of that
  moment, which is what makes the ordering above decidable at all: after fire()
  the guard's cancellation is otherwise indistinguishable from the caller's.

Tests: the guard's own contract (fire-once, progress extension, yielding to
real causes, Stop's report, concurrent Stop) is unit-tested directly; the
observation rules are pinned by structural table cases and by two subprocess
fakes — a stalled non-shell `isBackground` must still end on the execution
timeout, not the guard.

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

* chore(agent): clarify Cursor fake dispatch comment in TestMain

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

* fix(cursor): recheck observed progress before liveness cancellation

Record semantic progress under the guard mutex and recheck its deadline when
handling timer or progress notifications, so a stale tick cannot cancel an
active run. Keep the background-liveness failure content-free.

Add a deterministic virtual-time regression for the expiry boundary and
strengthen the noisy subprocess regression to check the exact error.

Validation: focused regressions fail before the fix and pass after it;
Cursor/ParseCursor tests pass with -race; go vet ./pkg/agent passes.
Co-authored-by: multica-agent <[email protected]>

* fix(cursor): supervise background tools with the daemon tool budget

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

* fix(cursor): retain background ownership and refresh watchdog state

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

* fix(cursor): validate unreaped Darwin group anchors

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

* fix(cursor): distinguish empty Darwin groups from lost ownership

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

* test(cursor): isolate reviewed baseline regression checks

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

* test(cursor): stop platform checks at the first failure

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

* fix(cursor): verify Darwin process identities during cleanup

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

* test(cursor): use standard agent limits on macOS

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

* test(cursor): keep lifecycle reproductions ahead of full suite

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

* test(cursor): keep macOS CI focused on lifecycle

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

* chore(ci): drop the PR-specific Cursor regression reproduction steps

The two steps gated on `github.event.pull_request.number == 8050` proved the
new regression tests were RED on earlier heads of this PR. That is useful while
the PR is open and dead weight once it merges: the condition can never hold
again on main, and both steps pin commit SHAs that only exist on a closed PR's
branch.

The permanent coverage stays: the lifecycle job still runs on ubuntu, macOS and
Windows, and macOS still repeats the finalization test and checks the cgo-free
ownership path.

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

* docs(daemon): state what a zero tool budget means for a background shell

Keeping a Cursor background shell in flight for the launched process's whole
lifetime is what puts a legitimate long background job on the tool budget
instead of the shorter idle one. It also changes what MULTICA_AGENT_TOOL_WATCHDOG=0
means in practice: such a run is now bounded only by MULTICA_AGENT_TIMEOUT,
which is 0 by default.

That follows from the invariant rather than contradicting it — a foreground tool
that never returns already behaves this way — but an operator reading "never
force-stop during a tool call" would not predict it, so say it where the knob is
defined.

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

* test(daemon): pin the captured-background zero tool budget outcome

TestCursorBackgroundUncapturedDisabledToolWatchdog covers the case where nothing
was claimed, and releases the tool so the idle budget still applies. Its mirror
was untested: when ownership IS held the process is genuinely in flight, so a
zero tool budget deliberately force-stops nothing.

Without this test the two cases look like an inconsistency and invite a later
"fix" that quietly changes what the knob promises.

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

* fix(daemon): let an observed terminal result outrank the idle watchdog

A Cursor run could still be reported as a hang after it had already succeeded.
On reading its authoritative result the backend called background.Close()
synchronously, before Session.Result reached the daemon. That call takes the
tracker lock, so it can queue behind a tool-watchdog Interrupt already in
progress — and that Interrupt is allowed to return false without moving native
accounting or its timestamp. The watchdog's revalidation then saw an unchanged
count, an unchanged timestamp and an empty queue, cancelled the run, and
executeAndDrain re-tagged the completed result as idle_watchdog.

The missing piece was a lifecycle boundary the daemon could see: cleanup
succeeding is not the same fact as the outcome being decided, and only the
latter should silence a liveness policy. Session.TerminalObserved carries it,
the Cursor backend publishes it before any cleanup that can block or fail, and
the watchdog checks it both before interrupting and immediately before
force-stopping. The re-tag at Session.Result is gated on it too, so even a
watchdog that wins the last instant cannot rewrite a decided outcome.

Both regression tests drive the exact interleaving through channel handshakes
rather than sleeps: the cleanup callback itself publishes the terminal
observation, so the terminal result provably lands while cleanup is in
progress. They fail on the parent commit with 'fired=true' and
'Status:idle_watchdog' respectively.

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

* docs: sync the Cursor background watchdog contract to ja/ko/zh

The English page gained three operator-facing contracts that the other three
locales still described with the pre-change one-liner: that a Cursor background
shell stays in flight until its owned processes exit, that normal finalization
stops those processes, and what the per-platform fail-closed limits are. These
decide whether an operator sets an overall timeout, sets a tool budget, and
expects a background server to survive a run, so they cannot ship in one locale
only — the same watchdog settings are already maintained in four languages.

Also states explicitly, in all four, that a zero tool budget leaves claimed
background work in flight for as long as it lives, bounding the run only by
MULTICA_AGENT_TIMEOUT, which is itself 0 by default.

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

* fix(daemon): keep a decided outcome through the drain-timeout classifier

The terminal boundary was checked in the watchdog and on the Result arm, but
not on the one path a losing race actually takes. The watchdog reads
terminalObserved and only then writes fired and cancels, so Cursor can publish
its terminal result in between. The cancellation that follows does not surface
on the Result arm — the backend is still finalizing and cannot deliver Result
yet — it surfaces on drainCtx.Done(), which classified purely on the fired flag
and returned idle_watchdog with an empty output for a run that had already
succeeded.

Check the boundary there too and wait, bounded, for the real result. Falling
through on expiry keeps the previous behaviour, so the backstop cannot hang a
run; the backend bounds its own finalization, which is what makes the wait
short in practice.

The earlier regression test could not reach this: there the watchdog returned
at its final gate, so fired was never set and neither the rewrite guard nor
this classifier ran. The new test makes TerminalObserved publish on the read
that the watchdog's final gate performs while still answering false to that
reader, which is the losing race exactly, and asserts the watchdog really did
cancel before checking the outcome. It fails on the parent commit with
Status:idle_watchdog and an emptied output.

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

* fix(agent): bound the whole of Cursor background cleanup at close

Each process termination was bounded, but the number of background shells a run
may launch is not, so finalization as a whole was not. That matters only after
a terminal result — precisely when the daemon's watchdog has deliberately
stopped supervising the run and MULTICA_AGENT_TIMEOUT is 0 by default, leaving
nothing else to bound it.

Give Close() one budget for the whole pass. Work still unconfirmed when it
expires takes the existing unconfirmed-at-close path, so its launch result is
still surfaced and its cleanup is still logged as unconfirmed rather than
reported as successful. Reap and Interrupt keep their previous behaviour.

The test drives 40 tools whose termination never confirms; it takes ~8s on the
parent commit and stays inside the budget here.

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

* docs(ja): match the repo's existing wording in the Cursor watchdog contract

Uses 最終結果 as elsewhere in the docs, makes the two signalling clauses say
シグナル送信 explicitly, and names プロセス捕捉時 and the background process
rather than the launch.

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

* fix(daemon): linearize the post-force-stop verdict on the result hand-off

Checking terminalObserved before classifying only moved the window: the backend
can publish between that read and the classifier that follows it. Reading a
flag and then acting on it cannot be made safe by adding more read sites.

Enter the hand-off unconditionally once the watchdog has fired, and decide from
what arrives. The result's delivery is then the linearization point, and the
backend contract — publish the observation before sending Result — is what
makes the check after delivery reliable rather than lucky. The Result arm
already read in that order; this brings the drain arm in line. An expired
budget still linearizes to the liveness verdict, so the branch stays bounded
whatever a backend does.

The previous test could not reach this: its backend sent Result immediately on
cancellation, so the outer select could satisfy itself on the Result arm and
the drain classifier never ran. The new one withholds Result until the daemon
has entered the hand-off, using the hand-off's own log record as both the gate
and the evidence that the branch executed — otherwise a passing status proves
nothing about which arm produced it. On the parent commit it fails with 'the
post-force-stop hand-off never ran'.

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

* fix(agent): bound every locked background-cleanup pass, not just the closing one

Close() took its deadline before the lock, which is correct, but the deadline
could not shorten the wait for it. A concurrent Interrupt() still scanned an
unbounded number of tools with no deadline of its own, so an unbounded pass
held by anyone else was an unbounded Close() one level down — and
interrupt-holds-the-lock-while-the-terminal-result-arrives is the exact
topology this boundary exists for.

Bound every pass that holds the lock by the same budget. If waiting for the
lock consumed it, the closing pass takes a fresh one rather than pushing every
tool onto the unconfirmed path without trying; two budgets is the honest
ceiling, and terminalResultHandoffBudget is derived from exactly that.

The existing test had no concurrent holder, so it could not see this. The new
one puts an Interrupt() pass on the lock first: it takes ~1.1s on the parent
commit against a 100ms budget.

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

* fix(daemon): scope the result hand-off to backends that publish a boundary

Entering the hand-off on every force stop taxed the case the watchdog exists
for. A wedged backend has no result that could outrank the stop, so waiting for
one only delays freeing the runtime slot — the three existing idle-watchdog
latency tests caught this immediately, each taking the full budget instead of
one window.

Nil TerminalObserved is the signal and is now kept distinguishable rather than
collapsed into a false-returning shim at the point of use. Backends without a
boundary keep exactly their previous behaviour. Cursor, which has one, always
closes Result, so even a wedged Cursor run ends this wait through the closed
channel rather than the budget; the budget remains as the bound for a backend
that neither delivers nor closes.

The new test pins the fast path so the hand-off cannot quietly become a tax on
every force stop again.

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

* test(agent): prove the interrupting pass holds the lock before Close asks

The gate was closed just before calling Interrupt(), which only says the
goroutine was about to try. Close() could win the lock, finish on its own, and
the test would pass even against an unbounded Interrupt() — a regression test
that can go green without exercising what it guards.

Close the gate from inside the first process's terminate() instead: finish()
holds mu for the whole pass, so reaching it is proof the pass owns the lock.
The ordering is now a fact rather than a scheduling habit.

It did not misbehave in practice — with the old gate the test still failed
110/110 against an unbounded Interrupt(), including at GOMAXPROCS=1 and 2,
because close() does not yield the P. It is now deterministic by construction:
20/20 at GOMAXPROCS 1, 2 and 8.

Also corrects the handoff budget derivation: closing the reaper's stop channel
wakes it immediately, so its tick is not a term. What deserves the margin is an
already-started termination overshooting its pass's deadline by its own bound.

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

---------

Co-authored-by: worker-opencode <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Co-authored-by: J <[email protected]>
2026-09-08 16:24:41 +08:00
603519d158 MUL-6977: test: remove implementation-only coverage (#7932)
* test: remove implementation-only coverage

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

* test: remove remaining implementation-only coverage

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

* test: restore compatibility and fragment navigation coverage

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

* test: preserve behavioral cleanup contracts

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-03 13:23:25 +08:00
4dfa08917b fix(daemon): isolate managed OpenClaw MCP servers without reading user config (MUL-6684, MUL-6857) (#7279)
* fix(daemon): build the managed-MCP config from `config get mcp --json`

Preparing a task for an OpenClaw agent with a managed `mcp_config` reads the
user's resolved configuration so the wrapper can replace `mcp.servers` while
keeping the user's other `mcp.*` settings. That read asks for the resolved root,
`openclaw config get --json` with no key path, and current releases do not
answer it: measured against 2026.5.27 the CLI writes zero bytes to stdout and
never exits, and the same shape is reported against 2026.7.1-2. Every
managed-MCP task on such a host therefore fails preparation.

`config get mcp --json` is answered, and it is also the only part of the
configuration this code needs. Switching to it means the snapshot can no longer
be a flat copy of the user's resolved config, so it becomes an include chain:

  1. `$include` the user's live config,
  2. merge `{"mcp": null}`, which OpenClaw's include merge treats as a
     wholesale replacement of the user's MCP object,
  3. restore the resolved non-server MCP settings.

The wrapper then contributes the managed server set, which is the only server
definition the chain can yield. `sessionIdleTtlMs` and its siblings still
resolve; user-only servers do not.

Security posture moves in both directions, so both are worth stating:

- Better: the task directory no longer receives a copy of the user's resolved
  configuration. That copy was a flat snapshot of everything the loader
  produced, which the code it replaces acknowledged could carry API keys and
  model-provider tokens, mitigated only by 0o600. The chain writes include paths
  and MCP tuning, and nothing else.
- Worse: because the chain reaches out to the user's real config, the daemon has
  to grant its directory through OPENCLAW_INCLUDE_ROOTS. The flat copy was
  self-contained and needed no grant. The trade is a read grant on one directory
  instead of duplicating that directory's secrets into per-task state, which
  also means the include never goes stale relative to the user's config.

The MCP read uses the same deadline as every other call in the preparation
rather than a shorter budget of its own. It is one of the
openclawMaxCLICallsPerPreparation calls the budget arithmetic already accounts
for, it is the same class of invocation as the discovery calls — measured at
4.3s on the slow host in #7142 — and it has to honour
MULTICA_OPENCLAW_CLI_TIMEOUT for the same reason those do. A shorter private
budget would fail closed on exactly the machines that raise the override because
they need it.

`openclawResolvedFullConfig` and `stripUserMcpServers` lose their only caller.
The discovery cache's note about deliberately not caching the resolved config is
updated: the reason is no longer that the payload holds secrets, but that the
MCP subtree is per-agent state that has to reflect the user's configuration as
it is now.

Tests: the three strict-replace tests move to the MCP subtree and assert the
chain rather than a copy, including that the snapshot contains none of the
user's server bytes. TestPrepareOpenclawConfigIncludeRootGrants covers the grant
for all three include targets, because getting it wrong fails at OpenClaw's
include-confinement check with nothing pointing back here. The real-CLI test
behind the agentintegration tag asks the installed binary to resolve the
generated chain, since the correctness of the reset bridge lives in OpenClaw's
merge semantics rather than in the JSON this package writes.

* fix(ci): compile the agentintegration tests, and correct the snapshot doc

Two review follow-ups on the managed-MCP include chain.

The `openclawUserSnapshotFile` comment still described the flat sanitized
copy this PR replaced — "the user's config minus the `mcp` block". The file
now holds no user config bytes at all, only an include chain, which is also
why its 0o600 is about the paths it names rather than secrets it carries.
The name stays because the wrapper's include target is not worth churning
for a rename; the comment says why.

The design's load-bearing claim — that `{"mcp": null}` makes OpenClaw's
include merge replace the user's MCP object rather than merge into it —
is verified by openclaw_mcp_real_integration_test.go, and nothing compiled
that file. `agentintegration` guards nine files across the module and no CI
job referenced the tag, so a helper renamed in an ordinary test file, or two
branches each defining one in the same package, breaks the tagged build with
every untagged job still green. That is not hypothetical here: #6275 adds
openclaw_real_integration_test.go to this package with its own
`realOpenclawBin`, and dropping it in beside this one leaves `go build ./...`
passing while the new step reports

  vet: openclaw_real_integration_test.go:18:6: realOpenclawBin redeclared

vet type-checks test files, so the gate needs no agent CLI and no runner
that can execute one. It sits next to Build, before the suite that would
otherwise be the first thing to notice.

* docs(execenv): scope the root-read claim to what was actually measured

Two comments asserted that "current releases" do not answer
`config get --json`, citing a report against 2026.7.1-2 as corroboration. The
measurement behind them is one host on 2026.5.27, and the citation does not
support the claim: the 2026.7.1-2 reports (@kaverjody in #6275, #7308) are both
about `openclaw config file` — cold-start timings and a descendant holding
stdout — not about the root read. #7308 in fact measures `config file` exiting
cleanly in 4.2s on that version.

Which command is which is the whole premise of this change: `config get mcp
--json` is answered while the root is not, so treating an observation of one as
evidence about the other undercuts the argument it was meant to support.

Scoped both comments to the measurement, and stated that the root read's
behaviour on 2026.7.1-2 is unmeasured. The decision does not need the broader
claim: the subtree is the only part of the configuration this code needs, and
the fail-open branch being removed is a silent-degradation bug on its own terms,
reachable from any cause that makes the read come back empty.

* fix(daemon): harden OpenClaw MCP isolation

* fix(execenv): correct the root-read diagnosis against measured channels

Measured 2026-08-26 on Windows 10.0.19045.6466 against the three npm channels.
Two claims in this branch were wrong, and the corrected version is a stronger
argument for the change rather than a weaker one.

**The root read does not hang; it is not a valid invocation.** `config get`
requires a path, and the pathless form is rejected outright:

    2026.6.34   (extended-stable)  exit 1 in 2.9s  Missing required argument "path".
    2026.7.1-2  (latest)           exit 1 in 3.0s  same message
    2026.8.1-beta.3 (beta)         exit 1 in 4.4s  same message, JSON envelope

So `openclawResolvedFullConfig` did not degrade on some hosts — it failed
preparation for every managed-MCP task on all three channels shipping today.
2026.5.27 writing nothing and never exiting within 60s is one version differing
from every current one, and is now recorded as that rather than as the general
case. Switching to the subtree stops being a workaround for a hang and becomes
the request this code should always have made.

**The "exits 0 with empty stdout" shape is unobserved.** It needs exit 0; all
three channels exit 1. The fail-open branch removal still stands on its own —
it is a silent-degradation path and the new chain has no equivalent — but it is
no longer offered as the mechanism behind the reported `Unknown model`, which
remains a real symptom with an unestablished cause.

Also corrected from the same measurements:

- The missing-key transports are not a migration. 2026.6.34 and 2026.7.1-2 are
  stderr-only with empty stdout; only 2026.7.2-beta.7 onward puts the envelope
  on stdout. Both paths are load-bearing for channels shipping today, which the
  detector doc now states with the version table.
- `config get agents.list --json` on 2026.6.34 and 2026.7.1-2 emits
  "Config path not found: agents.list" and names the path, so requiring the
  diagnostic to name the requested key does not regress the registry fallback on
  current stable. `agents list --json` answers on all three.
- 2026.8.1-beta.3's `mcp` schema is exactly `servers` + `apps` with
  additionalProperties:false; `sessionIdleTtlMs` is gone. Stage 3 of the chain
  can only carry keys the same CLI just returned, which is what keeps it safe
  against that constraint. Comments now use `mcp.apps` as the current example.

New regressions, all from verbatim measured output:

- the pathless usage error must not read as a missing key, in both the stderr
  and the JSON-envelope form. This is the shape that actually occurs, and
  misreading it would turn a broken invocation into a silent "user has no mcp
  block";
- beta.3's invalid-config envelope (schema violation in a sibling `issues`
  array) must not read as a missing key either.

The smoke workflow now fails when the test skips. A skipped Go test reports `ok`
at the package level, so the job would have gone green whenever the CLI was
absent or the -run pattern stopped matching — green exactly when nothing was
verified. #6897's Windows step documents the same hazard.

The real-CLI test's doc records the platform, the three resolved versions and
their PASS durations (36.9s / 40.6s / 34.2s), and the hand-run control: dropping
the reset stage leaks `user-only` back into the resolved server map on all three
channels, which is what makes those passes mean something.

* test(execenv): pin the reset-stage/wrapper-mcp pairing the loader depends on

The reset stage is only safe because the wrapper always contributes its
own mcp object under the same condition: OpenClaw merges sibling keys
over the include result, and a null that instead survives to the
resolved root fails schema validation (zod .strict().optional() rejects
null), taking the whole config load down. The pairing held by
construction but nothing named it; this assertion fails in the PR that
breaks it, instead of a week later in the scheduled smoke.

Co-Authored-By: Claude Fable 5 <[email protected]>

* docs(execenv): match the deadline-budget arithmetic to the constant

openclawMaxCLIDeadlinesPerPreparation dropped from 4 to 3 when the
managed-MCP read step was removed, but the worst-case explanation on
openclawCLIMaxTimeout still said 4 x 60s = 4m. Say 3 x 60s = 3m so the
documented safety arithmetic matches the constant and its test.

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: weiweiwei <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
2026-09-03 12:33:39 +08:00
438a30ef00 MUL-6902 fix(agent): fix Pi v0.4.37 Windows session regression (#7843)
* fix(agent): avoid locking Pi transcript on Windows

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

* test(pi): cover sidecar lock contention and release

* test(ci): run the Windows Pi session-lock regression tests

The windows-execenv job runs ./pkg/agent through an explicit -run
allowlist, and the ubuntu backend job cannot compile a windows-tagged
file. Without this step the two tests added for #7840 would never
execute in CI, leaving the same coverage gap that let the v0.4.37
regression ship.

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

---------

Co-authored-by: worker-opencode <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Co-authored-by: J <[email protected]>
2026-09-01 13:12:38 +08:00
mikyan fe59262188 feat(agent): add Huawei Cloud CodeArts support (#6985)
* feat(agent): add Huawei Cloud CodeArts support

* fix(migrations): resolve CodeArts prefix collision

* fix(agent): address CodeArts review feedback

* fix(agent): tighten CodeArts integration scope

* fix(agent): resolve CodeArts follow-up review blockers

* fix(agent): redact CodeArts launch arguments

* docs: sync supported runtime counts

* fix(agent): own CodeArts model probe process tree
2026-08-31 12:30:59 +08:00
VvVandweiweiwei 7d128b5a45 fix(daemon): own the openclaw CLI process tree so its deadline is enforceable (MUL-5467, MUL-5630, MUL-6736) (#6275)
* fix(daemon): own the openclaw CLI process tree so its deadline is enforceable (MUL-5467)

openclawCLITimeout has carried a "Known gap (deliberately not fixed here)"
note since #6084: the 5s deadline could not actually bound an
`openclaw config ...` invocation. This closes it, and fixes a second
OpenClaw misbehaviour found alongside it.

## Bug 1 — the deadline was decorative

exec.CommandContext kills only the direct child. cmd.Output() blocks in
Wait() until the output pipes os/exec manages reach EOF, and cancelling the
context does not unblock that io.Copy. So when openclaw leaves a descendant
holding stdout — its `openclaw-config` helper, or cmd.exe → node on Windows
— the call runs for the *descendant's* lifetime.

#6084 measured this on linux/dash (a shim whose backgrounded child slept 6s
took 6.01s against a 150ms deadline), tried a cmd.WaitDelay backstop, and
reverted it on review because it bounds the call by leaving the descendant
running — trading a hang for a process leak that nothing on Unix reaps. The
note left behind names the real fix: process-tree ownership.

Three call sites had the shape:

  - execenv.execOpenclawCLI       on the task's critical path
                                  (Prepare → prepareOpenclawConfig)
  - agent.detectCLIVersion        runs for every provider inside the
                                  daemon's blocking registration preflight
  - agent.discoverOpenclawAgents  four invocations per call

## Bug 2 — the answer was already on stdout

Measured on an OpenClaw host (openclaw 2026.5.27):

  openclaw --version    258ms  exits cleanly
  openclaw config file    60s  correct path printed, then never exits
  openclaw agents list    60s  correct list printed, then never exits

Waiting for exit turns two working commands into a task-fatal error:

  agent_error.process_failure (prepare execution environment: execenv:
  prepare openclaw config: locate openclaw active config:
  openclaw config file: context deadline exceeded (process: signal: killed))

The contract of these commands is "print a value". Once the value has
arrived and nothing more is coming, whether the process tidies itself up is
not the caller's business, and it should not fail a chat task.

## The fix

Two helpers in server/pkg/agent, both built on the process-group primitives
codex/claude/opencode already use — no new platform-specific functions:

  - RunCollect: hands os/exec an *os.File instead of a buffer, so os/exec
    starts no copy goroutine and Wait returns the instant the direct child
    exits. Then signals the child's process group, which reaps the helper
    and releases the last write end, so our own readers see EOF. Unlike
    WaitDelay this reports the CLI's real exit status, which matters because
    a failed `--version` probe skips runtime registration entirely, and
    because openclawShimDiagnostic type-switches on *exec.ExitError.
  - RunCollectQuiet: returns when the process exits, or when stdout has
    produced output that then stayed idle for 400ms — whichever comes first.
    Reports quiet=true on the idle path so callers can log the CLI's failure
    to exit without failing on it.

The idle shortcut deliberately cannot mask a silent CLI: with no output
there is nothing to salvage, so the deadline still governs and the call
still fails. A non-zero exit still fails too, with stderr intact.

execOpenclawCLI keeps its shim diagnostics and its context-attribution
order byte-for-byte; only the exec mechanism changes.
discoverOpenclawAgents keeps its existing 30s function-level budget rather
than gaining a per-attempt one.

Windows: there is no process group to signal, so calls are bounded (by the
drain grace) but a descendant can still outlive one. Owning descendants
there needs a Job Object and is not attempted here.

## Verification

  - Both packages green with -race; 6 consecutive runs, no flakes.
  - All 51 existing Openclaw* tests in execenv pass, including
    TestExecOpenclawCLITimeoutIsNotMisdiagnosedAsMissingInterpreter and
    TestExecOpenclawCLICancellationIsWrapped — their shims produce no
    stdout, so the salvage path is not reachable and the
    errors.Is(err, context.DeadlineExceeded) contract is preserved.
  - 13 new tests. Both mechanisms are mutation-verified:
      * RunCollect reverted to buffers with no group reap → the guard test
        hangs and the dump is the production stack: cmd.Wait →
        awaitGoroutines blocked, writerDescriptor parked in io.Copy.
      * RunCollectQuiet's idle path disabled → both the unit test and the
        execenv end-to-end test fail after a full 60s.

* fix(daemon): address review on the openclaw CLI collector (MUL-5467)

Three blocking items from review of #6275. All three were reproduced before
being fixed, and the first one was a genuine correctness hole rather than a
style objection.

## 1. The deadline branch salvaged partial answers

RunCollectQuiet returned success from the ctx.Done() branch whenever stdout was
non-empty, without requiring the idle grace or any check that the answer was
finished. A CLI still streaming when the deadline arrived therefore had its
truncated output reported as a completed response. Reproduced with a stub
emitting a JSON document forever against a 250ms deadline: 9 runs in 10 came
back as success carrying an unparseable fragment.

The early return now needs two independent conditions, neither sufficient
alone:

  - an OutputComplete rule supplied by the caller accepts the buffer, and
  - the buffer has since been idle for idleGrace.

Reaching the deadline is never success. The captured bytes are still returned
alongside the error so a caller with its own rule can inspect them, but the
runner no longer decides on the caller's behalf. A nil rule disables the early
return entirely, which is the conservative default for a command whose output
shape has no completeness rule.

The rule cannot be inferred inside the runner, which is the point: only the
caller knows whether `{"agents":[{"id":"a"},` is truncated or legitimate.

  - `--json` subcommands -> agent.JSONOutputComplete (whole buffer parses).
  - `openclaw config file` -> openclawConfigPathComplete (last non-empty line
    looks like a path). Deliberately stricter than
    openclawParseActiveConfigPath, which resolves a relative line through
    filepath.Abs and would therefore accept a Doctor warning border as an
    answer. That matters because OpenClaw prints Doctor UI *before* the path
    (MUL-3136), so an early return on idle output alone hands back the banner
    as the config path — and nothing downstream catches it.
  - `agents list` text fallback -> RunCollect (wait for exit). Decorated text
    has no completeness rule and a silently short agent catalog is worse than a
    slow one; this path still gains the reaping and the bounded return.

## 2. quietWriter published bytes before their timestamp

The buffer write and the last-write timestamp were updated in separate critical
sections, so a reader could observe new bytes carrying a stale timestamp and
conclude the stream had gone quiet at the moment it was producing — truncating
an answer mid-write. Both now move in one critical section (outputBuffer).

## 3. The drain-timeout path raced the readers, and could park a goroutine

RunCollect read its buffers after the drain grace expired while io.Copy could
still be appending to them; the deferred Close on the read ends ran only after
the return values had been evaluated. Separately, RunCollectQuiet handed os/exec
io.Writers, so its cmd.Wait goroutine could sit forever on a descendant holding
the pipe — on Windows nothing released it, since there is no process group to
signal.

Both are now structural rather than patched. A single collector owns the pipes
for both helpers (the *os.File ownership RunCollect already had), and finish()
guarantees that when it returns, every goroutine startCollector spawned has been
joined and nothing can still append to the buffers: it reaps the tree, waits
bounded for Wait, and if the readers still have not seen EOF it closes the read
ends outright — which makes the in-flight Read return — and only then joins them.

## Verification

  - Both packages pass with -race under CI's flags (-p 2 -parallel 2).
  - All pre-existing Openclaw* tests still pass, including the timeout
    attribution and shim diagnostic ones.
  - New regression tests, all three named in the review: continuous output until
    the deadline (must not be success), a prompt followed by a delayed body (must
    not return the prompt), and a late non-zero exit within the grace (must be
    reported as the failure it is). Plus the completeness rules per subcommand,
    the banner-then-path case end to end through execOpenclawCLI, goroutine
    collection on both helpers, and the two outputBuffer contracts.
  - Windows contract coverage added (run_collect_windows_test.go) with a scoped
    step in the existing windows-execenv CI job, since the close-the-read-ends
    path is unreachable on Unix where the group kill releases the pipe.
  - Mutation-verified: restoring the unconditional salvage fails both the unit
    and the execenv regression; dropping the completeness rule from
    execOpenclawCLI makes the Doctor banner come back as the config path.

* test(daemon): make the openclaw config-path fixtures hermetic

The two execenv fixtures printed the literal path the host had printed,
`/root/.openclaw/openclaw.json`. openclawParseActiveConfigPath stats what it
parses and tolerates only os.ErrNotExist, so that path resolves differently by
environment: ENOENT on a developer machine, where /root does not exist, but
EACCES on a Linux CI runner, where it exists and is unreadable. The second case
is a hard error, so TestExecOpenclawCLIWaitsForThePathAfterDoctorBanner failed
in CI for a reason unrelated to the boundary it covers.

Both fixtures now print a real file inside t.TempDir(), which stats cleanly
everywhere. The second test only inspects raw stdout today, but a hard-coded
path there would be a trap for whoever next adds a parse to it.

The literal path stays in the completeness-rule table, which calls the
predicate directly and never stats, so it keeps documenting the shape the CLI
actually emits.

* fix(agent): own one-shot CLI process trees on Windows

* fix(daemon): support current OpenClaw config discovery

* Address review, and narrow this branch to the process-tree fix

Blocking items from the review:

1. The deadline branch no longer salvages. It returns an error unconditionally,
   and callers supply a per-subcommand completeness rule (openclawOutputComplete)
   that decides whether the captured bytes are a finished answer. Regression
   coverage for the three shapes named in the review: output continuing right up
   to the deadline, a banner ahead of the real answer, and a late non-zero exit.

   finish() now reports, rather than being wired to a field that was never
   assigned. Two cases do not converge and both cost the caller something it
   would otherwise never learn: a process still outstanding in Wait after the
   tree kill, and a descendant still holding the pipe when the read ends had to
   be force-closed, which can truncate trailing output. collectorError joins that
   with the command's own error, so nothing is displaced.

2. outputBuffer publishes bytes and the write timestamp inside one critical
   section, so a poller cannot see new bytes with a stale timestamp and conclude
   the stream went quiet while it was in fact producing.

3. The drain-timeout path closes the read ends and waits for both absorb loops
   before any snapshot is taken, so no io.Copy can be appending to a buffer the
   caller is about to read. Windows contract coverage runs as its own CI job.

   The goroutine guarantee is stated at its real strength instead of "no
   goroutine outlives the call, on any platform". Convergence holds whenever the
   tree kill takes. When it does not, one goroutine can stay parked in cmd.Wait
   on a process nothing can terminate; that residue is not removable here, so it
   is reported instead of asserted away. On Windows the same case extends the
   call rather than leaking, because an anonymous pipe is not pollable and
   closing the read end waits for an in-flight Read to release it.

Also from review:

- A JSON error envelope is valid JSON, so JSONOutputComplete accepts one and a
  CLI that prints `{"error": ...}` and then lingers past the idle grace hands
  back err == nil with the envelope in stdout — where the exit-status path never
  sees it. All three JSON readers now check for that explicitly. It matters most
  for `config get agents.list --json`, where the envelope is how a 2026.6+ host
  says the key moved to the registry: decoding it as data turned a graceful
  fallback into a failed preparation. For the object-target read the failure is
  silent, since the envelope would be sanitized and written into the task's
  snapshot as if it were the user's config.

- RunCollectCmd selects on ctx.Done(). It takes a command the caller built, so
  enforcement must not depend on that caller having used exec.CommandContext;
  the ordering matches RunCollectQuiet so the two entry points cannot report
  different error shapes for the same situation. startCollector drops its unused
  ctx parameter rather than implying it enforces one.

- checkOpenclawVersion keeps stderr in the parse. It replaced CombinedOutput(),
  and a build that prints its version there would otherwise fail the gate on
  empty output, which skips runtime registration entirely. extractVersionLine is
  bypassed on purpose: parseOpenclawVersion already scans the whole text, while
  picking the first line containing a semver lets unrelated stderr noise answer
  for it. The probe is bounded because the caller's ctx is the task's, and
  ExplainExecError is applied here too so an ENOEXEC shim is still named
  (MUL-6164).

- TestRunCollectQuietWithoutCompletenessRuleWaitsForExit synchronizes on a
  marker the CLI writes after its answer instead of a deadline. Its two
  assertions want opposite budgets, and the reviewer reproduced the resulting
  flake 3/3 under load. Waiting for the marker also proves more than a timeout
  could: that the runner was still blocked at a moment when the answer was
  already fully available.

Scope: the MCP work that shared this branch is reverted to main's behaviour and
will come back on its own, because it needs a discussion this one does not. It
changed which command supplies the resolved MCP settings, restructured the
include chain, and moved where the user's config bytes live — none of which the
deadline fix depends on. Preparation for a managed-MCP agent behaves exactly as
it does on main here.

$OPENCLAW_HOME path expansion stays: openclawConfigPathComplete calls
openclawHomeRest directly, so the completeness rule for `config file` cannot
tell whether that shape is a finished answer without it.

* fix(agent): converge the collector's tree kill and stop failing calls on cleanup (MUL-5467)

Review follow-up on #6275. All three findings share one root cause: whether the
caller got its answer depended on whether cleanup converged, and cleanup
convergence depended on a single kill(-pgid) landing.

- Retry the tree kill across collectReapWindow. One pass loses a descendant
  whose fork completes between the kill's enumeration of the process group and
  the signal's delivery — measured 3 misses in 10 runs of the forking stub, each
  leaving a `sleep 300` reparented to init and stalling the call until the drain
  budget expired. The window is deliberately short so it does not widen the
  pid-reuse window reapProcessTree already documents.

- Replace the 5s EOF budget with a 400ms settle. Once the direct child has
  exited, at most one pipe buffer of its output can still be unread — a child
  that had written more would have blocked in write() and could not have exited
  — so a short cap cannot truncate a well-behaved CLI's answer. What it cuts
  short is a surviving descendant's trailing output, which is not the answer.

- Stop reporting cleanup residue as a call error. finish() now logs and returns
  nothing: a probe whose output arrived and whose helper the kernel would not
  kill is exactly the case #6084's cmd.WaitDelay got wrong, and a failed
  version probe skips runtime registration entirely. This drops finishErr,
  collectorError and the drainForced/waitPending split, which the four call
  sites were interpreting two different ways by accident.

- Fix the reaping tests. They asserted on `$$` captured inside `( )`, which
  POSIX keeps at the invoking shell's pid, so they named the direct child that
  cmd.Wait reaps regardless — all of them passed with reapProcessTree removed,
  while the run leaked 7 orphans. Record `$!` instead, and add
  TestRunCollectRetriesTheTreeKill, which injects a missed pass because the race
  lives inside one syscall and cannot be forced from a stub.

Verified: collector group 0/10 failures (was 3/10) with 0 orphans, 0/5 under
-race, full ./pkg/agent -race green, execenv Openclaw -race green, and both
mutations — retry removed, reaping disabled — fail the tests that now cover
them, on their own assertions rather than on a cleanup error.

* refactor: move the `$OPENCLAW_HOME` expansion out of this branch

It is not process-tree work. `openclaw config file` printing
`$OPENCLAW_HOME\...` instead of the expanded path is the same class of bug as
#6630 — a path the daemon cannot stat, reported as a fresh install, wrapper
without the user's `$include` — and #6643 shipped the tilde half of it as its
own change. This half belongs there too, not folded into a branch about
owning the CLI's process tree.

Removed here: openclawHomeRest, its branch in expandOpenclawPath, the unit
test, and the completeness-rule row that depended on it. Neither PR now
depends on the other.

openclawConfigPathComplete therefore does not recognize the variable shape,
which is correct in isolation: `main` cannot expand it either, so a rule that
accepted the line would hand the caller a path the parser then fails on.
Such a host keeps `main`'s behaviour — no rule match means RunCollectQuiet
waits for exit — so it loses the hang tolerance and nothing else. The comment
on that function says so and names the change that should close it, because a
gap nobody wrote down is one a rebase re-opens silently.

* fix(daemon): teach the config-path rule the `$OPENCLAW_HOME` shape (#7310 hand-off)

#7310 landed the parser side of this, so the deferred half is now due — and it
was recorded in the code precisely so a rebase could not drop it silently.

While expandOpenclawPath could not resolve `$OPENCLAW_HOME\...`, this rule had
to refuse the shape: accepting it would have reported a finished answer and then
handed the caller a path the parser failed on. The cost was that a host with that
variable set lost the early return entirely and went back to waiting for the CLI
to exit — which is the hang this branch exists to survive. With the parser
fixed, the refusal was the only thing left standing between those hosts and the
tolerance.

Resolved through openclawHomeFromEnv rather than read raw from the environment.
The value may itself be a tilde path that upstream expands before printing, so
`filepath.IsAbs` on the raw string answers false for `OPENCLAW_HOME=~/svc` and
silently withholds the early return on exactly the configuration #7310's review
was about. Mutation-checked: the raw-env version fails the new
`tilde-valued OPENCLAW_HOME` row with `rule(...) = false, want true`.

TestOpenclawOutputCompleteRules becomes subtests so a row can carry the
environment it is about, and gains three: an absolute OPENCLAW_HOME, a
tilde-valued one, and an unset one — the last staying false, so a line with
nothing to resolve it against still waits for exit and lets the parser report
the real error instead of this rule guessing.

* docs(agent): site the collector against outputOwned, not cmd.Output()

#7531 moved every one-shot probe in this package onto launch.go's
runOwned / outputOwned / combinedOutputOwned, which makes three claims in
this branch stale rather than wrong-in-detail:

- RunCollect called itself "the safe replacement for cmd.Output()". There
  are no cmd.Output() call sites left in pkg/agent. The comparison a
  reader needs is against outputOwned, which owns the same process tree
  and differs only in who owns the pipes — so that is what the doc
  compares, including why the probeWaitDelay bound is the right default
  for the probes it serves and the wrong one here.
- RunCollect's path-taking form advertised itself as the migration entry
  point for "the remaining cmd.Output() call sites (models.go's catalog
  probes, codex.go, deveco_models.go, thinking.go)". #7531 migrated all
  four to the owned helpers. It stays as the form for a caller holding
  only a path, and for a probe that turns out to need pipe ownership.
- collector's doc now says outright which of the two properties launch.go
  cannot provide, since "os/exec owns the draining" now describes
  in-package code rather than a stdlib call nobody here makes.

Also adds the `if: needs.changes.outputs.backend == 'true'` guard the new
Windows CI step was missing. Every other step in that job has it; without
it the step ran on frontend-only changes.

* docs(agent): cite upstream for the hang tolerance, not just the timings

The measured "prints its answer and then never exits" for `config file` and
`agents list` came from one host on openclaw 2026.5.27, which invites the
obvious objection: this is an upstream bug, so why compensate for it here
rather than wait for a fix.

Read at openclaw v2026.7.1, it is not a bug in a build — it is the exit
model. `src/cli/one-shot-exit.ts` provides an explicit opt-in,
`requestExitAfterOneShotOutput`, which `runCli` flushes after both streams
drain. In the shipped 2026.7.1 tarball exactly three modules take it:
`models list`, `models status`, and the hooks CLI. The commands this
package invokes — `config file`, `config get`, `agents list` — do not:
their success paths only print, and `runtime.exit` appears solely on their
failure paths, so termination is whatever the event loop happens to do.
A version that exits promptly is not promising the next one will.

Also pins openclawHomeRest's citation forward. `runConfigFile` still
prints shortenHomePath at v2026.7.1, and resolveHomeDisplayPrefix there
still selects `$OPENCLAW_HOME` on a non-empty trimmed variable and `~`
otherwise. Worth recording that the separator comes from the original path
rather than being inserted by the prefix, since that is what the rest-slice
in this function relies on.

* fix(daemon): size the config-file idle grace to the real warning gap

E2E on a real OpenClaw host (2026.7.1-2, plus 2026.6.8 and 2026.7.2-beta.7)
falsified two claims this branch was making. Both are corrected here; one
needed a code change.

**The 400ms idle grace was sized against the wrong gap.** The claim was
that a one-shot response arrives in back-to-back writes, so 400ms is
"orders of magnitude longer than the gap between those writes". The
first half is confirmed — 0.012-0.033ms between writes. The gap that
matters is a different one: with a config that produces Doctor and plugin
warnings, `openclaw config file` spread its stdout over 3289ms, of which a
single 3285ms silence sat between the end of the warning block and the
path. The default grace is eligible to fire inside that silence, on
whatever the warnings left in the buffer.

Why it lands on `config file` alone: upstream routes incidental console
output to stderr when argv carries `--json`
(withConsoleLogsRoutedToStderrForJson, src/cli/json-output-mode.ts at
v2026.7.1), so every `--json` caller here gets a stdout that is the JSON
document and nothing else, and a partial document cannot parse however long
the writer pauses. `config file` has no `--json`, so the warnings share its
stdout with the answer.

openclawConfigPathComplete is still the guarantee — a box-drawing border is
not an absolute path — and a grace can only be defence in depth, since the
gap is a property of the host's plugin and Doctor work. But it should not be
defence that loses by three seconds, so openclawQuietIdleGrace gives
`config file` 5s and leaves `--json` on the default. The cost is paid only
by a CLI that hangs: `config file` then returns in path + 5s rather than
path + 400ms, against a 30s deadline.

**RunCollect guarantee 2 overstated what a group kill can reach.** The
helper was measured holding the same stdout pipe as its parent (both
`pipe:[135202]`) while running with its own PGID and SID. `kill(-pgid)`
never reaches that, and neither does #7531's ownership, which uses the same
group. "Invoking a CLI on a timer cannot accumulate orphans" was therefore
wrong on Unix, and the three comments that repeated it are corrected. What
holds regardless is guarantee 1: it is pipe ownership, not the kill, that
makes Wait return and the deadline enforceable. Windows is unaffected — a
Job Object owns the tree irrespective of process groups.

Tests: TestOpenclawQuietIdleGraceCoversTheWarningGap pins the per-command
grace and asserts the `config file` value exceeds the measured gap;
TestExecOpenclawCLISurvivesTheWarningGapBeforeThePath drives a stub with a
warning block, a gap past the default grace, then the path, and requires
the path back.

* fix(agent): make pipe EOF the completion signal, and bound what is kept

Second review round found three blockers on b9b39eaea. All three reproduce
exactly as reported, and all three are cases where this branch was *worse*
than the launch.go helpers it replaced — which is the bar a replacement has
to clear, so the fix narrows the branch rather than patching around them.

**1. Leader exit was treated as answer completion (worst of the three).**
RunCollect*Cmd reaped the process group as soon as the direct child exited.
A wrapper can exit 0 while the real CLI — its descendant, holding the
inherited stdout — has not printed yet, which is the normal shape for an npm
or PowerShell shim. Measured here: the wrapper exits 0, its child prints the
version 500ms later, and this head returned an empty version with a *nil
error* in 427ms where outputOwned returns the version in 517ms. Empty-and-nil
is the worst available shape, and detectCLIVersion routes all 23 providers
through it.

Pipe EOF is the only signal that means "no more output is coming", so:

- detectCLIVersion, checkOpenclawVersion and discoverOpenclawAgents go back
  to launch.go's outputOwned / combinedOutputOwned, i.e. back to os/exec's
  own EOF wait. What this branch still fixes there is the *reporting*: a
  lingering descendant makes Wait report exec.ErrWaitDelay over an answer
  that arrived, and a failed --version probe skips runtime registration
  entirely (#6084 reverted a WaitDelay backstop over exactly that). So
  salvageProbeAnswer drops the error when, and only when, the caller's own
  parse found the answer — never on a non-zero exit, never on a deadline.
- The collector keeps its reason to exist (execenv's task-critical path) and
  gains awaitOutputAfterExit: after the direct child exits it waits for pipe
  EOF, bounded by collectDrainGrace (2s, matching probeWaitDelay), unless the
  caller's completeness rule says the answer is already in — so a CLI that
  prints and exits with a helper on the pipe still pays nothing.
- RunCollect / RunCollectCmd are deleted. With the probes back on
  outputOwned they had no production caller, and an exported wait-for-exit
  collector is exactly the API that invited this bug.

**2. A path-shaped warning could be returned as the config path.**
openclawConfigPathComplete accepted any absolute-looking last line, and the
previous commit's answer — a 5s grace — was the wrong dimension, as the
review pointed out by quoting this branch's own comment that no fixed grace
can bound the host's plugin work. Reproduced: a CLI printing an existing
plugin-cache.json path, pausing 6s, then printing the real path returned the
warning path after 5.51s.

Fixed by not asking a question content cannot answer. `openclaw config
validate --json` reports the path in a named `path` field, on every branch
(valid / invalid / file-not-found / error), in every version from v2026.5.5
— which is minOpenclawVersion — through v2026.8.1-beta.3, and `--json` makes
upstream route incidental output to stderr so stdout is the document alone.
openclawActiveConfigPath asks that first and keeps `config file` as a
fallback with no completeness rule at all: it waits for exit and fails closed
on the deadline. Upstream runs the same readConfigFileSnapshot() for both
commands, so preferring the JSON form costs no extra work; it costs one extra
invocation only on a CLI too old for either, which is pinned in
TestPrepareOpenclawConfigFallsBackWhenConfigFileUnsupported.

openclawConfigPathComplete, openclawQuietIdleGrace and
openclawConfigFileIdleGrace are gone. `--json` is now the only shape with a
rule, and the doc says why that is the design and not a gap.

**3. Retention was unbounded.** absorb appended without limit, where
outputOwned keeps the last probeStderrSampleBytes. Measured: 13,107,400 bytes
of stderr retained from a CLI writing continuously, ~400x the bound it
replaced. stderr now keeps its tail at collectStderrTail (= 32 KiB, so moving
a call site between mechanisms cannot change what a failed probe reports) and
stdout is capped at collectStdoutLimit and reports overflow as an error —
deliberately asymmetric, because a head-truncated answer is how a partial
catalog becomes a confident empty one.

Every new guard was mutation-verified by restoring the mechanism it covers:

| Mutation | Test | Failure |
|---|---|---|
| collector + reap on leader exit | TestDetectCLIVersionWaitsForAWrapperDescendant | `version = ""` in 0.42s |
| awaitOutputAfterExit returns immediately | TestRunCollectQuietWaitsForAWrapperDescendant | `stdout = ""` |
| drop the rule short-circuit | TestRunCollectQuietDoesNotWaitWhenTheAnswerIsIn | pays the full 2s |
| unbounded buffers | TestCollectedStderrKeepsOnlyItsTail | retained 13,107,417 bytes |
| unbounded buffers | TestCollectedStdoutOverflowIsReportedNotTruncated | 524,296 bytes, nil error |
| shape rule for `config file` | TestOpenclawActiveConfigPathIgnoresAPathShapedWarning | returns plugin-cache.json |

The new fallback test also caught a bug in this commit before it shipped:
openclawValidatedConfigPath accepted upstream's `CONFIG_PATH ?? "openclaw.json"`
literal, because expandOpenclawPath turned that relative name into a confident
path under the daemon's cwd. It now requires the reported value to be absolute
before expansion, allowing only the `~` and `$OPENCLAW_HOME` forms.

* fix(agent): repair the Windows-only collector test call sites

The mechanical conversion of the deleted RunCollect call sites over-widened
three tuples in run_collect_windows_test.go, which already used
RunCollectQuiet's four results. Host `go build` and `go vet` never compiled
them — the file is `//go:build windows` — so the break surfaced on the
windows-execenv job as `assignment mismatch: 5 variables but RunCollectQuiet
returns 4 values`.

Verified this time the way it should have been the first time:
`GOOS=windows go build ./...` and `GOOS=windows go vet ./...`, plus darwin and
linux. That is the check to run whenever this package's exported signatures
move, since a third of its process-lifecycle tests only compile on Windows.

* test(execenv): pin the config-path payload shapes E2E measured

E2E on OpenClaw 2026.7.1-2 confirmed `config validate --json` as the config-path
source and corrected two things this branch had inferred from source.

**The exit status is load-bearing in the wrong direction, and this was untested.**
Two of the three real states exit *non-zero* while carrying the answer: a missing
config file gives `{"valid":false,"path":"…","error":"file not found"}` and an
invalid one `{"valid":false,"path":"…","issues":[…]}`, both exit 1 with stderr
empty. A fresh install is the missing-file case, so reading a non-zero exit as
"no answer" would break first run. openclawValidatedConfigPath already ignored
the status, but nothing pinned it — writeOpenclawConfigStub could only exit 0, so
the tests covered the rarest branch. It now takes an exit code, and
TestOpenclawActiveConfigPathReadsThePathFromANonZeroExit drives all three
measured payloads.

**Two source-based claims corrected.**

- "Preferring the JSON form costs nothing extra" understated it. Measured over
  three runs each on the same host and config: 1.65/1.66/1.65s for
  `config validate --json` against 6.36/4.16/4.06s for `config file`. The JSON
  form is ~2.5-4x faster, which tracks the output it does not render — 715 bytes
  of JSON against 2663 bytes and 28 lines of warning UI.
- The reason a `--json` stdout is clean was attributed to upstream's
  console-log reroute (`withConsoleLogsRoutedToStderrForJson`). That function
  exists, but it is not what was observed doing the work: the Doctor and plugin
  warnings arrive as a `warnings` array *inside* the payload. The distinction
  matters for why JSONOutputComplete is a real signal — the stream is
  structurally one document, not merely quieter — so the comments now describe
  the measurement rather than the mechanism inferred from it.

Unrelated and pre-existing: `./pkg/agent`'s ACP timing tests (1-6s wall-clock
budgets) flake under load, and which ones fail varies per run. Control run of
`origin/main`'s whole package on this machine, same conditions: fails
TestCodexExecuteSemanticInactivityAllowsContinuousMessages and
TestCodexExecuteLegacyFirstTurnMessageSatisfiesProgress. Not introduced here.

* fix(agent): require a recognised version before salvaging a probe answer

The ErrWaitDelay salvage in detectCLIVersion was gated on `version != ""`,
and extractVersionLine falls back to the trimmed raw output when no line
carries a semver token — so any non-empty text satisfied it, including
whatever a wrapper printed before the real version existed.

Review reproduced it with a stub that prints a banner, exits 0, and has a
descendant print the version past the 2s WaitDelay: detectCLIVersion
returned "initializing plugins" with a nil error, and logged "CLI answered
but left its output pipes open" — the opposite of what happened. That value
is then persisted as the runtime version, for every provider, since
DetectVersion routes all 23 through here.

extractVersionLine now reports whether the semver scan actually matched, and
only that answers the salvage question. The trimmed-raw fallback still
stands on the success path: a CLI that exits 0 with an unusual version
format is reporting its version (#2516), and it is only unusable as evidence
that a bounded, incomplete read already holds the answer.

Mutation-checked: with the old gate,
TestDetectCLIVersionDoesNotSalvageABannerAsTheVersion fails with exactly the
reviewed shape — version="initializing plugins", nil error, misleading warn.

* test(agent): derive the collected-stdout cap from the largest answer

collectStdoutLimit argued for why a fail-closed cap is needed but not for
its value, and where it falls is a product compatibility boundary rather
than only a memory bound: a legal config past the cap stops a task from
starting at all.

The derivation is now recorded and executable. Measured answers on 2026.7.1-2
are 715 B (`config validate --json`) and 2663 B (`config file`); the largest
answer any call site asks for is the resolved config from `config get --json`,
which upstream puts no ceiling on. So the worst case is constructed — 1000
agents and 250 MCP servers with the fields OpenClaw carries, 547,291 bytes,
already orders of magnitude past a real deployment — and 8 MiB leaves 15x on
top of it while matching what the daemon already holds for a comparable
per-task payload (maxLocalSkillBundleSize).

Two tests pin it: the constructed high-water mark must round-trip through the
collector byte-identically with the stated headroom, and the boundary itself
is exact — at the limit the answer is returned whole, one byte past it fails
closed with errCollectStdoutTooLarge.

* fix(execenv): share one CLI deadline across both path-resolution attempts

openclawActiveConfigPath asks `config validate --json` first and falls back
to `config file`, and the fallback carried a fresh full deadline. That made
the worst case five budgets against a ceiling derived from four: at the 60s
override ceiling, 5m of CLI time alone, landing exactly on
daemon.defaultTaskPrepareTimeout — so the specific, non-retryable
ErrOpenclawCLITimeout collapses back into the generic retryable prepare
timeout, which is the outcome openclawCLIMaxTimeout exists to prevent.

Both invocations answer the same question, so they now share one context.
The multiplier is renamed to openclawMaxCLIDeadlinesPerPreparation because
that is what it always measured — the budget is per step, not per invocation.

The consequence is deliberate and asserted: a validate that burns the whole
budget leaves the fallback none, os/exec reports the expired context on Start,
and the failure still carries ErrOpenclawCLITimeout. A CLI that cannot say
where its config is within the entire deadline will not answer the same
question on a fresh one.

TestPrepareOpenclawConfigWorstCaseCLIBudgets now drives the real worst case
(validate fails, config file answers, registry fallback, managed MCP) and
counts distinct ctx deadlines rather than calls — the old test synthesized a
validate success from the `config file` response, so the two never fired in
the same run. Mutation-checked: with separate deadlines it reports 5 budgets
against a constant of 4.

---------

Co-authored-by: weiweiwei <[email protected]>
2026-08-28 14:04:10 +08:00
bdcb231ba0 MUL-5466 fix(agent): deliver the qwen prompt on stdin, not argv (#7343)
* fix(agent): route qwen launch through the Windows invocation chooser

On Windows npm installs qwen as qwen.cmd, a batch shim. Spawning a .cmd
goes through cmd.exe, whose %* forwarding re-tokenises the raw command
line, so the `-p <prompt>` value and the `--output-format stream-json`
flags buildQwenArgs puts behind it are mangled whenever the daemon's
structured chat prompt carries newlines, quotes or `&`. Qwen then runs
without a usable prompt or without stream-json, exits 0 after emitting a
single unparseable stdout line, and finalizeStreamResult reports "qwen
stream ended without terminal result". A short manual `qwen -p "hallo"`
never hits the mangling, which is why the CLI looked healthy in isolation.

Mirror the existing cursor/pi/copilot fix instead of inventing a new
mechanism: resolve qwen.ps1 next to the .cmd and invoke PowerShell with
`-File <ps1>` directly so Go hands each argv token whole, staying on the
official launch path. qwenBackend.Execute now builds its process through
Command.execVia(chooseQwenInvocation, ...) rather than Command.exec; on
macOS/Linux the chooser is a passthrough and behaviour is unchanged.

Fixes #6082

* test(agent): cover qwen's stdin prompt delivery and update signatures

buildQwenArgs dropped its prompt parameter in the prior commit (the
prompt now goes on stdin, not argv), so the existing unit tests needed
their call sites and assertions updated to match — including an
explicit check that "-p" never appears in argv again.

Adds TestQwenBackendDeliversPromptOnStdin: a round-trip regression
guard proving a prompt containing embedded double quotes, an em dash,
and shell metacharacters reaches the child process byte-for-byte via
stdin, the way cursor-agent and Pi already do (#5649's fix). The fake
qwen script now optionally captures stdin to a file the same way it
already captures argv.

* test(agent): cover qwen Windows PowerShell launch

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-24 15:08:14 +08:00
8d2da6b8db MUL-6479 feat(dev): make a local environment a named object with one verb per lifecycle step (#7325)
* MUL-6479 feat(dev): make a local environment a named object with one verb per lifecycle step

Local development had six entry points, thirty-four make targets and no way to
see or delete what they created. `make up` / `status` / `list` / `down` /
`destroy` / `gc` / `env-exec` replace that with one set of verbs, shared by
humans and agents, with `C=api,web,daemon,desktop` selecting components.

Three defects made the old flow untrustworthy rather than merely verbose.

1. The database the tooling created was not the one the application reached.
   `ensure-postgres.sh` creates through `docker exec`; when a native PostgreSQL
   owns 5432 the container never binds the host port, so the create lands in a
   server the backend never talks to. It printed "✓ PostgreSQL ready" and the
   next step died with `SQLSTATE 3D000`. On the machine this was written on the
   two servers had already diverged: 94 same-named databases, 69 more in the
   container with no counterpart. `up` now creates through DATABASE_URL and
   treats a successful `migrate up` — which pings that same string — as the only
   proof; failure diagnoses the port owner instead of reporting a missing
   database.

2. Identity was computed, not allocated. Ports, database names and CLI profiles
   all came from `cksum($PWD) % 1000`, and a collision was silent: the loser
   fails to bind while the winner keeps answering 200. Allocation now happens
   under a lock, probing from the same path hash so a checkout keeps its
   familiar numbers, and requires the slot to be both unregistered and free of a
   live listener. The manifest in ~/.multica/dev/envs/<name>/ is what every
   later command reads.

3. Creating an environment left no record of how to destroy it, so there was no
   destroy — only archaeology. `down` stops processes and keeps the data;
   `destroy` consumes the manifest: processes, database, profile, slot. `list`
   makes leaks visible and `gc` collects environments whose checkout is gone or
   whose TTL passed.

/health now reports pid, commit and started_at. A 200 alone proves something is
listening, which is exactly what let a failed restart keep serving the previous
build; `up` compares started_at against its own launch and refuses an answer
that predates it.

Two constraints are enforced rather than documented: the daemon is started from
a built server/bin/multica, never `go run` (the daemon re-execs its own path for
every task, and `go run` deletes that binary when the launcher exits), and
`C=daemon` under a .multica/daemon_task_context.json marker stops with that
explanation up front instead of spending a login on a refusal.

scripts/dev-env.test.sh covers the registry verbs against a throwaway
MULTICA_DEV_HOME with no services running, and pins the regression that made
`down` exit 1 after reporting success: on bash 3.2 a command substitution whose
function ends in a failing command aborts the script under `set -e`, and "no
process is listening" is that function's normal answer.

`make dev` is unchanged and still runs in the foreground.

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

* fix(dev): close local environment lifecycle gaps

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-23 16:43:58 +08:00
520359a2c9 MUL-6518 fix(daemon): reclaim orphaned per-task temp dirs in the GC (#7391)
* refactor(daemon): give the lock and dir-stat helpers neutral names

openEnvRootLockFile, releaseEnvRootLock and codexStoreStat are all
generic: they take a path, and nothing in them knows about env roots or
Codex stores. The next commit reuses the same execution lock for per-task
temp directories, where the old names would read as a bug at the call
site.

No behaviour change.

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

* fix(daemon): reclaim orphaned per-task temp dirs in the GC

Every task gets a private temp dir (multica-task-*) under the system temp
base, exported to the agent as TMPDIR/TMP/TEMP. Its only exit was the
RemoveAll deferred at the end of runTask, which does not run when the
daemon is killed and does not succeed while a file inside is still open —
the Windows case reported in #7364, where one machine had accumulated 174
leftover directories. The GC never covered them: it scans WorkspacesRoot,
and the temp base is a different root, so multica-task- had exactly one
create site and zero reclaim sites.

Sweep the temp base as a fourth out-of-root pruner. Liveness is decided by
the directory's .task_lock — the same OS advisory lock an env root uses —
not by age: the sweep removes a directory only once it has itself acquired
the lock the owner would still be holding, which the kernel grants only
after that owner is gone.

Using the lock rather than a marker or a TTL is what makes this correct
rather than probabilistic. It is held by an open file description, so it
answers for a live task in this very process as well as one owned by a
different daemon sharing /tmp, which no in-memory active set could see.
And it needs no upper bound on task runtime — there is none to assume,
since DefaultAgentTimeout is 0.

Directories left by daemons predating the lock carry no lock file, so age
is the only signal available for them; they are reclaimed after
MULTICA_GC_TASK_TEMP_LEGACY_TTL (72h). That branch exists to clear the
existing leftovers and can be dropped in a release or two.

The end-of-run RemoveAll stays exactly as it was, and nothing was added to
the task-completion path: the run releases the lock before removing, so a
removal that fails there costs nothing and the next GC cycle finishes the
job.

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

* fix(daemon): keep the temp dir marker when cleanup cannot finish

Two fixes from review.

1. A failed cleanup destroyed the marker the next sweep needs.

   os.RemoveAll keeps walking after a failure, so removing a directory
   whose payload is still open deleted the .task_lock sitting next to it
   and then returned the error. The directory came back as one with no
   marker — indistinguishable from a pre-lock leftover, so it was no
   longer reclaimable on liveness at all. That is exactly #7364's failure
   path, and it made the claim this change is built on ("a failed removal
   costs nothing, the next cycle retries") false.

   RemoveTaskTempDir removes contents first and stops at the first one it
   cannot remove, leaving the marker behind; the lock file goes last, once
   everything else is gone. Both the task-completion path and the GC use
   it. A cleanup that fails now leaves a directory that is still
   lock-bearing and still unlocked, so the next cycle takes the lock and
   retries — for as many cycles as the holder needs.

2. The legacy TTL could delete a live task's TMPDIR.

   Pre-lock directories were reclaimed on age after 72h by default, but
   age cannot establish liveness: DefaultAgentTimeout is 0, so a task may
   legitimately run far longer than any TTL; a daemon on another profile
   can still be running the old binary during an upgrade; and every daemon
   on the machine shares one temp base. The default is now 0 — leave them
   alone — and reclaiming them is an operator's explicit opt-in. Each
   cycle logs how many were left in place so the choice stays visible
   rather than silent.

   Classification also fails closed: only os.ErrNotExist means "no lock
   file". Any other stat error (another user's dir under a shared /tmp)
   now reads as in-use instead of legacy.

Regressions for both: a Windows test that makes a real sharing violation
with CreateFile minus FILE_SHARE_DELETE, asserting the marker survives the
failed cleanup and the directory goes once the handle closes (wired into
the Windows CI job, which runs targeted filters rather than whole
packages); and unix equivalents driving the same paths through an
unlinkable payload, plus fail-closed coverage for an unreadable directory.

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

* fix(daemon): publish the temp dir marker atomically, restore it on rmdir failure

Two more windows in the marker's lifecycle, from review.

1. A directory could be swept between marker creation and lock acquisition.

   LockTaskTempDir created .task_lock and locked it as two steps, so there
   was an instant where the marker existed unlocked. A sweep landing there
   reads exactly what a dead owner leaves behind: it takes the lock,
   concludes the owner is gone, and deletes the directory — after which
   the starting task's own lock call succeeds and it runs with a TMPDIR
   that no longer exists.

   The marker is now locked under .task_lock.claiming and renamed into
   place once held. Rename within one directory is atomic and the lock
   rides on the open file description, not the name, so the marker becomes
   visible and held in the same step.

   Before that rename the directory carries no marker, which classifies as
   legacy — and legacy only reclaims what is OLDER than legacyTTL. A
   directory created microseconds ago is younger than any positive TTL,
   and a TTL of 0 disables the branch outright, so no configuration can
   sweep the pre-publish state either.

2. A failed rmdir left a directory with no marker.

   The marker has to be deleted before the directory can be removed, so a
   failure at that last step — a Windows process holding a handle to the
   directory, or content an orphaned child recreated — left a directory
   nothing could classify again. With the legacy branch disabled by
   default that is a permanent leak, and it was the one path where cleanup
   got furthest. The marker is now put back when that removal fails, so
   every failure mode leaves the retry available.

Regressions: the publication order and the mid-claim on-disk state (spared
under a disabled TTL and under positive ones), a sweeper running flat out
against 200 publish/use/cleanup cycles, and the rmdir-failure branch —
which fails without the restore, verified by removing it.

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

* fix(daemon): decide the legacy sweep on content, not on age alone

The publication window was still open. The claim-then-rename ordering
stopped .task_lock from ever existing unlocked, but a directory mid-claim
carries no .task_lock at all, so the sweep still reaches it through the
legacy branch — and the argument that protected it there was wrong.

That argument was "a directory created microseconds ago is younger than
any positive TTL". MULTICA_GC_TASK_TEMP_LEGACY_TTL takes any duration,
down to a nanosecond, and an owner stalled between MkdirTemp and the
rename outlasts a large TTL just as easily. Reproduced at 1ns, 1ms, 1h
and 72h alike: the sweep deleted a directory whose claim was held.

Age cannot separate a pre-lock leftover from a directory being published,
so stop asking it to. Content can: a directory being published holds
nothing but its own lock files, because the task has not been handed the
path yet and cannot have written anything, while a leftover worth
reclaiming is by definition one with something in it. The legacy branch
now reclaims only directories that hold task content, which covers both
halves of the window — the empty directory before the claim exists, and
the claim-only directory after — at every TTL value, with no timing
assumption left in the argument.

The cost is that a pre-lock daemon which crashed before its task wrote
anything leaves an empty directory nothing reclaims. That is a few bytes
against deleting the temp dir of a task that is starting.

Tests: the mid-claim cases now run at 1ns/1ms/1h/72h with now pushed PAST
the TTL, so they fail if anything reintroduces a timing argument; the
pre-claim (empty directory) half is covered the same way; and the
invariant itself is pinned directly, so relaxing it breaks a test rather
than a race. The existing legacy TTL test now models a real leftover by
giving it content. Verified all of these fail with the content rule
removed.

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

* fix(daemon): re-check the lock immediately before removing a legacy dir

The legacy branch's decision is several separate observations — classify,
then walk for content, then walk for mtime — and a directory can finish
publishing in the middle of them. Classified as legacy while its marker
was still unpublished, then published and written to by its task, and the
removal went ahead without ever looking at the marker that had appeared:
the sweep deletes a live task's TMPDIR. What was accidentally holding this
together was that the task's own write refreshes the mtime — a timing
crutch, in the branch whose whole point is not to rely on one.

Re-read the classification immediately before removing, and skip unless it
is still legacy.

That closes the window rather than narrowing it, because of what content
means: a task only writes into the directory after it has been handed the
path, which happens only after LockTaskTempDir returns and therefore only
after the marker is published and held. So content observed at the top of
the branch, plus no marker seen at the bottom, proves no live task owns
this directory — and none can arrive later either, since MkdirTemp only
ever hands out a fresh empty directory under a new name.

The regression drives that exact interleaving through a barrier: the sweep
commits to removing the directory, the owner then publishes and its task
writes, and the sweep must notice. `now` is far past the TTL so age cannot
mask it. Verified it fails without the re-check.

Also from review: drop a stale test comment that still claimed a positive
TTL can only be configured far above a directory's age, and record in the
config and operator docs that a directory holding no task content is never
reclaimed on age — including a shell left by a CURRENT daemon that died
between creating the directory and publishing its lock, not just an old
daemon's empty leftover.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 17:38:44 +08:00
53e15b35c6 MUL-6502: fix startup recovery during PostgreSQL outages (#7372)
* fix(startup): retry transient database outages

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

* fix(startup): address database retry review

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

* test(startup): address retry review nits

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

* fix(startup): preserve native pgx connect timeout

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

* fix(startup): apply timeout fallback to services

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 14:33:04 +08:00
1ea40db1c4 fix(build): honor GOOS as a Make variable when naming build outputs (#7309)
#7271 derives the Windows `.exe` suffix from a parse-time `go env GOOS`,
which cannot see GOOS passed as a Make variable: `make build GOOS=windows`
still emits PE binaries named `bin/server`, `bin/multica`, `bin/migrate` —
the exact artifacts #7255 reported as unable to re-exec themselves. The
top-level `export` puts the Make variable in the recipe's environment, so
`go build` targets Windows while the name says otherwise.

Reading `$(GOOS)` first covers both invocation forms, and making the
assignment target-specific keeps the toolchain probe on `build` alone: a
global assignment is expanded on every make invocation — `export` expands
even a recursive one — so `make help` / `make clean` printed
`go: Command not found` on checkouts without Go.

Adds scripts/makefile-build.test.sh to pin both invocation forms, the
non-Windows names, and the no-Go case, wired into the backend CI job that
already runs the other Makefile-adjacent shell tests.

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 16:24:22 +08:00
Bohan Jiangandmultica-agent f7abcdded3 docs: publish the community-maintained areas and their maintainers (#7249)
Until now, "community-maintained" appeared in the docs without ever
naming who maintains what, and the actual maintainers were recorded only
in Go package comments and PR threads. Users filing an issue had no way
to learn any of it.

Add a Community-maintained areas page in all four locales listing each
area, its code path, its maintainers, and the retirement rule, and make
that page the single source of truth: channels, the DingTalk and
Telegram guides, the README, and the three package doc comments now
point at it instead of restating it. Those package comments no longer
carry maintainer handles or a second copy of the policy, so ownership
changes are recorded in exactly one place.

The page is deliberately a credit-and-expectations page, not a support
directory — it asks readers to file a GitHub issue rather than page a
volunteer directly, since what we promised these maintainers is that we
would route work to them, not that they are on the hook for it. It also
avoids the term "code owner": we are not adding a GitHub CODEOWNERS
file, and that term carries a specific automated meaning there.

Both issue forms gain a required Area field so a report can be routed
without asking the reporter to read source, with the community-maintained
areas marked as such in the option list.

Co-authored-by: multica-agent <[email protected]>
2026-08-20 00:22:04 +08:00
801c671973 chore(assets): convert bitmaps to WebP, dedupe, and gate image size (MUL-6352) (#7149)
* chore(assets): convert web and docs bitmaps to WebP

The repo carried 21.7MB of PNG/JPG and zero modern formats. next/image
already serves visitors AVIF/WebP, but the raw masters are still paid for
on every clone, every Docker build context, and every deploy upload.

Re-encode with cwebp and cap the source width at the largest size any
layout can request: 1920px for the decorative landing backgrounds (they
render behind content under object-cover), 2640px for the landing hero
(2x its 1320px slot), 1600px for screenshots (2x the ~720px docs and
use-case column). EXIF goes with it — the four feature backgrounds were
144dpi print exports.

Encoder choice is per image rather than uniform. Most compress best as
lossy; two of the docs screenshots are flat palettized PNGs that encode
smaller with `-lossless -z 9`, so they use that instead. Screenshots are
q80 to keep UI text crisp, decorative art q72.

  apps/web/public    10.70MB -> 1.57MB
  apps/docs/public    7.47MB -> 2.44MB

Verified: 1:1 crops against losslessly-downscaled references show no
visible difference; all 266 image references in the tree resolve; docs
prerenders all 177 pages and the landing/use-case pages render with no
broken images.

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

* chore(assets): drop the duplicated README hero image

docs/assets/hero-board.png was byte-for-byte identical to the docs site's
workspace-overview screenshot — 633KB stored twice. Only the two READMEs
referenced the copy, so point them at the canonical file instead.

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

* chore(assets): losslessly recompress app icons

oxipng -o max --strip safe over the app icon PNGs: -640KB with pixels
untouched (verified by comparing lossless-WebP encodes of the before and
after of every file — all 16 identical).

Deliberately not switched to WebP and not regenerated: the prerendered
sizes under apps/desktop/build/icons/ exist because electron-builder's
auto-derivation silently shipped only the 1024x1024 source in the v0.2.31
.deb (#2515), and the PWA manifest and .icns/.ico containers want PNG.
Filenames, dimensions, and the electron-builder config are unchanged, so
packaging behaves exactly as before.

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

* ci: gate pull requests on a 300KB image budget

Keeps the previous three commits from being undone one screenshot at a
time. A bitmap added — or grown — past 300KB fails the check until the PR
description carries an `Oversized image exemption:` line saying why.
Shrinking an oversized file always passes: the check compares against the
size at the base ref, not just the absolute size.

Path-filtered to PRs that touch a bitmap, so the full-history checkout it
needs to diff against the base is only paid for when it is relevant.

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 21:27:07 +08:00
Bohan Jiang 4040345e09 perf(ci): path-filter the backend jobs to skip irrelevant PRs (MUL-6389) (#7198)
Adds a `backend` path filter to the `changes` job and gates backend-tests, go-vulnerability-scan and windows-execenv on it, so a PR that never touches server/ no longer pays for a `go test -race` run, govulncheck and a Windows runner.

The filter is deliberately wider than `server/**`: four Go tests read three files outside the module (packages/core/runtimes/cli-version.ts, scripts/agent-cli-command-names.txt, scripts/go-test-with-agent-cli-guard.sh), and each must be listed or the PR that breaks such a contract is exactly the PR that skips the test.

Steps are gated rather than jobs, matching the existing frontend/sqlc-check pattern: the aggregate check reports a genuine green instead of staying pending, and still fails when a job goes missing for a reason other than the path filter.
2026-08-19 14:39:33 +08:00
41ddf6c0a1 refactor(frontend): drop dead UI components and phantom deps, add knip (MUL-6353) (#7147)
* refactor(frontend): drop dead UI components and phantom deps, add knip

`.npmrc` sets shamefully-hoist=true, which hides both halves of dependency
drift: an undeclared import still resolves, and an obsolete declaration still
installs. Neither build, typecheck, nor lint reads the import graph, so
package.json stopped being the source of truth for what is actually used.

Removed 16 zero-reference components from packages/ui (1,907 lines, all
`pnpm ui:add` output that never got wired up) plus mention-hover-card.tsx,
which was written but never imported — member and agent mentions render as a
plain `.mention` span. The comment in issue-hover-card.tsx claiming otherwise
was stale; corrected to match rich-content.tsx.

Deleting pagination.tsx orphaned its two i18n keys, dropped from all four
locales and from the `ui` namespace type.

Dependency declarations removed where nothing imports them:
- vaul, embla-carousel-react — only the deleted drawer/carousel used them
- date-fns, @tanstack/react-query-devtools — zero imports repo-wide
- motion, tw-animate-css from packages/ui — real users are views/desktop/web
- 44 of apps/web's 58 declarations, including all 15 @tiptap/* entries.
  One had already drifted: apps/web pinned tailwind-merge ^3.5.0 against the
  catalog's ^3.4.0, a second source of truth for the same package.

Added @types/hast to packages/views, which imports it directly.

knip enforces this going forward, scoped to files/dependencies/unlisted —
`exports` is unusable here because ui and views are consumed through per-file
exports maps. The CI step runs in warn mode (continue-on-error) and reports 9
pre-existing findings left out of this cleanup; clear those before promoting
it to blocking.

Verified: build, typecheck, lint green across web/desktop/ui/core/views.
6,503 tests pass; the 9 failures are pre-existing on a clean tree (a local
jsdom localStorage issue). Web dev server boots and serves /login 200.

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

* fix(ci): cover the packages/ui blind spot knip cannot see

Review caught that knip provided no regression protection for the exact
thing the preceding cleanup was about. knip derives entry points from a
workspace's package.json `exports` — graph/build.js calls
`getEntrySpecifiersFromManifest` unconditionally, with no config flag to opt
out — and packages/ui exports four wildcards. Each expands to a glob over its
whole directory, so every file under components/ui, components/common,
markdown, and hooks registers as an entry point and can never be reported
unused. A zero-reference probe component confirmed it: knip's output was
byte-identical with and without it.

`entry: []`, negated `entry` patterns, `--production`, and `--strict` were all
tried. Manifest entries come from a separate code path that none of them
subtract from. packages/views is unaffected because 44 of its 45 exports name
a specific file, which is why knip does flag dead files there.

Added scripts/check-ui-wildcard-exports.mjs to cover those four directories,
blocking from the start since it reports nothing once the one file it found is
gone. It reads the wildcards from the manifest, so narrowing an export hands
that directory back to knip without the two overlapping. Its test asserts all
three states: clean tree passes, a stranded component fails and is named, and
importing that component clears it again.

The check found packages/ui/hooks/use-auto-scroll.ts — 80 lines whose only
occurrence repo-wide was its own definition. Same category as the 16
components already removed, and invisible to knip for the same reason.

Also from review:
- The path filter missed knip.jsonc and apps/docs/**, so config-only and
  docs-only PRs skipped knip even though it analyzes docs.
- The knip step paired `continue-on-error` with `|| true`, which zeroed the
  exit code before Actions saw it — no warning surfaced, only log text.
  Dropping `|| true` makes the warn phase visible and the later promotion to
  blocking a one-line change.
- Corrected the knip.jsonc comment that claimed apps were the only entry
  points for all three shared packages. True for views and core, never true
  for packages/ui.

Verified: build, typecheck, lint green (12/12, 0 errors). 6,503 tests pass;
the same 9 pre-existing failures as before, reproduced on a clean tree.

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

* fix(ci): resolve import paths in the ui export check instead of matching names

Review found the check passed a dead component whenever any file elsewhere in
the repo imported a same-named module. The relative-import branch was a
whole-repo text regex on the basename, so `../components/option-card` in
packages/views vouched for an untouched packages/ui/components/ui/option-card.

That is not a corner case: 17 of the 59 guarded files already share a basename
with another file in the repo — `button`, `card`, `input`, `label`, `avatar`,
`tabs`, `switch`, `skeleton`, `separator`, `theme-provider` among them. shadcn
names are generic by construction, so the hole covered the most likely names a
future `pnpm ui:add` would produce.

Specifiers are now resolved against each importing file's own directory and
compared against real paths, with `@multica/ui/...` mapped through the same
manifest wildcards the candidate list is built from.

Liveness is now reachability from outside the guarded directories rather than
a reference count, so two dead components importing only each other no longer
vouch for one another.

Also implements the exemption the failure message promises: a file named by a
non-wildcard export is an entry point in its own right and is skipped. The
candidate loop previously read the wildcard directories unconditionally, so
following that advice would not have cleared the check.

Tests cover all five states: clean tree, stranded component named, cleared by a
real import, the option-card basename collision, and a mutually-importing dead
pair reported as two files.

Verified: check and its tests pass, knip unchanged at 8 files + 1 dependency,
build/typecheck/lint 12/12 green.

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

* fix(ci): parse imports with the TypeScript parser, not a source regex

Review found the scanner counted commented-out imports as real edges. It ran
a regex over raw source, so a component kept its liveness from a line that
does not execute — and "delete the last real import, leave the comment
behind" is a normal step in a refactor, which makes the false edge appear at
exactly the moment the component stops being used.

Edges now come from real syntax nodes: static import/export declarations,
`import()`, `require()`, and `import("x")` type nodes. `vi.mock("...")` is
excluded on purpose — it names a module without importing it, so a component
whose only remaining mention is a test mock is dead, and counting it would let
one outlive its last real use.

Scope, measured rather than assumed: 52 specifiers in this repo appear only in
comments or prose, spread over 43 files, so the mechanism is live. None of
them currently resolve into packages/ui, so no dead component is being missed
today — this closes a latent hole, not an active miss. (An earlier count of 3
was wrong: those were `lazy(() => import(...))` calls that my throwaway
measurement script failed to treat as real.)

Test gains a sixth state: a component referenced only by a commented-out
import and a string containing one must still be reported.

Verified: six-state test passes, clean tree passes in 0.9s over 2,089 files,
knip unchanged at 8 files + 1 dependency, build/typecheck/lint 12/12 green.
`typescript` is already a root devDependency, so no new declaration.

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-18 22:29:33 +08:00
a1cbd7c2ef MUL-6354: tighten build and generation guardrails (#7150)
* chore(build): tighten repository hygiene guardrails

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

* chore(ci): remove snapshot ignore check

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

* chore(ci): address repository hygiene review nits

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-18 18:32:19 +08:00
b946f3a729 MUL-6290: split views tests onto a dedicated runner (#7072)
* perf(ci): isolate views tests on a dedicated runner

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

* perf(ci): shard views tests across runners

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-17 19:04:25 +08:00
d0bd6683f1 MUL-5733: keep Go security patches current (#6409)
* fix(release): keep Go security patches current

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

* fix(release): address vulnerability review

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

* fix(release): restrict vulnerability bypass

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

* fix(release): require Go 1.26.6

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

* docs: align Go prerequisite with security floor

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

---------

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Sol-Boy <[email protected]>
2026-08-17 16:18:53 +08:00
YYClaw e9528c722a MUL-5855: feat(dev): add worktree database cleanup (#6560)
* feat(dev): add worktree database cleanup

* fix(dev): handle database cleanup cancellation

* fix(dev): protect nested current worktree
2026-08-14 15:40:34 +08:00
e7e01638ad MUL-6118: fix(agent): own Codex process trees with a Job Object on Windows (#6897)
* fix(agent): own Codex process trees with a Job Object on Windows

Windows has no process groups, so configureProcessGroup was a no-op,
waitProcessGroupGone always reported false, and signalProcessGroup killed
only the direct child. That child is usually a cmd.exe wrapping a .cmd
shim, so the real tree — the Node wrapper, the native app-server, and the
sandbox helpers under them — was left running as orphans.

Assign the started process to a Job Object with KILL_ON_JOB_CLOSE, which
every process it later creates inherits. Termination now targets the job,
emptiness is read from job accounting, and ownership is dropped only once
the tree has been reaped. The process handle is held for the lifetime of
the entry so Windows cannot recycle the pid out from under the key.

Assignment failure is not fatal: cleanup degrades to the previous
kill-the-child behaviour, and the cleanup_confirmed gate keeps suppressing
initialize retries on any host where ownership could not be taken.

Refs #6883

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

* fix(agent): close the pre-attach window and stop keying trees by pid

Two defects in the Job Object ownership added by the previous commit.

Windows grants job membership only to processes created after the
assignment, never retroactively. Assigning after cmd.Start left a window
in which the direct child — usually a cmd.exe wrapping a .cmd shim — had
already spawned the real agent outside the job. That is worse than owning
nothing: the job then holds a wrapper that exits immediately, so
accounting reports the tree empty while the escaped app-server is still
running and cleanup is reported as confirmed. startOwnedProcessTree now
creates the child suspended, assigns it, and only then resumes it, so it
cannot create anything before it belongs to the job. A child is never
left suspended: ownership that cannot be taken is logged and the child
runs unowned, and a child that cannot be resumed is killed and reported.

Ownership was also keyed by pid. A pid is unique only while its process
is alive, so a launch that outlived its process could look up — and
terminate — an unrelated task that inherited the number. Key by the
*exec.Cmd of one launch instead; signalProcessGroup and
waitProcessGroupGone take the command on both platforms.

The tests assert job membership through the job's own accounting rather
than through timing, so an escaped descendant fails deterministically.

Refs #6883

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-13 16:27:24 +08:00
b5fdbd56f9 MUL-6118: fix(daemon): keep npm .cmd agent entry points launchable on Windows (#6895)
* fix(daemon): keep npm .cmd agent entry points launchable on Windows

GetFinalPathNameByHandleW returns the extended-length form, so every
Windows launch target became \\?\C:\... . That prefix is required past
MAX_PATH, but cmd.exe rejects it — and an agent installed through npm is
entered via a .cmd shim that Windows can only run by handing the path to
the command interpreter.

Drop the prefix whenever the plain Win32 path can stand on its own, and
keep it when it is what makes the path nameable or when the remainder is
not drive-qualified. Existing Windows coverage stages only real .exe
files, which hides the difference entirely, so add the .cmd shim shape at
both canonicalExecutablePath and the launch boundary.

Refs #6883

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

* fix(daemon): measure Windows path length in utf-16 code units

MAX_PATH counts UTF-16 code units and includes the terminating NUL, but
the check used len(string), which counts UTF-8 bytes. Every non-ASCII
character costs more bytes than code units, so a short path under a CJK
user directory was over-counted, judged too long, and kept an extended-
length prefix it did not need — reintroducing the .cmd launch failure for
exactly the users least likely to be tested against.

Encode to UTF-16 and compare against MAX_PATH directly, and cover a
100-character CJK path: 323 UTF-8 bytes but only 124 code units with its
NUL, so the old rule kept the prefix and the new one does not.

Refs #6883

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-13 16:27:14 +08:00
Jamison-J d582dca062 MUL-6025: fix(daemon): resolve Windows installer junctions (#6802)
Windows standalone Codex exposes its installer bin directory as an IO_REPARSE_TAG_MOUNT_POINT junction, which filepath.EvalSymlinks cannot traverse, so the daemon launched the shim path and Codex could not find its sandbox helpers.

Discovery now keeps the stable entry point and the final target is resolved per launch via GetFinalPathNameByHandleW, so installer retargets take effect while old releases remain installed. Resolution failures fail closed on both the initial and rediscovery paths.

Refs #6762
2026-08-13 12:57:52 +08:00
a1da8e536f MUL-6000 fix(daemon): link user Codex skills into codex-home instead of copying them (#6742)
* fix(daemon): link user Codex skills into codex-home instead of copying them

Every Codex task got a full recursive copy of ~/.codex/skills/ inside its
per-task CODEX_HOME. For a user with npm-backed skills that is ~100 MB per
task directory, re-copied on every task start — hydrateCodexSkills wipes and
re-seeds on both the Prepare and the Reuse path — and never reclaimed: the GC
artifact sweep only matches node_modules / .next / .turbo, and whole-directory
reclaim needs the issue to reach a terminal status first. Total cost scales
with the number of live task directories.

Link instead. The same per-task home already links its other two shared
resources, sessions and the plugin cache, and a skill directory is read-only
input to the CLI, so the write isolation a copy incidentally bought has no
requester. EvalSymlinks still runs first, so an installer-shipped symlink
(lark-cli into ~/.agents/skills/) resolves to the real directory rather than
producing a link to a link.

The three paths that could reach through the new link are unchanged in
behavior: os.RemoveAll deletes a link and not its target, writeSkillFiles
probes with os.Lstat so a workspace skill colliding with a linked user skill
lands in a `-multica` sibling, and the GC refuses to walk into links. Tests
pin all three, plus a windows-tagged test for the junction form of the link.

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

* fix(daemon): stop GC walks from descending into Windows directory junctions

The GC's artifact sweep and its size accounting both refuse to follow links
out of a task directory, but they tested for os.ModeSymlink alone. That misses
the link shape Windows actually produces: createDirLink falls back to a
directory junction (mklink /J) whenever os.Symlink is denied, which is the
default without Developer Mode, and since Go 1.23 os.Lstat reports a junction
as ModeDir|ModeIrregular with no ModeSymlink bit while its DirEntry still
answers IsDir() == true. filepath.WalkDir therefore descends straight into the
link target, and the sweep would delete a node_modules / .next / .turbo
directory that lives inside the user's real home.

Match both mode bits in all three walks. The task directory links user content
in three places today — the Codex session store, the plugin cache, and now the
user's skills — so this is reachable on any Windows host running Codex tasks.

Windows-tagged tests create the junction with mklink /J directly, so the
junction shape is covered even on a runner where symlinks are permitted, and
they run in the existing windows-execenv CI job.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-11 13:51:50 +08:00
734ad51c93 MUL-5774 fix(daemon): isolate Git metadata for Windows Codex checkouts (#6565)
* fix(daemon): isolate Git metadata for Windows Codex checkouts

Codex's sandbox resolves a linked worktree's gitdir to the shared .repos
cache and keeps it read-only even when the task workdir is an explicit
writable root, so `git add` / `git commit` fail from inside a
`multica repo checkout`. Linux was fixed by moving those tasks to a
task-local clone (#2925); Windows native sandbox still took the linked
worktree path and hits the same wall (#6449).

- repoCheckoutModeFor now returns isolated for Windows Codex as well as
  Linux Codex. The MULTICA_REPO_CHECKOUT_MODE -> CLI -> daemon ->
  WorktreeParams.IsolatedGitMetadata chain already carries the mode, so
  no new API surface is needed.
- The isolated clone passes --no-hardlinks on Windows. NTFS hard links
  only exist within one volume and every link shares one underlying file
  and security descriptor, so linking would both break a cross-drive
  cache/workdir pair and let the sandbox re-permission the daemon-owned
  cache's objects. Objects are copied there instead.

The shared cache stays read-only; this does not widen ACLs, add a
writable root, or lean on safe.directory.

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

* fix(daemon): correct Codex sandbox facts and prove Windows checkout in CI

Review follow-up on the Windows isolated-checkout fix.

The source map claimed both Linux and Windows sandboxes keep a linked
worktree's external gitdir read-only. That is not what the daemon does:
execenv's codexSandboxPolicyFor defaults BOTH platforms to
danger-full-access, so only a user who opted into windows.sandbox
actually trips the read-only gitdir. Linux keeping the isolated checkout
is a retained layout, not a live workaround. Agents read this file to
decide whether their checkout can be committed to, so it must not
contradict the sandbox policy. Corrected the source map, the stale
"(Linux Codex)" comment in partial_clone_test.go, and the
repoCheckoutModeFor doc — which now also records why the layout is a
per-platform choice rather than a per-policy one.

builtin_skills_test.go gains "Linux and Windows Codex" and "task-local
Git metadata" anchors; the previous assertions covered only generic repo
wording and would not have caught this platform contract regressing.
Verified the anchor fails when the SKILL.md sentence is reverted.

Windows evidence: cache_windows_test.go asserts on a real Windows host
that the gitdir resolves inside the task checkout, that branch/add/commit
succeed, that the shared cache base ref is unchanged, and that objects
are private copies rather than NTFS hard links — plus a cross-volume
checkout, which is what a hard link cannot express at all. Wired into the
existing windows-execenv CI job, which exists for exactly this class of
"only a real Windows host proves it" regression. The shared no-hardlink
assertion is now one helper used by both the cross-platform and Windows
tests.

This does not replace a manual smoke under a real Codex elevated sandbox
identity; CI cannot assume that identity.

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

* ci: scope the Windows repo-checkout step to the windows-tagged tests

The step used a prefix match, which also selected the package's
cross-platform isolated-checkout test. That test cannot run on Windows:
repocache derives a cache directory name from the full source repo path,
so a t.TempDir() path already embedding a long test name doubles and
exceeds MAX_PATH, and git fails with "Filename too long". It belongs to
the ubuntu backend job, which runs the whole package.

Both windows-tagged tests already passed in the failing run, including
the cross-volume one, which used the runner's real C:/D: pair rather
than skipping. Anchoring the selector to their exact names turns the job
green without weakening any assertion.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-07 16:53:40 +08:00
3c7221492b fix(agent): deliver the OpenCode prompt on stdin, not argv (MUL-5841) (#6552)
* fix(agent): deliver the OpenCode prompt on stdin, not argv (#6538)

The daemon appended the whole task prompt as an argv element, so on Windows
every OpenCode task whose command line cleared CreateProcess's 32,767
character lpCommandLine limit failed to start at all. Go surfaces the
resulting ERROR_FILENAME_EXCED_RANGE (206) as "The filename or extension is
too long", which reads as an exe-path problem and is not one. A workspace
with a realistic set of models and skills clears that ceiling on the prompt
alone (39,629 bytes in the report), leaving no usable in-app workaround.

`opencode run` merges its variadic [message..] positional with whatever is
piped in, so passing no positional makes the piped text the entire run
message. That path is present in the reported v1.18.14 and back to v1.4.10.

Write the prompt from its own goroutine, as the Pi and Cursor backends
already do: a prompt larger than the pipe buffer blocks mid-write until
OpenCode drains it, and OpenCode cannot drain while nobody consumes its
stdout. OpenCode reads stdin to EOF, so stdin is closed exactly once as the
end-of-prompt signal — including on the cancellation path, where closing it
releases a writer still blocked on a full pipe. The existing process-group
SIGTERM -> SIGKILL ordering from #4533 is unchanged.

Keeping the prompt off argv also stops it being echoed into the "agent
command" log line; prompt_bytes replaces it there.

Scoped to OpenCode. DevEco, OpenClaw (#6032), Qwen, Copilot and Antigravity
inline their prompts the same way, but each CLI's input contract needs
verifying on its own before it moves.

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

* fix(agent): do not fail a completed run on a benign prompt-write EPIPE

The backend CI run for this PR failed in TestPiExecuteRetainsOnlyLastTurnOutput
with "pi prompt write failed: write |1: broken pipe". The cause is a latent
race shared by pi and the OpenCode code added in the previous commit, which
copied pi's error handling.

A child that exits without draining stdin closes the read end while the parent
is still writing the prompt, so the parent's write returns EPIPE. Both backends
converted that into a failed result even when the agent had already emitted a
complete, successful stream — making the outcome depend on whether the write
landed before or after the child exited, i.e. on machine load.

OpenCode reads stdin to EOF before it does any work, so a run that produced a
complete stream necessarily received the whole prompt; an EPIPE recorded after
that only means the pipe closed on the way out. Report the write error only for
a run that did NOT complete, where it can actually explain the failure, and
append rather than overwrite so the stream's own diagnosis survives. cursor.go
already guards this correctly by ignoring writeErr once a result was seen.

Test fakes now drain stdin, matching what the real CLIs do. A fake that exits
without reading is what manufactures the spurious EPIPE, and that is what broke
CI — pi's piEventStreamScript is the fixture that failed.

Adds a deterministic regression test: a child that emits a full successful
stream and never reads a 1.2 MB prompt. Against the previous commit it
reproduces the CI signature exactly (status "failed", "broken pipe"); with this
change it passes.

pi.go's production handling has the same defect and still needs the equivalent
fix, but that belongs to whoever owns #6485 — this change keeps to OpenCode
plus the test fixture that was breaking the build.

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

* fix(agent): gate prompt-write suppression on a positive terminal signal

Review caught a false-success path opened by the previous commit. It
suppressed a failed prompt write whenever status was "completed", on the
reasoning that a completed run must have received the whole prompt. That
reasoning does not hold: processEvents starts finalStatus at "completed" and
only fails closed on structural evidence — an open step, or a step awaiting a
continuation. A child that emits NOTHING and exits 0 sets neither, so the
default survives.

The full path: the parent writes a large prompt, the child exits 0 without
reading a byte, the write returns EPIPE, stdout hits EOF with no events, and
the run is reported as completed with empty output and no error. The prompt
never reached the agent and nothing said so. Reproduced deterministically
before the fix: status="completed" output="" error="".

Absence of a failure signal is not proof of success, so record the positive
one instead. eventResult.sawTerminalSignal is set only when a step_finish
closed the last step with no continuation pending, and the write error is
suppressed only on that evidence. It is deliberately not the negation of
noTerminalSignal: an empty stream sets neither, because there is nothing to
fail closed on and nothing proving completion either. When the signal is
absent the write error now also flips the default "completed" to "failed".

Adds the empty-stream regression alongside the existing complete-stream one,
so both directions of the branch are pinned.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-07 12:31:55 +08:00
25d26c2bf7 fix(agent): send Pi prompts over stdin (MUL-5779) (#6485)
Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-06 16:26:23 +08:00
cac453fe01 MUL-5505: fix(selfhost): one source of truth for the backend host port (#6168)
* fix(selfhost): read the published host port back from Compose (MUL-5505)

`make selfhost` polled the wrong port when the backend host port was passed
through the environment. Make and Compose disagree on variable precedence: an
assignment in an included makefile (the Makefile `include`s .env) overrides a
real environment variable, while for Compose the environment overrides .env.
Because .env.example ships BACKEND_PORT uncommented, .env always pinned the
recipe's view of the port, so `BACKEND_PORT=9000 make selfhost` published the
stack on 9000 while the health check hammered 8080 for 60s and then reported
"Services are still starting" on a perfectly healthy install. The success
message printed the same wrong backend URL. FRONTEND_PORT had the same
inversion, affecting the printed frontend URL.

Stop re-deriving the host port in the recipe and ask Compose, which is the only
authority on what it published. The wait-and-report block (three port
references per target, duplicated across selfhost and selfhost-build) moves into
scripts/selfhost-wait.sh, which resolves both host ports via `docker compose
port` and falls back to the compose default chain when the stack is not up.

Also fix the input contract: `PORT` was the only port variable documented in
SELF_HOSTING_ADVANCED.md, yet compose read only BACKEND_PORT, so setting the
documented variable was silently ignored. The backend host port mapping now
falls back to `${PORT:-8080}`, matching Makefile and scripts/local-env.sh, and
the docs describe both variables as host ports.

Container-internal ports (8080/3000), the global PORT derivation used by local
dev, and the 127.0.0.1 bindings are unchanged; the default self-host experience
is byte-identical.

Fixes #6145

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

* fix(selfhost): make PORT the real backend port and test the whole chain

Addresses review feedback on #6168.

The first round's root-cause claim was wrong. It said `BACKEND_PORT=9100 make
selfhost` published on 9100 while the health check probed 8080. It does not:
when make drives Compose, Compose inherits make's exported values, so both
sides agree on 8080 and the override is silently dropped instead. Re-measured
through the real recipe, the genuine disagreements were:

  - `.env` setting PORT with no BACKEND_PORT: probe followed PORT, Compose
    published ${BACKEND_PORT:-8080} — 9100 vs 8080.
  - `make selfhost PORT=8080` over a .env with BACKEND_PORT=9100: probe 8080,
    Compose published 9100.

Both are the "wrong port variable" defect the GitHub issue names, and reading
the port back from Compose fixes both. The comments and PR text that blamed
make/Compose precedence are corrected.

The PORT fallback added to docker-compose.selfhost.yml was also unreachable:
.env.example shipped BACKEND_PORT=8080 uncommented, so the fallback never
engaged and `SELF_HOSTING_AI.md`'s "edit PORT and FRONTEND_PORT" instruction did
nothing. Pick one contract and apply it everywhere: PORT is the backend port to
edit, BACKEND_PORT/API_PORT/SERVER_PORT are optional aliases that override it.
That is already what Makefile, scripts/local-env.sh, scripts/install.sh and
install.ps1 do; compose and the web dev fallback were the outliers.

  - .env.example ships BACKEND_PORT commented out, like its sibling aliases, so
    editing PORT works out of the box.
  - apps/web/config/runtime-urls.ts walks the same alias chain instead of
    reading BACKEND_PORT alone, so `pnpm dev` follows an edited PORT.
  - SELF_HOSTING_AI.md and SELF_HOSTING_ADVANCED.md state the contract.

Configuration that cannot take effect is now reported instead of ignored.
scripts/selfhost-preflight.sh warns when an alias in the env file overrides an
edited PORT, and when a port from the shell environment is overridden by the env
file. The Makefile captures the pristine origins before its include, because
afterwards there is no way to tell that `PORT=9000 make selfhost` was asked for.

Tests now cross environment -> make -> compose -> report for real. The docker
stub is no longer told what to publish: on `up` it asks the real
`docker compose config` to interpolate the compose file with the environment the
recipe actually handed it, records that, and answers `port` from the recording.
Verified failing on the old code — with the old recipe and compose file the
suite reports published 8080 against probe 9100, the exact defect.

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

* ci: gate the self-host test on scripts/selfhost-preflight.sh too

The new preflight script was missing from the frontend path filter, so a
change to it alone would skip the test that covers it.

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

* fix(selfhost): honour the full backend port alias chain in Compose

Addresses the second review round on #6168.

The contract this PR documents is

  BACKEND_PORT -> API_PORT -> SERVER_PORT -> PORT -> 8080

and Makefile, scripts/local-env.sh, scripts/install.sh, scripts/install.ps1 and
apps/web/config/runtime-urls.ts all implement it. docker-compose.selfhost.yml
implemented only BACKEND_PORT -> PORT -> 8080, so `API_PORT=9100` or
`SERVER_PORT=9200` in .env still published 8080.

That is not only a direct-Compose concern. scripts/install.sh:407 derives its
health-check port from the full chain and then runs this compose file, so an
alias it honours but Compose ignores puts the probe on 9100 while the stack
listens on 8080 — the original #6145 defect, reached through the installer.

  - docker-compose.selfhost.yml publishes the full nested chain, verified to
    keep the same precedence and the same 8080 default.
  - scripts/selfhost-wait.sh's fallback matches, so the degraded path agrees
    with what Compose would have published.
  - The Makefile captures the pristine origins of API_PORT and SERVER_PORT too,
    and the preflight reports them, so no alias can be silently overridden by
    the env file while the others are reported.

Tests pin the chain on the boundaries a recipe-level test cannot see, because
Makefile normalises all four aliases into PORT before any recipe runs: a matrix
over the direct Compose path asserts each alias moves the published port with
the right precedence, and every case additionally asserts that
scripts/install.sh's resolver returns the same value Compose published. That
invariant is the one that actually protects the installer.

Verified failing without the fix: with the two-level chain restored the suite
reports `expected 9200 / compose published 8080 / installer resolved 9200`, and
with the alias warnings narrowed it reports the missing API_PORT notice.

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

* fix(selfhost): resolve one winner before reporting ignored port config

Addresses the third review round on #6168.

The preflight walked each alias independently, so it made a separate "wins"
claim per alias. With

  PORT=9000
  BACKEND_PORT=8000
  API_PORT=7000
  SERVER_PORT=6000

in .env it announced that BACKEND_PORT, API_PORT and SERVER_PORT each won, when
only BACKEND_PORT=8000 can. Two of the three notices were simply false.

It also compared only same-named shell and file variables, so shadowing across
aliases stayed silent: .env with PORT=8000 and BACKEND_PORT=8000 plus a shell
API_PORT=7000 produced no output at all, even though API_PORT was discarded.

Resolve the winner along the documented chain first — within a variable the env
file beats the environment, then BACKEND_PORT beats API_PORT beats SERVER_PORT
beats PORT — and report only the inputs that winner actually shadows. Output now
names one winning input and lists the rest, whichever variable or source they
came from. An input carrying the winning value is not reported: it is redundant
but the user still gets the port they asked for, so there is nothing to fix.

Tests cover both cases the old logic got wrong: every alias set at once must
yield exactly one winner claim and list the others as unused, and an env-file
alias beating a lower-priority shell alias must be reported. Also asserted that
a redundant same-value alias and a default configuration both stay silent.

Measured against the previous implementation, phrasing aside: with all four
values set it made 3 winner claims where there is now 1, and on the cross-alias
case it printed nothing where API_PORT is now reported.

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

* fix(selfhost): track env-file existence so empty port assignments resolve right

Addresses the fourth review round on #6168.

The Makefile passed only values to the preflight, so the script inferred "the
env file does not set this" from an empty string. An explicit empty assignment is
a different input from an absent one: make lets `BACKEND_PORT=` in the env file
override `BACKEND_PORT=9000` from the environment, and the variable then drops out
of the alias chain instead of setting a port.

With .env holding PORT=8080 and BACKEND_PORT=, invoked as
`BACKEND_PORT=9000 make selfhost`, the stack published 8080 and the health check
probed 8080 — correct — while the preflight announced

  the backend host port resolves to 9000 from BACKEND_PORT (environment)
  Set but unused: PORT=8080 (.env)

naming the ignored value as the winner and the winning value as unused. A report
that contradicts the startup is worse than no report. FRONTEND_PORT= had the
matching hole: it shadowed the environment, Compose fell back to 3000, and the
preflight said nothing.

The Makefile now captures ENV_FILE_<VAR>_IS_SET from $(origin) alongside the
value, for all five port variables via one $(foreach) instead of hand-written
lines. The preflight treats a variable the file defines as taking the file's
value even when empty, so it shadows the environment, and an empty value never
wins — resolution continues down the chain and falls back to 8080. Inputs the
winner shadows are still listed, including ones shadowed by an empty assignment.
The frontend check mirrors this and now names the port that actually resolved.

Recipe-level tests cover an empty alias over a shell value, every link of the
chain emptied at once, and the frontend equivalent — each asserting the reported
winner, the Compose published port and the probed port all agree. Against the
previous implementation the first case fails with `reported: 9000 / actual: 8080`.

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

* fix(selfhost): installers read the published port; drop the second parser

Addresses all four blockers from the consolidated review on #6168.

1. Both installers probed and printed the wrong port. scripts/install.sh:402 and
   scripts/install.ps1 start Compose inheriting the current environment, and
   Compose lets that environment outrank .env — but the installers then derived
   the probe port and the summary URL from .env alone. Measured on this branch
   before the fix, every port variable diverged:

     ambient PORT=9100        -> compose 9100, installer 8080
     ambient BACKEND_PORT=9200 -> compose 9200, installer 8080
     ambient API_PORT=9300     -> compose 9300, installer 8080
     ambient SERVER_PORT=9400  -> compose 9400, installer 8080
     ambient FRONTEND_PORT=3100 -> compose 3100, installer 3000

   Both now read the ports back once with `docker compose port` after `up -d`
   and reuse that single result for the health check and the summary. If the
   query fails they fail loudly instead of claiming success. The .env-derived
   helpers are deleted rather than left beside the new path.

2. The Make preflight is removed entirely, as the review recommended. It
   modelled `origin=environment` and `origin=file` but not `command line`, so
   `make selfhost BACKEND_PORT=9000` over a .env with API_PORT=7000 announced
   7000 while Compose published 9000. That was its fourth wrong report in four
   rounds, because it was a second parser of precedence rules — the very thing
   this PR removes elsewhere. `docker compose port` is the runtime truth, so the
   script, the Makefile captures and the macro are gone.

3. Tests now cover the installer paths that were unguarded. scripts/install.test.sh
   gains a `--with-server` matrix (defaults, .env PORT, all four backend aliases,
   FRONTEND_PORT, ambient overriding .env for all five, and explicit-empty
   fallback) plus a case proving an unresolvable port fails loudly. A new
   scripts/install.ps1.test.ps1 drives the same matrix through Start-LocalInstall.
   Every case asserts Compose's published port == the probed URL == the printed
   URL. Ambient variables are explicitly cleared per case so a runner's own PORT
   cannot leak. The Makefile matrix gains the command-line origin cases. CI runs
   the PowerShell suite on windows-latest in the always-on installer job, and the
   frontend filter now includes both installers.

4. Docs state the real contract: the alias order is shared, but which *source*
   wins is per entry point — Compose prefers the environment, make prefers the
   included env file, and a make command-line assignment outranks both. The
   removed preflight's promise is gone from SELF_HOSTING_AI.md, and the compose
   header no longer claims the installer derives its own health-check port.

Verified failing without the fixes: the Bash matrix reports
`[ambient PORT beats .env] compose published 9500 / probed 9100 / printed 9100`
against the old installer, and the PowerShell matrix reports
`printed backend=8080` against an injected summary divergence.

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

* fix(selfhost): keep web dev proxy off frontend port

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

---------

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-31 14:46:13 +08:00
a0ef1a43f3 fix(daemon): diagnose silent OpenClaw npm shim failures on Windows (MUL-5422) (#6084)
* fix(daemon): diagnose silent OpenClaw npm shim failures on Windows (MUL-5422)

#6061 reported every OpenClaw task failing in execenv prep on Windows with
a bare `exit status 1` and no stderr, leaving the user nothing to act on.

An npm-installed `openclaw.cmd` is a batch shim that re-execs OpenClaw's
`openclaw.mjs` entrypoint through `node`, resolved from PATH. The daemon pins
`openclaw` to an absolute path, so the shim always looks correct — but that
interpreter lookup is a second, invisible resolution step that can fail on its
own. The reporter had to run their own subprocess experiments to find it.

Enrich the error instead of guessing at a fix: when a `.cmd`/`.bat` shim exits
non-zero with no stderr, report whether the interpreter resolves. Both
directions are useful — missing names the likely cause with a next step,
present clears PATH of blame and points at the remaining hypotheses (PATH
drift between the runtime `--version` gate and task prep, or a broken install).

Deliberately NOT included: rebuilding or freezing a Windows PATH. The
version-probe gate (probeBuiltinRuntime skips a provider whose `--version`
fails) and the prep helper both inherit the same daemon environment, so a
daemon that could not resolve `node` would never have registered OpenClaw at
all. That contradiction is unresolved, and a boot-time PATH snapshot would also
fight the MUL-4486 self-heal design, which re-resolves per attempt on purpose.
This change collects the evidence needed to settle it.

- Error text only; no control flow change, and real stderr still wins.
- PATH summarised as an entry count, never dumped, so daemon logs and pasted
  bug reports carry no environment detail.
- Tests: shim detection (case, spaces, Unicode), both diagnostic directions,
  out-of-scope no-ops (timeout, missing binary, native exe), and end-to-end
  through execOpenclawCLI. A windows-tagged file reproduces a real npm shim
  with and without node on PATH, and pins TEMP/TMP as not load-bearing — the
  originally reported root cause, since retracted upstream.
- New scoped step in the existing ci.yml windows-execenv job.

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

* fix(daemon): address review on OpenClaw shim diagnostics (MUL-5422)

Four must-fix items from PR review, each verified against the real behaviour
rather than assumed.

1. Timeout was misdiagnosed as a missing interpreter. openclawCLITimeout kills
   the child via CommandContext, and a killed process surfaces as
   *exec.ExitError ("signal: killed") — the same type a genuine exit 1
   produces. The errors.As gate accepted it and appended "install Node.js",
   sending users to fix something that was never broken. execOpenclawCLI now
   attributes ctx.Err() before consulting the diagnostic. Confirmed locally:
   `signal: killed`, errors.As(*exec.ExitError)=true, ctx.Err() set. The old
   test passed context.DeadlineExceeded directly and so never saw the real
   shape; replaced with a genuine CommandContext timeout regression on both
   Linux and Windows.

2. The interpreter lookup did not match npm's. npm's cmd-shim template emits
   `IF EXIST "%dp0%\node.exe" (...) ELSE ( SET "_prog=node" )`, so a co-located
   node.exe wins over PATH entirely. Checking only LookPath reported "node is
   not resolvable" for installs that actually run fine — confidently wrong,
   which is worse than silence. Now resolves co-located `node.exe`/`node`
   first, then PATH, and reports which. Wording is also conditional now
   ("if <name> is an npm-generated shim"): a batch extension does not prove npm
   authorship, since MULTICA_OPENCLAW_PATH can point at any batch file.

3. The message leaked local paths off-box. On prep failure this text is not
   log-local — it travels reportTerminalTask → Client.FailTask and is persisted
   server-side as the task error, so an absolute Windows shim path uploads the
   account name and install layout. Now reports only the shim's base name,
   whether the interpreter resolved and from where, and a PATH entry count.
   Never an absolute path, never PATH contents.

4. Windows CI was green without exercising the new code. The job log showed
   `cmd.exe stderr DID reach Go's pipe` with `'node' is not recognized`, so the
   missing-node case takes the existing stderr branch and the diagnostic never
   ran — masked by an "either branch passes" assertion. That disjunction is
   gone: the missing-node test now asserts the observed stderr behaviour
   (disproving #6061's premise), and a new test drives a genuinely silent shim
   to prove the diagnostic branch itself works on Windows. Also added Windows
   coverage for the co-located interpreter and the timeout case.

The windows-tagged shim is now npm's real generated template rather than a
hand-simplified `node ...` one-liner, so the co-located branch is reproduced
faithfully instead of hidden.

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

* test(daemon): make the OpenClaw timeout regression PATH-independent

The new timeout test stripped PATH (so a stray interpreter lookup would report
"missing") while its hanging shim invoked `sleep` through a PATH lookup. macOS
`sh` quietly falls back to a default PATH so this passed locally; dash on Linux
does not, so CI failed with `exit status 127 (stderr: sleep: not found)` — the
shim died instantly instead of hanging, and the assertion never saw a timeout.

Resolve `sleep` before PATH is stripped and embed it by absolute path, so the
shim needs no PATH of its own. Verified the failure mode and the fix directly:
`env -i /bin/sh -c 'PATH=/nonexistent; sleep 0.05'` reproduces
"sleep: command not found", while the absolute path runs fine with the same
empty PATH.

Windows is skipped here and covered by TestWindowsOpenclawShimTimeoutIsNotMisdiagnosed,
which has a real cmd.exe host and a System32 PATH that can resolve its own helper.

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

* fix(daemon): bound execOpenclawCLI so its 5s timeout is actually enforceable

The new timeout regression exposed a real bug in the code it was testing, not
just a flaky test: openclawCLITimeout could not bound the call at all.

CommandContext kills only the direct child, and cmd.Output() blocks in Wait()
until the stdout pipe closes. Any grandchild that inherited stdout keeps the
call parked for its own lifetime. Verified on linux/dash: a shim whose child
slept 5s ran the FULL 5.01s against a 150ms deadline. With a WaitDelay backstop
the same case returns in ~2.17s.

This is not a hypothetical shape — it is precisely an npm shim on Windows
(cmd.exe → node), so a wedged node could stall task prep far past the 5s cap
that comment claims. detectCLIVersion already carries this exact backstop for
the `--version` probe for the same reason; execOpenclawCLI now matches it.

Also corrected the test comment: an earlier revision claimed a trailing
`exit 0` was needed to force the grandchild. Docker showed otherwise — dash
hangs either way and macOS reproduces neither, which is why CI caught this and
local runs did not. The comment now records the measured behaviour.

Verified in a linux/dash container (the CI platform, not just macOS): the full
execenv package passes with -race, and the timeout case takes 2.17s.

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

* fix(daemon): drop the WaitDelay change and wrap the context error (MUL-5422)

Round-2 review: take option 1 — keep this PR to diagnostics and split the
timeout/process-tree work out.

The reviewer is right that WaitDelay traded a hang for a process leak, and I
had the mechanism wrong. Measured on linux/dash by recording the grandchild PID
and reading /proc/<pid>/stat at the moment Output returns:

  no WaitDelay:   elapsed 6.01s (6s sleep, 150ms deadline), grandchild state Z
  with WaitDelay: elapsed 2.17s (60s sleep, 150ms deadline), grandchild state S

So without WaitDelay the call is hostage to the descendant's lifetime but no
live process is left behind — it returned precisely because the descendant had
exited. With WaitDelay the call is bounded but a live descendant survives. My
earlier claim that the orphan pre-existed was an artifact of a sleep duration
that happened to equal the return time.

Go's WaitDelay contract covers killing the direct child and closing our pipe
ends; it does not reap orphans. Closing this properly needs process-tree
ownership (Unix process group, Windows Job Object) so the deadline can terminate
the whole tree — and on Unix nothing else will, since
preparationProcessController.finish() is a no-op there (isolation_unix.go).
That is its own change with its own risk surface, so it is tracked separately
and openclawCLITimeout now documents the gap with the measurements rather than
shipping half a fix.

Also fixes the round-2 nit: the context branch %w-wrapped the process error
while printing ctxErr with %v, so errors.Is(err, context.DeadlineExceeded) was
false despite the text containing it. The context error is now the wrapped
cause and the process error is attached for diagnosis:

  openclaw config file: context deadline exceeded (process: signal: killed)

Tests: the timeout cases no longer depend on WaitDelay and no longer leave a
live process — short sleeps keep them about attribution, which is what they are
for. Added an explicit errors.Is assertion for both DeadlineExceeded and
Canceled. Verified in a linux/dash container (the CI platform): full execenv
package passes with -race and `ps` shows no leftover sleep processes.

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

* docs(daemon): correct two stale comments on the OpenClaw CLI timeout (MUL-5422)

Both nits from the third review. Comment-only; no code change.

1. openclawCLITimeout's doc contradicted itself — it opened with "caps ...
   without letting a hung CLI stall task dispatch indefinitely" and then
   explained that the deadline cannot actually bound the call. Reworded to say
   what it is (a 5s context deadline) and to point at the gap rather than assert
   a guarantee it does not provide. Also names MUL-5467 instead of the vague
   "tracked separately".

2. The two timeout tests claimed a long wait would "leave a live process
   behind". That described the reverted WaitDelay behaviour, not the current
   code. Without WaitDelay, cmd.Output() returns only once the descendant has
   closed stdout — its exit is what produces the EOF — so a long wait makes the
   test slow, it does not leak. Re-verified on linux/dash after the fix: the
   case takes 1.01s for a 1s sleep and `ps` shows no leftover process, and the
   earlier PID probe recorded the grandchild in state Z at the return point.

Rebased onto c25a82eee.

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

* docs(daemon): tighten OpenClaw timeout wording and pipe-EOF claims (MUL-5422)

Round-4 review nits. Comment-only; verified no non-comment line changed.

1. OpenclawConfigPrep.Timeout still said it "caps each CLI invocation", which
   contradicts the openclawCLITimeout doc corrected last round. It now says it
   sets the context deadline and points at that note. Fixed the same word in the
   struct's own doc comment, which had the identical claim.

2. The two timeout-test comments equated "descendant closes the pipe" with
   "descendant has exited". That holds for these helpers but is not a general
   property — a process can close its pipes and keep running — so the comments
   now scope the claim to the helper and say so explicitly.

   Also corrected "stdout" to the output pipes os/exec manages for both stdout
   AND stderr. Verified rather than assumed: with cmd.Stderr set to an
   in-memory writer (as execOpenclawCLI does), a grandchild holding EITHER
   stream parks cmd.Output() for its full 3s lifetime, while one holding
   neither returns in 0s. So Wait genuinely depends on both.

No rebase this round: the branch is 1 commit behind main, that commit does not
touch execenv, and GitHub already reports MERGEABLE — not worth another forced
CI rerun.

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

---------

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-29 16:30:35 +08:00