mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-24 11:10:25 +02:00
v0.3.39
970 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
4c510dfef6 |
fix(daemon): harden background-task-safety brief against background-and-yield (MUL-4140) (#4998)
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> |
||
|
|
39ccb7d342 |
fix(server): do not cancel issue tasks on assignee change (MUL-4113, #4963) (#4975)
* 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> |
||
|
|
b2db309618 |
Skip local directory lock for squad leaders (#4951)
Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
3f17c2717b |
WS-1465 fix autopilot duplicate issue dispatch (#4936)
Co-authored-by: JC的AI分身 <tangyuanjc@JCdeAIfenshendeMac-mini.local> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
359ef61dc3 |
fix(search): pg_trgm index fallback + statement_timeout guard (MUL-4059) (#4925)
* fix(search): add pg_trgm index fallback + statement_timeout guard (MUL-4059) Root cause of the "search freezes with no response" symptom reported in MUL-4059: the search handler runs LOWER(col) LIKE '%pattern%' queries that expect a pg_bigm GIN index (migrations 032, 033, 036), but every migration wraps the CREATE EXTENSION + CREATE INDEX in a DO/EXCEPTION handler that silently skips when pg_bigm is unavailable. The bundled self-host / dev / CI Postgres image (pgvector/pgvector:pg17) does not ship pg_bigm, so on every self-hosted deployment the migrations no-op and no GIN indexes get built. Every /api/issues/search + /api/projects/search request then falls back to a Seq Scan on `issue` + correlated Seq Scans on `comment` — verified with EXPLAIN on the local dev DB, which has zero title/description/comment search indexes before this change. Two independent guardrails are added, either of which alone would have prevented the reported hang: 1) Migration 134 installs pg_trgm (ships in all standard Postgres + pgvector images) and builds GIN indexes with gin_trgm_ops on `LOWER(title)`, `LOWER(COALESCE(description, ''))`, and `LOWER(content)`. The expression signatures match the search handler's WHERE clauses exactly, so the planner picks the index without further changes. The pg_bigm indexes from 036 are left intact — deployments on AWS RDS with pg_bigm 1.2 keep the CJK-friendly bigram path; deployments without it get the trigram fallback. Verified against a local 25k-row fixture: the description LIKE hits `Bitmap Index Scan on idx_issue_description_trgm` in 0.5 ms. 2) runSearchQuery wraps both search handlers in a short-lived read-only transaction with SET LOCAL statement_timeout = 3 s. In the pathological case where indexes are still missing or the query plan is bad, callers see a fast 503 with a descriptive error instead of a stalled request. Verified against a live Postgres: a deliberate pg_sleep(2) with the test override at 200 ms is cut off in 230 ms with SQLSTATE 57014, as asserted by TestRunSearchQuery_StatementTimeoutFires. Non-goals: this change does not remove the pg_bigm code path, does not change the SQL the handler builds, and does not change the API response shape. It is the minimum diff to unblock production while preserving the CJK-search advantage that pg_bigm provides where it is available. Co-authored-by: multica-agent <github@multica.ai> * fix(search): scope comment subqueries to workspace to unblock prd hang (MUL-4059) Follow-up correction after PRD investigation: pg_bigm IS installed on prd `multica-prod` and all five bigm indexes exist in the correct `LOWER(...) gin_bigm_ops` form. The initial "missing index" hypothesis was wrong; migration 134 (pg_trgm fallback) still helps self-host but does not touch the production hang path. Actual prd EXPLAIN (workspace with 60k issues, keyword "search"): Index Scan using idx_issue_workspace on issue i Rows Removed by Filter: 59123 SubPlan 2 Bitmap Heap Scan on comment c Rows Removed by Index Recheck: 1928275 Heap Blocks: exact=48297 lossy=164696 Bitmap Index Scan on idx_comment_content_bigm rows=536761 Execution Time: 32345.002 ms Root cause: the correlated `EXISTS` over `comment` gets rewritten by the planner into a *hashed* subplan. Without a workspace_id filter in the subquery, that hashed set covers every comment in every workspace matching the LIKE — 536k rows for "search" — which spills work_mem into a lossy bitmap and rechecks 1.9M rows. Two-part fix: 1. Query rewrite. buildSearchQuery now emits `c.workspace_id = $wsParam` inside every comment subquery (WHERE phrase match, WHERE multi-term match, tier 7 rank, tier 8 rank, and the matched_comment_content COALESCE). The same $4 parameter is reused so Postgres treats it as a compile-time constant and pushes it into the hashed subplan's key, collapsing the set to this workspace's comments. 2. Supporting index (migration 135). New `idx_comment_workspace ON comment (workspace_id)`. Without it, the pushed-down filter still triggers a Seq Scan on `comment` because comment has no btree on workspace_id (only the FK constraint and composite (issue_id, ...) indexes). Locally verified against a repro that mirrors prd (5k issues in the target workspace, 100k comments in a sibling workspace all containing "search"): the plan drops from 60 ms (hashed global scan, no support index) to 3 ms (subplan uses idx_comment_workspace). Prd extrapolation from the same shape: 32.3 s → tens of milliseconds. Regression test TestBuildSearchQuery_CommentSubqueryWorkspaceScope asserts every `FROM comment c` in the generated SQL is followed by a `c.workspace_id = $4` filter, so a future refactor can't silently regress the plan back to the global-hash pathology. The statement_timeout guard from the earlier commit in this branch is kept — it still bounds the worst case if any future query shape regresses. Co-authored-by: multica-agent <github@multica.ai> * fix(search): address PR review — unwrap 135 + add project trigram indexes (MUL-4059) Both must-fix items from GPT-Boy's review: 1. Migration 135 unwrapped. The previous version buried `CREATE INDEX idx_comment_workspace` inside `DO $$ ... EXCEPTION WHEN OTHERS $$` — exactly the anti-pattern that caused MUL-4059 in the first place. `idx_comment_workspace` is not a CJK-bonus fallback; it is the critical support that makes the query rewrite land on an Index Scan instead of a Seq Scan. A silent failure (lock timeout, disk full, permission denied, schema drift) MUST abort the migration and fail deployment, not slip through as green. The unwrapped `CREATE INDEX IF NOT EXISTS` now propagates real errors to the migration runner, which aborts and does NOT record the version as applied. IF NOT EXISTS keeps idempotency for the operator-precreated case (`CREATE INDEX CONCURRENTLY ...` before running migrations on large prd tables). 2. Migration 134 now covers project search too. SearchProjects reads `LOWER(project.title)` and `LOWER(COALESCE(project.description, ''))`, and the pg_bigm equivalents in migration 039 silently no-op on pg_bigm-less images just like 032/033/036. Without the trigram fallback, project searches on self-host would still Seq Scan and hit the 3 s statement_timeout guard as a 503 — technically bounded but not actually fixed. Added `idx_project_title_trgm` and `idx_project_description_trgm`; the down migration drops them too. Also: fixed the search.go comment that said callers get a "standard 500" — they get a 503 with SQLSTATE-57014 mapping; the comment now matches reality. Verified: build clean, vet clean, existing search / timeout tests still green. Migration 135 dry-run (dropping the index, re-applying the unwrapped SQL under `ON_ERROR_STOP=1`) creates the index cleanly; a deliberate `CREATE INDEX` on a non-existent column now aborts psql with exit 3, confirming the migration runner would fail loudly on any real error. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
7116691c07 |
fix(github): hide reference-only PR links from the issue PR list (#4611)
* fix(github): hide reference-only PR links from the issue PR list A PR that merely mentions an issue key in passing in its description (e.g. "Related to MUL-3739") was auto-linked and shown in that issue's right-side PR list as if it were a working PR for the issue. Add a reference_only flag to issue_pull_request. The webhook keeps linking generously (so close_intent stays trackable across edits) but flags a link as reference_only unless the key is a genuine target: a title prefix, a branch reference, or a body closing keyword (Closes/Fixes/Resolves). ListPullRequestsByIssue filters reference_only rows, so passing body mentions are hidden from the CLI and the UI PR list while real targets remain. reference_only follows the same terminal preserve gate as close_intent; the auto-advance gate is unchanged. Closes MUL-3739 Co-authored-by: multica-agent <github@multica.ai> * fix(github): exclude reference_only links from the close aggregate A reference_only link is hidden from the issue PR list, but GetIssuePullRequestCloseAggregate still counted it toward open_count. An open body-only mention ("Related to MUL-X") could therefore block the issue from auto-advancing to `done` after a real closing PR merged, while being invisible in the right-side PR list. Filter `AND NOT reference_only` in the aggregate too (reference_only rows never carry close_intent, so merged_with_close_intent_count is unchanged). Add TestWebhook_HiddenBodyMentionDoesNotBlockAutoAdvance. Addresses code review on PR #4611. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
5901997bf6 |
fix(squad): wake private-leader squad parent leader on child-done (MUL-4063) (#4934)
* fix(squad): wake private-leader squad parent leader on child-done The child-done parent wake routed squad leaders through canEnqueueSquadLeader/canInvokeAgent, while the agent-parent path (triggerChildDoneAgent) has never gated. Agents default to private visibility, so a default squad leader is private; when a child is closed by an agent/system actor (the normal process-squad pipeline) there is no resolvable human originator, the gate fails closed, and the leader is never woken -- stranding every multi-stage squad pipeline after its first stage. Assigning the parent directly to the leader agent worked only because that path is ungated. Remove the child-done leader-invocation gate so agent and squad child-done follow one path. The parent was already permission-checked at squad-assign time (validateAssigneePair); waking its own leader to advance the next stage is a coordination handoff, not a fresh invocation, and grants no new privilege -- the actor can only wake the leader on the specific parent that leader already owns. If invocation permission is ever reintroduced it must be added to both paths together. Also drops the now-dead actor plumbing threaded solely for the gate, flips the plain-member child-done test to assert the leader is woken, adds an agent-actor regression, and updates the squad / mentioning skill docs. MUL-4063 Co-authored-by: multica-agent <github@multica.ai> * docs(squad): refresh Private Leader Access source map to canInvokeAgent The squad + mentioning source maps still described the old canAccessPrivateAgent model (visibility!=private, agent short-circuit, system->agent remap). The trigger gate is canInvokeAgent (MUL-3963); update both to match and note the child-done wake is now ungated (MUL-4063). Review nit follow-up, docs only. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
cc1f5cda8a |
fix(issues): don't call an intermediate stage final in child-done comment (MUL-4062) (#4932)
The staged child-done system comment derived its "final stage vs next stage" wording from stageProgressSummary over the sub-issues that currently exist. The server has no declarative workflow model — stages are agent-driven and often created lazily (stage N+1's sub-issues are written only after stage N produces the inputs they depend on), so an intermediate stage reaches nextStage==0 exactly like a true final stage. The old else branch then asserted "This was the final stage. Wrap up the parent", pushing leaders/humans to wrap up mid-workflow (GH #4927). Extract the trailing instruction into stageAdvanceInstruction and, when no later stage exists among the created sub-issues, stop asserting finality: name both possibilities (create the next stage, or wrap up) and hand the decision back to the leader. Add a unit test locking in that the nextStage==0 message never claims a definitive final stage. Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
d90ee9fa35 |
fix(agents): thread permission_mode/invocation_targets through the template create path (MUL-4010) (#4897)
CreateAgentFromTemplate accepted only the legacy visibility field and dropped
it on the floor: neither permission_mode nor invocation_targets flowed into
the INSERT, so the SQL default (COALESCE(sqlc.narg('permission_mode'),
'private')) pinned every template-created agent as private in the new
invocation-permission model (MUL-3963). Since canInvokeAgent reads
permission_mode — not the legacy visibility column — a request that asked
for a workspace-shared agent (old Web/CLI/Desktop sending
visibility="workspace", or new Web sending permission_mode/public_to +
invocation_targets) silently landed as owner-only. The public_to+targets
inputs from the new Web front-end were also being ignored.
Fix (mirrors handler/agent.go:CreateAgent so the two entry points can't
drift):
- CreateAgentFromTemplateRequest gains PermissionMode *string and
InvocationTargets []AgentInvocationTargetDTO.
- Decode via decodeJSONBodyWithRawFields to distinguish an absent
invocation_targets from an empty one (same rawFields lookup CreateAgent
uses).
- Call parsePermissionInput(wsUUID, req.PermissionMode,
req.InvocationTargets, req.PermissionMode != nil, hasTargets,
&legacyVis) so the legacy 'workspace' mapping ('workspace' -> public_to +
workspace target) is applied uniformly.
- Pass perm.legacyVisibility() into Visibility and perm.mode into
PermissionMode on CreateAgentParams so the visibility mirror column stays
aligned and the permission_mode column reflects the caller's intent
rather than the SQL default.
- Persist the invocation allow-list inside the same tx as the agent row via
a new tx-friendly helper replaceInvocationTargetsWithQueries — an agent
is never observable in a state where the row exists but its targets are
missing. handler-level replaceInvocationTargets delegates to it with
h.Queries, keeping the CreateAgent/UpdateAgent call sites unchanged.
- Enrich the response with invocation targets after commit so a client that
just asked for visibility='workspace' sees the derived legacy visibility
round-trip correctly (previously the response echoed empty
invocation_targets and legacy 'private' regardless of intent).
Regression coverage in agent_template_permission_test.go:
- TestCreateAgentFromTemplate_LegacyVisibilityMapsToPermission: both
legacy visibility values are exercised. workspace -> permission_mode
public_to + a workspace invocation-target row (row-level SELECTs assert
the persistence, not just the response echo); private -> permission_mode
private + zero target rows.
- TestCreateAgentFromTemplate_PublicToWithMemberTarget: new-shape request
(permission_mode='public_to' + a member invocation-target) is honoured
verbatim, derived legacy visibility collapses to 'private' (member-only
public_to), and the DB row for the member target exists.
Uses commit-message as the fixture template (zero external skills), so the
tests don't need to reach any network fetcher.
Co-authored-by: multica-agent <github@multica.ai>
|
||
|
|
910bbe9309 |
MUL-4024 tighten squad leader self-trigger guard (#4896)
Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
4d968ba875 |
fix(agents): gate create-agent access picker on composio_mcp_apps flag (MUL-4010) (#4888)
Reuse the existing `composio_mcp_apps` feature flag instead of the separate `agent_access_picker` flag introduced in #4879. The MUL-3963 permission_mode + invocation_targets model exists to gate Composio sharing, so the create-flow access picker ships on the same switch as the rest of the Composio rollout — environments that already enable Composio (`FF_COMPOSIO_MCP_APPS=true`) now also see the aligned Private / Public-to picker in Create / Duplicate. - Drop `AGENT_ACCESS_PICKER_FLAG` (frontend keys.ts + index re-export). - Drop `AgentAccessPicker` (server featureflags list). - `CreateAgentDialog` reads `COMPOSIO_MCP_APPS_FLAG` instead. - Tests updated to set the composio flag. All 10 create-agent-dialog tests + Composio-related tabs tests pass. Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
098b1f6362 |
MUL-4015: stamp source_task_id on HTTP-authored agent comments (#4886)
The HTTP CreateComment handler read X-Task-ID for parent-id validation and the no_action gate, but never stamped source_task_id on the comment row. That silently broke the originator inheritance chain used by every task the comment triggers downstream (resolveOriginatorFromTriggerComment climbs comment.source_task_id → parent task's originator_user_id). Consequence: on a squad-assigned issue where the leader agent is private (the default permission_mode for agents), the leader → worker mention hop would enqueue the worker's task with originator_user_id = NULL. When the worker later posts its result comment, invokeOriginatorFromRequest reads that NULL back out, opts.OriginatorUserID becomes "", and routeAssignedSquadLeaderFallback → canInvokeAgent denies the leader wake (private agents admit only the owner, and effectiveUser is empty). The leader → worker → leader coordination loop stayed broken until the leader was triggered by something else. Public-to-workspace leaders papered over the issue via the workspaceBroad admittance path in canInvokeAgent. Fix: capture the same X-Task-ID the handler already reads and pass it as SourceTaskID on the CreateComment call. Only stamp when the task belongs to this issue — a same-agent, different-issue comment must not attribute itself to an unrelated task's originator. All existing gates (parent_id-vs-trigger_comment mismatch, no_action) still fire before the stamp is applied. Regression coverage in squad_worker_comment_wakes_leader_test.go: - worker-agent completion comment wakes a public_to squad leader (baseline: was passing before the fix, kept as guardrail) - worker-agent completion coalesces when a leader task is already queued - worker-agent completion wakes a PRIVATE squad leader through the leader → worker mention hop (the failing case, red before → green after) Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
77ba0fdddb |
MUL-4009: hide not-configured Composio toolkits at Service layer (#4880)
* MUL-4009: hide not-configured Composio toolkits at Service layer Filter out toolkits with no enabled auth config in Service.ListToolkits so the Settings UI only shows connectable apps. A dead 'Not configured' card is noise for end users, so drop the entry entirely instead of showing a greyed label (which existed only to avoid a dead Connect button, MUL-3720). - service.go: only append connectable toolkits; drop the connectable-first sort (all entries are connectable now); a resolver error now returns an error (502) instead of masking to an empty catalog, so the UI shows its honest load-failed state rather than a misleading 'no apps configured'. - Keep the wire 'connectable' field (always true) for backward compat with older desktop clients that branch on it. - composio-tab.tsx: remove the 'Not configured' branch; keep the toolkit.connectable guard as a client-side backstop. - i18n: drop composio.not_connectable / not_connectable_hint from en/ja/ko/zh-Hans. - Update service + handler tests to assert filtering and the resolver-error path. Co-authored-by: multica-agent <github@multica.ai> * MUL-4009: address review — empty-state copy, 502 handler test, stale comments Review follow-up on #4880: - i18n: rewrite composio.page_description / empty_title / empty_description in all 4 locales. The empty state now means 'no toolkit with an enabled auth config in the project', not 'Composio returned no catalog' — the old copy misled users after filtering landed. - Add TestComposio_ListToolkits_ResolverErrorIs502: fakes an auth-config resolver error and asserts ListComposioToolkits returns 502, pinning the no-silent-empty-catalog behavior (composioFakeSDK gains listAuthErr). - Refresh stale 'full catalog / false connectable' docs in packages/core/types/composio.ts, composio/queries.ts, api/client.ts to the connectable-only model. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
b24b63c513 |
feat(agents): align create-agent visibility with MUL-3963 access model (MUL-4010) (#4879)
Adopt the private / public_to invocation-permission model in the Create Agent (and Duplicate) dialog to match the AccessPicker on the agent detail page. When the new `agent_access_picker` feature flag is ON, the visibility section is replaced with an inline access editor that: - Toggles between Private (owner-only) and Public - Under Public, offers "Everyone in workspace" plus a member allow-list (multi-select, current user excluded, mirroring AccessPicker semantics) - Preserves team targets on the source agent when duplicating - Collapses an empty public_to (no workspace, no members) back to private on submit — same normalisation the AccessPicker emits The dialog submits `permission_mode` + `invocation_targets` in that mode, matching the authoritative gate; when the flag is OFF (default) it keeps sending the legacy `visibility` field so production is unaffected until the rollout is greenlit. Backend: register the flag in the frontend-public list so it flows to the web config bootstrap. Tests: legacy toggle still submits `visibility`; flag-on submits `permission_mode`/`invocation_targets`, collapses empty public_to to private, and preserves ticked member grants. Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
cb68669c73 |
feat(composio): gate MCP apps behind feature flag (#4876)
* feat(composio): server-side connect flow + connections REST (Notion MVP) (MUL-3720) (#4608)
* feat(composio): server-side connect flow + connections REST (Notion MVP) (MUL-3720)
Compose the merged server/pkg/composio SDK into a user-facing connection
manager: signed-state connect handshake, local user_composio_connection
mirror, idempotent disconnect, and a per-user MCP session helper (not yet
wired into task dispatch).
- migration 127_user_composio_connection (no FK/cascade, per DB rules)
- sqlc queries: upsert (idempotent on user_id+connected_account_id), list
active, owner-scoped get, mark revoked
- internal/integrations/composio: signed HMAC-SHA256 state, BeginConnect,
CompleteCallback (idempotent upsert), ListConnections, Disconnect
(upstream 404 = idempotent success), CreateMCPSession (no-op when empty,
pins connected_accounts per toolkit), CallbackRedirect
- REST handlers under /api/integrations/composio (user-scoped, 503 when
COMPOSIO_API_KEY unset): connect/init, callback (302), connections list,
delete
- router wiring gated by COMPOSIO_API_KEY; COMPOSIO_AUTH_CONFIGS_JSON maps
toolkit->auth_config (MVP: notion); state secret from COMPOSIO_STATE_SECRET
or derived from JWT_SECRET; callback base from COMPOSIO_CALLBACK_BASE_URL
or MULTICA_PUBLIC_URL
- tests: state (expire/tamper/wrong-secret), service (mapping, callback
idempotency, non-success, disconnect owner/404 idempotency, MCP pin),
handlers (httptest), redact regression for Bearer mcp_ tokens
MVP scope: Notion only; no task-dispatch overlay, sharing, or webhook
event handling (later stages).
Co-authored-by: multica-agent <github@multica.ai>
* fix(composio): bind callback account to user + idempotent revoked disconnect (MUL-3720)
Address PR 4608 review (CHANGES_REQUESTED):
- callback: verify connected_account_id with Composio before mirroring it.
The signed state only proved user/toolkit/exp, so a valid state paired with
a tampered connected_account_id would be written verbatim. CompleteCallback
now calls ListConnectedAccounts and fails closed (ErrAccountVerification)
unless the account belongs to the state's user (composio_user_id == multica
user id) and was created under the toolkit's auth config. No row is written
on mismatch / unknown account / upstream error.
- disconnect: short-circuit to a no-op when the local row is already revoked,
before touching upstream. Previously a second DELETE re-hit Composio and a
non-404 upstream error surfaced as a 502, breaking the 204-idempotent
contract.
- CreateMCPSession: document the v1 single-active-connection-per-(user,toolkit)
constraint and make duplicate selection deterministic (newest-wins, rows are
connected_at DESC) instead of order-dependent map overwrite. Stage 3 owns the
real single-account-enforcement vs multi-account-shape decision.
Tests: tampered/wrong-auth-config/unknown-account callback rejection, revoked-row
disconnect no-op (asserts upstream not re-hit). composio pkg 85% coverage; all
green.
Co-authored-by: multica-agent <github@multica.ai>
* feat(composio): list all toolkits + dynamic auth-config resolution (MUL-3720)
Yushen's follow-up to the Notion MVP: surface the full Composio toolkit
catalog, render it in Settings, and drop the static env mapping in favor of
dynamic auth-config discovery.
Config correctness (per Composio docs):
- Remove COMPOSIO_AUTH_CONFIGS_JSON entirely. The toolkit→auth_config mapping
is now resolved at request time from the project's /auth_configs (cached,
5-min TTL), so enabling a toolkit is a dashboard action, not a redeploy.
- Do NOT add COMPOSIO_PROJECT_ID. The project API key (x-api-key) authenticates
to exactly one project; the project is resolved from the key. Only org-level
endpoints use x-org-api-key, which this integration never calls.
Backend:
- SDK: server/pkg/composio/auth_configs.go — ListAuthConfigs (toolkit_slug,
is_composio_managed, show_disabled, limit, cursor).
- service: dynamic resolver (authConfigMap cache; betterAuthConfig prefers a
custom/white-label config over Composio-managed, newest wins); BeginConnect
and CompleteCallback resolve via it; ListToolkits fetches the full catalog
(paginated, capped) annotated with connectable = has an enabled auth config,
connectable-first ordering.
- handler + route: GET /api/integrations/composio/toolkits (user-scoped, 503
when COMPOSIO_API_KEY unset) returning slug/name/logo/category/connectable.
Frontend:
- core: ComposioToolkit/ComposioConnection types, api client methods, and
composio query options (@multica/core/composio).
- views: Settings → Integrations now has a Composio section rendering every
toolkit as a card with search. Connect is gated on `connectable`;
non-connectable toolkits show a muted "not configured" hint instead of a
dead button. Connected toolkits show a badge + Disconnect (with confirm).
- i18n: composio block added to en/zh-Hans/ja/ko settings.
Tests: SDK + service (dynamic resolution, custom-over-managed preference,
connectable flag, resolver-error soft-degrade) and handler toolkits endpoint;
composio pkg 85.7% coverage. go build/vet/gofmt clean; core+views typecheck,
core+views lint, and core tests (691) all green.
Co-authored-by: multica-agent <github@multica.ai>
* fix(composio): close cross-toolkit callback fail-open by signing auth_config_id into state (MUL-3720)
Re-review blocker: CompleteCallback resolved the toolkit's auth config at
callback time and ignored a resolve error/empty result, while
verifyAccountOwnership skipped the auth-config comparison when the expected
value was empty. A user could then pass another toolkit's connected_account_id
into this toolkit's callback — the owner check passed and it was written under
the wrong toolkit_slug/account binding.
Fix: the auth_config_id is already resolved in BeginConnect (before the state
is signed), so sign it into the state and compare it exactly at callback. No
re-resolve, no fail-open. verifyAccountOwnership now fails closed when the
expected auth config is empty (rejects instead of skipping) and requires an
exact match — closing the cross-toolkit binding gap.
Tests: state round-trips auth_config_id; BeginConnect signs it; callback
rejects wrong/cross-toolkit auth config and an empty (no-mapping) auth config
fails closed. composio pkg 85.2% coverage, all green.
Frontend (non-blocking): the Composio settings tab now surfaces an error when
the connections query fails instead of silently rendering everything as
unconnected.
Co-authored-by: multica-agent <github@multica.ai>
* fix(composio): hide Settings section entirely when integration unconfigured (MUL-3720)
Decision (option 2, hide-then-merge): don't show a card that leaks the internal
COMPOSIO_API_KEY env-var name to every end user. IntegrationsTab now gates the
whole Composio section (heading + body) on the toolkits query — a 503 means the
key is unset, so the section is withheld instead of rendering the not-configured
card. Admin-only setup guidance is a later, role-gated affordance.
Removed the notConfigured card (and now-unused ApiError import) from
ComposioTab; it only mounts when configured. views typecheck + lint clean.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(composio): Stage 2 frontend polish — callback toast, last_used & expired UI, e2e (MUL-3718) (#4688)
* feat(composio): callback toast + refresh, last_used & expired UI, e2e (MUL-3718)
Co-authored-by: multica-agent <github@multica.ai>
* fix(composio): real callback redirect route + StrictMode-safe toast dedup (MUL-3718 review)
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* fix(composio): callback endpoint should not require Multica auth (MUL-3843) (#4709)
* fix(composio): move OAuth callback out of the Auth group (MUL-3843)
Composio 302-redirects the browser to /api/integrations/composio/callback
at the end of the OAuth flow, but PR #4608 mounted it inside the cookie-auth
middleware group. When the session cookie is absent (expired session,
SameSite=Strict / Safari ITP, private window, self-hosted callback subdomain)
the Auth middleware returned a hard 401 and a JSON blob instead of the
settings redirect, breaking the flow.
Identity never came from the cookie anyway: it is carried by the HMAC-signed
state param that CompleteCallback verifies (signature, expiry, replay) and
cross-checked by verifyAccountOwnership; h.Composio == nil still 503s. So the
callback is registered alongside the other public OAuth/webhook routes; the
other four composio endpoints stay session-gated.
Refs MUL-3843, MUL-3715.
Co-authored-by: multica-agent <github@multica.ai>
* fix(composio): correct stale callback routing comments (MUL-3843)
The package header and ComposioCallback doc comments still described the
callback as sitting under the Auth middleware group. After the route was
moved out (this PR), update both to state it is a public route whose identity
comes from the signed state — addressing review nit from 张大彪.
Refs MUL-3843.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(composio): inject MCP overlay into agent runtime at task dispatch (MUL-3721) (#4704)
Stage 3 of the Composio epic. Wires the per-user Composio MCP session into
every agent task so the agent process sees the initiator's connected tools
without any prompt-time plumbing.
Server side
- Migration 128 adds agent_task_queue.runtime_mcp_overlay JSONB plus a
BEFORE-UPDATE trigger that wipes the column on any transition into a
terminal status (completed / failed / cancelled). A trigger is the single
source of truth — future queries that flip status cannot bypass it.
- composio.Service.BuildTaskOverlay(userID) reuses CreateMCPSession and
emits the Claude-style { mcpServers: { composio: { type: http, url,
headers } } } shape the daemon's existing sidecar generators consume.
Returns (nil, nil) on zero active connections so we never burn a
Composio session for a user with nothing to call.
- TaskService grows a Composio ComposioOverlayBuilder seam, wired in
router.go after composiointeg.NewService succeeds. Five enqueue paths
(issue / mention / quick-create / chat / auto-retry) attach the overlay
after CreateAgentTask returns and before the daemon is notified — so
every claim reads a settled row, with no second daemon hop. Best-effort:
a builder failure logs and proceeds with no overlay.
- resolveInitiatorFromTriggerComment derives the initiator user from the
trigger comment when it was authored by a member. Agent-authored
triggers are not treated as initiators (their connected-apps view is
empty by construction).
Daemon side
- handler/daemon.go claim path merges task.runtime_mcp_overlay onto
agent.mcp_config via mergeMCPOverlay before populating
TaskAgentData.McpConfig. Overlay wins on server-name collisions
because it carries the live user-scoped session URL. Errors fall back
to the agent config unchanged — a bad overlay must not surprise-disable
saved MCP tools. The existing execenv sidecar generators (cursor /
codex / openclaw / opencode / hermes-kiro) need no changes: they keep
consuming the merged result through TaskAgentData.McpConfig.
Tests
- 9 merge cases (mcp_overlay_test): both-nil short-circuit, agent-only
pass-through, overlay-only canonicalization, two-side merge, name
collision (overlay wins), top-level key preservation, malformed agent
fallback, malformed overlay fallback, non-object server rejection.
- 4 dispatch cases (composio): zero-connections returns nil without
CreateSession, happy-path emits the right shape with the right user
id, empty-URL defensive branch, SDK error surfacing.
- 4 TaskService helper cases: nil Composio is a no-op (Queries-safe),
invalid initiator does not call the builder, nil overlay skips the
UPDATE, builder error swallowed without panic.
- Migration 128 verified to roll up + down + up cleanly against the test
database.
Out of scope (deferred): assignment-triggered enqueue paths with no
trigger comment get no overlay attached today (no initiator UUID flows
through enqueueIssueTask in that case). Retry paths recompute the overlay
fresh from the parent's initiator_user_id instead of inheriting the bearer
from the parent row, so a stale token can never resurface on a retry.
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(composio): per-agent allowlist + originator-scoped MCP overlay (MUL-3869) (#4736)
* feat(composio): per-agent allowlist + originator-scoped MCP overlay (MUL-3869)
Stage 3.1 of the Composio epic (MUL-3721 parent). PR #4704 wired in the
runtime_mcp_overlay column and a per-task dispatch hook; this change
inverts the default from "all-on" to opt-in and locks the overlay to the
agent owner's own connected apps:
- Agents carry composio_toolkit_allowlist TEXT[]. NULL or [] => no MCP.
Owner-only read/write; non-owner GET/PUT silently redacts/drops the
field (same shape as mcp_config).
- agent_task_queue carries originator_user_id UUID. Set from the
top-of-chain HUMAN at every enqueue path:
* issue/mention comment by member -> author_id
* issue/mention comment by agent -> inherit via comment.source_task_id
-> parent task originator_user_id
* quick-create -> requester_id
* chat -> initiator_user_id
* retry -> SQL-inherited from parent row
* autopilot -> NULL (system-driven)
- BuildTaskOverlay (composio dispatch) now takes (ctx, originatorUserID,
agent) and short-circuits on five gates: invalid originator,
originator != agent.owner_id, empty allowlist, empty intersection of
allowlist ∩ active connections, defensive empty session URL. Composio
CreateSession is called with BOTH `toolkits.slugs` (the intersection)
AND `connected_accounts` (the pinned account ids), narrowing the
tool-router twice.
- The originator-vs-owner gate closes the agent-fanout privacy hole: any
workspace member who can @-mention a public agent used to project the
owner's connected apps into their run. Now the overlay only mounts
when the human at the top of the chain IS the agent owner.
Tests:
- dispatch_test.go covers all 5 gates plus uppercase/whitespace slug
normalisation.
- task_runtime_mcp_overlay_test.go covers the no-op gates of the new
applyRuntimeMCPOverlay signature.
- agent_composio_allowlist_test.go (handler): owner roundtrip
(list/empty/null), workspace-admin silent-drop, owner-only GET
visibility, pure normaliseComposioToolkitAllowlist.
- resolve_originator_test.go (service, DB-backed): member-authored,
agent-authored inherits via comment.source_task_id, invalid id.
Migration 129 up/down/up verified against docker postgres.
Co-authored-by: multica-agent <github@multica.ai>
* chore(composio): gofmt + regenerate sqlc with v1.31.1 (MUL-3869 review nits)
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(composio): accept nested connected account auth config
* feat(views): creator-only MCP tab for per-agent Composio allowlist (MUL-3870) (#4743)
Stage 3.2 frontend on top of the Stage 3.1 backend (MUL-3869,
|
||
|
|
d4f57aff7a |
MUL-3944: Fix daemon agent discovery around hook wrappers (#4817)
* Fix agent path discovery around hook wrappers Co-authored-by: multica-agent <github@multica.ai> * Fix login shell hook wrapper discovery Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
8797667fac |
fix(slack): prefer attachment body over fallback (#4866)
Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
e3e3e7e23f |
fix(realtime): bounded replay window for ShardedStreamRelay on restart (#4875)
ShardedStreamRelay shard readers previously started with lastID=, which meant events published while a pod was down were silently lost. Replace the cursor with a bounded time-window start ID derived from (now - ReplayGrace), defaulting to 5 minutes. The timestamp is clamped to 0 to handle misconfigured clocks gracefully. Key changes: - Add ReplayGrace field to ShardedStreamRelayConfig (default 5m) - Add replayStartID() helper with non-negative clamp - Extract readShardOnce() from readShard() for testability - Add REALTIME_RELAY_REPLAY_GRACE env var for runtime tuning - Add regression tests for bounded cursor and replay behavior Closes #4797 |
||
|
|
942255d283 |
fix(autopilot): keep create_issue runs visible when runtime offline (#4848)
Co-authored-by: JC的AI分身 <tangyuanjc@JCdeAIfenshendeMac-mini.local> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
4ed8f7478f |
fix(server): key reviewer-loop dedup on reviewed commit SHA (MUL-4003) (#4873)
The agent-task run-dedup keyed only on (issue_id, agent_id), so a completed/pending verdict for commit A was silently reused to satisfy a review request for a NEWER commit B pushed after A's run began — giving B zero review coverage (nearly shipped an unreviewed commit; sibling of the daemon disposition-loss bug in #4337). Fix (no migration — reuses the existing context JSONB column): - CreateAgentTask stamps the reviewed head_sha into the task's context. - HasPendingTaskForIssueAndAgent(+ExcludingTriggerComment) now key dedup on that head_sha: a pending task only dedups a request carrying the SAME head. If HEAD advanced (or the pending task predates the stamp), dedup MISSES and a fresh review enqueues. Empty head_sha (no linked PR) falls back to the previous (issue_id, agent_id) key, so non-PR issues keep coalescing unchanged. - head_sha resolves from the issue's linked PR via GetIssueReviewHeadSha (prefers open/draft, newest by pr_updated_at); ResolveIssueReviewSHA fails soft to '' so a github-table hiccup can never over-dedup a review out of existence. - Threaded through all six dedup trigger sites (comment @mention + edit preview, issue-status, squad-leader assign, child-done agent + squad). Issue-linked tasks never reach quick-create context parsing, so the key rides harmlessly alongside. Adds DB-backed regression tests pinning: advanced-head misses dedup, repush invalidates dedup, same-SHA still dedups, and no-linked-PR legacy fallback (verified non-vacuous against the pre-fix query). Co-authored-by: Multica Ops <multica-ops@tenanture.com> |
||
|
|
65269ef922 |
fix(daemon): copy Codex model catalog into task home
Fixes #4825 Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
6b70146570 |
test(rollup): serialise shared-singleton rollup tests across packages (MUL-3980) (#4854)
`go test ./...` compiles internal/handler and internal/scheduler into
separate binaries and runs them in parallel against the same DATABASE_URL.
Both mutate the global task_usage_hourly_rollup_state singleton (id=1) and
contend for the rollup function's advisory lock 4246, so under `-race` on CI
they interleave and fail flakily:
- TestRollupTaskUsageHourlyCapsWindowAtOneDay reads the scheduler test's
forced-back watermark (0.063 days ≈ the scheduler's now-90min) instead of
"now".
- TestPgCronConcurrentNoDoubleWrite sees a handler rollup tick advance the
watermark past its window, yielding winners=0.
Add a dedicated session-level advisory lock (42463980, distinct from the
function's own 4246) that every test touching the singleton acquires for its
duration, serialising them across test processes. Reproduced the exact CI
failures on a concurrent stress loop (5/5 rounds) and confirmed the guard
eliminates them (8/8 rounds green).
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
|
||
|
|
e4994cd431 |
fix(github): allow one installation to bind multiple workspaces (#4855)
Connecting the same GitHub App installation in a second workspace silently overwrote the first workspace's binding: github_installation was UNIQUE(installation_id) and CreateGitHubInstallation's upsert overwrote workspace_id on conflict (#4823). Widen the uniqueness key to (workspace_id, installation_id) so each workspace keeps its own binding row, and teach the webhook/lifecycle paths to handle N bindings per installation_id: - CreateGitHubInstallation upserts per (workspace_id, installation_id). - Webhook lookup lists all bindings; PR/check_suite routing uses the oldest binding as the deterministic fallback and still routes per-repo via the existing workspace.repos registry. - installation.deleted/suspend drops every workspace binding and broadcasts to each affected workspace. - installation.created/unsuspend refreshes account metadata across all bindings. - Add a standalone index on installation_id (the dropped unique constraint was the only index behind the webhook lookup). MUL-3950 Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
c328fdbcd0 |
fix(issues): wake parent squad leader on same-squad/shared-leader child-done (MUL-3969) (#4843)
* fix(issues): wake parent squad leader on same-squad/shared-leader child-done (MUL-3969) The child-done stage-barrier wrote the 'Stage N complete / wrap up the parent' system comment on the parent but suppressed the parent squad leader's wake whenever the finished child was owned by the same squad (childAssigneeIsSquad) or a squad sharing the leader (effectiveChildAgentOwner). That stranded the common 'a squad decomposes its parent into sub-issues it works itself' pattern: the parent silently stalled in in_progress because the leader was never woken to advance the next stage or wrap up. The prior guards assumed the leader had already observed the work via its own coordination cycle on the child, but that wake lands on the CHILD and never carries the parent-level stage-barrier instruction. Remove both self-trigger guards so the squad path mirrors the agent path (MUL-2808): always dispatch, bounded only by HasPendingTaskForIssueAndAgent. The private-leader access gate is unchanged; member/unassigned parents still never wake. Drop the now-dead effectiveChildAgentOwner / childAssigneeIsSquad helpers and the unused child param. Fixes #4838 Co-authored-by: multica-agent <github@multica.ai> * test(issues): fix stale child-done squad-guard comment (MUL-3969) The TestChildDoneTriggersParentAgentWhenChildSquadSharesLeader comment still claimed the both-sides-squads-sharing-a-leader case was guarded on the squad path and referenced the pre-rename test. That guard was removed in MUL-3969; point at the renamed test and state the current behavior. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
0c4c3ff038 |
fix(cli): prevent daemon-managed CLI from silently using user tokens (MUL-3922)
Treat MULTICA_DAEMON_PORT and a workdir daemon-task marker as daemon-managed signals so a task subprocess that loses MULTICA_TOKEN / MULTICA_AGENT_ID / MULTICA_TASK_ID fails closed instead of silently falling back to the user config-file PAT (which made agent writes land as the workspace owner). Adds an actionable error naming a leftover marker for local_directory recovery. Fixes #4204. |
||
|
|
101cc29e20 |
fix(daemon): time out repo cache git commands
Closes #4795 |
||
|
|
da624a8835 |
feat: add configurable S3 path-style addressing (#4739)
Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
078f2aa65c |
fix(handler): keep runtime pending redis keys in one slot
Closes #4767 |
||
|
|
ac75a0df72 |
fix(attachments): align text preview whitelist (#4834)
Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
ade6b34e5f |
MUL-3903: Extract shared issue surfaces (#4774)
* MUL-3903 refactor project issue surface state Co-authored-by: multica-agent <github@multica.ai> * Refactor project issue surface ownership Co-authored-by: multica-agent <github@multica.ai> * Extract shared issue surface entrypoints Co-authored-by: multica-agent <github@multica.ai> * Fix issue surface create defaults and selection reset Co-authored-by: multica-agent <github@multica.ai> * test(editor): add missing AbortSignal to suggestion items() calls The suggestion items() contract gained a required signal param; the mention/slash test call sites were never updated, breaking pnpm typecheck for @multica/views. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(issues): server-side assignee_types filter on ListIssues ListGroupedIssues has taken assignee_types since squads shipped, but ListIssues never did — so the workspace Members/Agents tabs had to fetch the unfiltered workspace list and post-filter loaded pages client-side, which made column totals and load-more pagination reflect the unfiltered counts. Add the same parse + WHERE clause to ListIssues (count query shares the WHERE, so totals agree), thread the param through the TS client, and widen MyIssuesFilter so scoped list caches can carry it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(issues): route issue cache writes through a membership-aware coordinator useUpdateIssue, useBatchUpdateIssues, and the WS issue:updated handler each maintained their own similar-but-diverging patch/invalidate rules. Consolidate them into cache-coordinator.ts (applyIssueChange / rollbackIssueChange / invalidateIssueDerivatives) so local writes and remote echoes follow one rules table by construction. The coordinator is membership-aware via surface/membership.ts (true | false | unknown against each list cache's own filter contract): - a change that moves an issue off a filtered surface removes the card surgically (bucket total decremented) — fixes assignee changes leaving stale cards on My Assigned with no local safety net (previously only the WS echo recovered it), and replaces the blanket invalidate-myAll net for project moves (MUL-3669) with per-key precision - possible entry into a loaded list marks that key stale — never hard-insert; page/slot is server knowledge - stale keys flush on settle for mutations (a mid-flight refetch would stomp the optimistic state) and immediately for WS - batch updates now patch detail + inbox like single updates; the off-screen bucket-count recovery previously exclusive to the WS path now covers local mutations too Preserved invariants: synchronous optimistic patches (dnd-kit), MUL-3375 control-field stripping, and no refetch of surgically reconciled lists (the drag-flicker fix). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(issues): resolve surfaces via core query plan/repository with window-keyed remount Read-path convergence and the loading/empty semantics that fall out of it: - scope -> API params moves from scope.ts helpers into surface/query-plan.ts; workspace members/agents become server-filtered scoped plans (assignee_types) and the client postFilter machinery is deleted — tab counts and load-more are now exact - query selection moves behind surface/repository.ts; the views data hook no longer branches on workspace-vs-scoped plumbing - IssueSurfaceContent remounts on data-window change (wsId + scope): keepPreviousData placeholders keep sort/filter changes flicker-free within one window but must never let project A's (or workspace A's) cards impersonate B's with no loading state — cold window shows the skeleton, warm window hits cache instantly - isEmpty is only asserted from full-window data; the gantt scheduled-only projection can't prove the window is empty, so GanttView's own "no scheduled issues" empty state renders instead of the generic create-issue one - per-card project lookups hoist into a surface-level projectMap (drops a per-card useQuery), create-defaults typing tightens to IssueCreateDefaults Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(issues): count-only arithmetic for off-window status/membership changes An issue beyond a list's loaded page window used to force a full first-page refetch just to fix two column counts. When the change is CERTAIN (base entity known, membership definitive) the coordinator now does the arithmetic locally: - stayed a member + status changed: move one unit of total between the two buckets (loaded arrays untouched; hasMore stays consistent) - left the list (reassigned / re-projected): old status bucket total -1 - member-to-member reassignment: counts unaffected, not even a stale key Entering a list and any uncertainty (no base, unknown membership) still refetch — the right page/slot is server knowledge. Branches on membership OUTCOMES, not on which field changed, so future dimensions (team) join automatically. Biggest win is the WS path: agents flipping off-screen statuses no longer trigger refetch storms. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(issues): deferred view-refresh indicator during placeholder revalidation Sort/date changes (and any grouped-board filter change) revalidate behind the previous snapshot — correct, but on a slow network the click felt dead: content stays put and isLoading never fires. Surface the state as isRefreshing (isPlaceholderData of the active query) and render a shared ViewRefreshIndicator in every issues header: a fixed-width slot (zero layout shift) whose spinner fades in after 300ms, so sub-second responses show nothing (NN/g) while slow ones get a working signal. Bound to the revalidation STATE, not to any particular control — any current or future server-side view change lights it automatically. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: multica-agent <github@multica.ai> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
88377c1b1d |
fix(slack): recover attachment/blocks text for empty-Text chat messages (MUL-3931) (#4805)
* fix(slack): recover attachment/blocks text for empty-Text chat messages (MUL-3931) `multica chat thread`/`chat history` dropped any message with an empty top-level Text via normalizePage(), intended to skip join/system markers but also discarding alerting/webhook bot roots (Grafana cards, incoming webhooks) whose body lives in Attachments/Blocks. This regressed the #4717 fix for its most common trigger: @mentioning the agent under an alert card. Add flattenSlackText(): fall back to Attachments[].Fallback, then title/text/fields, then a best-effort Block Kit flatten (section/header/ context/markdown) before discarding. Derived text is capped so a verbose card cannot flood the agent context; genuine content-less markers are still dropped. Both read paths share normalizePage, so one fix covers chat thread and chat history. Closes #4803 Co-authored-by: multica-agent <github@multica.ai> * fix(slack): flatten rich_text blocks in chat history fallback (MUL-3931) Addresses the review nit on #4805: flattenBlocks() previously handled section/header/context/markdown but skipped rich_text, so a message whose only body is a rich_text block (the shape Slack rich-text input produces) could still be dropped. Walk rich_text sections/lists/quotes/ preformatted runs and concatenate their text and link runs. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <agent-j@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
7d310a9f6a |
fix: expose local skills for ACP providers (#4800)
Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
7b3cad664b |
revert(self-host): remove source channel reporting (#4799)
* Revert "test(onboarding): cover official source reporting controls (#4782)" This reverts commit |
||
|
|
159d9bebb1 |
feat(slack): route /issue slash command through quick-create (MUL-3908) (#4793)
The Slack `/issue` slash command used to directly create a raw issue: the
typed line became the title verbatim and a `todo` issue was assigned to the
agent to work on immediately. That files a rough, unstructured issue and starts
the agent on it before it is well-formed.
Switch the slash command to the quick-create pipeline instead
(TaskService.EnqueueQuickCreateTask, the same path as the web "quick create"
modal): the invoker's natural-language description is handed to the
installation's agent as a prompt, and the agent authors a well-formed issue
(proper title + structured description) in the background, attributed to the
bound member. Because creation is now asynchronous, the ephemeral reply is an
acknowledgement ("On it…") rather than a created-confirmation with a number;
the agent's completion surfaces to the invoker as a Multica inbox notification
through the shared quick-create completion path.
Installation routing and identity/membership checks are unchanged, so the same
workspace boundary and account-binding rules apply. Scope is the slash command
only — the message-based `@bot /issue` still runs through the shared
cross-platform engine (which also serves Lark) and keeps its direct-create
behavior.
- slash_command.go: swap IssueService.Create for EnqueueQuickCreateTask via a
narrow quickCreateEnqueuer interface; prompt is the full text (no title/body
split); drop the now-unused splitIssueText / issueCreatedText / GetWorkspace.
- router.go: wire h.TaskService instead of h.IssueService.
- tests: cover enqueue + ack, multiline prompt pass-through, empty prompt,
unbound, non-member, inactive, team mismatch, and enqueue-failure.
- docs (4 locales): describe the quick-create behavior.
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
|
||
|
|
247e8ed5ab |
feat(slack): reuse account link across apps in one Slack workspace (#4786)
A Slack user who linked their Multica identity to one bot was re-prompted to link again when messaging a second bot (a different Slack app) in the SAME Slack workspace, because bindings are keyed per-installation (installation_id, channel_user_id). On an unbound inbound, the identity resolver now looks up an existing binding for the same (Multica workspace, Slack team, Slack user) via the new FindReusableChannelUserBinding query and, if that user is still a workspace member, materializes a binding for the new installation instead of returning ErrSenderUnbound — so the second bot resolves the user silently. Reuse is fenced to one Multica workspace AND one Slack team, so it never crosses either boundary; legacy installs with no recorded team never reuse. Adds resolver unit tests for every decision path and updates the Slack integration docs (en/zh/ja/ko). MUL-3911 Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
170750242b |
BHI-12314: add Claude Sonnet 5 catalog and pricing support (MUL-3910) (#4783)
Co-authored-by: Ember <ember@Embers-iMac.localdomain> |
||
|
|
240ec4efd0 |
feat(slack): native /issue slash command over Socket Mode (MUL-3908) (#4780)
* feat(slack): native /issue slash command over Socket Mode (MUL-3908) A message beginning with `/issue` is intercepted by the Slack client as a slash command and never delivered to the app, so the message-prefix /issue never worked on Slack (no event, no 👀, no issue). Register /issue as a real slash command in the app manifest and handle EventTypeSlashCommand over the existing per-installation Socket Mode connection. It is a one-shot issue creation (no chat session / agent run) that reuses the shared IssueService and the same installation-routing + identity/membership checks as the message path, replying privately via the command's response_url (ephemeral) since a slash command has no message to react to. Docs: register the command in the manifest and describe the slash-command behavior across all four locales. Co-authored-by: multica-agent <github@multica.ai> * docs(slack): add commands scope to /issue manifest; fix chat-run wording (MUL-3908) Review follow-up: the manifest examples registered features.slash_commands but omitted the commands bot scope, so updating + reinstalling could still fail to grant the /issue command. Add - commands to oauth_config.scopes.bot in all four locales and document it in the permissions table. Also correct the misleading "no agent run" wording in the slash-command header and router comment: a todo issue assigned to the agent still triggers it via maybeEnqueueOnAssign (issue-assignment), like the message /issue — the slash command only skips the chat session / chat run. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
ad1afdd48d | fix(self-host): restore official source report endpoint (#4781) | ||
|
|
26142d74aa |
feat(self-host): collect anonymous source channels mul-3878 (#4741)
* feat(self-host): collect anonymous source channels * feat(self-host): include other source text * feat(self-host): report source channel domains * feat(self-host): add source domain reporting control * fix(self-host): simplify source reporting copy * fix(self-host): simplify source channel reporting gate * fix(self-host): limit source reporting triggers * chore(self-host): point source reporting at staging |
||
|
|
dbf11f0958 |
fix(attachments): relax frame-ancestors on local /uploads static route (MUL-3821) (#4777)
Self-hosted local-disk deployments serve document previews straight from the public /uploads/* static route. That route inherited the global `frame-ancestors 'none'` CSP from the middleware, so iframe-based previews (PDF/HTML) were blocked by the browser — only the /api/attachments/* download endpoint had been exempted (#4635 / #4679). Serve /uploads/* through a new Handler.ServeLocalUpload that applies the same preview security headers as the download endpoint (setAttachmentPreviewSecurityHeaders), so the relaxed, config-aware `frame-ancestors 'self' <configured origins>` policy applies to both same-origin and split frontend/backend origin setups. Inline <img> rendering is unaffected (frame-ancestors does not gate images); cloud storage (S3/CloudFront) never hits this route. Adds regression tests covering the relaxed CSP on /uploads and the non-local-storage 404 guard. Refs #4477 Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
4b9ea4aa68 |
feat(agent): add ByteDance TRAE CLI (traecli) as an ACP backend (#4724)
Adds the official ByteDance TRAE CLI (the `traecli` binary documented at https://docs.trae.cn/cli — the product paired with the Trae IDE, not the open-source bytedance/trae-agent) as a built-in agent backend. traecli is ACP-native, so it is driven over the standard ACP JSON-RPC transport via `traecli acp serve --yolo`, reusing the shared hermesClient exactly like the Kiro and Qoder backends. Validated end-to-end against the real traecli v0.120.42 with a logged-in account: initialize advertises loadSession:true + mcpCapabilities{http,sse}; session/new returns result.sessionId + models.availableModels (18 models discovered); session/prompt streams session/update notifications with sessionUpdate=agent_message_chunk (hermesClient already normalizes this Zed-ACP wire shape); a real board task ran 14 tool calls and completed in ~47s. Implementation: - server/pkg/agent/traecli.go: ACP backend; session/load resume (loadSession:true), session/set_model, MCP via ACP mcpServers, --yolo bypass-permissions for headless runs, blocked-arg filtering (acp, serve, --yolo, --print, --output-format, --permission-mode) - agent.go: New() + launch header "traecli acp serve" - models.go: discoverTraecliModels via the shared discoverACPModels - daemon/config.go: auto-detect the `traecli` binary (MULTICA_TRAECLI_PATH / MULTICA_TRAECLI_MODEL) - daemon.go: inline the runtime brief (traecli reads .trae/rules/, not AGENTS.md) and surface the runtime as "Trae" (providerDisplayName) - execenv: AGENTS.md + .traecli/skills wiring; ~/.traecli/skills local root - packages/core mcp-support: traecli consumes mcp_config - frontend: official Trae provider logo - docs: providers.mdx matrix + section, CLI_AND_DAEMON.md, README Tests: fake-ACP unit tests matching the real wire format (streaming, blocked-arg filtering, session/set_model failure, session/load resume) plus a gated real-binary smoke test (TestTraecliRealACPSmoke) that skips when traecli is absent or not logged in. Built-in provider only (mirrors qoder): not in SupportedTypes / RUNTIME_PROFILE_PROTOCOL_FAMILIES, so no migration is needed. Resolves #4376. |
||
|
|
444c2f29a5 |
fix(slack): stop the chat agent narrating its history reads (MUL-3871) (#4776)
Every Slack reply was prefixed with process narration like '我先读取 Slack 频道概览, 再打开相关线程…' before the actual answer — the model announcing the history reads the channel-awareness prompt tells it to do. That narration is internal context-gathering, not part of the answer. Add an instruction to the channel-awareness block: do the reads silently and reply with the answer only, no preamble about what it is about to read or just read. Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
3a6d3522c8 |
feat(slack): two-command channel reads — chat history (overview) + chat thread [id] (MUL-3871) (#4762)
Replaces the single scoped `multica chat history --scope` read with two clean noun-commands so the agent can navigate a channel with many threads (e.g. read the specific thread a user referred to): - `multica chat history` — the channel OVERVIEW: recent top-level messages, each thread tagged with thread_id + reply_count + latest_reply (it does NOT expand thread contents). Backed by GET /api/chat/history + slack.History.ChannelOverview (conversations.history). - `multica chat thread [id]` — read one thread: no id = the thread you're in, an id = a specific thread IN THE SAME channel. Backed by GET /api/chat/thread + slack.History.Thread (conversations.replies; DM falls back to history). The channel stays server-pinned to the session; a thread id is only a within-channel locator, so the security boundary (no cross-channel reads) is unchanged. `--scope` is removed. The prompt now teaches both commands and, via a new chat_in_thread signal (derived from the binding: last_thread_id != last_message_id), tells the agent which to start with — `chat history` for a top-level @mention, `chat thread` for an in-thread one. Tests: slack ChannelOverview/Thread (current/by-id/DM-fallback/no-binding/clamp), handler both endpoints + auth, prompt top-level vs in-thread guidance. Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
e57288ba60 |
feat(usage): log per-run prompt-cache hit ratio (MUL-3887) (#4759)
Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
a961d63611 |
feat(slack): make the chat agent explicitly channel-aware (MUL-3871) (#4755)
Before this, the chat prompt only carried a generic, always-on hint ('if this
came from a chat channel...'), and the task carried no channel signal — so the
agent never definitively knew it was inside Slack. For an ambiguous ask like
'what did you just talk about', it could read Multica instead of the Slack
conversation.
- Thread a chat_channel_type ('slack') signal: the server sets it on the chat
task response when the session has a Slack binding
(GetChannelChatSessionBindingBySession); the daemon Task carries it.
- buildChatPrompt now emits an EXPLICIT block only when channel-backed: 'You are
operating inside a Slack conversation … this conversation and its history live
in Slack, NOT in Multica … read it with multica chat history, do NOT look in
Multica.' Web-only chat sessions get no such block (their history is the
Multica chat_session the agent already resumes).
Tests: slack-backed prompt asserts the explicit Slack/“NOT in Multica”/command
copy; web-only prompt asserts the block is absent.
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
|
||
|
|
50a48cef1e |
feat(slack): unified multica chat history pull for channel backfill (MUL-3871) (#4747)
* feat(slack): add unified `multica chat history` pull for channel backfill (MUL-3871) Agents @mentioned in a Slack thread/channel only saw the triggering message, never the prior conversation (GitHub #4717). Instead of force-assembling a recent-context block on every inbound (the Feishu approach), expose a single channel-agnostic pull command the agent runs on demand. - channel: normalized HistoryMessage/HistoryPage/HistoryOptions vocab so the agent sees one shape regardless of platform. - slack.History: resolves session -> binding -> installation -> bot token and reads conversations.replies (real thread) or conversations.history (DM / top-level channel, capturing sibling messages). thread_ts is recorded on the binding config at session creation to pick the right call. - handler GET /api/chat/history: authorized purely by the task-scoped token (stamped X-Task-ID -> the task's own chat session), so an agent can only read the conversation it is currently running for. - multica chat history CLI command (no args; same for every channel). - buildChatPrompt nudge so the agent discovers the command. Feishu is intentionally untouched. Adding a platform = implement the reader. Co-authored-by: multica-agent <github@multica.ai> * fix(slack): require task-token actor source on chat history endpoint Niko's review caught a privilege-boundary hole: the endpoint trusted X-Task-ID, but it is mounted under the general Auth group where a normal JWT / mul_ PAT request does NOT strip a client-forged X-Task-ID — only the mat_ task-token branch stamps it. A workspace member who knew a chat task id could forge the header and read that task's Slack channel/DM/thread history. Gate on the server-set X-Actor-Source == "task_token" (the Auth middleware deletes any client-supplied value and re-stamps it only on the mat_ branch), then trust X-Task-ID. Adds a regression test: a forged X-Task-ID without the task-token actor source is rejected with 403 and never reaches the reader. Co-authored-by: multica-agent <github@multica.ai> * fix(slack): thread-first history for follow-ups, channel for first turn (MUL-3871) A Slack conversation has two nested histories: the surrounding channel and the agent's own thread (the bot's first reply opens a thread on the @mention). The first version picked replies-vs-history from a thread_ts fixed at session creation, so a session started by a top-level @mention always read CHANNEL history — even on follow-ups inside the bot's thread, which should read THREAD history first. - Add a HistoryScope (auto|thread|channel). The handler resolves auto: first turn (no prior bot reply) -> channel; follow-up -> thread. The agent can override with --scope channel|thread, and the response reports the scope read. - The thread root is derived from the binding (last_thread_id / composite-key suffix), available for every engaged group session, instead of the creation-time thread_ts (now removed from the binding config). - A DM degrades a thread request to channel history (DMs have no threads). - Prompt guidance + CLI help updated to explain the policy. Tests: scope selection (thread/channel/DM-fallback/no-root), root derivation, and handler auto-resolution (first->channel, follow-up->thread, explicit override). Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
630feff1af |
MUL-3879: restore agent-authored squad-leader fallback in comment cascade (#4748)
After MUL-3794 rewrote the comment routing cascade, computeCommentAgentTriggers returned early for every non-member author, so worker-agent result comments on a squad-assigned issue no longer woke the assigned squad leader, breaking the leader->worker->leader coordination loop. Restore a narrow agent-authored fallback: when the issue is squad-assigned and the author is not a member, route to routeAssignedSquadLeaderFallback. Member/ thread routing and explicit @agent/@squad mention routing are untouched, and the lastTaskWasLeader self-trigger suppression is preserved (it lives inside routeAssignedSquadLeaderFallback). Explicit mentions are handled before this branch, so a mentioned target is never double-enqueued alongside the leader. Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
b90816264e |
feat(skills): import skills from a .skill/.zip archive (#4735)
Import a skill from a local .skill/.zip archive: POST /api/skills/import now accepts a multipart upload (file + on_conflict) alongside the JSON URL body, and the CLI gains `multica skill import --file <path>`. Reuses the existing create + on_conflict contract, per-file/bundle/count caps, reserved-SKILL.md rule, and a zip-slip guard. Closes #4730 MUL-3865 |
||
|
|
e5995c423f |
feat(slack): typing reaction on inbound message (MUL-3874) (#4737)
* feat(slack): add typing reaction on inbound message (MUL-3874) Mirror the Feishu typing indicator on Slack: react with 👀 on the user's message when it is ingested, then remove the reaction when the agent's run finishes (EventChatDone) or fails (EventTaskFailed). - New slack.TypingIndicatorManager: Add on ingest, Clear on terminal run events; state keyed by chat_session_id, bot token re-resolved from the DB on clear (never held in memory), all failures logged and swallowed (best-effort). - Wire via the channel-agnostic engine.TypingNotifier seam (slackTypingNotifier in the ResolverSet) — the Router already calls OnIngested off the ACK path. - Clear subscribes to the event bus directly so a failed run also drops the reaction (the outbound replier only handles EventChatDone). - Skip messages older than 2m so Socket Mode reconnect replays don't restamp. Requires the installed Slack app to hold the reactions:write scope. Co-authored-by: multica-agent <github@multica.ai> * fix(slack): clear typing reaction when no task runs; document reactions:write (MUL-3874) Addresses review feedback on the typing-indicator PR. 1. Stuck reaction on offline/archived agent. The debounced flush (flushChatRun) enqueues no task when the agent has no runtime or is archived (or on any enqueue/reload error), so no task lifecycle event is ever published and the bus-driven clear never fires — leaving the 👀 (and Feishu's Typing) reaction stuck on the user's message. Fix at the shared engine seam: add TypingNotifier.OnSettled(ctx, sessionID), which the Router calls from the flush on every no-task exit (before any offline/archived notice). Both the Slack and Feishu notifiers route it to manager.Clear, so the latent Feishu case is fixed too. Adds engine coverage (offline/archived clear, success does not) and a Slack OnSettled test. 2. Missing reactions:write scope in docs. reactions.add/remove silently fail without the scope, but the BYO app manifest/docs never listed it. Add reactions:write to the manifest + scope table and a reinstall note across all four locales (en/zh/ja/ko). Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <agent-j@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
93028d303b |
fix(daemon): reconcile in-flight task and workspace state on WS reconnect (#4718)
After a WebSocket disconnect, the daemon's view of running tasks and workspace state can lag the server for up to 5s (per-task cancellation poll) or 30s (workspace sync) because both loops park on coarse tickers that do not observe the WS wakeup channel. This change adds a small fan-out broadcaster (`reconcileBroadcaster`) that the WS connect path fires once per (re)connect. `watchTaskCancellation` and `workspaceSyncLoop` subscribe and re-check immediately on broadcast, without disturbing the ticker cadence. The broadcaster is edge-triggered with a one-slot replay so a broadcast that lands before a subscriber is ready is not lost (closes the daemon-startup race), and back-to-back broadcasts inside 1s are debounced so a flapping connection cannot fan out into a request stampede. Existing behaviour is preserved: shouldInterruptAgent still decides whether to interrupt, the 5s/30s ticker still bounds the worst case, and the WS heartbeat / HTTP heartbeat coordination is untouched. Closes #4665 |