Skip to content

feat: bidirectional issue sync (GitHub, GitLab, Jira Cloud) - #2

Merged
0xHexE merged 2 commits into
mainfrom
feat/bidirectional-issue-sync
Jul 6, 2026
Merged

feat: bidirectional issue sync (GitHub, GitLab, Jira Cloud)#2
0xHexE merged 2 commits into
mainfrom
feat/bidirectional-issue-sync

Conversation

@0xHexE

Copy link
Copy Markdown
Member

Summary

Adds a new bidirectional issue-sync engine that mirrors Multica issues with external trackers (GitHub Issues, GitLab Issues, Jira Cloud). Projects can attach multiple remote containers (GitHub repos, GitLab projects, Jira projects) whose issues sync both ways: title, description, status (with mapping), comments (with attribution), and assignees + labels.

This was a greenfield outbound direction — the existing GitHub/GitLab integrations only mirrored PRs/MRs inbound. No outbound path, no issue sync, no background worker existed before.

Architecture

Inbound: provider webhook → handler normalizes → Engine.ApplyRemote (upsert local issue/comment, ActorType="issue_sync")
Outbound: event-bus listener → issue_sync_outbox row → worker pushes to provider, records content hash

Three-layer echo suppression prevents sync loops:

  1. Actor filter — outbound listeners skip events with ActorType == "issue_sync"
  2. Connection identity + content hash + bot login — inbound webhooks from the connection's own identity are dropped; inbound content hashing to last_pushed_hash is dropped
  3. Stale-replay clock — inbound events older than remote_updated_at are dropped (last-write-wins)

What's included

Backend (server/)

  • internal/integrations/issuesync/ — provider-agnostic engine (provider.go interface, engine.go inbound apply + outbound enqueue, outbox.go polling worker with FOR UPDATE SKIP LOCKED + exponential backoff, mapping.go status maps) + three provider impls:
    • github.go — GitHub App installation tokens, reuses existing App credentials
    • gitlab.go — GitLab OAuth token via existing connection + secretbox
    • jira.go + jira_adf.go — Jira Cloud REST + Atlassian Document Format ↔ markdown converter
  • Migration 136jira_connection, issue_sync_source, external_issue_link, external_comment_link, external_identity, issue_sync_outbox (+ sqlc queries)
  • handler/issue_sync.go — sync-source CRUD API, remote-container picker, inbound webhook routing
  • handler/jira.go — Jira OAuth 3LO (rotating refresh tokens), connection lifecycle, webhook handler
  • cmd/server/issuesync_listeners.go — outbound event-bus listeners (issue/comment/labels → outbox)
  • Extended HandleGitHubWebhook + HandleGitLabWebhook with issues/issue_comment and Issue/Note hooks
  • Outbox worker + listener registration wired in main.go/router.go
  • New gitlab_repo project resource type (alongside existing github_repo)

Frontend (packages/)

  • packages/coretypes/issue-sync.ts (zod schemas), issue-sync/ hooks (React Query, wsId-scoped keys), WS event invalidation, malformed-response tests
  • packages/viewsjira-tab.tsx settings tab, project-sync-sources-section.tsx (attach/manage sources), issue-sync-badges.tsx
  • i18n — en + zh-Hans + ja + ko keys (locale parity tested)

REST API

MethodPathPurpose
GET/POST/api/projects/{id}/sync-sourcesList/create sync sources
PUT/DELETE/api/projects/{id}/sync-sources/{sourceId}Update/delete a source
GET/api/workspaces/{id}/sync/{provider}/remote-projectsRemote repo/project picker
GET/api/workspaces/{id}/jira/connectJira OAuth authorize URL
GET/api/jira/oauth/callbackJira OAuth callback
POST/api/webhooks/jira/{connectionId}Jira webhook ingress
GET/DELETE/api/workspaces/{id}/jira/connections[/{connectionId}]Jira connection lifecycle

Testing

  • Go: go build ./... ✅ · go vet clean ✅ · 26 issuesync tests pass (engine hash/normalization, GitLab provider httptest, Jira ADF round-trip + refresh-token rotation) · go test ./internal/handler/ passes
  • Frontend: pnpm typecheck all 6 packages ✅ · pnpm test (views) 1627 pass ✅ · core 769 pass ✅ · locale parity ✅
  • sqlc: regenerates with zero diff

Notes

  • Jira Cloud OAuth 3LO uses rotating refresh tokens (the refresh response includes a new refresh token; always persisted — tested)
  • Jira dynamic webhooks expire after 30 days (refresh scheduler is stubbed for a follow-up; the webhook handler works today via manual registration)
  • The outbox worker is multi-replica safe (FOR UPDATE SKIP LOCKED)
  • Per-project push_default source controls where new Multica issues are created remotely (partial unique index enforces one per project)

Migration notes for operators

New optional env vars (all gated — absence disables that provider cleanly):

  • MULTICA_JIRA_SECRET_KEY — at-rest encryption for Jira OAuth tokens
  • JIRA_OAUTH_CLIENT_ID / JIRA_OAUTH_CLIENT_SECRET — Jira Cloud OAuth app credentials
  • Existing MULTICA_GITLAB_SECRET_KEY + GITHUB_APP_ID/GITHUB_APP_PRIVATE_KEY are reused

🤖 Generated with Claude Code

Add a new issue-sync engine that mirrors Multica issues bidirectionally
with external trackers. Projects can attach multiple remote containers
(GitHub repos, GitLab projects, Jira Cloud projects) whose issues sync
both ways: title, description, status (with provider-specific mapping),
comments (with attribution), and assignees + labels.
Architecture:
- server/internal/integrations/issuesync/ — provider-agnostic engine
(provider.go, engine.go, outbox.go, mapping.go) + GitHub/GitLab/Jira
provider impls. Inbound: webhook -> normalize -> ApplyRemote (upsert
local issue/comment with ActorType 'issue_sync'). Outbound: event-bus
listeners -> issue_sync_outbox -> worker pushes to provider.
- Three-layer echo suppression: actor-type filter, connection-identity +
content-hash + bot-login, and stale-replay clock.
- DB: migration 136 (jira_connection, issue_sync_source,
external_issue_link, external_comment_link, external_identity,
issue_sync_outbox) + sqlc queries.
- REST: sync-source CRUD under /api/projects/{id}/sync-sources, remote
container picker, Jira OAuth 3LO + connection lifecycle.
- Frontend: core types/zod hooks + WS invalidation, settings jira-tab,
project sync-sources section, issue sync badges, i18n (en/zh/ja/ko).
Jira Cloud uses OAuth 3LO with rotating refresh tokens; GitLab reuses the
existing OAuth connection; GitHub reuses App installation tokens. A new
gitlab_repo project resource type is also added so GitLab repos can be
attached alongside GitHub repos.
@0xHexE
0xHexE marked this pull request as ready for review July 6, 2026 09:46
The gitlab_repo section header used a literal "GitLab" string which
violated the i18next/no-literal-string lint rule. Add a gitlab_label
key to all 4 locales (en/zh-Hans/ja/ko) and reference it via t().
@0xHexE
0xHexE merged commit e29819a into mainJul 6, 2026
6 checks passed
0xHexE pushed a commit that referenced this pull request Jul 9, 2026
…UL-4195) (multica-ai#5068)
* fix(comments): guarantee at-least-once processing of user comments (MUL-4195)
Consecutive comments on an issue were silently dropped: a new comment that
arrived while the agent already had a queued/dispatched task was discarded by
the HasPendingTaskForIssueAndAgent dedup, losing the user's follow-up
instruction with no visible trace. Comments — unlike chat — are deliberate,
addressed, persisted input and must never vanish.
This makes comment handling at-least-once while keeping concurrency bounded to
one run per (issue, agent):
- Merge, don't drop (PR1): a comment landing while a not-yet-started task
exists is folded into that task — the prior trigger becomes a coalesced
comment and the new one becomes the trigger, so a single run still covers
every deliberate comment. Falls back to a fresh enqueue if the pending task
was claimed mid-flight, so nothing is lost in the race.
- Completion reconciliation (PR2): on task completion, a member comment newer
than the run's started_at schedules exactly one follow-up via the normal
trigger pipeline. Loop-safe: member-authored only, capped by the existing
per-(issue,agent) dedup, and terminating.
- Visibility (PR3): coalesced_comment_ids is surfaced on the task API and in
the run prompt so the covered comments are explicit.
Migration 145 adds agent_task_queue.coalesced_comment_ids UUID[].
Tests: merge-not-drop preserves all three of a rapid burst and repoints the
trigger to the newest; reconciliation query gates on member/since; e2e
CompleteTask enqueues a follow-up for a mid-run member comment and does not for
none.
Co-authored-by: multica-agent <github@multica.ai>
* fix(comments): address review — originator gate, agent-scoped reconcile, cross-thread coalesced prompt (MUL-4195)
Resolves GPT-Boy's Request-changes review on PR multica-ai#5068.
Must-fix#1 — merge no longer inherits a stale originator/runtime context.
MergeCommentIntoPendingTask now only folds a comment into a pending task
whose originator_user_id IS NOT DISTINCT FROM the new comment's originator.
runtime_mcp_overlay / runtime_connected_apps are a pure function of
(originator, agent) and the agent is fixed, so a matching originator keeps
the stored overlay/attribution valid; a differing originator (e.g. user B
commenting on a task originated by user A) matches no row and the caller
enqueues a fresh follow-up with B's own context instead of reusing A's.
trigger_summary is refreshed to the new trigger comment.
Must-fix#2 — completion reconcile no longer re-wakes unrelated agents.
reconcileCommentsOnCompletion computes the latest member comment's triggers
and keeps ONLY the agent that just completed, instead of fanning the comment
out through the full pipeline. An @-mention of agent B during agent A's run
is triggered once at creation time and is no longer replayed (double-run)
when A completes.
Should-fix#3 — coalesced-comment prompt no longer assumes a single thread.
The claim response now carries each folded comment's thread id / author /
created_at / content (CoalescedCommentData); the prompt embeds them directly
so the agent addresses cross-thread folded comments without the wrong
"they are in the triggering thread" hint. Old servers that ship only ids
fall back to an issue-wide fetch, still without the same-thread assumption.
Tests: TestMergeCommentIntoPendingTask_OriginatorGate (query gate),
TestCompleteTask_DoesNotReTriggerOtherAgentMentionedDuringRun (reconcile
scoping), TestBuildCommentPromptCoalescedCrossThread / IDsOnlyFallback
(prompt). Existing MUL-4195 suites still pass.
Co-authored-by: multica-agent <github@multica.ai>
* fix(comments): close unique-index drop + dispatched-window race in comment coalescing (MUL-4195)
Second-round review follow-up on PR multica-ai#5068.
Must-fix#1 — originator-mismatch no longer drops the comment.
The previous originator gate returned ErrNoRows on a different originator and
the caller fell through to a fresh enqueue, which collided with the
idx_one_pending_task_per_issue_agent unique index (one queued/dispatched task
per (issue, agent)) — silently dropping the second user's comment. Replaced
the gate with recompute-on-merge: MergeCommentIntoPendingTask now re-stamps
originator_user_id, runtime_mcp_overlay, runtime_connected_apps and
trigger_summary to the new comment's originator. A different member's comment
folds into the single coalescing run carrying the latest instruction's own
identity/overlay (no cross-user capability bleed, no drop, no collision).
Must-fix#2 — comment arriving in the claim→StartTask window is no longer lost.
Merge now targets only PRE-CLAIM states ('queued','deferred'); a
dispatched/running task is never a merge target, so a post-claim comment is
never falsely stamped into coalesced_comment_ids as "delivered". Completion
reconcile is re-anchored on dispatched_at (the moment the claim response is
built) instead of started_at, and sweeps ALL undelivered member comments since
that anchor — replaying each through the normal enqueue path so they coalesce
into one bounded, agent-scoped follow-up run. This covers the dispatch→start
window a started_at anchor missed.
Enqueue path: on a merge miss the caller no longer blindly fresh-enqueues
(which could collide with a dispatched sibling); it defers to the active
task's completion reconcile via HasActiveTaskForIssueAndAgent, and only
fresh-enqueues when no active task exists.
Tests: rewrote the query test to
TestMergeCommentIntoPendingTask_RecomputesOriginatorAndSkipsDispatched;
added TestConsecutiveCommentsDifferentOriginatorsFullEnqueuePath (full handler
enqueue path, two distinct originators) and
TestCompleteTask_ReconcilesDispatchedWindowComment (claim→start window). All
existing MUL-4195 handler/cmd-server/daemon/service suites still pass.
Co-authored-by: multica-agent <github@multica.ai>
* fix(comments): catch pre-dispatch merge-race comment in completion reconcile (MUL-4195)
Third-round review follow-up on PR multica-ai#5068.
Race: a member comment is created while the task is still queued, but its
merge loses the race to the daemon claiming the task (queued→dispatched). The
merge then finds no pre-claim row (ErrNoRows), the enqueue path defers to
reconcile — but the comment's created_at is BEFORE dispatched_at, so the
dispatched_at-anchored reconcile skipped it and the comment vanished with no
task coverage.
Fix: anchor completion reconcile on the task's created_at (which always
precedes dispatch) instead of a dispatch/start timestamp, and exclude the
run's DELIVERED SET — trigger_comment_id ∪ coalesced_comment_ids. Because
merges only ever touch pre-claim rows, that set is exactly what the claim
response carried, so any member comment created since the task was made that
is NOT in it was genuinely undelivered and earns a bounded follow-up. This
catches the pre-dispatch merge-race comment and the dispatch→start comment,
while never re-firing a comment that was delivered as a pre-claim coalesced
entry.
Test: TestCompleteTask_ReconcilesPreDispatchMergeRaceComment reproduces the
race (comment created pre-dispatch, task dispatched before merge, plus a
delivered coalesced comment) and asserts exactly one follow-up, triggered by
the race comment, with the delivered coalesced comment excluded. Existing
reconcile fixtures updated to set a realistic created_at (the production
invariant that created_at is the earliest task timestamp).
Co-authored-by: multica-agent <github@multica.ai>
* fix(comments): merge only into the queued task, never a deferred fallback (MUL-4195)
Fourth-round review follow-up on PR multica-ai#5068.
MergeCommentIntoPendingTask targeted status IN ('queued','deferred') ordered
by created_at DESC. When a (issue, agent) pair had both an older queued task
(the run about to be claimed) and a newer deferred assignee-fallback task, a
new comment merged into the deferred row instead of the queued one — so the
comment missed the imminent run and the deferred fallback could later promote
into a duplicate/conflicting run.
This merge is only ever reached when HasPendingTaskForIssueAndAgent matched a
queued/dispatched task (it never inspects deferred), so the coalescing target
must be the queued row. Restricted the merge target to status = 'queued'
(the unique index guarantees at most one). Deferred fallbacks keep their own
fire_at/promotion escalation lifecycle and are never a merge target.
Test: TestMergeCommentIntoPendingTask_TargetsQueuedNotDeferred seeds an older
queued task + a newer deferred fallback for the same (issue, agent), merges a
new comment, and asserts it lands on the queued task (trigger repointed, old
trigger coalesced) while the deferred fallback is left untouched.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
0xHexE pushed a commit that referenced this pull request Jul 10, 2026
Syncs the fork with multica-ai/multica upstream (35 commits ahead of the
previous sync). Brings in: thread quick-jump minimap on issue detail,
Mod-Enter IME-composition guard in the editor, Runtimes tab CLI-update red
dot removal, GitHub PR/check_suite webhook fan-out to all bound workspaces
(MUL-4343), daemon task transcript ordering + log rotation, Codex gpt-5.6
model series + dynamic catalog, coalesced per-thread direct-chat replies
(MUL-4348/MUL-4351), Lark binding token clock-skew tolerance, merged-comment
delivery preservation, claim-time comment scope + attachment path guardrails
(MUL-4252), unified avatar size tiers + rounded avatars (MUL-4277/MUL-4184),
overflowing desktop tab additions, chat FAB unread-badge removal, and the
v0.3.43 release.
Conflicts resolved:
- server/internal/handler/quick_create_parent_test.go: take fork (ours). The
fork removed the daemon CLI version gate for quick-create
(d588f8e "remove daemon CLI version gate"), so MinQuickCreateCLIVersion no
longer exists in non-test code. Upstream's improved version-gate test setup
(bump cli_version on the agent's bound runtime) references that now-undefined
symbol; taking ours keeps the test compiling against the gateless handler.
Additional fixes (merge-introduced regressions in fork-only code):
- server/internal/handler/gitlab.go: restore repoIdentityFromURL as a local
helper. Upstream's webhook fan-out refactor (53f05cc, MUL-4343) removed
repoIdentityFromURL + resolveWorkspaceForRepo from github.go because GitHub
routing no longer gates on workspace.repos. The fork's GitLab webhook
auto-registration still uses it to match repos-registry entries against a
connection's instance — a different concern (registering hooks, not routing)
— so the helper is re-added local to its only remaining caller.
- packages/views/settings/components/mattermost-tab.tsx: pass size="lg" instead
of size={32} to ActorAvatar. Upstream's avatar refactor (f4de094,
MUL-4277/MUL-4184) retyped size from a raw pixel number to the AvatarSize
union ("xs".."2xl"); 32px == "lg" tier (AVATAR_SIZE_PX.lg === 32).
- server/migrations: renumber 157_issue_origin_type_reconcile -> 161. Upstream
claimed prefix 157 (157_agent_task_delivered_comments). The fork's reconcile
migration restores the full issue.origin_type CHECK union after the two
same-prefix-149 migrations (agent_create + mattermost_chat) each drop the
other's value; it is idempotent and forward-only, so running it last at 161
is correct on both fresh and already-migrated databases.
Verified: go build ./... + go vet ./... clean; migration uniqueness/direction
lint passes; turbo typecheck passes (6/6 packages); @multica/views tests pass
(174 files / 1810 tests). The remaining local test failures
(@multica/core workspace/mutations + @multica/desktop runtime-config-loader)
are pre-existing sandbox environment issues (Node experimental localStorage
without --localstorage-file; Electron not installed) on files the merge did
not touch.
0xHexE pushed a commit that referenced this pull request Aug 4, 2026
…from the sidebar (MUL-5465) (multica-ai#6132)
* feat(issues): Issue Quick Actions — preset agent + prompt, one-click from the sidebar (MUL-5465)
Preset "who to call and what to say" once in Settings, then trigger it from
any issue's sidebar with a single click.
Running one is NOT a new dispatch path. The server renders the prompt, posts
a `quick_action` comment carrying the target's mention markup, and hands off
to the existing comment -> mention -> task trigger. Permission
(canInvokeAgent), attribution, squad-leader routing, the execution log, and
pending-task coalescing are inherited rather than reimplemented — the
MUL-3375 lesson about four drifting copies of one trigger decision.
Three things the UI has to be honest about, because the backend already
decided them:
- One pending task per (issue, agent) is a DB invariant
(idx_one_pending_task_per_issue_agent). A second click against a busy agent
starts no new run; the comment merges into the pending task. The toast says
"Added to Lambda's current run", not "Lambda started working".
- An offline target defers rather than fails; the run reuses the existing
dispatch.ReasonCode vocabulary instead of inventing one.
- Private agents are deny-by-default with no admin bypass. The sidebar filters
by the caller's own invoke verdict, so a dead button is never rendered, and
a direct API call still 403s with `invocation_not_allowed`.
Visibility is DERIVED from the bound agent's permission_mode on every request,
never stored — so it cannot drift after someone flips an agent between private
and public_to. Binding a workspace action to a private agent is allowed (the
alternative pressures people into making agents public just to satisfy a
config constraint) but the settings form says so at bind time, and the
catalog badges it. The target's name is withheld from callers who cannot see
it, so the response never discloses a private agent's existence.
Prompt templating is flat substitution over a closed whitelist. No
conditionals, loops, or filters — the agent already reads the whole issue, so
natural language is the control flow. One optional runtime input ({{input}})
keeps a single action from splitting into five near-identical variants; both
directions of the input/{{input}} agreement are rejected at write time so a
typo can never land silently.
Surfaces: sidebar (top 5, rest behind More), the `/` menu in the comment
composer (inserts the server-rendered body to edit before sending), and
Alt-click for the same hand-off from the sidebar.
Migrations 234-236: quick_action table, its listing index (CONCURRENTLY, own
file), and comment.type + comment.quick_action_id.
Co-authored-by: multica-agent <github@multica.ai>
* refactor(issues): simplify quick action permissions to a stored public/private intent (MUL-5465)
Replaces the derived four-value visibility model with a two-value choice made
at creation, and collapses permission handling to a single check.
The old model computed visibility per request from the bound agent's
permission_mode and used it to filter the sidebar. That filtering was the
problem: two people on one issue saw different sidebars with nothing to
explain the difference, which is harder to debug than a button that tells you
why it refused. It also required the list endpoint to run an invocation-target
query per action per request.
Now:
- `visibility` is stored INTENT — 'public' or 'private' — chosen up front.
- A public action must bind a target every workspace member can invoke
(public_to carrying a workspace target), enforced at write time. So a
public action is runnable by construction and dead buttons are eliminated
at the source rather than filtered out later.
- A private action allows any target and is returned only to its creator.
That scoping is what the field MEANS, not a permission check.
- Permission is checked in exactly one place: RunQuickAction. A refusal is a
structured 403 the client renders as one dialog. The dialog does not
distinguish "no permission" from "the binding drifted" — the person
reading it takes the same next step either way, and the person who can fix
it looks at settings.
Removed: can_run, position + manual ordering (settings sorted by usage while
the sidebar sorted by position — one list, two orders), the derived
visibility_broken flag, the runnable_only projection and its second cache
entry, target_name redaction, the alt-click composer hand-off (the `/` menu
covers insert-then-edit and is discoverable), and the sidebar_limit response
field (now a shared constant).
Ordering is use_count DESC everywhere. Settings shows the target's current
reachability as plain metadata ("Nova · private"), so a public action pointing
at a now-private agent reads as visibly wrong without a bespoke error state.
The tradeoff — no active signal when that drift happens — was accepted
deliberately: drift is rare and the failure is loud at click time.
Migration 234 is edited in place rather than layered, since the PR is
unmerged and the table has never been deployed.
Co-authored-by: multica-agent <github@multica.ai>
* refactor(issues): drop quick action variables and runtime input (MUL-5465)
V1 ships a preset prompt sent verbatim, triggered from the sidebar or the `/`
slash command. Two features are removed and one guard is kept.
Runtime input goes because `/` already covers it. Typing `/code review` drops
the rendered body into the composer, where any part of it can be edited before
sending — strictly more flexible than one fixed field, and the field was
specified before `/` was in V1. Two UIs for one need.
Variables go because none of them passed their own test. The rule was that a
variable earns its place only if it changes what the agent ATTENDS TO, not what
it KNOWS. Checked one by one — {{issue.title}}, {{issue.identifier}},
{{issue.url}}, {{user.name}}, {{date}} — the agent already has every one from
the issue context and from the fact that the comment is authored by the person
who triggered it. They were inherited from autopilot's title template rather
than justified.
The REJECTION survives the feature: any `{{...}}` is refused at write time,
naming the offending token. Someone carrying the habit over would otherwise
have `{{issue.title}}` rendered literally into an agent's instructions and
never notice — the exact silent-typo failure the whitelist existed to prevent.
The check is a fraction of the interpolation engine it replaces and keeps the
door open to enabling variables later without touching stored data.
Removed: 4 columns (input_enabled/label/placeholder/required),
renderQuickActionPrompt + the variable whitelist + quickActionIssueURL, the
two-way {{input}} agreement logic, the run/render `input` parameter, the
variable insert chips, the entire "Ask for input on click" block, and the
sidebar's Popover branch — every row is now a plain button. The settings
dialog drops from six field groups to four.
Migration 234 is edited in place rather than layered, since the PR is unmerged
and the table has never been deployed.
Co-authored-by: multica-agent <github@multica.ai>
* refactor(settings): align Quick Actions with the Labels/Properties list, then fix what the UI review found (MUL-5465)
The tab used a bespoke card list while its two siblings — Labels and
Properties — share one table layout. These three are the workspace's catalog
of small named things and should read as one surface, so Quick Actions now
uses the same structure: search + primary action row, bordered card, responsive
column grid that collapses to stacked rows under `md`, and an overflow menu
instead of a row of icon buttons. Columns are Name / Runs as / Who / Used /
Updated. The tab joins the max-w-5xl group for the same reason.
A UI review pass over the result found five things, four of which are fixed
here:
- The visibility chooser communicated selection through border and background
only, so a screen reader announced both options identically. Added
aria-pressed.
- The editor dialog was max-w-xl while both siblings use sm:max-w-lg, and the
unprefixed cap applied at every breakpoint.
- The empty-state hint diverged from the Properties tab it was copied from
(text-sm and no max width vs mx-auto max-w-sm text-xs).
- Two hardcoded `text-amber-600 dark:text-amber-400` usages replaced with the
`text-warning` semantic token, per the repo's design-token rule.
Also fixed a signal-quality bug the review surfaced: the usage column
highlighted anything with use_count 0, so an action was flagged the instant it
was created. Staleness now means "has had time to be used and wasn't" — 90
days since last use, or 90 days since creation for one never used.
Not fixed here: the overflow trigger is size-7 (28px), under the 44px touch
floor. Labels and Properties use the identical size, so changing only this tab
would break the consistency this commit exists to create; it needs one pass
across all three.
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): drop the quick_action comment type, widen the mention guard, harden the slash race (MUL-5465)
Second review round on PR multica-ai#6132. All four remaining findings.
**Comment type removed entirely (#2 blocker + #3).** Adding a `quick_action`
type meant dropping and re-adding comment_type_check, and re-adding a CHECK
holds ACCESS EXCLUSIVE on `comment` for a full table scan — a read/write stall
on one of the hottest tables in the product, every deploy. It was also
forgeable: `type` is client-supplied on POST /comments, so any member could
post type='quick_action' and have an ordinary comment render as an action
audit record with its body collapsed out of view.
Both go away by not having the type. A quick action now posts an ORDINARY
comment marked with `quick_action_id`, and the collapsed card keys off that id.
There is no request field for it, so the marker cannot be forged, and the
migration is a bare nullable ADD COLUMN — metadata-only and instant. Verified
against a fresh database: comment_type_check is untouched.
The generic comment endpoint now also validates `type` instead of letting the
DB CHECK reject it. An unknown type surfaced as a 500 on a constraint
violation, which reads as a server fault for plainly bad input; it is a 400
now. `status_change` and `system` are excluded from what a client may author —
claiming those would be forging system narration.
**Member mentions rejected too (#1).** The first pass allowed
`mention://member/...` in prompts on the reasoning that it "only renders a
link". That was wrong: notification_listeners.go adds member mentions to the
recipient set and creates an inbox item, so a saved prompt pinged that person
on every single click. Only `mention://issue/...` reaches nobody and stays
allowed.
**Slash race, properly this time (#4).** The previous fix checked only that the
range still started with "/". Rewriting `/review` into `/fix` while the request
was open passed that check, and the stale response overwrote the new command.
The exact original text is now captured and compared; if the command was
edited, moved, or removed, the pick is abandoned rather than inserted
somewhere wrong. Adds the three regression tests the review asked for:
delayed resolve, rejection, and edit-during-flight.
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): stop the quick action card repeating its own prompt, and insert the `/` body as markdown (MUL-5465)
Two fixes, one reported and one found while verifying it.
**The card printed the prompt twice.** The collapsed header previewed the
prompt's first line, and expanding showed the mention line plus that same
prompt again. The header now identifies WHICH action ran — "Code Review via
Lambda" — which is both non-redundant and something the body never told you:
the prompt text alone does not say which action produced it. This is what the
original design called for; previewing the prompt was the implementation
drifting from it.
When the action cannot be resolved — deleted, or another member's private one
and so absent from this viewer's catalog — the header falls back to the
prompt's opening line, which is the previous behaviour.
**The `/` menu inserted its body as literal text.** insertContentAt was called
with a plain string, so Tiptap treated the server-rendered markdown as text
rather than parsing it. The mention never became a node; it serialised back out
with escaped brackets (`\[@lambda\](mention://agent/…)`) and rendered as raw
markup in the thread. Passing `contentType: "markdown"` — the same option the
description editor already uses — parses it properly. Found by reading the
comment rows while checking the first fix: one had escaped brackets and no
quick_action_id, which is what a slash-inserted comment looked like.
The existing async test now asserts the contentType, so the option cannot be
dropped again without failing.
Co-authored-by: multica-agent <github@multica.ai>
* docs(issues): correct the stale quick actions sidebar comment (MUL-5465)
The comment still claimed the section renders nothing when no action is
runnable by the member. Permission filtering was removed several rounds
ago -- the list is deliberately unfiltered and a refusal is explained at
run time -- so the comment described behavior that no longer exists.
Co-authored-by: multica-agent <github@multica.ai>
* refactor(settings): cut the quick action dialog's helper copy in half (MUL-5465)
The dialog had five blocks of explanatory prose around four fields, and
three of them wrapped to two lines, so the form read as a paragraph with
inputs in it.
Each helper now earns its line or loses it:
- The header explained the implementation ("keeps the same history,
permissions, and execution log as an @mention") -- an architecture note
the person creating an action does not need. Reduced to the one fact
they do: it posts a comment.
- "Who can use it" is a question, so the hints answer it as noun phrases
("Everyone in the workspace" / "Only you") instead of restating the
verb. Both now fit one line, which also makes the two cards the same
height -- the shorter one used to sit in dead space.
- The target and prompt hints front-load the constraint rather than
burying it mid-sentence.
70 words to 32 across the dialog, with no fact dropped. Field spacing
goes 4 -> 5 so the gap between groups beats the gap inside one.
Co-authored-by: multica-agent <github@multica.ai>
* refactor(issues): render a quick action comment as an ordinary comment (MUL-5465)
The card had a collapsed one-line header that expanded to reveal the
prompt, on the theory that repeated runs of the same action would bury
the discussion. That was solving a problem the feature does not have:
prompts are a sentence or two, the header restated what the body already
said, and the disclosure only put a click between the reader and the
text.
A quick action posts a real comment through the real mention path, so
the honest rendering is the one every other comment gets. Drops
QuickActionCommentBody, its query for the action catalog, and the
now-orphaned quick_action_ran_via string in all four locales.
quick_action_id stays on the comment: it is provenance, and it was never
the reason the card looked different -- keying the special rendering off
it is what is going away, not the record itself.
Co-authored-by: multica-agent <github@multica.ai>
* fix(settings): use the faint tone token for the empty-state icon (MUL-5465)
main added apps/web/app/text-contrast.test.ts, a guard that rejects
transparency standing in for a text tone. The empty-state Zap used
text-muted-foreground/60, which is exactly the pattern it forbids: an
alpha-dimmed tone lands at a different contrast on every surface it is
composited over, so it cannot be reasoned about the way a token can.
text-faint-foreground is the token the guard names for icons and glyphs.
The rule arrived on main after this branch's last merge, so local runs
never saw it -- CI tests the merge commit, which is why only CI caught
it. Merged main first so the branch is checked against the same rules.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
0xHexE pushed a commit that referenced this pull request Aug 21, 2026
* feat(agent): add Dim (DimCode) ACP runtime
Add Dim (dimcode, the `dim` CLI) as a first-party agent runtime, driven
over the ACP (Agent Client Protocol) transport via `dim acp`.
Key integration points:
- New dim backend (pkg/agent/dim.go): spawns `dim acp`, performs the
ACP initialize/session/new handshake, and raises the runtime's
hardcoded read-only permission preset to full-access (plus agent mode)
via session/set_config_option before the first prompt — without it every
file write is denied by a capability rule. Model override uses
session/set_model; the model catalog is read from session/new.
- Session resume is intentionally skipped: Dim binds sessions to the
creating process, so a later process's session/load is rejected with
"held by another process" even after a clean session/close. The backend
always starts a fresh session and reports ResumeRejected so the daemon
classifies the run correctly. A best-effort session/close keeps Dim's
own session list free of orphaned entries.
- Registration: SupportedTypes whitelist, New() factory, launch header,
daemon probe (MULTICA_DIM_PATH / MULTICA_DIM_MODEL), default agent
command name, protocol_family CHECK migration (255), model discovery,
MCP config support, metrics label, and runtime display.
- Docs/UI: provider logo, landing i18n (en/zh/ja/ko), providers and
install-agent-runtime docs, README, CLI_AND_DAEMON, SELF_HOSTING.
- Tests: dim backend unit tests (fresh session + resume behavior), shared
ACP deliverable case, logo and MCP-support tests.
* feat(views): use official DimCode app icon for Dim provider logo
Replace the placeholder inline SVG with the official DimCode desktop
client app icon (from /opt/DimAgent/resources/build/icon.png, resized
to 128px), imported as a static asset the same way the Qwen Code mark
is handled.
* test(agent): add real dim ACP smoke tests (agentintegration)
Two gated tests behind MULTICA_RUN_REAL_AGENT_SMOKE=1:
- TestDimRealACPSmoke: full end-to-end against real `dim acp` —
initialize, session/new, set_config_option (permission/mode), prompt,
and assert completed output.
- TestDimRealResumeRejected: pass a fake ResumeSessionID, assert the
backend starts a fresh session (no session/load), and reports
ResumeRejected=true so the daemon classifies the run correctly.
Verified against dimcode 0.3.2 and 0.3.8.
* fix: renumber dim migration 265 → 271 (upstream added 265-270)
* fix: renumber dim migration to 272 (upstream added 271)
* fix(agent): dim cross-run resume via session/load + review fixes
Address review feedback on the Dim ACP runtime:
- #1 Deliver runtime brief to Dim: add dim to the AGENTS.md provider
list (verified dim 0.3.8+ reads AGENTS.md from the session cwd).
- #2 Cross-run session continuity: dim 0.3.10+ releases its per-process
session lock ~5s after the owning process exits, so resume now goes
through the standard ACP session/load (same as traecli/kiro/grok)
instead of always starting fresh. A loaded session retains its
permission/mode, so set_config_option runs only on fresh sessions.
Add a two-run regression (TestDimRealCrossRunResume) proving run B
recalls context established only in run A.
- #3 Process lifecycle races: the deferred cleanup now cancels the run
context before cmd.Wait (a child ignoring stdin EOF no longer hangs
Result delivery) with a bounded force-kill fallback; the success path
waits for the final prompt notification with a grace window instead
of a non-blocking read that could miss the last message.
- #4 Isolate model discovery by executable path (discoveryCacheKey).
- #6 Real smoke test now writes a sentinel file to prove full-access is
effective; a best-effort session/close is sent before teardown.
- #7 gofmt.
* test(agent): add dim process-lifecycle regressions (review #3)
- TestDimCleanupKillsHangingChild: a child that ignores stdin EOF and
SIGTERM after an early failure is force-killed within
dimProcessWaitTimeout, so Result still closes.
- TestDimPromptMissingNotificationStillCompletes: when session/prompt
returns without stopReason (onPromptDone never fires), the bounded
final-notification wait falls through and Result completes instead of
hanging.
* fix(migrations): renumber dim runtime_profile migration 272 → 273 (upstream added 272)
* fix(agent): review round 2 — process tree, quiescence, fail-closed, version check
Address all remaining blockers from the second review:
- #1 Require dim >= 0.3.10 (version check at initialize); bounded retry
on session/load when the lock is not yet released instead of silently
starting fresh; integration test no longer sleeps before resume.
- #2 Process-tree cleanup: configureProcessGroup + signalProcessGroup +
waitProcessGroupGone; cmd.Cancel returns nil so we own all signalling;
regression asserts the descendant PID is gone.
- #3 Notification quiescence: onActivity + waitForACPNotificationQuiescence
(same as hermes); Dim equivalent of the late-final-notification test.
- #4 Permission fail-closed: set_config_option runs on BOTH fresh and
resumed sessions; on config failure, session/close is sent before
returning so a partially configured session is not resumed; regression.
- #5 Migration renumbered 273 → 274 (upstream added 273).
- #6 AGENTS.md mapping regression, two-executable cache isolation test,
session/close unit assertion, smoke test now executes a command.
- #7 README.md + CLI_AND_DAEMON.md updated with 22 CLIs including Dim.
* chore: remove temporary review actions doc (not for PR)
* fix(migrations): renumber dim migration 274 → 310 (upstream added 274-309)
* review: fix retry break scope, dedup isACPSessionNotFound comment, update stale notes
* fix(agent): pass *exec.Cmd to signalProcessGroup/waitProcessGroupGone (upstream signature change)
* fix(agent): close session on set_model failure + resume regression test
Audit-found gaps from self-review:
- set_model failure now sends session/close before returning (same as
set_config_option failure) — reviewer #4 said 'permission, mode, or
model'.
- Add TestDimConfigFailThenResumeReestablishes: run A config fails →
session/close sent → run B resumes and re-applies set_config_option
(fail-closed), completing successfully.
- Fix two stale comments that contradicted the code (config block now
runs on both fresh and resumed sessions).
* fix(agent): add WaitDelay, use labeled break in retry loop
Self-audit improvements:
- Add cmd.WaitDelay=10s for consistency with claude.go (hard backstop
if a process somehow survives SIGKILL).
- Use labeled break (break loadRetry) so runCtx cancellation during the
retry delay exits the for loop directly, not just the select.
- Update PR description: migration 273→310, set_config_option now
re-applied on both fresh and resumed sessions.
* fix(migrations): renumber dim migration 310 → 313 (upstream added 310-312)
* fix(migrations): remove stale 313 dim migration (replaced by 314 after upstream added dsh at 313)
* fix: DSH compatibility + thinking levels + reviewer round 3
Systematic fix of all 39 items from the compatibility gap analysis:
Rebase + migration:
- Rebase to latest upstream/main (resolves all DSH conflicts)
- Migration 314_runtime_profile_add_dim (whitelist includes both dsh + dim)
- Remove stale 313 dim migration
DSH + Dim coexistence (provider lists, counts, docs):
- SupportedTypes, config.go, metrics labels: both dsh + dim
- i18n (4 files): count 23, lists include DSH + Dim + Oh-My-Pi
- README.md/README.zh.md: count 23
- CLI_AND_DAEMON.md: 23-row table
- environment-variables.mdx (4 langs): MULTICA_DIM_PATH/MODEL callout
- display.ts, mcp-support.ts, types/agent.ts, provider-logo.tsx: both dsh+dim
Thinking levels (was MISSING — dim supports thought_level):
- dim.go: call applyACPEffortOption after set_model
- thinking.go: add dim to acpCatalogThinkingProviders
- dim.go: retain sessionResult for effort option
Reviewer round 3:
- Version check fail-closed: empty/malformed → reject (not allow)
- Cache test through ListModels call site with fake executables
- set_model failure regression test
- Test companions: sidecar_manifest_test, runtime_config_test add dim
Code quality:
- agent.go: fix unreachable duplicate return (rebase artifact)
- dim.go: labeled break in retry loop, WaitDelay, session/close on set_model fail
* fix: deliverable test fake version + provider-logo rebase artifact
* fix(migrations): renumber dim migration 314 → 315 (upstream added 314_workspace_mcp_config)
* fix(migrations): renumber dim migration 315 → 319 (upstream added 315-318)
* fix: Windows process tree, MinVersions, retry tests, migration 327
Address all 5 remaining blockers from review round 4:
1. Windows process-tree ownership: use startOwnedProcessTree instead of
cmd.Start(), releaseProcessGroup in cleanup defer. This attaches the
child to a Job Object on Windows (no-op on Unix), ensuring descendants
are captured and terminated.
2. Migration renumbered 319 → 327 (upstream added 319-326).
3. Add dim: 0.3.10 to MinVersions in version.go so the daemon registers
old Dim binaries as offline and refuses triggers (defense in depth on
top of the ACP agentInfo.version check in dim.go).
4. Session-lock retry contract tests: success after bounded retries,
exhaustion without falling through to session/new. Fake script
supports DIM_LOAD_HELD_N (held for N calls then succeed) and
DIM_LOAD_HELD_ALWAYS (always held).
5. README.md:206 20→23 runtimes. PR description updated.
* test: add missing reviewer-requested regressions (round 4)
- Windows Job Object regression: dim_windows_test.go proves the Dim
backend captures and terminates descendants via startOwnedProcessTree
(Windows-only build tag; companion to TestStartOwnedProcessTree).
- MinVersions registration: add dim test cases to TestCheckMinVersion
(0.3.10 ok, 0.3.9 rejected, invalid rejected).
- Retry cancellation: TestDimSessionLoadRetryCancelled cancels the
context during the retry delay, asserts no session/new fallback.
* fix: sync upstream Command signature changes + restore dim additions
Upstream changed ListModels/discoverXxxModels/detectCLIVersion to accept
Command (struct with Path+Prefix) instead of string. The previous rebase
kept our old-signature versions, causing widespread compile failures.
Restored all affected files from upstream, then re-applied dim-specific
additions:
- agent.go: dim in SupportedTypes, New(), launchHeaders
- hermes.go: isACPHeldByProcess + -32002 in isACPSessionNotFound
- thinking.go: dim in acpCatalogThinkingProviders
- models.go: dim case in ListModels + discoverDimModels
- version.go: dim in MinVersions
- All dim test files preserved (dim_test.go, dim_integration_test.go,
dim_windows_test.go, models_test.go dim cache test)
* fix: rebase to latest main, migration 327→341, resolve DetectVersion conflict
* fix: remove unused os/exec import in dim_windows_test.go
* fix(migrations): renumber dim migration 341 → 342 (upstream added 341)
* fix: use Command.exec instead of exec.CommandContext (upstream GH multica-ai#7046)
Upstream's TestOnlyLaunchGoSpawnsRuntimeProcesses requires all backends
to build processes through Command.exec (launch.go), so a custom runtime's
fixed_args are carried into the subprocess. Dim was the only backend still
using exec.CommandContext directly.
* fix: launch prefix policy + remove empty test files + deterministic retry tests
Address all 4 findings from review round 5:
1. Add "dim": dimBlockedArgs to launchPrefixBlockedArgs so protocol-breaking
flags (--help/--auth-setup/--remote) are filtered from fixed_args.
2. Remove 77 zero-byte *_test.go files accidentally added at repo root.
3. Make retry tests deterministic: TestDimSessionLoadRetryCancelled waits
for the first session/load request before cancelling (no fixed sleep);
TestDimSessionLoadRetryExhausted asserts exactly 4 load attempts.
4. PR description will be updated separately.
* fix: rebase to latest main, migration 342→343 (upstream added mcode at 342)
* chore: trigger CI
* chore: refresh PR
* fix: restore mcode that was lost during merge conflict resolution
Merge took 'ours' side which predated mcode. Restore mcode in:
- SupportedTypes, New() factory, launchHeaders
- agent_supported_types_test.go want map
- metrics/labels.go
- config.go defaultAgentCommandNames
- migration 343 up/down whitelists
* fix: restore mcode probe in agents_probe.go + agent-cli-command-names.txt
* fix: restore all remaining mcode references lost during merge
Files fixed:
- README.md: mcode row in runtimes table
- mcp-support.test.ts: mcode assertion
- config.go: mcode in Agents comment + error message
- runtime_config.go: mcode in AGENTS.md case
- version.go: mcode in MinVersions
- version_test.go: mcode test cases
Verified: no file has fewer mcode references than upstream.
* fix: restore mcode in environment-variables docs + CLI_AND_DAEMON ACP list
- environment-variables.{mdx,ja,ko,zh}: restored 'MiniMax Code' in the
QwenPaw model-variable sentence
- CLI_AND_DAEMON.md: added MiniMax Code to ACP-family list + loadSession
fallback description
Verified: every changed file now has >= mcode references vs upstream.
* fix(migrations): renumber dim migration 343 → 344 (upstream added 343)
* fix(migrations): renumber dim migration 344 → 348 (upstream added 344-347 plugin migrations)
* fix(migrations): update down migration comment 344 → 348
* chore: retrigger CI (flaky TestOpenclawDiscoveryCacheConcurrentPreparations — upstream test, passes locally x5)
* fix(migrations): renumber dim migration 348 → 352 (upstream added 348-351)
* fix: Dim launch-prefix regression test + version.go comment repair (review #6)
- Add TestDimLaunchPrefixFiltersBlockedFlags: proves allowed prefix
reaches command before acp, and --help/--auth-setup/--remote/-h are
stripped from Dim's launch prefix.
- Add dim to TestLaunchPrefixReachesACPFamilies family list.
- Repair version.go: dim's cross-run session/load comment was appended
to mcode entry during a rebase conflict; restore two independent
accurate comments.
* fix(migrations): update down migration comment 348 → 352
* chore: retrigger CI (backend-tests stuck 50+ min)
* fix(migrations): renumber dim migration 352 → 362 (upstream added 352-361)
* fix: use logAgentCommand for dim argv logging (upstream redaction requirement)
Upstream's TestOnlyLaunchGoLogsAgentCommandArgs enforces that all
runtime argv logging goes through Config.logAgentCommand so sensitive
args are redacted. dim.go was still using Logger.Info directly.
* fix(migrations): renumber dim migration to 370
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Sol-Boy <sol-boy@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@0xHexE