#5057 restricted version derivation to `git describe --tags --match 'v[0-9]*'`,
but the command was passed to `execSync` as a shell string. On Windows the
shell is cmd.exe, which does not strip the POSIX single quotes around
'v[0-9]*', so git received the quotes literally, matched no tag, fell through
to `--always`, and the version degraded to the `0.0.0-g<hash>` fallback.
That is what shipped a `0.0.0-gc05b67ae4` Windows Desktop build (electron-builder
`--publish always` then auto-created a bogus release) during the v0.3.41 release,
even though the tag was sitting exactly on HEAD. Linux/macOS were unaffected
because /bin/sh strips the quotes.
Fix: invoke git with an argv array via execFileSync in every version-derivation
path, so the match pattern reaches git as one literal argument regardless of
platform:
- apps/desktop/scripts/package.mjs (Desktop version → electron-builder)
- apps/desktop/scripts/bundle-cli.mjs (bundled CLI ldflags version)
- apps/desktop/src/main/app-version.ts (dev-mode version fallback)
The Makefile is intentionally left as-is: make's `$(shell ...)` always runs via
/bin/sh (even on Windows) and the CLI release runs on Linux, so its single
quotes are stripped correctly.
Tests: export `deriveVersion` and `DESCRIBE_ARGS` and add coverage that runs the
real `git describe` against throwaway repos (clean semver tag, semver tag chosen
over a nearer non-semver tag, and the no-tag fallback), plus a structural check
that the match pattern is a bare argv token with no embedded quotes. The prior
suite only unit-tested the `normalizeGitVersion` string transform, which is why
this slipped through.
MUL-4256
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(runtimes): make rename a machine action + fix picker search copy (MUL-4217)
Follow-up to the runtime-naming feature based on testing feedback:
1. The create-agent runtime picker filters by MACHINE (its search matches the
machine title / host / provider names), but the placeholder said "Search
runtimes", which misled users into typing a runtime name. Change the
placeholder and the empty-state to "Search machines" / "No matching
machines" (all 4 locales).
2. Rename was framed as "rename this runtime" with an opt-in "apply to whole
machine" checkbox, but the intent is naming the machine (the computer), not
an individual runtime. Rework it into a machine-level action:
- New RenameMachineDialog always names the whole machine (apply_to_machine);
the per-runtime dialog + checkbox are gone.
- The entry now lives on the Runtimes page, on the selected machine's header
(a pencil next to the machine title), owner/admin gated — that's where the
user looked for it. Removed the per-runtime pencil from the runtime detail
page.
No backend change — apply_to_machine and machine-name inheritance already exist.
Verified: pnpm typecheck (all apps), full views suite (1650) + locale parity,
lint (0 errors).
Co-authored-by: multica-agent <github@multica.ai>
* fix(runtimes): always show machine header in picker; pre-fill rename with shared name only
Testing + review follow-ups on #5087:
1. The create-agent picker hid the machine group header when there was only
one machine (e.g. after a search narrowed to one), collapsing to a flat
list. Always render the machine header so grouping stays consistent.
2. (Elon) RenameMachineDialog pre-filled from the first non-empty custom_name
on the machine, but the machine title only uses a name when ALL runtimes
share one (sharedCustomName). A lone per-runtime name would thus pre-fill as
if it were the machine name — the same runtime-vs-machine confusion this PR
set out to remove. Export sharedCustomName and use it for the pre-fill;
otherwise pre-fill empty. Added direct unit tests.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(lark): let an agent's owner bind/manage its Lark bot (MUL-4213)
Scan-to-bind was authorized by workspace role only, so a non-admin
member could not bind a Lark bot even to an agent they own. Authorize
the device-flow install, status poll, and revoke by the same rule that
governs every other agent-management op — canManageAgent: the agent's
owner OR a workspace owner/admin.
Backend:
- router: begin/status/revoke drop to workspace-member level; the
per-agent check moves into the handlers (agent_id is a query param /
installation id, which the role middleware can't see).
- BeginLarkInstall + RevokeLarkInstallation load the target agent and
run canManageAgent.
- GetLarkInstallStatus scopes the read to the session initiator or a
workspace owner/admin; others get 404 (no existence leak). Session
state now carries InitiatorID for this.
Frontend:
- LarkAgentBindButton takes agentOwnerId and lets the agent owner
through (mirrors canEditAgent).
- Agent Integrations tab gates Lark per-agent (owner or admin) while
Slack stays workspace-admin-only, since its routes are unchanged.
Tests: begin/status/revoke authorization (owner, agent owner, unrelated
member) on the backend; agent-owner bind visibility on the frontend.
Co-authored-by: multica-agent <github@multica.ai>
* fix(lark): keep orphan installation revoke available to workspace admins (MUL-4213)
RevokeLarkInstallation loaded the bound agent and ran canManageAgent
unconditionally, so once the agent was hard-deleted the load 404'd and
a workspace owner/admin could no longer disconnect the orphan Lark
installation — a documented cleanup path (ListByWorkspace lists orphans;
the active-connection query filters them; Settings surfaces "Unknown
Agent" Disconnect).
Fall back to workspace owner/admin-only revoke when GetAgentInWorkspace
finds no agent; agents that still exist keep the owner-OR-admin
canManageAgent check. A plain member gains no orphan-row cleanup rights.
No FK/cascade — resolved in the application layer.
Adds a backend regression test: orphan installation is revocable by a
workspace owner but not a plain member.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(lark): isolate topic-group sessions by thread
A Feishu topic group (话题群) collapsed every topic into one
chat_session: the session binder passed the raw group chat id as the
engine BindingKey, violating the engine.EnsureSessionInput contract
that a threaded platform must never key sessions by raw chat id.
Multiple users @-mentioning the bot in different topics shared one
transcript, and replies all landed in whichever topic wrote
last_thread_id last.
Adopt the Slack channel:threadRoot model: a message inside a topic
(thread_id present) keys the session by "chat:thread" and persists the
real chat id in the binding config (larkBindingConfig); outbound paths
(chat reply, error card) resolve the send target via outboundChatID —
config first, falling back to the key for pre-topic rows, which keeps
legacy bindings routing unchanged. P2p and plain (non-topic) group
chats keep the raw chat id key and existing behavior.
No migration: existing topic-group sessions stay as-is; new topic
messages create per-topic sessions from the first @-mention onward.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(lark): remove unused CreateLarkChatSessionBinding helper
The helper and its CreateChatSessionBindingParams had no callers; all chat-session bindings are created through the shared engine.EnsureSession path. Dropping the dead code removes a way to bypass the engine and create topic bindings with a hardcoded empty config.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Non-semver tags (e.g. release-train tags) could become the nearest match
for `git describe --tags`, producing a version string that is not a valid
semver prefix. Restrict describe to `v[0-9]*` tags across the CLI ldflags,
desktop bundling, and app-version paths so the resolved version always has
a `major.minor.patch` shape.
* feat(runtimes): custom runtime names + searchable machine-grouped picker
MUL-4217. Runtime names were daemon-generated ("Claude (host)") and
uneditable, so picking one at agent-create time was painful once a
workspace had many machines.
Phase 1 — create-agent RuntimePicker: add a search box (>6 runtimes) and
group options by machine (Local/Remote/Cloud, online-first, current
machine first) reusing buildRuntimeMachines/filterRuntimeMachines. Rows
show the provider under a machine header instead of a flat repeated list.
Phase 2 — custom names: new nullable agent_runtime.custom_name column,
never written by the registration/heartbeat upsert so the daemon can't
clobber it; display is coalesce(custom_name, name) via runtimeDisplayName.
PATCH /api/runtimes/:id gains custom_name (+ apply_to_machine to name every
runtime sharing a daemon_id in one action, owner-scoped for non-admins).
Rename UI on the runtime detail page; `multica runtime rename` CLI command.
Verified: go build/vet, sqlc, handler tests (incl. new custom-name single
+ machine-fanout), 1650 views + 764 core TS tests, typecheck, locale parity.
Co-authored-by: multica-agent <github@multica.ai>
* fix(runtimes): address review — persist machine name on new registrations, keep custom_name in register response
Elon's review on #5070 (MUL-4217):
1. Machine name looked "lost" when a new provider registered on an
already-named machine — the new row landed with custom_name=null and
broke sharedCustomName. Now a fresh runtime inherits the machine's shared
custom name at register time (ListDaemonCustomNames + sharedDaemonCustomName),
so the machine title stays stable as providers come and go.
2. DaemonRegister rebuilt the response row by hand and dropped custom_name,
so register returned custom_name:null — inconsistent with list/get/update.
Both branches now carry CustomName.
Also: tighten the updateRuntime patch type to custom_name?: string (drop the
misleading `| null`, since the server treats null as "unchanged", not "clear").
Tests: register response preserves custom_name; new runtime inherits machine name.
Co-authored-by: multica-agent <github@multica.ai>
* fix(runtimes): inherit machine name for failed-profile registrations too
Elon's re-review of #5070 (MUL-4217): the machine-name inheritance added
last round only covered the normal req.Runtimes path. The req.FailedProfiles
branch also upserts a daemon_id-scoped agent_runtime row (offline, profile
registration error), which shows up in the runtime list / machine grouping —
so on a named machine a failed custom-profile row landed with custom_name=NULL
and dragged the machine title back to the hostname.
Extract the inheritance into h.inheritMachineCustomName and call it from both
the normal runtime path and the failed-profile path. Add a test: named daemon
+ failed profile upsert -> the failed row's persisted custom_name is inherited.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Squad create/manage was gated behind workspace owner/admin, inconsistent with
agents and projects which any member can create. Move squads to a creator-scoped
model: any member can create a squad and becomes its creator, and manages only
the squads they created; owner/admin continue to manage every squad.
Backend (server/internal/handler/squad.go):
- Add canManageSquad (admin/owner OR creator) and gate UpdateSquad, DeleteSquad,
AddSquadMember, RemoveSquadMember, UpdateSquadMemberRole on it (member load +
squad load + per-squad check, replacing requireWorkspaceRole).
- CreateSquad is now member-creatable.
- Add memberCanWireAgent: a non-admin may only wire agents they can @-trigger
(canInvokeAgent as themselves) as squad leader (create/update) or worker
(add member); admins may wire any workspace agent. Prevents a creator from
smuggling an agent they cannot invoke into a squad.
Frontend:
- squad-detail-page: compute per-squad canManage (admin || creator) and render
the inspector, members tab, instructions and archive read-only otherwise,
mirroring the agent detail canEdit pattern.
- squads-page: per-row actions and the actions column now key off per-squad
canManage instead of workspace-admin.
Squads stay visible workspace-wide (ListSquads unfiltered); creator transfer is
out of scope for this iteration.
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
position (the manual board order) is always sorted ascending server-side,
so --direction was silently dropped for the default/position sort. A passed
-but-ignored flag is a footgun, especially in scripts. Reject the combination
up front with a message that names the directional sort columns instead.
MUL-4222
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The coordinator's absent-card status-move branch shifted one unit of
server total between the two status buckets and stopped there — no
stale key. Under the app's staleTime: Infinity + no focus-refetch
setup, explicit invalidation is the only channel that reconciles a
loaded list, so an open board would show the moved count with the row
permanently missing from the destination bucket's visible window
(e.g. "done 61" with 60 visible rows) until an unrelated event
happened to invalidate the list.
Push the list key onto staleKeys after a successful moveBucketTotal.
The count still moves instantly (optimistic UX unchanged); the flush
timing follows the existing contract — mutations invalidate on
onSettled, the WS path immediately. No mutation/WS fork, no new
coordinator parameters (MUL-4182).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(lark): allow rebinding a revoked Feishu bot to a different agent
When a Feishu/Lark Bot is disconnected from agent A (status → revoked),
the row is preserved for audit but still holds the (channel_type,
config->>app_id) unique index slot. Binding the same Bot to agent B
would fail with:
duplicate key value violates unique constraint
"idx_channel_installation_type_appid" (SQLSTATE 23505)
because UpsertChannelInstallation conflicts on (workspace_id, agent_id,
channel_type) — a different agent_id means no conflict match, so it tries
INSERT and hits the app_id unique index.
Fix: before the upsert, inside the same transaction, hard-delete any
revoked installation with the same app_id in the same workspace. The
delete is fenced to status=revoked so an active installation can never
be silently removed. If no revoked row exists the delete is a no-op
(deletes zero rows, returns nil error) and the upsert proceeds normally.
Co-Authored-By: Claude <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(lark): preserve same-agent bindings when reconnecting a revoked Feishu bot
The cleanup added in the previous commit hard-deletes every revoked
channel_installation sharing the app_id in the workspace before the
upsert — including the row belonging to the agent currently being
(re)installed. That regresses the common "disconnect then reconnect the
same bot to the same agent" flow: disconnect only flips status to
'revoked' (bindings are preserved), and UpsertChannelInstallation
conflicts on (workspace_id, agent_id, channel_type), so before this the
same agent's row was reactivated in place — installation_id and every
channel_user_binding / channel_chat_session_binding kept. Deleting it
first forces an INSERT with a fresh installation_id, orphaning every
member's account link (they must re-link) and all chat-session
continuity; only the installer is re-bound.
Fence the delete with `agent_id <> $agent_id` so it only clears a
DIFFERENT agent's revoked row (the genuine app_id-slot blocker). The
same agent's revoked row is left for the upsert to reactivate losslessly.
Since idx_channel_installation_type_appid is globally unique on
(channel_type, app_id), at most one row ever holds a given app_id, so the
excluded row is exactly the one the upsert will reuse.
Adds DB-backed regression tests: same-agent revoked row preserved,
different-agent revoked row deleted, active row never deleted, other
workspace fenced, plus end-to-end reactivation semantics (same agent
keeps installation_id + bindings; different agent gets a fresh id).
Co-authored-by: multica-agent <github@multica.ai>
* fix(lark): clean dependent rows when hard-deleting a rebound Feishu installation
Addresses review on #4997 (MUL-4134). channel_* has no FK/cascade
(MUL-3515 §4), so hard-deleting a different-agent revoked installation
left application-owned rows dangling at a removed installation_id:
- channel_chat_session_binding: the outbound patcher would resolve a
binding, then fail loading the deleted installation — turning a clean
no-op into error logs.
- channel_binding_token: a still-unexpired bind link (15 min TTL) could
be redeemed into the deleted installation, reporting "bound" against a
bot that no longer reaches the user.
- channel_inbound_audit: dangling installation_id, where migration 124
models the old ON DELETE SET NULL as an app-layer NULL.
- channel_user_binding: dead member links (a different agent is a
distinct connection; links do not follow and can never be reused).
Rework RemoveRevokedInstallationByAppID to resolve the single row holding
the app_id and act only when it is revoked, in this workspace, and owned
by another agent; then, on the caller's transaction, clear chat-session
bindings, pending binding tokens and member links, NULL the audit
references, and finally delete the row via the fenced query (defense in
depth). Same-agent reconnect and active/other-workspace rows are no-ops.
Adds DeleteChannelUserBindingsByInstallation,
DeleteChannelBindingTokensByInstallation, and
NullChannelInboundAuditInstallationID queries, plus a DB-backed test
(TestChannelStore_RebindCleansDependentRows) asserting every dependent is
cleaned and the audit row survives detached. Verified the test fails when
the cleanup is skipped.
Co-authored-by: multica-agent <github@multica.ai>
* fix(lark): make the rebind cleanup race-safe with a guarded delete gate
Addresses the concurrency must-fix on #4997 (MUL-4134). The prior shape
read the candidate installation, checked revoked/workspace/agent in Go,
cleaned the dependent rows, then ran the fenced delete. That read-then-
clean-then-delete order has a TOCTOU: while B is rebinding the bot to a
different agent, A can reconnect to the SAME agent and reactivate the row
to 'active' in between. B still wipes A's user/chat/token bindings and
NULLs its audit based on the stale "it was revoked" read, then the fenced
delete no-ops (status is no longer revoked) — so A's installation
survives active but its bindings are gone. Concurrent same-agent data
loss, reintroduced.
Make the guarded DELETE the atomic gate. DeleteChannelInstallationByAppID
becomes DeleteRevokedChannelInstallationByAppID `:one ... RETURNING id`,
and RemoveRevokedInstallationByAppID keys all dependent cleanup off the
id the delete actually claimed. No separate read. Under READ COMMITTED a
concurrent reactivation makes the DELETE re-check status='revoked'
against the live row (EvalPlanQual): it claims nothing, returns
pgx.ErrNoRows, and no dependents are touched. With no FK the cleanup can
follow the claiming delete in the same transaction; any failure rolls the
whole thing back.
Adds TestChannelStore_RebindGuardedDeleteRaceWithReactivation: two real
transactions race on one revoked installation — one reactivates and holds
the row lock, the other runs the rebind cleanup and blocks on the guarded
delete — asserting the installation and every binding stay intact.
Verified this test fails on the old read-then-clean-then-delete shape and
passes (also under -race) on the gated version.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: jiangliangyou <jiangliangyou@xiaomi.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: J <j@multica.ai>
The core writeJSON helpers streamed the body via json.NewEncoder(w).Encode
after WriteHeader, which forces net/http into chunked transfer encoding and
omits Content-Length. Buffer the marshaled body first, set an accurate
Content-Length, then write — so API (and health) JSON responses advertise
their exact size. writeMeasuredJSON gets the same header. Adds a test
asserting the header matches the on-wire body length.
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
The /login page's already-authenticated effect fired on any user change,
including the one verifyCode writes mid form-login while handleVerify is
still fetching the workspace list. It then read the cold list cache
(getQueryData ?? []) and raced handleSuccess with a replace to
/workspaces/new — users with existing workspaces could land on the
create-workspace page after login.
Two changes, both in the arrival effect:
- Ownership: latch once auth settles as logged-out on this page; any
user appearing afterwards came from the login form, whose
handleSuccess owns post-login navigation. The effect now only serves
visitors who arrived authenticated. The desktop-handoff branch stays
above the latch so form logins with platform=desktop still mint the
deep-link token.
- Correct data: for genuine arrived-authenticated visitors, fetch the
list via ensureQueryData instead of reading a possibly-cold cache,
which misrouted workspace owners to /workspaces/new on fresh page
loads.
Regression tests verified red against the previous implementation.
Fixes#5009
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(daemon): add worktree_pool mode for local_directory (MUL-3483)
## What changed
Squad workflows bound to the same `local_directory` resource used to
serialise on a single path mutex — a documented pain point from GitHub
issue #4377. This introduces an opt-in `worktree_pool` mode on the
`local_directory` project resource. When enabled, each task gets its own
`git worktree add` under a daemon-managed pool root, so sibling tasks on
the same base repo now run truly in parallel while `git worktree
add/remove/prune` stays serialised behind a per-repo mutex.
## Shape
- `local_directory.resource_ref` gains three optional fields:
`mode` ("in_place" default / "worktree_pool"), `pool_root` (defaults
to `<parent>/.multica-worktrees/<base>`), `max_parallel` (defaults
to 4). Legacy rows are byte-identical after round-trip: the server
validator strips the pool fields on the default in_place path so
older clients keep behaving exactly as before.
- New `WorktreePoolManager` (`server/internal/daemon/worktree_pool.go`)
owns pool allocation, per-repo git-metadata mutex, and cleanup.
- `acquireLocalDirectoryLockIfNeeded` now branches on the ref's mode.
in_place stays on `LocalPathLocker` and the shared tree; worktree_pool
routes through the pool manager, publishes a lease keyed by task ID,
and pins the agent to the freshly allocated worktree in
`execenv.PrepareParams.LocalWorkDir`.
- Pool saturation is a structured wait_reason
(`worktree_pool saturated (N/M) on <path> (holders: ...)`), retrying
on the existing cancel-poll interval — same UX as the historical
path-mutex wait.
## Safety guardrails (also known footguns from prior art)
- Repos with initialised submodules are refused up front. Multi-checkout
of a superproject is explicitly unsupported by `git worktree(1)` BUGS
and the per-worktree `modules/` directories bloat disk by pool size ×.
- Dirty worktrees are NEVER `--force` removed on release. If the agent
left uncommitted changes behind we keep the directory (and free the
slot) so users can inspect. This is the failure mode
claude-code#55724 documented and the pool must not regress into.
- The per-repo mutex covers every `git worktree add/remove/prune` and
`submodule status` invocation for a given base, matching the
in-process-queue fix Anthropic settled on for claude-code#34645
(`.git/config.lock` races on concurrent add).
- Task UUID is the source of truth for both branch (`multica/<uuid>`)
and worktree path (`<pool_root>/<uuid>`) so a single agent running
multiple worker tasks in parallel can never collide.
- Non-empty leftover directories at the target path abort the
allocation instead of silently starting the agent in an unknown state.
## Explicit MVP non-goals (deferred, tracked as follow-up work)
- Windows worktree-remove retry (permission-denied on locked handles).
- Detached-HEAD fast path for read-only exploration tasks.
- `post-checkout` hook opt-out / serialisation.
- Automatic `git lfs install`.
- UI surfacing of the pool state / dirty worktree list.
## Tests
- `worktree_pool_test.go` (new): full acquire→release lifecycle,
parallel allocation, saturation with holder list, slot re-use after
release, dirty-worktree preservation, concurrent-acquire serialisation
(the config.lock guard), submodule refusal, missing base rejection,
pool root auto-mkdir, non-empty leftover refusal, ctx cancel.
- Handler validator gains three rejection cases (unknown mode, relative
pool_root, negative max_parallel) and a round-trip test that pins the
normalised JSON shape for both modes.
- Daemon `localDirectoryRef` helpers get a defaults test and the pool
root path derivation is pinned.
## Wire-compat and rollout
- Default off. Existing rows keep the historical shape (no `mode`,
`pool_root`, or `max_parallel` in the JSON) and behave exactly as
before.
- Opt-in via `--ref '{"local_path":"...","daemon_id":"...","mode":"worktree_pool"}'`
today. CLI flag shortcuts (`--mode`, `--pool-root`, `--max-parallel`)
can follow in a small tail PR — not blocking.
- No DB migration. No UI change required.
Co-authored-by: multica-agent <github@multica.ai>
* feat(daemon): address worktree_pool review nits (MUL-3483)
Follow-up to #4986. Three non-blocking review points from GPT-Boy:
1. **Daemon integration test for lease → runTask plumbing.**
`TestAcquireLocalDirectory_WorktreePoolPublishesLease` (and its
in_place counterpart) pin the exact contract runTask relies on
when it reads `d.localLeases.Load(task.ID)` and feeds
`lease.WorkDir` into `execenv.PrepareParams.LocalWorkDir`. A future
refactor that drops the Store, mistypes the key, or swaps back to
`assignment.AbsPath` on the pool branch will now fail here rather
than silently defeat the whole point of worktree_pool mode.
2. **Untracked-only dirty case now classifies as dirty.**
`worktreeIsDirty` used `--untracked-files=no`, which meant a
worktree with only untracked files was reported "clean" and hit
the `git worktree remove` branch — git itself would then refuse
the removal because the file exists (so no data was lost), but the
log path lied about what happened on disk. Switching to
`--untracked-files=normal` routes agents' fresh drafts directly
through the "leaving on disk for user inspection" branch, and
`TestWorktreePool_UntrackedOnlyIsKept` pins the guarantee so
nobody quietly reverts the flag later.
3. **Skill doc note on default `pool_root` location.**
`multica-projects-and-resources/SKILL.md` now spells out the three
new ref fields (`mode`, `pool_root`, `max_parallel`), the default
`<parent>/.multica-worktrees/<repo>` location (next to the repo,
not inside it), the write-permission requirement on the parent
directory, and the submodule restriction — so agents advising
self-host users hit the right doc line rather than reading source.
Existing test suite still green:
- `go vet ./...` clean
- `go test ./internal/daemon/... ./internal/handler/... ./internal/service/...` all pass
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
* fix(workspace): drop workspace from list cache while delete is pending (MUL-4129)
The delete-workspace flow navigates away before awaiting the DELETE
(required ordering — see navigateAwayFromCurrentWorkspace's
CancelledError notes), but useDeleteWorkspace left the workspace in the
list cache until onSettled. During the pending window any list refetch
re-presented the deleting workspace as a selectable/current option.
Optimistically remove it in onMutate (after cancelling in-flight list
fetches), roll the snapshot back in onError so a failed delete restores
the workspace alongside the existing error toast, and keep the
onSettled invalidate as the server-truth reconcile.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(workspace): own storage cleanup on delete success and tombstone pending deletes (MUL-4129)
Address final-review blockers on #4980:
1. The realtime workspace:deleted handler reverse-looks-up the slug from
the list cache to clear the ${key}:${slug} persisted namespace; the
optimistic removal empties that row on the initiating client, so the
lookup misses and cleanup was silently skipped. Capture the slug in
onMutate before removal and clear storage in onSuccess only — a
failed DELETE rolls back and must not touch persisted state.
2. cancelQueries only covered fetches already in flight at onMutate.
Add a pending-delete tombstone (marked onMutate, lifted onSettled
before the reconcile invalidate) filtered inside workspaceListOptions'
queryFn, so invalidation/reconnect/fetchQuery refetches that land
mid-pending cannot write the not-yet-committed row back into cache.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* docs(claude-md): scope optimistic updates to same-screen field patches
Replace the blanket "mutations optimistic by default" state rule with
three scoped rules: optimistic only for predictable same-screen field
patches; navigating/confirming flows (create/delete/leave) await the
server first; chat send uses the pending-message pattern.
Aligned with TanStack Query maintainer guidance and React Router's
pending-UI criteria; the old blanket rule is what steered the original
MUL-4129 fix toward optimistic entity removal.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(workspace): await delete before navigating; drop optimistic removal (MUL-4129)
Rework of the previous approach on this branch. The optimistic removal
emptied the workspace list cache at click time, while the settings page
was still mounted on the old slug — useWorkspaceId (URL slug + list
lookup) then threw 'no workspace selected'. Root cause of the original
navigate-first ordering was dual ownership of delete handling between
the initiating flow and the realtime workspace:deleted handler.
- useDeleteWorkspace: no optimistic removal, no rollback; onMutate only
marks the delete self-initiated and captures the slug; onSuccess owns
storage cleanup; onSettled invalidates.
- pending-delete.ts: repurposed from tombstone filter to self-initiated
marker; kept on success (suppresses the WS echo), lifted on failure.
- use-realtime-sync: workspace:deleted no-ops for self-initiated
deletes; it now only serves deletes initiated elsewhere.
- workspace-tab: confirm dialog stays open in loading state, navigate
only after the DELETE succeeds; failure leaves the user in place with
nothing to roll back. Replace throwing useWorkspaceId with
workspace?.id + enabled gating (independent crash on external
deletes of the current workspace).
Known debt: useLeaveWorkspace still navigates before awaiting
(member:removed has no self-initiated marker yet).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* perf(chat): fix pending-task index mismatch + cut aggregate request storm (MUL-4159)
ListPendingChatTasksByCreator was a top DB hotspot. Root cause: the partial
index idx_agent_task_queue_chat_pending (migration 040) only covers
status IN (queued, dispatched, running), but migration 109 added a fourth
in-flight status (waiting_local_directory) that both pending chat queries now
filter on. Postgres can only use a partial index when the query predicate is a
subset of the index predicate, so the 4-status query stopped using it and
degraded to a Seq Scan over the whole agent_task_queue.
Implements the reviewed P0-P3 plan in one PR:
P0 index fix (split single-statement CONCURRENTLY migrations per repo convention)
- 143: CREATE INDEX CONCURRENTLY idx_agent_task_queue_chat_pending_v2 covering
all four in-flight statuses (same column list, so GetPendingChatTask still
benefits).
- 144: DROP the superseded 3-status index, in its own migration.
P1 SQL + handler hot path
- ListPendingChatTasksByCreator now returns cs.agent_id and states
chat_session_id IS NOT NULL so the planner can prove the partial-index subset.
- ListPendingChatTasks filters private-agent access against the already-loaded
accessible-agent set using the returned agent_id, dropping the extra
ListAllChatSessionsByCreator scan on the hot path.
- Regenerated sqlc.
P2 frontend request amplification
- FAB uses the new boolean has-any query gated on enabled:!isOpen, so the
minimised button never holds the full aggregate.
- use-realtime-sync maintains the pending aggregate (list + has-any) in place
from task lifecycle events (queued/dispatch/running/waiting_local_directory
-> upsert; completed/failed/cancelled -> remove) instead of invalidating on
every chat:message/chat:done, with a debounced fallback invalidate for
reconnect / unknown payloads.
P3 boolean endpoint
- GET /api/chat/pending-tasks/has-any backed by HasPendingChatTasksByCreator
(EXISTS). Permission filtering is baked in via agent_id = ANY($3); an empty
accessible-agent set short-circuits to false. The detailed list stays for the
ChatWindow history / stop-task flows.
Tests: new handler tests cover the private-agent gate on both endpoints
(hidden from a creator who lost access, visible to the agent owner) plus the
boolean status/terminal semantics.
EXPLAIN (ANALYZE, BUFFERS) on a 300k-row reproduction:
- before (3-status index): Parallel Seq Scan, ~300k rows filtered,
shared hit=3012, 12.1 ms.
- after (v2 index): Index Scan on idx_agent_task_queue_chat_pending_v2,
shared hit=131, 0.07 ms.
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): stop optimistic cross-session pending aggregate writes from workspace-fanout task events (MUL-4159)
Review on PR #5018 flagged a real privilege-escalation bug in the P2 change:
use-realtime-sync optimistically upserted the cross-session pending aggregate
(pendingTasks / pendingTasksHasAny) from chat task:* events. Those events are a
workspace fanout delivered to every member (server still BroadcastToWorkspace,
see cmd/server/listeners.go), and the payload carries no creator / agent
visibility. So member B starting a chat task could flip member A's FAB to
has_pending=true, bypassing the server-side permission filter on
/api/chat/pending-tasks[/has-any].
Fix (option 1 from the review — the self-contained one): never optimistically
write the aggregate from task:* events. On every task lifecycle transition,
debounced-invalidate the aggregate so it is refetched through the
permission-filtering endpoint, which only returns the caller's own
creator-owned, accessible-agent tasks. The per-session pendingTask cache is
still written directly — it is keyed by chat_session_id and only rendered for
sessions the user can open (server-gated), so it is not a cross-user leak.
chat:message is still excluded from aggregate refresh, so the MUL-4159 request
storm stays fixed; task transitions are per-task and coalesced by the debounce.
- Removed upsertPendingAggregate / removePendingAggregate.
- Added exported refetchPendingChatAggregate(qc, wsId) — an invalidate, never a
setQueryData — used by the debounced handler.
- Regression tests: refetchPendingChatAggregate leaves the cached
has_pending/list untouched (no optimistic write) and only invalidates for an
authoritative server-filtered refetch; no-ops without a workspace id.
Verified: @multica/core + @multica/views typecheck; full core vitest suite
(752 tests) green including the 2 new guard tests.
Co-authored-by: multica-agent <github@multica.ai>
* chore(chat): address review nits on pending-tasks endpoints (MUL-4159)
- Restore the GetPendingChatTask godoc first line that was clipped when the
has-any handler was inserted (nit#1).
- ListPendingChatTasks short-circuits to an empty list when the caller has no
accessible agents, mirroring HasPendingChatTasks — skips the DB round-trip
(nit#2).
- Add a cross-creator negative test: user A's in-flight task on a
workspace-visible agent returns has_pending=false / empty list for user B,
locking the cs.creator_id tenant gate that the agent-visibility filter does
not cover (nit#3).
Verified: go build ./... and go test ./internal/handler -run PendingChatTasks
(7 tests) green against live Postgres.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Add HTTP Range support to the attachment proxy-download path so an interrupted
download can resume from where it left off instead of restarting at byte 0.
- Seekable backends (local disk) delegate to http.ServeContent for full
Range / If-Range / 206 / Content-Range / 416 handling.
- Forward-only backends (S3/MinIO streaming) get a single-range fallback that
advertises Accept-Ranges and serves 206 + Content-Range, with a
rangeParseOutcome that returns 416 only for genuinely unsatisfiable byte
ranges and otherwise ignores unsupported/empty-object ranges (full 200),
matching the seekable path.
Closes#4831. MUL-3962.
Detect the root/sudo + bypassPermissions launch condition before starting Claude Code and fail fast with an actionable error (run as non-root, or set IS_SANDBOX=1 in a genuine container/sandbox).
Closes#3278
MUL-4095
* feat(issues): add Cmd+F in-page find to issue detail
Replace the stopgap "find-in-page is virtualized" toast with a real find
bar (MUL-4126). Cmd/Ctrl+F opens a floating bar with keyword input, live
match count, and prev/next navigation that scrolls to and highlights each
match.
- Opening find force-renders the comment timeline flat (reusing the
existing highlightCommentId escape hatch) so off-screen comments become
searchable — the root cause of the original complaint.
- Matches are painted with the CSS Custom Highlight API (ranges only, no
DOM mutation), so highlighting layers cleanly over React-rendered
markdown and the contenteditable title/description editors.
- Scroll-to-match drives container.scrollTop directly (never native
scrollIntoView; #3929).
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): keep in-page find usable without CSS Custom Highlight API
On browsers lacking the CSS Custom Highlight API, `!supported` was folded
into the match-collection path, so Cmd/Ctrl+F opened the bar and swallowed
native find but reported 0 matches and could not navigate — strictly worse
than the native find it replaced (MUL-4126 review).
Feature-guard only the paint calls now: match collection, count, active
index, and scroll-to-match run regardless of support, while
`CSS.highlights.set/delete` / `new Highlight` stay behind the guard. The
MutationObserver re-derives ranges even when unsupported so fallback
counting/navigation track live DOM churn.
Adds a hook test that drives the degraded path (jsdom has no highlight API)
and asserts counting + prev/next still work.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* chore(analytics): retire redundant PostHog tracking (MUL-4127)
PostHog had become a chaotic, largely-unused second copy of data we already
query from the DB and Grafana. Remove the redundant instrumentation.
Server: every product event (signup, workspace_created, issue_created,
issue_executed, chat_message_sent, team_invite_*, onboarding_*, agent_created,
cloud_waitlist_joined, feedback_submitted, contact_sales_submitted,
squad_created, autopilot_created) is now in metricsOnlyEvents, so
metrics.RecordEvent still increments the Prometheus/Grafana counter but no longer
ships to PostHog. DB rows remain the source of truth. Runtime/autopilot/
agent_task lifecycle were already Prometheus-only.
Frontend: delete the PostHog-only funnel instrumentation — $pageview (+ web and
desktop trackers), download_intent_expressed/page_viewed/initiated, the
onboarding_started mirror, onboarding_runtime_path_selected/detected,
feedback_opened, and source_backfill_*. The source-backfill modal itself stays
(it PATCHes the questionnaire to the DB).
Kept on PostHog (frontend only): $exception autocapture and the
client_crash / client_unresponsive stability telemetry (no DB equivalent), plus
$identify/$set. captureSignupSource (attribution cookie) stays — it still feeds
the signup_source Prometheus label.
Verified: pnpm typecheck, pnpm lint (0 errors), vitest (core/views/web/desktop),
go test ./internal/analytics/... ./internal/metrics/...
Co-authored-by: multica-agent <github@multica.ai>
* docs(analytics): fix stale PostHog references after MUL-4127 (review follow-up)
Addresses review of #4996 — three spots still described server events as active
PostHog signals after they became metrics-only:
- docs/analytics.md: issue_executed is no longer a PostHog success signal; it is
Prometheus-only (multica_issue_executed_total) + issue.first_executed_at, in
both the event contract and the Reconciliation section.
- docs/analytics.md: the signup $set_once person properties (email, signup_source)
are no longer emitted — signup is Prometheus-only; only the bucketed
signup_source survives as the multica_signup_total label.
- server/internal/metrics/business_events.go: RecordEvent doc comment no longer
claims it ships product events to PostHog / "PostHog is reserved for
user/product-behaviour events" — every server event is now metrics-only.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Opening a workspace no longer pops the chat window for users who have
never toggled it — the FAB keeps chat discoverable. An explicit stored
open/closed preference is still honoured on reload.
Closes MUL-4149
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* docs(readme): sync runtime list, drop retired Gemini
Gemini CLI was removed from the runtime whitelist (agent.SupportedTypes)
back in v0.3.29; Google also officially retired Gemini CLI on 2026-06-18
in favor of Antigravity CLI. Both README.md and README.zh-CN.md still
advertised Gemini and had drifted from the canonical list.
Sync every runtime/provider list in both READMEs to the 14 entries in
server/pkg/agent/agent.go: remove Gemini, add the missing CodeBuddy,
Antigravity and Trae CLI, and align ordering.
* docs(readme): use consistent "Trae CLI" naming
Align the intro and "Create an agent" provider lists with the
architecture diagram and runtime table, which already say "Trae CLI".
MUL-4145
---------
Co-authored-by: Bohan Jiang <bhjiang@outlook.com>
A Multica-managed run goes terminal the moment the top-level turn exits;
there is no "background work finishes later and wakes you up" step. When an
agent starts background work (a run_in_background shell, a Monitor, an async
subagent) and ends its turn to "wait for a completion notification", the work
is orphaned and the result comment it meant to post is never sent (MUL-4091 /
PR #4970).
The existing claude-only protocol guard forces run_in_background tool inputs
to foreground and fails loud on async_launched tool results, but it cannot
catch the actual MUL-4091 mechanism: a turn that ends cleanly with a
"Standing by, I'll report when CI finishes" message. That shape is only
addressable behaviorally, and it is harness-agnostic.
Harden the Background Task Safety brief (both the legacy/verbose production
path and the slim staging path) with explicit hard pins:
- never background-and-yield / expect a future wakeup that does not exist here;
- do every wait synchronously in a single foreground call (e.g. gh run watch);
- the standalone-harness "running in the background, keep working" hint does
not apply in Multica-managed runs;
- never end a turn with a "standing by" / "I'll report back" sign-off.
Add verbose- and slim-path test coverage for the new pins so a future brief
trim cannot silently drop them.
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(server): don't cancel issue tasks on assignee change (#4963)
Changing an issue's assignee previously called CancelTasksForIssue,
which cancels every active task on the issue by issue_id alone —
regardless of which agent owns the task or how it was triggered. In a
multi-agent workspace this silently dropped unrelated in-flight work
(a mention-triggered run for another agent, a squad task) with no
requeue, and it self-cancelled a run that reassigned the issue from
inside its own turn (the daemon then interrupted the live run before
its post-handoff cleanup could finish).
Reassignment now cancels nothing: ownership handoff no longer implies
interruption. The new assignee's run, if any, is still enqueued by
WillEnqueueRun and runs alongside whatever was already in flight.
Explicit terminal actions — issue -> cancelled and delete issue —
still cancel active tasks, unchanged.
Applies to both UpdateIssue and BatchUpdateIssues. Adds handler tests
that fail against the old behavior (both the previous assignee's own
run and an unrelated agent's run got cancelled) and pass now.
MUL-4113
Co-authored-by: multica-agent <github@multica.ai>
* test(server): cover agent→agent reassign; fix stale WillEnqueueRun comment
Addresses review nits on #4975 (MUL-4113, #4963):
- Rewrite the outdated WillEnqueueRun doc comment. The assign source no
longer cancels existing tasks, so the old "assign cancels existing
tasks before enqueuing, pending task moot" premise is wrong. Describe
the real invariant instead: the write is guarded by the
(issue_id, agent_id) partial unique index, only the status source needs
the pending-task dedup, and the assign source safely skips it.
- Add a handler test for the core agent→agent handoff path. The existing
no-cancel tests only reassigned to a member; this one reassigns from one
agent to another and asserts both effects independently: the previous
agent's running task survives (no collateral cancel) and the new
assignee still gets exactly one run enqueued.
MUL-4113
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Under the hoisted linker (node-linker=hoisted) electron-vite's bin only
lands in the repo-root node_modules/.bin, so the hardcoded
apps/desktop/node_modules/.bin path fails. Use envWithLocalBins to put
both .bin directories on PATH and invoke electron-vite by name.
* docs(changelog): add v0.3.39 (2026-07-06) release notes
Adds today's release entry across en / zh / ja / ko locales.
Co-authored-by: multica-agent <github@multica.ai>
* docs(changelog): simplify v0.3.39 copy, drop technical jargon
Rewrites every entry to describe user-visible outcomes instead of implementation details. Also adds the squad-leader lock fix (#4951) which was merged into main after the initial cut.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Multica Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
The delete-workspace flow navigates away before awaiting the DELETE
(required ordering — see navigateAwayFromCurrentWorkspace's
CancelledError notes), but useDeleteWorkspace left the workspace in the
list cache until onSettled. During the pending window any list refetch
re-presented the deleting workspace as a selectable/current option.
Optimistically remove it in onMutate (after cancelling in-flight list
fetches), roll the snapshot back in onError so a failed delete restores
the workspace alongside the existing error toast, and keep the
onSettled invalidate as the server-truth reconcile.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
The server-side running-task sweeper failed rows purely on `started_at >
now() - runningTimeoutSeconds` (2h30m). By its own comment the wall clock
is "mainly for runs whose daemon died without reporting" and "only needs
to sit generously above any realistic single run" — but the predicate
does not actually distinguish a healthy long-running task from an
orphaned one. On self-hosted deployments this kills multi-hour research
/ training runs mid-flight even though the daemon is still heartbeating
and the run is actively producing output.
The daemon side is intentionally unbounded (only inactivity watchdogs:
idle 30m, tool 2h); the server backstop was silently the only wall
clock. `FailStaleTasks` is now AND-gated on runtime liveness:
* dispatched — unchanged; already excludes rows with a live
`prepare_lease_expires_at` (renewed every 15s by the daemon between
claim and StartTask).
* running — new: excluded when the task's `agent_runtime` row is
`online` AND `last_seen_at` is within the runtime stale window
(staleThresholdSeconds = 150s, the same signal
sweepStaleRuntimes already uses).
Healthy long-running tasks on live daemons are no longer killed by the
wall clock. The daemon-dead case remains primarily handled by
sweepStaleRuntimes in the same tick (Redis LivenessStore + DB stale +
FailTasksForOfflineRuntimes); the wall-clock branch is now a defensive
backstop for the pathological case where a runtime row lingers online
with a stale DB heartbeat for longer than the wall clock. `runtime_id
IS NULL` is treated as "not proving liveness" so the wall clock still
fires on that (rare / historical) shape.
The 2h30m default is unchanged — this is a gate, not a threshold
change. Tests updated: 4 existing running-task tests now age out the
runtime so they still exercise the wall clock; 2 new tests cover both
new invariants (healthy runtime → skipped; stale runtime → still
killed).
Fixes#4958
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
Update public docs and landing copy to the current 14-runtime list; add Qoder / Trae CLI across localized docs. Follow-up: finish JA tool counts (tasks.ja / skills.ja) and align localized Trae section anchors with the /providers#trae links.
Closes#4945
Co-authored-by: vicksiyi <zeroicework@163.com>
* fix(agent): pi agent final output excludes intermediate steps
Updated PI agent to only retain the final result in JSON output.
Previously, `text_delta` included both intermediate steps and final
content.
Now, output is reset on each `text_start` to concatenate only the
final text.
* fix(agent) Replace `message_update.text_start` with `turn_start` event. add test
`turn_start` begins a new turn, Reset output on it to exclude
intermediate texts.
a1b336d73e/packages/coding-agent/docs/rpc.md (events)
The issue action menu (3-dot / right-click) nested a "More" submenu inside the
already-open menu, so opening the menu surfaced yet another "More" — the first
level told you nothing about what was inside.
Rename that submenu to the semantically explicit "Relations" (关系 / 関係 / 관계)
with a Network icon, matching the noun-labelled pattern of the sibling submenus
(Status, Priority, Start date, Due date). Its contents are unchanged — create/add
sub-issue and set/remove parent — and stay grouped so future relation types
(blocks, duplicates, related) have a home.
- Rename i18n key actions.more -> actions.relations across en/zh-Hans/ja/ko
- Swap MoreHorizontal icon for Network
- Update the shared menu test
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Trae (traecli) already has a New() backend, launch header (traecli acp
serve) and provider branding, but was missing from every protocol_family
whitelist, so custom runtime profiles based on Trae were rejected and it
never appeared in the family picker.
Add traecli to SupportedTypes (Go), RUNTIME_PROFILE_PROTOCOL_FAMILIES
(TS), the lockstep test's want map, and a new migration 136 widening the
runtime_profile_protocol_family_check constraint.
MUL-4094, #4945
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>