mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-10 10:26:46 +08:00
8ade96e671bca7d84fd2d764ffca305e2fdc358d
63
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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. |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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 |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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 |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
25d26c2bf7 |
fix(agent): send Pi prompts over stdin (MUL-5779) (#6485)
Co-authored-by: Eve <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
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]>
|
||
|
|
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
|
||
|
|
982a8c5784 |
perf(ci): split frontend jobs and cache turbo results (MUL-5347) (#5982)
* perf(ci): split frontend build and test onto separate runners (MUL-5347) The frontend job is the critical path of every CI run that touches web code: p50 576s, versus 267s for backend. 94% of it is a single `turbo build typecheck lint test` invocation. Profiling showed the cost is contention, not inefficient tasks. A standard runner has 4 vCPUs, and the job scheduled two CPU-saturating tasks onto them concurrently: `@multica/views:test` (259 jsdom files) and `@multica/web:build` (a webpack production build). Measured against the same suites running with the machine to themselves: @multica/views:test 104s owning 4 cores -> 500s sharing them @multica/web:build 26s owning 10 cores -> 342s sharing 4 Split the work across two runners so neither starves the other. The split is weighted rather than even, because the halves are not close: the test group costs ~575 CPU-seconds and everything else ~250, so tests get a runner alone. Also drop `^typecheck` from the `test` and `lint` task definitions. No package in this repo emits build artifacts -- core/ui/views export raw .ts that the consumer transpiles -- so that edge ordered tasks without producing anything they consumed, and left the heaviest task in the graph idle for ~38s while core/ui ran `tsc --noEmit`. Removing it also drops three redundant typecheck tasks from the test job, taking it from 7 tasks / 798 CPU-seconds to 4 / 575. Type errors still fail CI through the `typecheck` task, which frontend-build owns. `frontend` is retained as an aggregate gate so the existing status-check name keeps reporting, including the established behaviour that it goes green rather than pending on PRs the path filter excludes. Co-authored-by: multica-agent <[email protected]> * perf(ci): cache turbo task results across runs (MUL-5347) Every CI run rebuilt all 16 frontend tasks cold -- turbo reported `Remote caching disabled` / `Cached: 0 cached, 16 total` on every run, because only the pnpm store was cached. Persist turbo's filesystem cache with actions/cache instead. No Vercel remote-cache credentials exist in this repo, so this is the local cache keyed per job. Measured, simulating a fresh checkout against a warm cache: frontend-build 54.7s -> 184ms (12/12 cached) frontend-test 115.7s -> 50ms (7/7 cached) Cache footprint is 20MB for the build job and ~60KB for the test job -- `test` declares no outputs, so turbo stores exit codes and logs, yet a hit still skips the most expensive task in the graph. Turning the cache on promotes two latent hashing bugs into real ones, so both are fixed here: `build` declared narrow `inputs` globs that matched neither `content/**/*.mdx` (compiled by fumadocs-mdx during the build), `public/**` assets, nor `package.json` / `tsconfig.json`. Editing any of them left the task hash byte-identical, which is inert when nothing is ever restored and a stale-build bug the moment something is. Dropped in favour of turbo's default input set. `test` regains its `^typecheck` edge, reverting part of the previous commit. That change was justified on the basis that the edge only imposed ordering, which was true without a cache: a turbo task hash covers a workspace dependency's sources only if a task edge reaches them, so with no edge, editing packages/views or packages/ui left `@multica/web#test` and `@multica/desktop#test` unchanged in hash and a cached pass would be replayed over changed code. `typecheck` is the only task every package defines, so it is the edge that closes the gap; `^test` would miss packages/ui, which has no test script. `lint` keeps no edge -- eslint here is not type-aware, so it cannot observe a dependency's sources. Also adds .github/workflows/ci.yml to globalDependencies: it pins the Node version, and without it a toolchain bump would replay results produced by the old runtime behind a green check. Co-authored-by: multica-agent <[email protected]> * fix(ci): scope turbo cache to the resolved runtime, drop typecheck from tests (MUL-5347) Addresses review feedback on the caching commit. Must-fix: the cache was not isolated by interpreter. `node-version: 22` floats across patch releases and turbo's global hash does not include the interpreter at all (`engines` is null in its dry-run cache inputs), so a runner moving to another 22.x would leave the workflow text and every task hash unchanged, restore the old cache through `restore-keys` and report green without executing anything. Both jobs now resolve `node --version` into a step output and carry it, plus `runner.arch`, in the cache key and the restore prefix. Must-fix: `actions/cache@v4` runs on the deprecated Node 20 runtime and was being force-migrated to Node 24 with a warning on every run. Moved to v6 -- the review suggested v5, but v6.1.0 is current and satisfies the same runner floor (>= 2.327.1; hosted runners are on 2.335.1). `test` no longer depends on `^typecheck`. It needs dependency sources in its hash, not a type check: a new hash-only `cache-inputs` transit task carries them. No package implements that script, so every node resolves to <NONEXISTENT> and nothing executes, while the edges still pull each dependency's files into the hash. Declared recursively so the chain survives a package gaining a purely transitive dependency. This returns the ~223 CPU-seconds the previous commit gave up: the cold test job goes from 7 tasks / 115.7s back to 4 tasks / 67.2s, and editing packages/ui still correctly re-runs the views, web and desktop suites while core:test stays cached. Corrects a comment that claimed `^test` would miss packages/ui because it has no test script. It would not -- turbo materialises a <NONEXISTENT> node that participates in hashing, verified against the dry graph. `^test` is still the wrong edge here, but because it serialises the suites behind each other, not for the stated reason. Also drops the stale note that tests no longer depend on `^typecheck`, and swaps the aggregate gate's `always()` for `!cancelled()` so a run superseded by a newer push does not spend a runner on a verdict nobody reads. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
73b0015475 |
feat(vcs): make self-hosted Git providers self-host-only (MUL-3772, MUL-5138) (#5888)
* feat(vcs): gate self-hosted Git providers to self-host deployments only (MUL-3772) The Forgejo/Gitea/GitLab integration is intended for self-hosted Multica, where Multica can reach a Git instance on the operator's own network. On the managed multi-tenant cloud it adds an SSRF surface (connect validates a user-supplied instance URL from the server) and would store third-party Git tokens for all tenants under one key, while only serving the small subset of users whose instance is publicly reachable. Product decision: offer it on self-host only. - Add an explicit deployment switch MULTICA_VCS_INTEGRATION_ENABLED (default off). Connect, rotate, and webhook now require BOTH the switch on AND a valid MULTICA_VCS_SECRET_KEY — the switch is the product boundary, not key presence alone. When off, connect/rotate return 404 and the webhook returns a bare 404 (no config leak), independent of the frontend. - /api/config exposes vcs_integration_available (mirrors the switch, omitted when false) so the Settings UI hides the whole "Git providers" section on cloud instead of surfacing an operator-only "missing key" hint. - docker-compose.selfhost.yml defaults the switch on; .env.example documents it. - Docs (en/zh) lead with a callout: available on self-hosted Multica only, not Multica Cloud, and clarify "self-hosted" means Multica itself, not just Git. #5006 / #5883 stay in place — the schema and backend capability are retained; this only gates availability. No cloud VCS connection can exist (connect always required the key, which the cloud never set), so nothing needs migrating. Verified: go build/vet + VCS/config handler tests on a fresh migrated DB (incl. a new disabled-deployment 404 test); pnpm typecheck (core + views) and the integrations-tab + core schema/config vitest suites pass. Co-authored-by: multica-agent <[email protected]> * fix(vcs): complete self-host integration gating (MUL-5138) Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
09dce598df |
MUL-5155: KAP-1051: diagnose Codex thread/start timeouts fail-closed (#5759)
* fix(agent/codex): diagnose thread start timeouts (KAP-1051) Co-authored-by: multica-agent <[email protected]> * fix(agent/codex): validate thread ID before success lifecycle Co-authored-by: multica-agent <[email protected]> * fix(agent/codex): confirm process tree cleanup Co-authored-by: multica-agent <[email protected]> * fix(agent/codex): bound Windows pipe cleanup Co-authored-by: multica-agent <[email protected]> * ci: run bounded Codex cleanup test on Windows Co-authored-by: multica-agent <[email protected]> * fix(agent): reap initialize timeout process groups * fix(agent): redact initialize timeout stderr * fix(agent): redact initialize context failures --------- Co-authored-by: multica-agent <[email protected]> |
||
|
|
ba0b711787 |
chore(ci): run test-go.sh self-test in Go test entry points (MUL-5169) (#5802)
Co-authored-by: Lambda <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
36533bbc2b | fix(test): prevent agent CLI execution in default tests (#5789) | ||
|
|
e5e278a1a2 |
fix(agent/cursor): send prompt on stdin so CLI-like flags cannot be re-tokenised (MUL-4992) (#5711)
* fix(agent/cursor): send prompt on stdin so CLI-like flags cannot be re-tokenised (MUL-4992) On Windows a Cursor task whose prompt contains CLI-like flags failed in ~2s with `error: unknown option '-X'` and no agent output (#5649). A pasted build log such as go build -ldflags "-X main.version=foo" -o bin/server ./cmd/server was enough to kill the run. buildCursorArgs put the whole prompt in argv as the positional after -p. The official Windows launcher chain ends in `& node.exe index.js $args` inside cursor-agent.ps1, where PowerShell re-serialises $args onto node's command line. Under Windows PowerShell 5.1 and pwsh <= 7.2 (Legacy native argument passing) an argument holding embedded double quotes is not re-escaped, so the quoted region closes early, node's argv parser re-splits at the interior spaces, and `-X` reaches commander.js as a standalone flag. #1709 removed the cmd.exe `%*` re-tokenisation but stopped at the Go -> PowerShell boundary, one hop before this. Its Windows tests only compare the argv slice Go builds and never execute a shim, which is why the gap stayed invisible. Fix: keep the prompt off every command line. cursor-agent's -p is a boolean print-mode switch and the prompt is positional; with no positional prompt and a non-TTY stdin the CLI reads stdin to EOF and uses that as the prompt. So drop the prompt from argv on all platforms and write it to stdin, leaving only fixed, content-free flags in argv. No shell or launcher on any platform can re-tokenise what is not on a command line. The write runs in its own goroutine: a prompt larger than the pipe buffer (~64 KiB) blocks mid-write until the child drains it, and the child cannot drain while nothing reads its stdout. Closing stdin signals end-of-prompt, so it is closed on both success and error paths, and on cancellation to release a blocked write. Write failures surface in the result diagnostic, ranked below explicit agent errors so an early child exit (bad auth, bad flag) is not masked by the resulting EPIPE. Tests: a prompt carrying the exact `-ldflags "-X ..."` shape must arrive byte-for-byte on stdin and appear nowhere in argv; a 512 KiB prompt must not deadlock; the prompt is written verbatim (the CLI trims it, we do not). Both new unix tests fail against the pre-fix code. A Windows-tagged test drives a real PowerShell host through the same .cmd -> -File rewrite. Closes #5649 Co-authored-by: multica-agent <[email protected]> * test(agent/cursor): prove the Windows launcher fix on both PowerShell hosts in CI The stdin fix has to hold on the host that actually exhibits the bug. powershell.exe (5.1) and pwsh <= 7.2 default to Legacy native argument passing; pwsh >= 7.3 defaults to Standard. A fix verified only on the newer host would not be a fix for the reporter. Run the shim probe against every PowerShell host on PATH rather than only the one defaultPowerShellLookup would select, and hook the windows-tagged launcher tests into the existing windows-execenv CI job. These tests are windows-tagged and the backend job runs on ubuntu, so until now they ran nowhere. Co-authored-by: multica-agent <[email protected]> * test(ci): run Windows launcher tests verbosely so skips are visible A skipped or unmatched test still reports "ok", which would make the Windows job look like coverage it is not providing. Co-authored-by: multica-agent <[email protected]> * test(agent/cursor): close both coverage gaps in the #5649 regression tests Two tests claimed guarantees they did not actually establish. Windows: the fake cursor-agent.ps1 called [Console]::In.ReadToEnd() and wrote the result itself, so it never launched a native child. The official shim ends in `& node.exe index.js $args`, and that last hop is precisely where the bug lives -- PowerShell re-serialises $args onto the child command line, and whether the child inherits stdin was left unproven. The fake ps1 now re-executes the test binary as a real native child (helper-process idiom), which records the argv it actually received and drains stdin. Both PowerShell hosts still run. Interlock: the large-prompt fake drained stdin before writing any stdout, so the child always unblocked the parent immediately and the test passed even against a synchronous write -- it could not fail for the reason it existed. The fake now floods stdout past pipe capacity *before* reading stdin, creating the real mutual block. Verified: with the writer made synchronous the test deadlocks to its 30s timeout, and passes only with the concurrent writer. Also adds the missing cancellation case: a child that never reads stdin leaves the writer blocked forever, so cancelling the context must close stdin, release the writer and settle the run as aborted. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
ed57707bb2 |
MUL-4923: bound daemon task preparation time (#5584)
* fix(daemon): bound pre-start task preparation Co-authored-by: multica-agent <[email protected]> * fix(daemon): isolate pre-start env preparation Run execution-environment Prepare and Reuse in a killable helper process so a timed-out attempt cannot keep writing after retry. Add FIFO lifecycle and squad Stage retry regression coverage. Co-authored-by: multica-agent <[email protected]> * fix(daemon): terminate Windows prepare process trees Assign the pre-start helper to a kill-on-close Job Object before releasing its request, wait for all job members to exit on cancellation, and add a Windows runtime regression job. Co-authored-by: multica-agent <[email protected]> * ci: target Windows prepare tree regression Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
65ce228e10 |
ci(frontend): path-filter the frontend job to skip irrelevant PRs (MUL-3667) (#4556)
* ci(frontend): path-filter the frontend job to skip irrelevant PRs (MUL-3667) The frontend job (~6min) is the CI bottleneck and runs in full on every PR, including pure backend-only / docs-only ones that change no frontend code. Gate it on a paths filter: a 'changes' job (dorny/paths-filter) decides whether anything the frontend job validates changed; the frontend job always runs but its steps are individually gated, so on an irrelevant PR all steps skip and the job reports a genuine green — the required 'frontend' check stays satisfied with no branch-protection change, and no top-level 'paths' that would also gate the shared backend/installer jobs. Push to main always runs the full job. Also fix a stale comment: mobile-verify filters packages/core/**, not packages/core/types/**. An earlier revision of this PR also cached apps/web/.next/cache. Two back-to-back CI runs (cold vs warm) showed it cut the web build compile 4.3min -> 2.0min but did NOT move the job wall (6m13s -> 6m14s): the floor is a cluster of typecheck/test tasks (web:typecheck ~2m13s, views:test, desktop:typecheck) co-critical with web:build and bound by the 4-vCPU runner, not the web build alone. Dropped the cache since it is a no-op on its own; the real wall-clock levers (turbo remote cache / larger runner) are tracked separately. Co-authored-by: multica-agent <[email protected]> * ci(frontend): include .npmrc in the frontend path filter (MUL-3667) Address review: root .npmrc (shamefully-hoist=true) affects the pnpm install layout, so a .npmrc-only PR must still run the frontend job. It was missing from the filter's install-graph group, which would have made such a PR a silent skip — exactly what the filter must avoid. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
b4c9e4423c |
test: enable -race detector in Go test pipeline (WOR-61) (#4274)
* test: enable -race detector in Go test pipeline (WOR-61) Add the -race flag to all three Go test invocation sites so the existing concurrency regression harness (workdir_race_test.go for #3999, runtime_gone_test.go, runtime_profile_drift_test.go) actually exercises the race detector. The daemon package alone has 28+ goroutine launch points with no automated race coverage before this change. Sites updated: - Makefile:299 (make test, local) - .github/workflows/ci.yml:101 (CI backend job) - .github/workflows/release.yml:55 (release verify job) go test already runs a vet subset by default, so no separate -vet flag is added. No production code touched. Co-authored-by: multica-agent <[email protected]> * test(execenv): serialize runtimeGOOS-mutating test (WOR-61) TestInjectRuntimeConfigIssueMetadataCodexFormattingUnchanged called t.Parallel() while mutating the package-level runtimeGOOS to drive the windows/linux branches, racing with the other parallel tests that read runtimeGOOS in buildMetaSkillContent. The -race flag enabled in the prior commit surfaced it as 3 WARNING: DATA RACE reports and 11 "race detected" failures in CI (only the execenv package failed). Drop t.Parallel() and add the "// Not parallel: mutates the package-level runtimeGOOS." comment already used by the six sibling writer tests across execenv_test.go and reply_instructions_test.go. This is test-isolation only; no production code, no mutex/atomic, no signature change. Verified locally: go test -race -count=1 ./internal/daemon/execenv/ -> ok 2.276s go test -race -count=1 ./internal/daemon/... -> all 3 pkgs ok Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: hzz <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
ae27058b0a |
fix(attachments): unified download endpoint with mode + presign + proxy (MUL-2976) (#3747)
Fix attachment download for self-hosted deployments using private S3-compatible buckets without CloudFront. Closes #3721. **Server** - New unified `GET /api/attachments/{id}/download` endpoint that picks CloudFront / S3 presign / server proxy at request time. - `ATTACHMENT_DOWNLOAD_MODE=auto|cloudfront|presign|proxy` and `ATTACHMENT_DOWNLOAD_URL_TTL` env knobs; `auto` routes Docker hostnames / localhost / private IPs through the proxy and public S3 endpoints through presign. - `Storage.PresignGet` capability; S3 implementation generates presigned GET URLs. - `attachmentToResponse` returns the unified relative endpoint instead of leaking raw unsigned S3 URLs when CloudFront is not configured. Proxy path streams via `io.Copy` with `Content-Disposition` / `Content-Length` / `Cache-Control: no-store` / `X-Content-Type-Options: nosniff`. **Clients** - CLI / Desktop / Mobile resolve relative `download_url` values against the configured API base. Desktop covers the Electron native download bridge and the media preview modal; Mobile covers `Linking.openURL`, the markdown image RN loader, and the composer's completed non-image file chip. - Mobile gains a minimal Node-environment vitest lane wired into `mobile-verify.yml`. **Docs** - `.env.example`, `docker-compose.selfhost.yml`, `SELF_HOSTING_ADVANCED.md`, and the `environment-variables` doc set updated with the new env keys and the `ATTACHMENT_DOWNLOAD_MODE=proxy` recommendation for Docker / VPC-internal object stores. **Tests** - `internal/storage`, `internal/cli`, `internal/handler` (download endpoint, mode selection, proxy header, `/content` non-regression), `cmd/server` (trusted proxy parser). - `packages/views/editor/use-download-attachment.test.tsx` and `attachment-preview-modal.test.tsx` exercise relative URL resolution + absolute pass-through. - `apps/mobile/lib/attachment-url.test.ts` covers every helper branch plus the composer non-image chip case. |
||
|
|
202200bc62 |
feat: publish helm chart to ghcr (#3415)
Co-authored-by: Eve <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
0dcc35edc8 |
ci: split mobile lint/typecheck out of frontend job (#3346)
* ci: split mobile lint/typecheck out of frontend job Mobile lint (~38s) + typecheck (~13s) ran on every web/desktop PR even though mobile has no vitest suite and main CLAUDE.md already promises a parallel mobile-verify workflow. Excluding @multica/mobile from the frontend turbo filter pulls those 50s off the critical path, and the new mobile-verify.yml runs them in parallel only when apps/mobile/** or packages/core/types/** changes. MUL-2729 Co-authored-by: multica-agent <[email protected]> * ci(mobile-verify): broaden path filter to cover real mobile deps The initial filter only watched `apps/mobile/**` and `packages/core/types/**`, but mobile imports runtime modules from many more `@multica/core/*` paths (agents, markdown, permissions, api/schemas, etc.). PRs that touched only those subtrees would skip main CI (via `--filter='!@multica/mobile'`) AND skip Mobile Verify — a coverage regression vs. the pre-split CI. Expand paths to: - `packages/core/**` (covers every importable subpath) - root install/turbo configs that affect mobile build: `package.json`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, `turbo.json` Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
bfb7c85491 |
fix(selfhost): derive local port URLs from env (MUL-2506) (#2939)
* fix(selfhost): derive local port URLs from env * fix(selfhost): derive local script URLs |
||
|
|
eaf8b14866 |
fix(installer): post-merge nits from #2881 (MUL-2458) (#2922)
- Capture `brew tap` output and print the same diagnostic tail on failure that `brew install` already prints, so #2867-style "no signal" reports are gone from both Homebrew failure paths. - Add a `brew tap` failure regression case to `scripts/install.test.sh` and refactor the test runner to share sandbox/curl-stub setup; both cases now also assert the diagnostic tail is emitted. - Move the shell installer test out of the heavy backend job into a dedicated `installer` matrix job that runs on `ubuntu-latest` and `macos-latest`, since the installer targets macOS/Homebrew and BSD vs GNU `tar` / `sed` / `mktemp` differences are the next likely break. - Surface `MULTICA_INSTALL_DIR`, `MULTICA_BIN_DIR`, and `MULTICA_SELFHOST_REF` in `install.sh --help` so `MULTICA_BIN_DIR` stops looking like a test-only knob. Co-authored-by: multica-agent <[email protected]> |
||
|
|
edded77691 | fix(installer): fall back when brew install fails (#2881) | ||
|
|
bda475cbba |
refactor(reserved-slugs): single JSON source for backend + frontend (#2148)
Reserved workspace slugs lived in two parallel files (`workspace_reserved_slugs.go` and `packages/core/paths/reserved-slugs.ts`) with no parity check. Adding or renaming a global route on one side without the other would slip through CI and surface only when a real user hit the collision. Collapse the two lists into one source: `server/internal/handler/reserved_slugs.json`. Go embeds the JSON via `//go:embed` and parses it at package init; the TS file is regenerated by `scripts/generate-reserved-slugs.mjs` (run via `pnpm generate:reserved-slugs`). CI re-runs the generator and `git diff --exit-code`s the TS output, so a stale TS file cannot land. The slug set is unchanged (87 entries, byte-equivalent slug literals). Update CLAUDE.md to describe the new "edit JSON, run generator" workflow. Co-authored-by: multica-agent <[email protected]> |
||
|
|
58db751089 |
ci(lint): enable lint in CI + fix existing lint debt (#2129)
CI was running build + typecheck + test, but never lint. The i18n guardrail (eslint-plugin-i18next on packages/views/**/*.tsx) was configured but not enforced, so PRs kept landing user-facing English strings (chat session delete, project resources, mermaid fallback, invitations batch page). Changes: - .github/workflows/ci.yml: add `lint` to the turbo command - packages/eslint-config/react.js: split React rules (JSX-only) from react-hooks rules (apply to .ts too) — hooks live in .ts modules like use-agent-presence.ts, and inline-disable comments need the rule registered to resolve - Translate the 10 lint errors that surfaced: - editor/readonly-content.tsx mermaid render-error + rendering - issues/issue-detail.tsx Archive tooltip - invitations/invitations-page.tsx full page (new invite.batch.*) - invitations-page.test.tsx wrap with I18nProvider so getByRole queries match translated button labels - core/auth/utils.ts intentional control-char regex: add eslint-disable Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]> |
||
|
|
d63e7c1c45 |
ci(release): skip homebrew-tap publish on forks (#1687)
The release job uses GoReleaser to bump the formula in multica-ai/homebrew-tap. Forks don't have HOMEBREW_TAP_GITHUB_TOKEN and should not publish to that tap, so the job currently fails on every fork tag push (401 Bad credentials against the upstream tap). This makes the workflow red on downstream forks even though the actual artifact pipeline (verify → docker-backend-build → docker-backend-merge) succeeds and produces a usable image. Gate the release job on `github.repository_owner == 'multica-ai'`. Upstream behaviour unchanged. Forks now see a clean green run for docker artifacts only. |
||
|
|
ad803b86ec |
fix(skills): shared-state runtime local-skill stores (MUL-1288) (#1557)
* fix(skills): shared-state runtime local-skill stores (MUL-1288)
Fixes the bug Bohan surfaced on MUL-1288: behind prod's multi-node API the
runtime-local-skill list/import flow would intermittently time out or 404.
Root cause: LocalSkillListStore and LocalSkillImportStore were per-process
sync.Mutex+map, so when the frontend POST, the daemon heartbeat and the
frontend GET landed on different API instances, each saw a different
pending set. Confirmed against production daemon logs — the failed
request_id never showed up in the daemon's "runtime local skills
requested" log, even though other requests around the same window worked.
Per Yushen's guidance (server must stay stateless; state lives in
storage), migrate both stores to Redis so every node agrees on the same
pending set.
What changed
- LocalSkillListStore / LocalSkillImportStore are now interfaces. Methods
take context.Context and return error.
- InMemoryLocalSkill{List,Import}Store — renamed from the existing types,
kept as the default for single-node dev and the in-process test suite.
- RedisLocalSkill{List,Import}Store — new. Keyed on
mul:local_skill:{list,import}:<id> (JSON record, TTL = retention), with
a per-runtime ZSET mul:local_skill:{list,import}:pending:<runtime_id>
(score = created_at UnixNano) providing cross-node ordering. PopPending
wins the claim via ZREM == 1, so concurrent pops from different nodes
never return the same request twice.
- NewRouter gets an optional *redis.Client; when non-nil it swaps in the
Redis-backed stores. main.go hoists the existing Redis client (already
used by the realtime relay) so both subsystems share one client.
- Handler fields flip to interface types; handler.New still constructs
in-memory stores by default.
- Daemon heartbeat's PopPending call sites thread r.Context() through so
Redis operations inherit request cancellation. Errors warn instead of
poisoning the heartbeat response.
Tests
- Existing in-memory tests updated for the new signatures (ctx + error).
- New runtime_local_skills_redis_store_test.go covers:
- Create/Get/Complete round trip preserves skills payload
- PopPending across two *store instances sharing one rdb (the exact
regression: node A creates, node B pops)
- N concurrent PopPending on one record => exactly one winner
- Pending-timeout threshold transitions the record and removes the zset
member so a later PopPending doesn't return a timed-out request
- Import store round-trips CreatorID (which is json:"-" on the public
struct — needs a Redis envelope so ReportLocalSkillImportResult can
still attribute the created Skill)
- Per-runtime isolation — a PopPending for runtime B does not disturb
A's pending zset
- Tests skip gracefully if REDIS_TEST_URL is unset; CI now spins up a
redis:7-alpine service and exports the URL so the suite actually runs
there.
Out of scope
PingStore / UpdateStore / ModelListStore have the same shape and the
same latent bug (they just fire rarely enough to have gone unnoticed).
Migrating them to Redis is a follow-up — MUL-1288 is specifically the
local-skills break Bohan is blocked on.
* fix(skills): atomic Redis claim + surface store write failures (PR #1557 review)
Two real gaps GPT-Boy flagged:
1. RedisLocalSkill{List,Import}Store.PopPending was doing ZREM then SET as
two separate round-trips. If the SET failed for any reason — transient
Redis error, context cancellation, pod getting SIGKILL'd mid-call — the
request was already gone from the pending zset but the stored record
still said "pending", and no subsequent PopPending would re-dispatch
it. Exactly the "request disappears" class of bug this PR is supposed
to kill.
Fix: push the claim into a Lua script so Redis runs ZREM + SET as one
atomic unit. If ZREM returns 0 (another node won the race), SET is
skipped and the caller retries.
2. ReportLocalSkill{List,Import}Result handlers were logging Complete/Fail
store failures at Warn and still returning 200 OK. That made the
daemon think the report landed when it hadn't, leaving the request
stuck in "running" until the server-side timeout and — worse for the
import flow — leaving the just-created Skill row orphaned in Postgres
so every retry collided with the unique-name constraint.
Fix: escalate to Error + return 500 so the daemon (and monitoring) can
see the write failed. For the import flow, Complete failure after the
Skill row is already committed also triggers a best-effort DeleteSkill
so a daemon retry lands on a clean slate instead of hitting
"a skill with this name already exists" forever.
Tests
- New TestRedisLocalSkillListStore_PopPendingAtomicClaim asserts the
happy-path invariant: after one PopPending the record is "running"
AND a second PopPending returns nothing. Deliberately does NOT poke
Redis internals directly so the test survives any future key-layout
refactor.
- Existing cross-instance / concurrent / timeout / per-runtime tests
continue to pass against the Lua-based claim path (verified locally
against a scratch redis-server; 8/8 Redis tests green).
|
||
|
|
f98a67dd90 |
ci(release): build docker images natively per arch and merge manifests (#1507)
Multi-arch images were built on a single amd64 runner with QEMU emulating arm64. The Next.js build (Dockerfile.web) under emulation took 30+ minutes per release and was the long pole of the workflow. Split each image build across two native runners (amd64 on ubuntu-latest, arm64 on ubuntu-24.04-arm), push by digest, then merge into a manifest list with docker buildx imagetools. QEMU is no longer needed. Backend and web each become a (matrix build + merge) pair, replacing the previous single docker-images job. Per-platform GHA cache scopes avoid cross-arch cache eviction. Co-authored-by: Copilot <[email protected]> |
||
|
|
fbf41bde73 |
feat(selfhost): ship public GHCR deployment flow
Publish stable GHCR self-host images, switch self-host deploys to official image pulls with a source-build fallback, and move self-host signup / Google OAuth config onto runtime /api/config. |
||
|
|
20c9d985f5 | ci: clarify release tag filters | ||
|
|
6ecf15e62c | ci: add desktop smoke build workflow | ||
|
|
6f63fae41a |
feat(desktop): support macOS cross-platform packaging (#1262)
* feat(desktop): support macOS cross-platform packaging * fix(desktop): use releaseType instead of publishingType in electron-builder publish config publishingType is not a valid electron-builder key; the correct GitHub provider option is releaseType. The previous value was silently ignored, causing uploads to be skipped and breaking auto-update. Co-authored-by: Copilot <[email protected]> * feat(release): standardize artifact naming across desktop and CLI Unified scheme: `multica-<kind>-<version>-<platform>-<arch>.<ext>` so a filename alone reveals kind, version, platform, and CPU arch. Desktop (apps/desktop/electron-builder.yml): mac → multica-desktop-<v>-mac-<arch>.{dmg,zip} linux → multica-desktop-<v>-linux-<arch>.{deb,AppImage} (fixes `\${name}` expanding the scoped `@multica/desktop` into a broken `@multica/desktop-*` filename path) windows → multica-desktop-<v>-windows-<arch>.exe CLI (.goreleaser.yml): multica_<os>_<arch>.tar.gz → multica-cli-<v>-<os>-<arch>.tar.gz (adds `-cli` marker + version; switches `_` to `-` for consistency) Matrix update in apps/desktop/scripts/package.mjs `--all-platforms`: - drop mac x64 (Intel not a target yet) - add linux arm64 Final: mac arm64, win x64/arm64, linux x64/arm64. Downstream updates so install paths match the new CLI names: - scripts/install.sh - scripts/install.ps1 (URL + checksum regex) - CLI_INSTALL.md Co-authored-by: Copilot <[email protected]> * feat(release): use multica_{os}_{arch} CLI archive naming Standardize on the GoReleaser default 'multica_{os}_{arch}.{tar.gz|zip}' asset names. Install scripts and the desktop CLI bootstrap now resolve assets via checksums.txt so they work without hardcoding versions. The Go self-update path queries the GitHub release API and accepts either the new or legacy 'multica-cli-<version>-...' names so existing releases keep updating cleanly. Co-authored-by: Copilot <[email protected]> * feat(release): ship both legacy and versioned CLI archive names GoReleaser now produces both 'multica_{os}_{arch}.{ext}' (legacy) and 'multica-cli-{version}-{os}-{arch}.{ext}' (versioned) archives in every release. The legacy name keeps already-released CLIs self-updating; the versioned name is what new clients should use going forward. Self-update / install paths flipped to prefer the versioned name and fall back to legacy: - server/internal/cli/update.go (multica update) - apps/desktop/src/main/cli-release-asset.ts (desktop CLI bootstrap) - scripts/install.sh, scripts/install.ps1 (fresh install) Homebrew formula is pinned to the versioned archive via 'ids: [versioned]'. Co-authored-by: Copilot <[email protected]> * feat(desktop): also build Linux .rpm packages Co-authored-by: Copilot <[email protected]> * feat(release): build Linux/Windows Desktop installers in CI; detect Windows ARM64 in install.ps1 Address review feedback on PR #1262: - .github/workflows/release.yml: add a 'desktop' job that runs after the CLI 'release' job and packages the Desktop installers for Linux (AppImage/deb/rpm) and Windows (NSIS) on x64 and arm64, then publishes them to the same GitHub Release via electron-builder. macOS Desktop continues to ship through the manual release-desktop skill so it can be signed and notarized with Apple Developer credentials. - scripts/install.ps1: detect Windows ARM64 hosts via RuntimeInformation::OSArchitecture so the new windows-arm64 CLI archive is downloaded on ARM64 machines instead of always falling back to amd64. Co-authored-by: Copilot <[email protected]> * fix(release): split Windows arm64 auto-update channel to avoid latest.yml collision electron-builder's update metadata file is hardcoded to `latest.yml` for Windows regardless of arch (only Linux gets an arch-suffixed name; see app-builder-lib's getArchPrefixForUpdateFile). With two separate electron-builder invocations for Windows x64 and arm64, both publish `latest.yml` to the same GitHub Release and the second upload silently overwrites the first — leaving one of the two architectures with auto- update metadata pointing at the other arch's installer. Route Windows arm64 to its own `latest-arm64` channel: * scripts/package.mjs appends `-c.publish.channel=latest-arm64` only for the Windows arm64 invocation, so x64 keeps producing `latest.yml` and arm64 produces `latest-arm64.yml` alongside it. * updater.ts pins `autoUpdater.channel = 'latest-arm64'` on Windows arm64 clients so they fetch the matching metadata file. Co-authored-by: Copilot <[email protected]> --------- Co-authored-by: Devv <[email protected]> Co-authored-by: Copilot <[email protected]> |
||
|
|
133a1f1c16 |
ci(release): restrict tag pattern to semver and reject -dirty tags (#1280)
The release workflow previously triggered on 'v*', which matched a stray 'v0.2.5-dirty' tag pushed to the repository. GoReleaser ran again and overwrote the Homebrew formula with a 0.2.5-dirty version whose tarball URLs 404. Tighten the trigger to semver-shaped tags and add an explicit guard that fails the job if the tag name contains '-dirty' (which can come from 'git describe --tags --dirty'). Co-authored-by: Copilot <[email protected]> |
||
|
|
b1b66ab05d |
ci: exclude apps/docs from frontend build/typecheck/test (#1279)
Docs site is no longer auto-deployed via Vercel (disabled in dashboard), so building it on every PR adds friction without catching anything actionable. Use turbo's negative filter to skip @multica/docs across all three tasks. |
||
|
|
fc0ef0fcd8 |
fix(ci): update backend Go version from 1.24 to 1.26.1 (#337)
Align the CI backend job with the Go version declared in server/go.mod and used in the Dockerfile (golang:1.26-alpine). Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]> |
||
|
|
26d3ecd06d |
fix(ci): replace turbo commands with direct pnpm calls
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> |
||
|
|
22f1005537 |
fix(release): add test step, fix checkout version, improve brew formula
- Fix actions/checkout@v6 → v4 (v6 doesn't exist) - Add Go test step before GoReleaser to prevent shipping broken binaries - Add license and test block to Homebrew formula Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> |
||
|
|
4c4f6c4a60 |
fix(release): use brews instead of homebrew_casks for CLI formula
homebrew_casks is for macOS .app bundles; brews is the correct GoReleaser v2 key for CLI binaries. Also read Go version from go.mod instead of hardcoding it. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> |