The deep-link highlight tint faded out over 700ms on the comment body
layers but the sticky header's background switched instantly, and its
4px bottom `after` gradient band recolored by class-switching that
`transition-colors` cannot animate. Both desynced from the body during
the fade, showing a white header and a pale seam under it.
Add `transition-colors duration-700` to the sticky shell so the header
background fades with the body, and make the `after` band derive its
color from the header via `bg-[inherit]` + a `mask-image` fade instead
of a per-state gradient color, so all three layers are driven by the
single header background transition.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
HandleFailedTasks resets a stuck in_progress issue back to todo via a direct UpdateIssueStatus, bypassing the HTTP handler that emits issue:updated. Without that event the frontend realtime reconcile never runs, so status-filtered board columns/lists stay stale until the next write. Publish issue:updated (status_changed + prev_status) after the reset. Fixes#4648 (MUL-3782).
* fix(dashboard): hide deleted agents from usage leaderboard (MUL-3771)
The usage leaderboard fell back to rendering the raw agent UUID when an
agent was no longer in the workspace agent list (`agent?.name ?? row.agentId`).
Hard-deleted agents only survive as legacy usage rollup rows, so they showed
up as a bare UUID.
Filter the leaderboard rows down to agents still present in the workspace.
The agent list is fetched with `include_archived: true`, so archived agents
keep their names and stay; only hard-deleted agents drop out. Filtering is
skipped until the agent list has loaded so a slow fetch doesn't transiently
blank the board. Top-line KPI totals are unchanged — only the per-agent list
is affected.
Co-authored-by: multica-agent <github@multica.ai>
* fix(dashboard): stabilize empty agent list
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Lambda <lambda@multica.ai>
* feat(issues): add Remove parent issue action to promote a sub-issue to standalone (MUL-3764)
Surfaces a discoverable UI affordance for clearing an issue's parent — the
backend and CLI (multica issue update --parent "") already support it, but
the Official App only exposed Set parent. Adds:
- A 'Remove parent issue' item in the issue actions menu (dropdown +
right-click), shown only when the issue has a parent.
- A hover unlink button on the parent card in the issue detail sidebar.
- A removeParent handler that clears parent_issue_id and stage in one
write (stage only orders sub-issues under a parent) with a success toast.
Closes#4629
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): toast remove-parent on success only, prune old parent's children cache (MUL-3764)
Addresses review feedback on #4630:
- use-issue-actions.ts: the remove-parent success toast fired eagerly after
mutate(), so a request that failed on permission/network/validation would
flash "removed" before the error toast and optimistic rollback. Move it to
onSuccess so only a server-confirmed detach is announced.
- mutations.ts: when a write re-parents an issue away from its current parent,
prune it from the old parent's children cache instead of patching it to
parent_issue_id: null in place. The parent's sub-issues list renders that
array directly, so the orphaned row used to linger until the settle refetch.
onError still restores prevChildren, so the prune rolls back on failure.
Adds cache-prune coverage (optimistic remove / rollback / non-reparenting
no-op) and onSuccess-vs-onError toast coverage.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(scheduler): advance autopilot next_run_at after each scheduled dispatch
The display-only autopilot_trigger.next_run_at column was written only on
trigger create/update and never advanced afterward, so for a recurring
schedule it froze at a past slot and the list rendered it as a 'next run'
in the past (e.g. '53m ago'). The intended AdvanceTriggerNextRun query was
dead code with zero callers.
Wire it up at the scheduler's existing post-dispatch seam (replacing the
last_fired_at-only TouchAutopilotTriggerFiredAt bump, which AdvanceTrigger-
NextRun already supersets). The advanced value is computed on the app local
clock via ComputeNextRun — the same path create/update use — so the whole
next_run_at display column is owned by one clock and stays consistent;
scheduling itself is untouched and still runs off DB time via
NextOccurrencesUTC. On a cron/timezone parse failure we fall back to the
last_fired_at-only bump.
Adds a deterministic regression test for the reported scenario (hourly
cron in America/New_York) and documents the local-clock ownership on
ComputeNextRun.
MUL-3749
Co-authored-by: multica-agent <github@multica.ai>
* fix(scheduler): floor next_run_at advance at plan_time to survive clock skew
Addresses review feedback on the next_run_at write-back (MUL-3749):
- The post-dispatch advance computed the value from time.Now() alone. The
handler is entered only after DB time judged the plan due, so if this app
instance's clock lags the DB clock at a period boundary, time.Now() could
recompute the slot that just fired and next_run_at would not advance —
the original staleness bug, at the boundary. Extract advancedNextRun,
which anchors at max(now, plan_time) via NextOccurrenceAfterUTC so the
written value is always strictly after the fired plan_time while still
tracking the local clock in the normal case.
- Add scheduler-layer tests asserting the written value is strictly after
plan_time across skew / on-slot / normal cases. The previous service-layer
test only exercised the helper with an explicit after, not this path.
- Sync the stale ListSchedulableAutopilotTriggers comment: the scheduler
now writes last_fired_at via AdvanceTriggerNextRun (sqlc regenerated).
MUL-3749
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* docs(changelog): add v0.3.31 entry for the 2026-06-26 release (MUL-3748)
Covers the cross-workspace inbox unread dot in the switcher (MUL-3695), the
Composio Go SDK foundation (MVP), per-worktree desktop dev isolation
(MUL-3724), and the new reusable VideoEmbed with the zh docs intro video.
Bug fixes include the editor Tab list-indent / focus-keeping behavior
(MUL-3697), squad leader briefing now keyed by task flag (MUL-3730) plus
the inherited @mention reply skip (MUL-3744), code-block selection
stability during background re-renders (MUL-3621), local handoff-note
version gate for direct agent assigns, comment-edit save loading state
(MUL-3709), search API response parsing, and an actionable error when
self-host hosts are missing Docker Compose v2.
Localized into en / zh-Hans / ko / ja with product-language wording per the
`Issue`-only exception (`agent` -> "agent" / 智能体, `Squad` -> "squad" / 小队).
Co-authored-by: multica-agent <github@multica.ai>
* docs(changelog): tighten v0.3.31 entries per review (MUL-3748)
Per Bohan's review on MUL-3748: the v0.3.31 copy was too wordy. Shorten
every bullet to a single user-facing sentence and drop the internal
details (worktree mechanics, signed webhooks, hljs spans, account-level
summary call, etc.). en / zh / ko / ja all updated; product-language
wording and the Issue / 智能体 / 小队 rule are preserved.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
Clarify that desktop dev worktree isolation (renderer port + app name) is
automatic and independent of .env.worktree, which only covers backend/
frontend DB names/ports. Follow-up to MUL-3724 (#4598).
Co-authored-by: Lambda <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(squad): inject leader briefing by task flag, not issue assignee
Key squad-leader briefing injection off task.IsLeaderTask + task.SquadID
instead of issue.AssigneeType=='squad'. The old gate missed the most common
path — an @squad mention in a comment on an issue assigned to a plain agent
(MUL-3724) — so the leader booted with zero squad context and did the work
itself instead of orchestrating.
- migration 127: add agent_task_queue.squad_id (no FK) + partial index
- sqlc: CreateAgentTask stamps squad_id; CreateRetryTask inherits it
- service: thread squadID through EnqueueTaskForSquadLeader(+WithHandoff),
enqueueMentionTask, and the rerun path; all 5 call sites pass the squad id
- daemon claim: unified injection keyed on leader-task + squad_id, with a
defensive leader-identity re-check; quick-create block retained (it serves
issue-less tasks and sets resp.SquadID/SquadName)
- briefing: strengthen leader Operating Protocol opening
- tests: claim-time injection (comment-mention/non-leader/null-squad),
squad_id enqueue stamping, retry inheritance; existing fixture updated
Co-authored-by: multica-agent <github@multica.ai>
* test+docs(squad): dangling squad_id regression + clarify quick-create path
Address review nits on #4606:
- Add TestClaim_LeaderTaskWithDanglingSquadID_NoBriefing: squad hard-deleted
after enqueue leaves task.squad_id dangling (no FK); claim still 200 and
skips injection via the err!=nil guard. This is the load-bearing contract
for dropping the FK.
- Rewrite the daemon.go injection comment to state quick-create does NOT use
the is_leader_task/squad_id columns — it routes squad via the context JSON
branch (qc.SquadID) and must not be folded into the column-based path.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: 魏和尚 <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(selfhost): fail early with actionable message when Docker Compose v2 is missing
The selfhost make targets hardcoded 'docker compose' (Compose v2). On hosts with only the legacy v1 'docker-compose' (e.g. apt install docker-compose) the plugin is absent, so 'docker compose -f ... pull' fell through to the top-level docker CLI and failed with the cryptic 'unknown shorthand flag: f in -f', which users mistook for a Docker version problem (MUL-3458).
Add a REQUIRE_COMPOSE preflight that checks 'docker compose version' and prints an actionable English install hint, and route every selfhost invocation through $(COMPOSE). v1 fallback is intentionally not supported: docker-compose.selfhost.yml uses compose-spec syntax (top-level name:, no version:) that v1 cannot parse.
Co-authored-by: multica-agent <github@multica.ai>
* fix(selfhost): shorten Compose v2 hint and reject legacy v1
Address review on PR #4354:
- Drop Linux-specific install commands from the runtime error; point to
the OS-agnostic https://docs.docker.com/compose/install/ and keep the
message short and stable. Per-OS steps belong in docs, not the Makefile.
- Reject Compose v1 explicitly: the preflight now also requires the
reported version to be v2, so COMPOSE=docker-compose no longer passes
and then fails later on the compose-spec file.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Lambda <agent@multica.ai>
* feat(desktop): isolate pnpm dev:desktop per worktree (MUL-3724)
Two worktrees could not run pnpm dev:desktop at once: both grabbed the
renderer port 5173 and the single-instance lock keyed by the app name
"Multica Canary". The env hooks to override each already existed
(DESKTOP_RENDERER_PORT in electron.vite.config.ts, DESKTOP_APP_SUFFIX in
src/main/index.ts) but nothing derived per-worktree values.
A new dev launcher (scripts/dev.mjs) derives both from the worktree path
for linked worktrees only — reusing the same cksum%1000 offset as
scripts/init-worktree-env.sh, so renderer port is 5173+offset and the app
becomes "Multica Canary <folder>" with its own userData/lock. The primary
checkout is untouched; explicit env vars still win. Backend targeting is
unchanged (apps/desktop/.env*). Also: brand-dev-electron honors the suffix,
turbo globalEnv passes it through, and CONTRIBUTING documents the flow.
Co-authored-by: multica-agent <github@multica.ai>
* fix(desktop): make worktree dev port/suffix collision-safe (MUL-3724)
Addresses code review on #4598:
- Renderer port base 5173 → 5174 so a worktree whose offset is 0 (e.g.
cksum("/tmp/multica-3494") % 1000 === 0) no longer collides with the
primary checkout's default 5173.
- DESKTOP_APP_SUFFIX is now "<folder>-<offset>" instead of just the folder
name, so worktrees that share a basename at different paths (or names that
slug to the same fallback) get distinct single-instance locks. Without it
the second Electron was still blocked by the shared lock.
- Tests: offset-0 port guard, and same-basename-different-path disambiguation.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(composio): add standalone Go SDK client (MVP)
Adds server/pkg/composio — a self-contained Go SDK for the Composio v3.1
REST API. Built on go-resty/resty v2; zero coupling to other Multica
packages so it can be vendored or extracted later without surgery.
MVP surface (just the endpoints Stage 2 needs):
- POST /connected_accounts/link Client.CreateLink
- POST /tool_router/session Client.CreateSession
- GET /connected_accounts Client.ListConnectedAccounts
- POST /connected_accounts/{id}/revoke Client.RevokeConnection
- DELETE /connected_accounts/{id} Client.DeleteConnectedAccount
(404 -> nil, idempotent)
- GET /toolkits Client.ListToolkits
- GET /toolkits/{slug} Client.GetToolkit
- POST /tools/execute/{slug} Client.ExecuteTool
- Webhook HMAC-SHA256 verification composio.VerifyWebhook /
VerifyHTTPRequest + ParseEvent
Other notes:
- Auth via x-api-key header (Composio v3.1 contract).
- Typed *APIError envelope with IsNotFound / IsUnauthorized /
IsRateLimited helpers; falls back to raw body when upstream returns
non-JSON.
- Webhook signature accepts the official "v1,<sig>" format and any
comma-separated multi-version list; 300s replay tolerance by default,
honors an injectable clock for tests; RFC3339 timestamps tolerated.
- README.md documents all public APIs and design choices.
Tests:
- All exercise httptest.NewServer - no real Composio calls.
- 36 tests covering happy paths, validation, 404 idempotence, error
decoding, signature verify (good / tampered / stale / multi-version /
bare / RFC3339 / missing headers / empty secret).
- go test ./pkg/composio/... -cover -> 82.2%, exceeds the >=80% bar.
Follow-ups (separate PRs):
- server/internal/integrations/composio - DB schema, REST handlers,
registration_service (CSRF), dispatch hook (MUL-3720 remainder).
- Pagination iterators, retry middleware, proxy execute, triggers.
Refs: MUL-3720, MUL-3715
Co-authored-by: multica-agent <github@multica.ai>
* fix(composio): align SDK with v3.1 wire contract (PR #4603 review)
Addresses GPT-Boy's review on PR #4603:
Must-fix
- ListConnectedAccountsRequest: switch UserID/AuthConfigID singular fields
to plural slices (UserIDs, AuthConfigIDs, ToolkitSlugs, ConnectedAccountIDs,
Statuses). The Composio v3.1 spec ships these as `*_ids` array params;
our singular form silently dropped the user-filter on the wire. Also
surfaces order_by / order_direction / account_type from the same spec.
- ExecuteToolRequest: rename ToolkitVersions -> Version with json tag
`version` (the actual v3.1 body field). Marks AllowTracing as
deprecated per the spec.
Nits
- ListToolkitsRequest.SortBy comment: `popular | alphabetical` -> the
real enum `usage | alphabetically`.
- Client constructor: when Options.HTTPClient is provided, use
resty.NewWithClient(hc) so the caller's Transport, Jar, CheckRedirect,
and Timeout all carry through — the prior code only forwarded
Transport + Timeout despite the field comment promising the full
*http.Client.
Tests
- TestListConnectedAccounts_QueryString now asserts plural query keys
(user_ids, auth_config_ids, connected_account_ids, statuses) and
explicitly guards that the legacy singular keys do not leak.
- TestExecuteTool_Success decodes the body as a raw map and asserts the
wire key is `version` (not `toolkit_versions`).
- New TestExecuteToolRequest_VersionSerialization locks the json tag.
- New TestNewClient_HonorsInjectedHTTPClient drives a request through a
recordingTransport and asserts the inbound *http.Client actually
handled it.
Verification
- go test ./pkg/composio/... -cover -> 85.1% (38 tests; was 82.2% / 36).
- go vet, go build, gofmt -l all clean.
Refs: PR #4603 review, MUL-3720
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
In a list item that cannot indent (first child / max depth), Tab was
returned unhandled, so the browser's native Tab moved focus out of the
editor onto adjacent controls. Decouple "swallow the key" from "did the
indent move anything": best-effort indent, then swallow whenever the
caret is inside the list (editor.isActive(name)) and only fall through
to focus navigation when not in a list. Covers bullet, ordered and task
lists via the shared keymap.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stock prosemirror-schema-list `sinkListItem` returns false without
dispatching whenever `range.startIndex === 0`, so selecting a list from
the top and pressing Tab did nothing. Bullet, ordered, and task lists
all routed through the same command and were equally affected.
Wrap the shared Tab keymap (PatchedListItem + PatchedTaskItem) with
`sinkListItemRange`: try stock sink first, and when it bails on a
multi-item range whose first item is the list's first child, re-run the
stock command on a selection narrowed to start inside the second selected
item. The first item stays as an anchor and the rest nest under it
(Notion/GitHub nested-list behaviour), in a single undoable transaction.
Shift-Tab / liftListItem already handles ranges and the first-item case,
so it is unchanged. No schema change.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(docs): add reusable VideoEmbed and embed intro video on zh homepage
Add a provider-agnostic, click-to-load <VideoEmbed> component (Bilibili now,
YouTube reserved) and embed the Chinese intro video (BV1cv7Y6gEg7) at the top
of the Chinese docs homepage. The facade renders on first paint; the
third-party player iframe only mounts on user click, so first paint pays
nothing for an external player or its trackers. Registered in both docs MDX
component maps so it is reusable on any docs page.
Scope is zh docs only — no usecase, no other locales, no analytics, no video
hosting.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* docs(zh): drop duration from intro video title
Use the duration-free title "Multica 中文介绍视频" for the homepage VideoEmbed
instead of a minute-count phrasing. Copy accuracy only; no component, layout,
or provider changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
Selecting text in a readonly code block (comment/issue markdown) lost the
selection within seconds, making copy impossible, whenever the surrounding
view re-rendered — most reliably while a sibling agent task streamed over
WebSocket (a re-render roughly every ~100ms).
Root cause: the `code` renderer emits highlighted HTML via
`dangerouslySetInnerHTML={{ __html }}`, a fresh prop object every render. Each
unrelated parent re-render re-ran react-markdown, and React rewrote the
`<code>` innerHTML even though the HTML string was byte-identical, tearing down
and rebuilding all 161 hljs `<span>` nodes. The native selection is anchored to
those nodes, so it collapsed.
Fix: memoize the entire `<ReactMarkdown>` subtree on its only real inputs
(`processed` + `components`). A stable element reference lets React bail out of
the subtree on unrelated re-renders, so the code-block DOM is never rebuilt
while content is unchanged. Confirmed via an instrumentation probe: zero
`<code>` DOM mutations during streaming after the fix.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sidebar): mark which workspace has unread in the switcher dropdown (MUL-3695)
The aggregate avatar dot only says "some other workspace has unread". When
the user opens the workspace switcher they couldn't tell which one. Add a
per-row brand dot next to each OTHER workspace that has unread inbox items,
in the same right-edge slot as the active-workspace check (the active
workspace is excluded — its unread is the Inbox nav count — so dot and
check never collide on one row).
Reuses the existing cross-workspace summary data; no backend change. New
pure helper unreadWorkspaceIds() + unit tests, and AppSidebar dropdown
tests covering: dot only on the other unread workspace, no dot at count 0,
and never on the active workspace.
Co-authored-by: multica-agent <github@multica.ai>
* fix(inbox): count switcher unread per issue, matching the inbox dedup (MUL-3695)
The unread-summary that drives the workspace-switcher dot counted raw
unread inbox_item rows, but the inbox UI deduplicates notifications per
issue and treats an issue as read when its NEWEST non-archived item is
read. Opening an issue marks only that newest item read (markInboxRead is
per-item; only archive cascades to siblings), so older siblings stay
unread in the DB. Result: a workspace whose inbox the user sees as empty
still lit the dot (reported on bohan-personal showing a dot for Multica AI
with no unread).
Rewrite CountUnreadInboxByWorkspace to pick the newest non-archived item
per (workspace, issue-or-id group) via DISTINCT ON and count only groups
whose newest item is unread — the exact semantics of
deduplicateInboxItems(...).filter(!read) on the client. No schema/handler
change; query-only. Adds TestInboxUnreadSummaryDedupesByIssue covering the
read-newest / unread-older case and its inverse.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Adds a cross-workspace unread summary so the workspace switcher shows the
existing brand dot when a workspace OTHER than the active one has unread
inbox items. The active workspace's own unread stays on the Inbox nav
count to avoid a duplicate signal, and the dot is shared with the pending-
invitation indicator.
Backend: new GET /api/inbox/unread-summary returns per-workspace unread
counts for the user, scoped via a member join so a left workspace can't
light the dot. One account-level query instead of N per-workspace inbox
fetches.
Frontend: schema-guarded api.getInboxUnreadSummary, a single account-level
TanStack Query, and a derived "other workspace has unread" boolean in
AppSidebar (shared by web + desktop). Inbox WS events (new/read/archived/
batch) and reconnect invalidate the summary, so the dot appears and clears
in realtime even for events from a non-active workspace.
Closesmultica-ai/multica#3773
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The run-confirm interception box gated its handoff-note field on the
preview round-trip's `handoff_supported`, so every open showed a
"checking…" wait before the note box could even be used — to learn
something the client already holds. For a concrete agent assignee the
target runtime is exactly that agent's, and its CLI version is already
warm in the prefetched agent + runtime caches, so the box can settle
synchronously, the same way the quick-create version gate does.
Add a frontend `handoffSupported` mirror of the server's
MinHandoffCLIVersion gate, resolve the agent → runtime → cli_version
locally, and drive the note box from that verdict without waiting on
loading. Squad / batch-status / unresolved-agent paths — whose resolved
trigger set is only known server-side — keep falling back to the
preview's `handoff_supported`, unchanged.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(editor): accept highlighted composer suggestion on Tab
Plain Tab now accepts the highlighted mention / slash-command suggestion,
matching Enter, across every composer built on the shared TipTap editor
(chat, issue description, comment/reply). A single shared isPickerAcceptKey
predicate centralizes the accept-key policy so the two picker lists stay in
sync instead of each re-deciding what counts as accept.
Shift+Tab and Ctrl/Cmd/Alt+Tab are intentionally NOT accept keys, so reverse
focus navigation and OS window switching are preserved. When no picker is
open, Tab keeps its existing behavior (list indent / focus traversal).
Adds unit coverage for both picker lists plus a plugin-order guard that fires
Tab through real ProseMirror dispatch with the caret inside a list item,
proving the suggestion layer outranks PatchedListItem's Tab -> sinkListItem.
Scope: web/desktop shared composer only; mobile and the generic combobox are
untouched.
Refs: MUL-3685
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* test(editor): make Tab/list-item priority guard actually exercise sinkListItem
The guard placed the caret in the first (only) bullet item, where
sinkListItem is a no-op (no preceding sibling to nest under), so the test
passed regardless of whether the suggestion layer intercepted Tab. Rebuild
the fixture as a two-item list with the caret in the SECOND item, where
Tab -> sinkListItem can fire, and add a sanity control proving a bare Tab
(no picker) does sink that item — so the accept-wins assertion is meaningful.
Production code unchanged.
Refs: MUL-3685
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
The per-project issue list rides the filtered myAll cache. Changing an
issue's project is a membership change, but the surgical patch
(patchIssueInBuckets) is filter-blind and never removes a card that no
longer matches the list's project filter — so a moved issue stayed visible
in the old project's list until a manual refetch (#4548 / MUL-3669).
Root cause: project_id was the only membership-affecting field with no
server *_changed flag. The WS handler fell back to diffing project_id
against its own cache, which breaks once onMutate has optimistically
overwritten the cached value on a local move.
- server: stamp project_changed on issue:updated (UpdateIssue + Batch),
alongside status_changed / assignee_changed.
- events.ts: surface project_changed (optional, additive — old clients ignore).
- ws-updaters: prefer the server flag, fall back to the cache diff only when
absent (older backend) so a new frontend on an old backend does not regress.
- mutations: onSettled invalidates myAll when project_id changed — a local
safety net that never depends on the WS echo (update + batch).
Tests: WS flag wins over a matching cache (local-move repro), explicit false
suppresses the legacy diff, the cache-diff fallback still fires, and both
mutations invalidate myAll on a project change.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Board column headers read byStatus[status].total, which #4415 began
maintaining purely client-side via patchIssueInBuckets ±1. That patch
adjusts the totals only when it can locate the issue in a loaded page,
so a status change on an issue outside the loaded window (very common
when an agent flips the status of something the viewer never scrolled
to) was a silent no-op. #4415 also removed the list invalidation that
used to reconcile counts, so the totals drifted until a full reload.
Thread the server's status_changed flag through onIssueUpdated and, when
a status-changed patch cannot be applied surgically (no-op because the
issue is off-screen), refetch just that one list to reconcile its
counts. The loaded/echoed fast path is untouched, so #4415's no-flicker
drag behavior is preserved.
Closes#4554
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes#4204
Add a daemon signal guard in resolveToken so daemon-managed subprocesses fail closed instead of falling back to the user-global config token when agent/task env markers are missing. Also adds boundary tests for MULTICA_DAEMON_PORT, MULTICA_SERVER_URL, explicit MULTICA_TOKEN priority, and normal config fallback.
Point Lark binding and issue-created links at the web app URL (MULTICA_APP_URL)
instead of the backend public URL, so users on split-domain / self-host
deployments get working links. appURLFromEnv falls back to FRONTEND_ORIGIN,
matching the backend's existing app-URL resolution.
When codex emits a single stdout line larger than the daemon's 10 MB
bufio.Scanner cap (typical trigger: thread/resume on a long-history
session), the reader goroutine returns scanner.Err()="token too long",
markProcessExited fails the in-flight RPC, and the lifecycle goroutine
enters its failure path. That path calls drainAndWait() — stdin.Close()
+ cmd.Wait() — before sending the failed Result. But cmd.Wait() never
returns: codex is alive and blocked writing the rest of the oversized
line into a stdout pipe nobody is reading, so it never reaches its
stdin read syscall and never sees the EOF. The lifecycle goroutine
therefore never sends Result{failed} to its caller, the outer daemon
blocks on the result channel, and the existing PriorSessionID-with-
empty-SessionID fallback never fires — the task is permanently
stalled and codex (Node wrapper + native Rust app-server) leaks until
the OS reaps them.
The cancel() that would have unblocked things via cmd.WaitDelay's
SIGKILL was registered as a defer AFTER drainAndWait, so LIFO defer
order put cancel last — drainAndWait blocks first, cancel never runs.
Fix:
1. drainAndWait now runs the existing graceful-then-cancel pattern
itself, in two bounded phases. Phase 1 waits for readerDone (capped
by codexGracefulShutdownTimeout, so we still give codex its OTEL
flush window on clean exits); on timeout it cancels the runCtx so
cmd.Cancel kills the tree and the reader unblocks. Phase 2 bounds
cmd.Wait() the same way for the scanner-overflow case, where
readerDone closed early but the process is still alive on a full
stdout pipe. The success-path cleanup that previously duplicated the
graceful-cancel pattern around readerDone collapses to a single
drainAndWait() call.
2. cmd.Cancel is set to send SIGKILL to the whole codex process group
(Setpgid via configureProcessGroup, signalProcessGroup on cancel)
instead of just the leader. This addresses YOMXXX's
orphaned-Codex-child concern: the Node wrapper and the native
app-server it spawns now both die when cleanup forces the kill,
rather than the native binary leaking as an orphan reparented to
init. configureProcessGroup is a no-op on Windows.
3. codexGracefulShutdownTimeoutNanos atomic.Int64 mirrors
opencodeTerminateGraceNanos so the regression test can shrink the
grace window from 10 s to 500 ms. Production code is unchanged
(default 10 s).
Outer daemon (daemon.go) already retries with a fresh session when
result.Status == "failed" && PriorSessionID != "" && result.SessionID
== ""; the failed Result now actually reaches it, so the recovery
fires on its own without any daemon-side change.
Tests:
- New regression TestCodexExecuteCleansUpWhenScannerOverflowsOnResume
spawns a fake codex that emits an 11 MB single-line thread/resume
response (trips the scanner cap) and then sleeps without re-reading
stdin. With the original drainAndWait body it blocks at the 10 s
executeFakeCodex deadline ("timeout waiting for result") — verified
by temporarily reverting just the helper body — and with the fix it
completes in ~1.3 s with Result.Status="failed",
Result.SessionID="" so the outer fallback can fire.
- Full codex test suite, full agent package, daemon + execenv +
repocache packages, go build ./..., and go vet on agent/daemon all
pass.
Out of scope (deferred to follow-up per YOMXXX): bumping the 10 MB
bufio.Scanner cap on codex / claude / copilot / cursor / hermes /
kimi / kiro / codebuddy / antigravity / qoder / openclaw / opencode
(pi already sits at 32 MB), and the shared bounded JSON-RPC line
reader that would eliminate the single-line-overflow risk class
entirely. Buffer size alone is not the fix — recovery behaviour is.
Refs: GH#4520
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
* feat(slack): Socket Mode channel.Channel adapter (MUL-3516)
First slice of the Slack adapter: implements channel.Channel (Type/Connect/Disconnect/Send/Capabilities) over Slack Socket Mode, normalizes inbound events to channel.InboundMessage (DM, channel @mention, thread reply; bot-loop + edit/delete guards), decodes the per-installation config/secret blob, and registers the Factory under TypeSlack. No engine, core, or channel_* schema change. Unit-tested (translation, capabilities, config decode, chunking, Send via httptest). Resolvers + engine wiring + Block Kit binding replier follow.
Co-authored-by: multica-agent <github@multica.ai>
* fix(slack): address adapter review (MUL-3516)
- Propagate InboundHandler errors through dispatchEventsAPI/handleSocketEvent to Connect so an infra failure tears down the connection for Supervisor reconnect/backoff instead of being silently swallowed (ACK still happens first).
- Capabilities: declare only CapText | CapThreadReply; drop CapRichCard/CapAttachment/CapMessageEdit until those Send paths are wired.
- slackChatType: map mpim (multi-party DM) to group, not p2p, so the 'must address bot' filter applies; only 1:1 im is p2p.
- Document the group-addressing decision: explicit @bot mention required in groups; mention-free thread continuation deferred to the session-aware layer.
- Tests: handler-error propagation, slackChatType table, mpim-requires-mention, capabilities negative assertions.
Co-authored-by: multica-agent <github@multica.ai>
* refactor(channel): shared channel-agnostic ChatSession service (MUL-3516)
Extract the session/append//issue machinery — currently locked inside the Feishu-pinned lark.chatSessionService — into a shared engine.ChatSession parameterized by channel_type + session titles, so every IM adapter reuses it instead of re-implementing it. Logic is verbatim (find-or-create session+binding with unique-violation race re-read; append+touch+reply-target+in-tx dedup Mark; /issue parse with bare-command previous-message fallback) but channel-neutral: command-parse source is supplied by the adapter (enrichment is platform-specific). Backed by a narrow SessionQueries interface so it is unit-tested with an in-memory fake (no DB). /issue parser moved to engine.ParseIssueCommand. Next: migrate Feishu onto it and wire Slack's ResolverSet, removing the lark duplicate.
Co-authored-by: multica-agent <github@multica.ai>
* fix(channel): decouple session binding key from outbound target (MUL-3516)
Addresses Elon's round-2 review. engine.ChatSession.EnsureSession previously keyed the binding on a raw chat id (EnsureSessionInput.ChatID), so a resolver wiring Slack straight through would collapse every @bot thread in one channel into a single chat_session and overwrite last_thread_id. Make the API un-misusable:
- EnsureSessionInput.ChatID -> BindingKey: the explicit session-isolation key (Feishu: chat id; Slack DM: channel id; Slack channel: channel id + thread root), documented so a raw threaded-platform chat id is never passed straight through.
- Add EnsureSessionInput.BindingConfig (opaque) persisted on the binding's config column, so the real outbound channel/thread is preserved when BindingKey is composite — outbound routing stays separate from the isolation key.
- channel.sql CreateChannelChatSessionBinding now writes config (additive, uses the existing NOT NULL column; lark caller passes '{}', no schema change, no Feishu regression).
- Tests: TestEnsureSession_ThreadRootIsolation (two thread roots in one channel -> two sessions; same root reuses) and TestEnsureSession_StoresBindingConfig.
No production wiring change yet (per review, the not-yet-wired shared service is an accepted preparatory state); this makes the API correct before Feishu/Slack are migrated onto it.
Co-authored-by: multica-agent <github@multica.ai>
* feat(slack): Slack ResolverSet with thread-root session isolation (MUL-3516)
Wires Slack into the channel-agnostic engine.Router via a ResolverSet built on the generic channel_* queries (installation route by team_id, identity + workspace-membership recheck, two-phase dedup, audit) plus the shared engine.ChatSession. No new query, no schema change.
slackSessionRouting is the per-message isolation rule (Elon round-2 / Niko round-3): a DM is one session per channel; a channel/group message is isolated by thread root (key = channel:threadRoot, root = inbound thread_ts or the message ts for a top-level @mention), so two @bot threads in one channel are two sessions. The real channel id rides in BindingConfig for outbound; the reply thread is returned separately. Tests cover DM/channel/thread routing, config, and that distinct thread roots isolate while a same-thread follow-up reuses its key.
Not yet wired into router.go (still a preparatory commit, per review); Feishu migration onto the shared service, router/config wiring, and the Slack outbound path follow.
Co-authored-by: multica-agent <github@multica.ai>
* feat(slack): Markdown->mrkdwn outbound formatting (MUL-3516)
Slack renders mrkdwn, not Markdown, so an unconverted agent reply shows literal ** , ## and [text](url). Add formatMrkdwn — a faithful Go port of Hermes Agent's slack format_message (MIT) — and apply it in slackChannel.Send before chunking/posting. Protects fenced+inline code, converted links, and existing Slack entities behind placeholders; converts headers/bold/italic/strike/links; escapes control chars. Unit tests cover each construct plus fenced-code protection and a link nested in bold.
Co-authored-by: multica-agent <github@multica.ai>
* docs(slack): preserve Hermes MIT notice for ported mrkdwn converter (MUL-3516)
Addresses Niko's review. formatMrkdwn is a substantial port of Hermes Agent's slack format_message; MIT requires preserving the copyright + permission notice. Add the full Hermes MIT copyright/permission notice + source URL as a header on mrkdwn.go (no repo-level third-party notice file exists, and the header cannot get separated from the ported code). Also add the suggested Send-layer regression test (TestSend_AppliesMrkdwn) that pins the wiring: slackChannel.Send converts Markdown to mrkdwn before posting.
Co-authored-by: multica-agent <github@multica.ai>
* refactor(lark): migrate Feishu onto shared engine.ChatSession, drop duplicate (MUL-3516)
Completes 'every IM reuses one shared session service' and removes the dual-path the reviewers flagged as temporary. Feishu's ResolverSet now drives the channel-agnostic engine.ChatSession (channel_type=feishu, Lark session titles preserved) instead of the Feishu-specific lark.chatSessionService, which is deleted. Behavior is unchanged: engine.ChatSession is the verbatim port of the old logic and is unit-tested; the new Feishu binder param-mapping (BindingKey=chat id, CommandText=un-enriched CommandBody from Raw) is covered by feishu_resolvers_test.go.
- Delete chat_service.go (chatSessionService + helpers) and issue_command.go/_test.go (parser now engine.ParseIssueCommand). Relocate the shared TxStarter interface to tx.go (still used by binding-token + registration services).
- chat.go keeps only the AuditLogger seam; remove the now-dead ChatSessionService / EnsureChatSessionParams / AppendUserMessageParams / AppendResult / IssueCommand types.
- router.go constructs engine.NewChatSession for Feishu; inbound_enricher_test + doc.go updated.
make-test parity: go build ./..., go vet, gofmt, and go test ./internal/integrations/{lark,channel/...,slack} all pass (full Feishu suite green).
Co-authored-by: multica-agent <github@multica.ai>
* feat(slack): wire Slack adapter + ResolverSet + outbound into router (MUL-3516)
Activates the full Slack pipeline, gated by MULTICA_SLACK_SECRET_KEY (the bot/app-token decryption key). When unset the block is skipped, so existing deployments are unaffected and Feishu is untouched.
- router.go registers slack.RegisterSlack (Socket Mode connect/send Factory) + channelRouter.Register(TypeSlack, NewSlackResolverSet) (inbound pipeline) + slack.NewOutbound(...).Register(bus) (outbound).
- New slack/outbound.go: an EventChatDone subscriber mirroring the Feishu Patcher. It finds the Slack chat binding for the finished session, recovers the real channel from the binding config (the channel_chat_id may be a composite thread-isolation key) + the reply thread from last_thread_id, and posts via slackChannel.Send (reusing formatMrkdwn / chunking / threading). Sessions with no Slack binding are ignored, so it coexists with the Feishu Patcher on the shared bus.
- Tests: posts to the bound channel/thread with the real channel id; ignores non-Slack sessions, empty completions, revoked installations, and non-chat events.
Slack now shares engine.ChatSession, channel_* tables, IssueService and TaskService with Feishu. Remaining: config-driven installation provisioning (an operator currently creates the channel_type='slack' row; the config block shape — which workspace/agent — is a product decision) and a live end-to-end smoke. go build ./..., go vet, gofmt, and go test ./internal/integrations/{slack,channel/...,lark} all pass.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
`useFileUpload` exposed a single `uploading: boolean` shared across all
concurrent upload calls. When the user drag-dropped N images into the
Quick Create modal, the first upload's `finally` flipped the flag back
to false while N-1 uploads were still in flight. The submit gate (which
only checked `uploading`) re-enabled, and on submit:
- `getMarkdown()` ran `stripBlobUrls()` and erased every still-pending
`` placeholder from the prompt.
- `pendingAttachments` only contained the first-completed upload, so
`activeAttachmentIds` shipped a single ID.
- The remaining attachment rows never got linked to the new issue —
their `issue_id` stayed NULL and the UI showed "Attachment doesn't
exist".
Fix:
1. Replace the boolean with an in-flight counter in `useFileUpload`.
`uploading = inFlight > 0` so the flag stays true until ALL concurrent
uploads resolve (or reject — the `finally` decrements either way).
The public return shape is unchanged; every existing call site keeps
working.
2. Belt-and-suspenders: add `editorRef.current?.hasActiveUploads()` to
the quick-create submit gate. The editor already tracks per-node
`uploading` attrs (the source of truth for "is there a blob preview
still resolving"); checking it on submit guarantees the strip step
can never run against an in-flight image even if some future code
path mis-clears `uploading`.
3. Regression coverage in `use-file-upload.test.ts`:
- Fire two concurrent uploads, resolve them out of order, assert
`uploading` stays true until BOTH resolve. Confirmed to fail
against the pre-fix code.
- Reject one of the concurrent uploads, assert the counter still
decrements correctly (the `finally` runs on rejection).
The mock ContentEditor in `quick-create-issue.test.tsx` gains a
`hasActiveUploads: () => false` stub so the new defense call doesn't
explode in existing tests.
Verified: `pnpm test` on @multica/core (663 tests) and @multica/views
(1471 tests) both green; typecheck + lint clean on both packages.
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
* 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 <github@multica.ai>
* 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 <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Honor an already-configured self-host server_url/app_url when re-running `multica setup self-host` without flags, instead of silently resetting to http://localhost:8080. The existing config is added as a fallback step in the URL resolution chain (flag → env → existing config → localhost), and the overwrite confirmation now shows URL changes as `old -> new`.
Closes#4536
MUL-3660
The sliding-window Lua script used the nanosecond timestamp as both the
ZSET score and member. Two requests landing in the same nanosecond
collided on an identical member, so ZADD updated in place instead of
inserting and the window under-counted — letting requests through past
the limit. This surfaced as a flaky CI failure in
TestRedisWebhookIPRateLimiter_HasSeparateBudgetFromTokenLimiter.
Keep the timestamp as the score (so ZREMRANGEBYSCORE trimming is
unchanged) and use a per-request UUID as the member so each admitted
request is counted exactly once.
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
On cancellation/timeout the opencode backend closed the stdout read end
immediately, leaving the child writing into a closed pipe. Every write then
returns EPIPE and, per anomalyco/opencode#33653, can spin an orphaned process
at 100% CPU — surfacing as high idle CPU after a cancelled task or daemon
restart (MUL-3655).
Cleanup now runs opencode in its own process group and, on cancel, drives a
graceful group-wide SIGTERM → grace → SIGKILL, closing the stdout pipe only as
a last-resort unblock once the tree has been signalled (SIGKILL is uncatchable,
so no member can write again — no EPIPE window). The group signal also reaps
tool subprocesses opencode spawned instead of orphaning them. WaitDelay remains
the hard backstop.
Adds unix tests covering the graceful path and the SIGTERM-ignored → SIGKILL
escalation, asserting the whole process group is reaped and the run never
deadlocks on the scanner. Windows behaviour is unchanged (no process groups).
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Add a dedicated checkbox/task-list toggle button to the editor bubble menu, between the List dropdown and Quote. It turns the current line(s) into a `- [ ]` task item or back to a paragraph in one tap, reusing the existing toggleTaskList() command and the same Toggle/Tooltip pattern as the neighboring controls. Adds bubble_menu.task_list locale keys for en/zh-Hans/ja/ko.
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): resolve skill bundles per-skill with size-scaled timeout (MUL-3650, #4505)
Cold-start skill resolution downloaded the agent's entire bundle in one
atomic request bounded by the shared 30s control-plane http.Client timeout.
On a slow/jittery link a large bundle (15+ skills) could not finish the body
read in 30s, and because the cache was only written after the whole batch
succeeded, nothing was persisted on failure — so every dispatch re-downloaded
the full bundle and timed out again, never converging.
Resolve each missing bundle in its own request and cache it the moment it
arrives:
- daemon: per-skill resolve with a deadline scaled to the bundle's declared
size (floor 30s, cap 5m, ~50KB/s floor throughput) instead of the fixed
control-plane timeout; each success is persisted independently, so a
dispatch that fails on one skill still caches the rest and the next dispatch
only re-fetches what is missing.
- client: dedicated bundleClient with no fixed Timeout (deadline comes from
ctx), a singular ResolveSkillBundle, and a short transient-retry schedule.
Tests cover the size-scaled timeout and the cross-dispatch incremental
caching / convergence (a failed skill does not discard its siblings, and
cached skills are not re-fetched).
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): accept server-side skill updates in per-skill resolve (MUL-3650)
Address review on #4530: resolveSkillBundle validated the returned bundle
against the claim-time ref, which pinned it to the requested hash. The resolve
endpoint intentionally serves the agent's current bundle and hash when the
requested hash is stale (the skill can be edited between claim and prepare), so
a legitimate updated bundle was rejected as invalid and the task failed.
Confirm only that the server returned the requested skill (source/id), then
validate self-consistency against a ref derived from the returned bundle and
cache it under its own hash — matching the documented endpoint contract. Adds a
regression test covering a stale-hash request answered with an updated bundle.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The lark_*->channel_* cutover (MUL-3515) is deployed to prod, and the
MULTICA_LARK_HUB_DISABLED park-switch was a one-time scaffold for that
rollout — the end state intentionally does not use it (prod never set the
env). Remove the env-gated branch from cmd/server/main.go so the channel
supervisor always starts when built; its existing nil-guard and shutdown
join are unchanged. Trim migration 124's now-obsolete switch runbook to a
short historical note (comment-only; 124 is already applied, so this does
not re-run).
Refs MUL-3515
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* docs: document MULTICA_<PROVIDER>_ARGS default agent argument env vars
The backend default-args env-var layer (MULTICA_CLAUDE_ARGS / MULTICA_CODEX_ARGS /
MULTICA_CODEBUDDY_ARGS) shipped in #1807 but was never added to the docs site
environment-variables page. Document the variable, its precedence relative to
per-agent custom_args, POSIX shell-word parsing, and the shared blocked-flags
filter. Closes the docs follow-up requested in #1467.
* docs: refine MULTICA_<PROVIDER>_ARGS wording and sync zh/ja/ko translations
Reword the English section so daemon-wide default args read as a default
baseline rather than a hard ceiling (per-agent custom_args are appended
afterward and can override), and drop the uncertain --max-budget-usd example.
Sync the new env var row and section into the zh/ja/ko docs pages.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Adapts OpenClaw execenv prep to the 2026.6.x agents schema (agents.list config path removed; agents live in a sqlite registry). Case-insensitive key-missing guard + registry fallback on read, version-aware emission on write so per-task workspace pinning keeps working.
Closes#3028
MUL-3643
* docs: add 2026-06-24 changelog entry
Co-authored-by: multica-agent <github@multica.ai>
* docs(changelog): refine 2026-06-24 entry wording and terms
- Surface the flagship features in the title (Feishu collaboration channel
upgrade + feature rollout) instead of leading with an improvement and a
vague "runtime rollout" phrase
- Fix glossary term: Autopilot -> 自动化 (was 自动任务) in zh
- Make Feishu naming consistent within the entry (was mixing 飞书/Lark)
- Reconcile cross-language mismatch (Gemini CLI removal + Qoder/CodeBuddy/
Antigravity guidance now stated the same in all locales)
- Replace internal/jargon phrasing with product language across en/zh/ja/ko
MUL-3640
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: J <j@multica.ai>
The race detector caught a flaky failure on main (passes on retry):
Supervisor.startSupervisor does s.wg.Add(1) under s.mu, while Supervisor.Wait
calls s.wg.Wait() with no lock. Calling WaitGroup.Add concurrently with
WaitGroup.Wait is a data race and undefined per the WaitGroup contract — so it
only trips occasionally (it passed locally and in PR CI).
Wait now blocks on stopChan (closed by Run's defer when Run returns) before
calling wg.Wait(). Run is the sole caller of startSupervisor, so once Run has
returned no further Add can happen and wg.Wait is race-free. WaitWithTimeout
inherits the fix (it calls Wait), and its timer still bounds shutdown.
This latent race existed in the original lark.Hub.Wait too; fixed properly in
the generalized Supervisor.
Verified: go test -race -count=300 on the flagged test and -count=8 on the
whole engine package, all clean; no deadlock from the stopChan gate (every
caller pairs Wait with a started Run + cancelled ctx).
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
A run could post running progress/plan narration as issue comments, and a
review run surfaced its in-progress narration as the result instead of a
conclusion (MUL-3605).
Add one rule to the Output section's issue-task branch, in both the
legacy and slim briefs: post exactly one comment per run — the final
result, before the turn exits — and keep plans/progress in the agent's
own reasoning. The pre-existing "Final results MUST be delivered … a task
that finishes without a result comment is invisible" line already makes
the comment mandatory, and "state the outcome, not the process" already
rules out progress dumps, so no second rule is added.
Chat / quick-create / autopilot keep their own delivery channels. Adds a
regression test across both brief paths.
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>