A Multica-managed run goes terminal the moment the top-level turn exits;
there is no "background work finishes later and wakes you up" step. When an
agent starts background work (a run_in_background shell, a Monitor, an async
subagent) and ends its turn to "wait for a completion notification", the work
is orphaned and the result comment it meant to post is never sent (MUL-4091 /
PR #4970).
The existing claude-only protocol guard forces run_in_background tool inputs
to foreground and fails loud on async_launched tool results, but it cannot
catch the actual MUL-4091 mechanism: a turn that ends cleanly with a
"Standing by, I'll report when CI finishes" message. That shape is only
addressable behaviorally, and it is harness-agnostic.
Harden the Background Task Safety brief (both the legacy/verbose production
path and the slim staging path) with explicit hard pins:
- never background-and-yield / expect a future wakeup that does not exist here;
- do every wait synchronously in a single foreground call (e.g. gh run watch);
- the standalone-harness "running in the background, keep working" hint does
not apply in Multica-managed runs;
- never end a turn with a "standing by" / "I'll report back" sign-off.
Add verbose- and slim-path test coverage for the new pins so a future brief
trim cannot silently drop them.
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(server): don't cancel issue tasks on assignee change (#4963)
Changing an issue's assignee previously called CancelTasksForIssue,
which cancels every active task on the issue by issue_id alone —
regardless of which agent owns the task or how it was triggered. In a
multi-agent workspace this silently dropped unrelated in-flight work
(a mention-triggered run for another agent, a squad task) with no
requeue, and it self-cancelled a run that reassigned the issue from
inside its own turn (the daemon then interrupted the live run before
its post-handoff cleanup could finish).
Reassignment now cancels nothing: ownership handoff no longer implies
interruption. The new assignee's run, if any, is still enqueued by
WillEnqueueRun and runs alongside whatever was already in flight.
Explicit terminal actions — issue -> cancelled and delete issue —
still cancel active tasks, unchanged.
Applies to both UpdateIssue and BatchUpdateIssues. Adds handler tests
that fail against the old behavior (both the previous assignee's own
run and an unrelated agent's run got cancelled) and pass now.
MUL-4113
Co-authored-by: multica-agent <github@multica.ai>
* test(server): cover agent→agent reassign; fix stale WillEnqueueRun comment
Addresses review nits on #4975 (MUL-4113, #4963):
- Rewrite the outdated WillEnqueueRun doc comment. The assign source no
longer cancels existing tasks, so the old "assign cancels existing
tasks before enqueuing, pending task moot" premise is wrong. Describe
the real invariant instead: the write is guarded by the
(issue_id, agent_id) partial unique index, only the status source needs
the pending-task dedup, and the assign source safely skips it.
- Add a handler test for the core agent→agent handoff path. The existing
no-cancel tests only reassigned to a member; this one reassigns from one
agent to another and asserts both effects independently: the previous
agent's running task survives (no collateral cancel) and the new
assignee still gets exactly one run enqueued.
MUL-4113
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The server-side running-task sweeper failed rows purely on `started_at >
now() - runningTimeoutSeconds` (2h30m). By its own comment the wall clock
is "mainly for runs whose daemon died without reporting" and "only needs
to sit generously above any realistic single run" — but the predicate
does not actually distinguish a healthy long-running task from an
orphaned one. On self-hosted deployments this kills multi-hour research
/ training runs mid-flight even though the daemon is still heartbeating
and the run is actively producing output.
The daemon side is intentionally unbounded (only inactivity watchdogs:
idle 30m, tool 2h); the server backstop was silently the only wall
clock. `FailStaleTasks` is now AND-gated on runtime liveness:
* dispatched — unchanged; already excludes rows with a live
`prepare_lease_expires_at` (renewed every 15s by the daemon between
claim and StartTask).
* running — new: excluded when the task's `agent_runtime` row is
`online` AND `last_seen_at` is within the runtime stale window
(staleThresholdSeconds = 150s, the same signal
sweepStaleRuntimes already uses).
Healthy long-running tasks on live daemons are no longer killed by the
wall clock. The daemon-dead case remains primarily handled by
sweepStaleRuntimes in the same tick (Redis LivenessStore + DB stale +
FailTasksForOfflineRuntimes); the wall-clock branch is now a defensive
backstop for the pathological case where a runtime row lingers online
with a stale DB heartbeat for longer than the wall clock. `runtime_id
IS NULL` is treated as "not proving liveness" so the wall clock still
fires on that (rare / historical) shape.
The 2h30m default is unchanged — this is a gate, not a threshold
change. Tests updated: 4 existing running-task tests now age out the
runtime so they still exercise the wall clock; 2 new tests cover both
new invariants (healthy runtime → skipped; stale runtime → still
killed).
Fixes#4958
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
* fix(agent): pi agent final output excludes intermediate steps
Updated PI agent to only retain the final result in JSON output.
Previously, `text_delta` included both intermediate steps and final
content.
Now, output is reset on each `text_start` to concatenate only the
final text.
* fix(agent) Replace `message_update.text_start` with `turn_start` event. add test
`turn_start` begins a new turn, Reset output on it to exclude
intermediate texts.
a1b336d73e/packages/coding-agent/docs/rpc.md (events)
Trae (traecli) already has a New() backend, launch header (traecli acp
serve) and provider branding, but was missing from every protocol_family
whitelist, so custom runtime profiles based on Trae were rejected and it
never appeared in the family picker.
Add traecli to SupportedTypes (Go), RUNTIME_PROFILE_PROTOCOL_FAMILIES
(TS), the lockstep test's want map, and a new migration 136 widening the
runtime_profile_protocol_family_check constraint.
MUL-4094, #4945
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* 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>
* 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>
* 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>
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>
Qoder CN (`qoderclicn`) users could only reach a working custom runtime by
misrouting through the Kiro backend, which launches `<cmd> acp
--trust-all-tools`. That is incompatible with Qoder's global `--acp` / `--yolo`
argv, so the task failed immediately with `kiro initialize failed` and no run
messages.
Expose `qoder` in the custom-profile protocol_family whitelist across every
lockstep layer:
- server/pkg/agent SupportedTypes (+ whitelist pin test)
- migration 134 runtime_profile protocol_family CHECK (NOT VALID, mirroring 126)
- packages/core RUNTIME_PROFILE_PROTOCOL_FAMILIES
The existing qoderBackend already honors an ExecutablePath override and launches
`<cmd> --yolo --acp`, so a profile with protocol_family=qoder and
command_name=qoderclicn now launches with the correct argv instead of the Kiro
shape. Provider branding/logo for qoder already exists.
MUL-4018
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
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>
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>
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>
* 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>
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>
* 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, 4708dba97).
Adds an agent-detail tab that lets the agent owner pick which of their own
active Composio connections this agent may mount as MCP servers, writing the
selection to agent.composio_toolkit_allowlist via the existing PUT /api/agents.
- core/types: composio_toolkit_allowlist (+ _redacted) on Agent; tri-state
composio_toolkit_allowlist on UpdateAgentRequest (omit/no-change, null/clear,
array/replace), matching the backend contract.
- core/agents: useUpdateAgentAllowlist - optimistic mutation hook (patches the
cached workspace agent list, rolls back on error, invalidates on settle).
- views: AgentMcpTab renders the owner's active connections as checkboxes;
empty state links to Settings -> Integrations; defensive redacted state.
- views: wired into AgentOverviewPane as tab "composio_mcp", labeled "MCP Apps"
to disambiguate from the existing raw-JSON "MCP" (mcp_config) tab. The entry
is gated to the creator (currentUserId === agent.owner_id), matching the
backend's owner-only read/write of the allowlist.
- i18n: tabs.composio_mcp + tab_body.composio_mcp.* in en/ja/ko/zh-Hans.
- tests: agent-mcp-tab.test.tsx (gating, toggle->allowlist body, active-only,
empty, redacted); e2e/agent-mcp.spec.ts (creator sees tab + PUT body,
non-creator hidden) with Composio + agent endpoints mocked at the boundary.
Note: the product spec says "creator"; the schema has no creator_id - the
backend gate and redaction are keyed on owner_id, so the tab uses owner_id.
Co-authored-by: multica-agent <github@multica.ai>
* fix(composio): mount remote MCP for codex
* feat(agents): agent invocation permission system (MUL-3963) (#4844)
* feat(agents): agent invocation permission system (permission_mode + invocation targets)
MUL-3963: split who may INVOKE an agent out of the overloaded visibility
column into an explicit, extensible model on feature/composio-integration.
- DB: agent.permission_mode (private|public_to) + agent_invocation_target
table (workspace/member/team targets) + lossless backfill from visibility
(migration 130).
- canInvokeAgent: owner-only for private (NO admin bypass, NO A2A bypass);
public_to honours the allow-list; A2A judged by the top-of-chain originator.
- All trigger paths rewired: issue assign, comment @agent/@squad, chat,
quick-create, autopilot, squad leader, child-done.
- Agent API: permission_mode + invocation_targets on responses and
create/update (owner-only writes); legacy visibility kept as a derived field
so old clients never see a permission widening.
- Composio: BuildTaskOverlay now FOLLOWS invocation permission and uses the
agent OWNER connection (removed the originator==owner gate); front-end warns
when a shared agent enables Composio apps.
- CLI: --permission-mode / --public-to-workspace / --public-to-member (legacy
--visibility still mapped).
- Frontend: AccessPicker (Private / workspace / specific people / team soon),
permission rules mirror canInvokeAgent, Composio warning banner.
- Tests: migration backfill, admin cannot invoke others private, public_to
workspace/member whitelist, A2A by originator, Composio overlay uses owner
connection.
Co-authored-by: multica-agent <github@multica.ai>
* feat(agents): stackable, mixed public_to invocation targets (MUL-3963)
Follow-up on PR #4844: public_to now supports selecting MULTIPLE, MIXED
targets on one agent (e.g. Public to workspace + specific people + team),
with canInvokeAgent admitting on ANY matching target (OR).
- Frontend AccessPicker: reworked from a single exclusive kind into a
stackable multi-select — an "Everyone in workspace" toggle, a member
multi-select checklist, and a (disabled, v1) team placeholder can be
combined freely. Emits the full union of selected targets; empty union
collapses to Private. Existing team targets are preserved across saves.
Added the access.public_group locale string (en/zh-Hans/ja/ko).
- Backend already supported this (agent_invocation_target is multi-row per
agent; create/update take a target ARRAY and batch-replace the whole
allow-list; canInvokeAgent OR-matches). Added tests to lock it in:
mixed member+team targets, overlapping-member batch replace, and
workspace+member stacking then narrowing.
Refs MUL-3963.
Co-authored-by: multica-agent <github@multica.ai>
* fix(agents): address review on invocation permission (MUL-3963)
张大彪 review on PR #4844 — three blockers + product ruling + nits:
1. Migration 130: drop the FK/cascade on agent_invocation_target
(agent_id, created_by) per the Multica no-FK rule; relationships are now
maintained in the app layer (matching MUL-3515 §4). Added
DeleteAgentInvocationTargetsByArchivedRuntimeAgents and call it before
DeleteArchivedAgentsByRuntime in all three runtime-delete paths
(runtime.go x2, runtime_profile.go) so hard-deleting agents can't orphan
target rows.
2. revokeAndRemoveMember: prune the leaving member's member-target grants
(DeleteAgentInvocationTargetsByMember) in the same tx as the member-row
delete, so a re-invited user can't reclaim a stale invocation grant.
3. Empty public_to is a phantom — parsePermissionInput now normalises a
public_to with no resolvable targets to a single workspace target, so
`--permission-mode public_to` alone (and any empty target array) means
"public to workspace" instead of "shared but nobody can run it".
Product ruling: the system/no-human-originator → workspace-target path in
canInvokeAgent is a deliberate, documented exception (webhook/system/
workspace-wide automation); member/team targets still fail closed without a
resolved originator. Documented in code + locked with a test.
Nits: refreshed the stale "originator must be owner" comments — models.go
(via migration 130 COMMENT ON COLUMN + sqlc regen for composio_toolkit_allowlist
and originator_user_id) and agent-mcp-tab.tsx — to the owner-connection +
invocation-permission rules.
Tests: member remove/re-add regression, system workspace exception + member
fail-closed, empty public_to → workspace (plus the earlier mixed/overlap/
batch-replace suite). Migration 130 applied to the test DB; Go handler/service/
composio suites green; views typecheck clean.
Refs MUL-3963.
Co-authored-by: multica-agent <github@multica.ai>
* fix(agents): scope member invocation-target cleanup to one workspace (MUL-3963)
张大彪 3rd review — cross-workspace permission bug + comment nits:
- DeleteAgentInvocationTargetsByMember was a GLOBAL delete by user id, so
removing a user from workspace A also wiped their member-target grants on
agents in workspace B. Scoped it to a single workspace by joining through
agent.workspace_id; revokeAndRemoveMember now passes (workspaceID, userID).
- Regression test TestRevokeMember_InvocationTargetCleanupIsWorkspaceScoped:
same user allow-listed by agents in two workspaces; removal from one leaves
the other workspace's target intact.
- Nits: refreshed the remaining stale "originator == agent.owner_id" /
"owner-vs-originator" comments — CreateRetryTask (agent.sql, regenerated),
and the AgentResponse allowlist doc + ListAgents/UpdateAgent redaction
rationale in agent.go — to the owner-connection + invocation-permission rule.
Migration 130 applied to the test DB; Go handler/service/composio suites green;
go vet clean.
Refs MUL-3963.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* fix(agents): agent access owner-only editable, read-only for others (MUL-3963) (#4853)
* fix(agents): make agent access owner-only editable, read-only for others (MUL-3963)
Interaction bug: a non-owner (incl. workspace admin) could open the AccessPicker
and set an agent public — the backend silently ignored it and the UI bounced
back to private. Access is owner-only, so non-owners must see a read-only state
and the backend must reject real changes explicitly.
Frontend:
- AccessPicker renders a static, non-interactive read-only state when the
viewer is not the owner: the current access value + a lock affordance + a
tooltip "Only the agent owner can change who can run this agent." No clickable
trigger is rendered, so a non-owner can never open a control the backend would
reject (the GitHub/Notion pattern for permission settings you can see but not
edit). The editable multi-select picker is unchanged for the owner.
- agent-detail-inspector gates the picker on ownership specifically
(currentUserId === agent.owner_id), NOT the general canEdit (which also admits
admins, who may edit other fields but not access).
- New locale key access.owner_only_readonly (en/zh-Hans/ja/ko).
Backend:
- UpdateAgent now returns an explicit 403 when a non-owner submits a REAL
permission change (permissionInputChangesAgent compares requested mode +
target set against the persisted state); a no-op resubmit (admin PATCH-as-PUT
echoing unchanged permission) is still tolerated so admin edits of other
fields keep working. Replaces the previous silent-drop that caused the bounce.
Tests:
- access-picker.test.tsx: non-owner gets a non-interactive read-only display
with the owner-only tooltip; owner gets an interactive picker; owner can pick
a member and stack workspace + member.
- TestUpdateAgent_AccessChangeIsOwnerOnly: admin real change → 403; admin no-op
resubmit → 200; admin editing other fields → 200; owner change → 200.
Incidental: fixed a pre-existing base typecheck break in
slash-command-suggestion.test.tsx (stray `signal` arg not in the suggestion
items type) that otherwise fails the whole @multica/views typecheck.
Refs MUL-3963.
Co-authored-by: multica-agent <github@multica.ai>
* fix(agents): compare legacy visibility, not expanded permission, for no-op detection (MUL-3963)
PR #4853 review: permissionInputChangesAgent expanded a legacy-only
visibility:"private" into a real private permission and compared it against the
agent's actual permission. A member-only public_to agent derives legacy
visibility "private", so an admin PATCH-as-PUT echoing visibility:"private"
while editing another field was misread as a public_to→private downgrade and
rejected with 403 — contradicting the "unchanged permission no-op is allowed"
contract.
Fix (per review): when a request carries ONLY legacy `visibility` (no
permission_mode / invocation_targets), derive the agent's CURRENT legacy
visibility from its real targets and compare the legacy string values. Equal =
no-op (allowed); a real legacy change (e.g. "workspace") still returns 403.
Requests that carry permission_mode / invocation_targets keep the precise
mode+target comparison.
Regression test TestUpdateAgent_LegacyVisibilityNoOpForMemberOnlyPublicTo:
member-only public_to agent — admin submitting visibility:"private" + a
non-permission field → 200 with targets unchanged; admin submitting
visibility:"workspace" → 403.
Go handler/composio suites green; migration 130 applied; go vet clean.
Refs MUL-3963.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(composio): brief agents on connected apps
* feat(composio): gate MCP apps behind feature flag
* fix(mobile): parse agent invocation permissions
* fix(tests): update agent fixtures for access fields
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Multica Eve <eve@devv.ai>
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Eve <eve@multica-ai.local>
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
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>
claudeEffortSuperset treated a --help with no --effort line as 'help
format drifted' and fell back to the full level superset. On a host
whose claude binary predates the flag (e.g. 2.1.2), that let
ValidateThinkingLevel approve the agent's persisted thinking_level, so
the daemon injected --effort and the CLI exited 1 with
"error: unknown option '--effort'" — hard-failing every task instead
of degrading to a plain run. Field case: a stale root-owned
/usr/local/bin/claude shadowing a current install for daemons whose
PATH orders /usr/local/bin first (Multica desktop app), while a shell-
launched daemon on the same machine resolved a current claude — the
same agent then failed or succeeded depending on which daemon won the
task claim race.
Distinguish the two cases: --effort present but value list unparsed →
keep the full-superset drift fallback; --effort absent entirely →
return no levels, so the daemon's existing per-model guard drops the
level with a warning and the task runs without the flag.
Co-authored-by: Fable Chief Strategist <noreply@anthropic.com>
`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>
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>
* 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>
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.
* 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>
Emit the recovered Antigravity transcript reply as a MessageText event so the
execution transcript catches up with Result.Output on empty-stdout recovery.
Closes#4779
MUL-3941
* 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>
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>
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>
Follow-up to #4780 (the /issue slash command), which merged the code + docs. The DB migration was pushed to that branch after it had already been squash-merged, so it never landed on main — the /issue command still fails at issue creation.
The issue.origin_type CHECK constraint (migration 111) only allowed autopilot / quick_create / lark_chat. Slack stamps origin_type='slack_chat' on every /issue create, so the INSERT trips SQLSTATE 23514 and IssueService.Create fails ("Something went wrong creating the issue"). This also silently broke the pre-existing message-based Slack /issue. Extends the constraint to include 'slack_chat', mirroring 111 (lark_chat). Numbered 131 because 129/130 were taken on main since #4780 branched.
Verified against a real Postgres: full chain up to 131 applies cleanly and an issue insert with origin_type='slack_chat' passes the CHECK after 131 (fails with the pre-131 constraint).
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* 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>