Commit Graph

206 Commits

Author SHA1 Message Date
Lambda
d49d309489 Merge remote-tracking branch 'origin/main' into agent/lambda/768b92e0
# Conflicts:
#	packages/core/realtime/use-realtime-sync-ws-instance.test.tsx
2026-07-15 11:26:40 +08:00
Bohan Jiang
4fac8d772f feat(attribution): Human Attribution Phase 1 (MUL-4302) (#5150)
* feat(attribution): Phase 1 foundation — provenance schema + resolver (MUL-4302)

Human Attribution, Phase 1 (地基) first increment. Every agent run must be
traceable to exactly one accountable human AND record at which waterfall level
that human was resolved, so a NULL originator can be told apart from a genuine
'no human in the chain'.

- migration 149: add originator_source (waterfall label) + delegation/retry/
  rerun/rule-version lineage + kind-tagged trigger evidence to agent_task_queue.
  No FK, no cascade, no CHECK on the source enum (MUL-4302 §7); nullable ADD
  COLUMNs = fast metadata-only change on the hot queue table.
- internal/attribution: the accountable-human vocabulary (Source, EvidenceKind,
  TriggerKind) + pure, unit-tested classification rules (ClassifyComment/
  ClassifyDirect). No DB, no authorization — provenance labeling only.
- service: attributionFor{IssueTask,TriggerComment} gather facts and delegate to
  the pure classifier; the legacy originator resolvers now delegate here so
  there is one source of truth. originator_user_id's VALUE is unchanged, so the
  Composio-overlay and canInvokeAgent A2A authorization boundaries are
  byte-for-byte preserved (MUL-4302 §1.3).
- enqueueIssueTask / enqueueMentionTask stamp originator_source + evidence;
  CreateRetryTask carries the parent attribution forward and records
  retry_of_task_id so retry and manual rerun stay separable (MUL-4302 §5).

Verified: go build ./..., go vet, gofmt clean; new attribution unit tests +
enqueue stamping integration test green; existing resolve_originator tests
unchanged.

Co-authored-by: multica-agent <github@multica.ai>

* feat(attribution): split accountable_user_id from originator, close enqueue bypasses (MUL-4302)

Phase 1, per Bohan's decision on the MUL-4302 thread: audit and authorization
answer different questions and get different columns.

- Migration 151 adds agent_task_queue.accountable_user_id (no FK, no cascade).
  Authorization keeps reading ONLY originator_user_id (canInvokeAgent A2A gate,
  Composio overlay); audit/UI/usage read accountable_user_id + source + evidence.
- Invariant (finalizeAttribution, single chokepoint + §11 tests): originator
  non-null ⟹ accountable equals it. The two diverge only when originator is null
  (autopilot / degraded fallback), which is the deferred rule_owner/owner_fallback
  increment; this lands the column + mirror-write so that split has a home.

- Close the NULL-source enqueue bypasses Elon flagged: chat, quick-create,
  deferred-fallback and run_only-autopilot now stamp originator_source + evidence
  (+ accountable where a human exists). Autopilot stays unattributed until the
  rule-version snapshot table lands, but is no longer a silent NULL-source row.
  Retry inherits accountable_user_id like the rest of the attribution lineage.

- Fix assign/promote attribution (§4): a member who assigns/promotes an existing
  issue is now the accountable human (and, by the invariant, originator) ahead of
  the issue creator. Threaded as an OPTIONAL actor override, so comment/rerun/
  autopilot paths keep today's resolution and create-with-assignee (creator ==
  actor) is unchanged. The squad leader gate already judged the same member.

Also merges origin/main: renumbers the attribution migration 149→150 (main took
149 for issue_origin_agent_create) and folds agent_create into ClassifyDirect's
origin inheritance.

go build/vet/gofmt clean; attribution unit tests + service stamp/actor tests +
handler suite pass on a fresh DB migrated through 151.

Co-authored-by: multica-agent <github@multica.ai>

* docs(attribution): fix accountable NULL semantics + close chat/quick-create evidence boundary (MUL-4302)

Addresses Elon's 2nd-round review on PR #5150 (pre-merge doc/evidence items):

- Migration 151 no longer overclaims NULL. accountable_user_id is NULL not only
  on pre-migration rows but on NEW rows whose audit source resolved no human yet
  (run_only autopilot writes originator_source='unattributed' with NULL
  accountable until rule_owner lands). Header + COMMENT ON COLUMN reworded so a
  schema reader does not misjudge the invariant.

- Chat now uses the UNIFORM evidence pair (kind=chat, ref=chat_session_id), like
  autopilot_run/issue_assignment, instead of relying only on the dedicated
  chat_session_id column — new EvidenceChat kind. Added a service test asserting
  chat stamps direct_human + chat evidence.

- Quick-create is documented as the ONE intentional no-antecedent-row path: no
  comment/issue/session/run exists at enqueue time (the run creates the issue), so
  trigger_evidence_kind/ref stay NULL while the human rides originator/accountable
  and source is direct_human — not a NULL-source bypass.

No authorization behavior change. attribution + service + handler suites pass on a
DB migrated through 151.

Co-authored-by: multica-agent <github@multica.ai>

* chore(attribution): renumber migrations 150/151 → 157/158 after merging main (MUL-4302)

main's #5162 ("unblock release migrations") renumbered the chat migrations and
took 150 (agent_task_coalesced_comments) and 151 (chat_read_cursor), colliding
with this branch's attribution migrations. Renumber them above main's new highest
(156) so TestMigrationNumericPrefixesStayUniqueAfterLegacySet passes:

- 150_agent_task_attribution      → 157_agent_task_attribution
- 151_agent_task_accountable_user → 158_agent_task_accountable_user

Fixed the internal "migration 150" references in 158's header to 157. Migrations
apply cleanly through 158 on a fresh DB; migration lint green.

Co-authored-by: multica-agent <github@multica.ai>

* feat(attribution): autopilot rule_owner — accountable = rule version publisher (MUL-4302)

Implements rule_owner (MUL-4302 §3.4), the first attribution source where the
accountable human diverges from the (NULL) authorization originator.

- Migration 159 adds the append-only autopilot_rule_version snapshot table (no FK,
  no cascade); migration 160 adds its CONCURRENTLY lookup index.
- Write-on-publish: CreateAutopilot appends v1 (publisher = creator); UpdateAutopilot
  appends a new version when a SUBSTANTIVE autopilot-row field changes (assignee /
  status / execution_mode) — cosmetic edits (title/description/template) write none.
  Both run inside the existing handler tx (atomic with the autopilot write).
- Dispatch resolution: both autopilot execution modes now resolve the active rule
  version and stamp originator_source='rule_owner', accountable_user_id=publisher,
  rule_version_id=<snapshot>, with originator_user_id left NULL (authorization
  unchanged). run_only stamps CreateAutopilotTask directly; create_issue resolves in
  attributionForIssueTask so both modes attribute identically. A missing version /
  non-member publisher degrades to unattributed — never fabricates a human.
- finalizeAttribution now enforces the invariant ONE-WAY: it mirrors originator onto
  accountable only when originator is valid, leaving an explicitly-set accountable
  (rule_owner / future owner_fallback) intact when originator is NULL. Added
  rule_version_id to CreateAgentTask so the create_issue path persists it too.

Also merges origin/main and renumbers this branch's attribution migrations
150/151 → 157/158 (main's #5162 took 150/151); rule_version table is 159/160.

Tests: attribution unit RuleOwner + one-way invariant table; service integration
tests proving an autopilot-origin issue stamps rule_owner + rule_version_id (and
degrades to unattributed with no version). Full service/attribution/handler/
migration suites pass on a DB migrated through 160; build/vet/gofmt clean.

Deferred (same PR): trigger-table republish (cron/webhook/event_filters) and
system-pause/archive versioning; owner_fallback + fail-closed; manual rerun.

Co-authored-by: multica-agent <github@multica.ai>

* fix(attribution): manual autopilot trigger → direct_human to the triggering member (MUL-4302)

Elon's blocking finding: a member manually triggering an autopilot was attributed
rule_owner (accountable = rule publisher, originator NULL) like a schedule/webhook
run, so member B triggering member A's autopilot landed accountable=A and carried
no originator authorization context for the run. Per MUL-4302 §4 a manual "run now"
is a direct human action and must attribute direct_human to the triggering member.

- Thread the triggering member from TriggerAutopilot into dispatch: new
  DispatchAutopilotManual carries actorUserID (resolved via resolveActor +
  memberActorUserID, so only a member actor is a human; an A2A agent actor falls
  back to rule_owner). DispatchAutopilot / DispatchAutopilotForPlan keep their
  public signatures (pass an invalid actor); only the internal dispatchAutopilot /
  dispatchCreateIssue / dispatchRunOnly gained the param, so the many existing
  callers are untouched.
- run_only: dispatchRunOnly stamps direct_human (originator == accountable ==
  actor, no rule_version) for a manual actor, else rule_owner. CreateAutopilotTask
  gains an originator_user_id param for the manual case.
- create_issue: dispatchCreateIssue enqueues a manual trigger via the actor-carrying
  *WithHandoff entry points; attributionForIssueTask's autopilot-origin rule_owner
  branch is now guarded on !actorUserID.Valid, so a valid actor falls through to the
  direct_human override. Both execution modes attribute identically.
- schedule / webhook keep rule_owner (no actor). Trigger-table + system-pause/archive
  versioning remain the pre-merge follow-ups.

Tests: the run_only row assertion Elon asked for (schedule → rule_owner row on
CreateAutopilotTask), plus manual direct_human on BOTH modes (run_only and
create_issue), including a manual actor distinct from the rule publisher. Full
service/attribution/handler/migration/scheduler/cmd suites pass on a DB migrated
through 160; build/vet/gofmt clean.

Co-authored-by: multica-agent <github@multica.ai>

* feat(attribution): owner_fallback + fail-closed policy, and manual-rerun direct_human (MUL-4302)

Two of the three remaining Phase 1 items (trigger-table / system-pause versioning
is deferred — see PR description).

owner_fallback + fail-closed (§1/§3.5) — the never-null accountable guarantee:
- attribution.OwnerFallback degrades an UNATTRIBUTED result to owner_fallback:
  accountable = agent owner, originator stays NULL (audit-only, authz untouched),
  Source.Precise()==false. finalizeAttribution's one-way invariant already allows
  accountable-set / originator-NULL divergence, so nothing else changes.
- Migration 161 adds workspace.attribution_fail_closed (default FALSE) + a lean
  GetWorkspaceAttributionFailClosed read. (Also added the column to ListWorkspaces'
  explicit column list so its row type stays db.Workspace.)
- applyAttributionFallback is applied at every enqueue boundary (issue, mention,
  chat, quick-create, deferred-fallback, autopilot run_only): unattributed →
  owner_fallback (agent owner) by default, or ErrAttributionFailClosed when the
  workspace is fail-closed, which the caller surfaces to refuse the enqueue (the
  run does not start). So no run is left without an accountable human, and a
  compliance workspace can block unattributable runs instead.

manual rerun (§5) — a rerun is a NEW direct_human trigger to the rerunning member:
- RerunIssue threads the acting member (resolved in the handler via resolveActor)
  down to enqueueRerunTask, and attributionForIssueTask is now actor-first so the
  actor wins over an INHERITED trigger comment (a rerun keeps the comment for the
  daemon's prompt context but must attribute to whoever clicked rerun, not the
  original comment's human).
- rerun_of_task_id lineage is recorded via a targeted SetAgentTaskRerunOf update on
  the rerun path only (keeping the shared CreateAgentTask insert untouched), so
  system retry (retry_of_task_id) and human rerun stay separable in reporting.

Tests: OwnerFallback unit test; owner_fallback + fail-closed-refusal + manual-rerun
(direct_human + rerun_of_task_id) service tests; the prior "degrades to unattributed"
test updated to owner_fallback. Full service/attribution/handler/migration/scheduler/
cmd suites pass on a DB migrated through 161; build/vet/gofmt clean.

Co-authored-by: multica-agent <github@multica.ai>

* fix(attribution): close fail-open holes + move rerun_of_task_id into creation snapshot (MUL-4302)

Addresses Elon's two must-fixes on PR #5150.

1. accountable-never-null / fail-closed had fail-open holes. applyAttributionFallback
   now, for an UNATTRIBUTED run, refuses the enqueue (ErrAttributionFailClosed) in
   THREE cases instead of silently degrading to a runnable NULL-accountable task:
   - workspace policy read fails (or no workspace) → fail closed; we cannot confirm
     fallback is permitted, so we don't run an unattributable task on a DB hiccup.
     (Only the rare unattributed path pays this; precise runs never read the policy.)
   - workspace is fail-closed → refuse (unchanged).
   - owner_fallback has no valid agent owner → refuse rather than enqueue a task with
     a NULL accountable_user_id.
   ErrAttributionFailClosed's doc now covers all three "cannot guarantee an
   accountable human" refusals. Added missing-owner / policy-read-failure /
   precise-passthrough tests.

2. manual rerun rerun_of_task_id was a post-notify UPDATE (race: the queued event /
   daemon claim could see rerun_of_task_id = NULL, and a failed update degraded the
   run to a plain direct_human). It now rides the CreateAgentTask insert — threaded
   through enqueueIssueTask / enqueueMentionTask as a creation param (like
   retry_of_task_id) so it is written in the same statement before the daemon is
   notified. Removed the SetAgentTaskRerunOf follow-up query.

Also merges origin/main (unrelated CLI fix #5167, no conflict). Full service /
attribution / handler / migration / scheduler / cmd suites pass on a DB migrated
through 161; build / vet / gofmt clean.

Co-authored-by: multica-agent <github@multica.ai>

* feat(attribution): rule_owner versioning on trigger edits + system-pause/archive (MUL-4302)

The final remaining Phase 1 item: substantive publishes beyond the autopilot row now
republish the rule version, so a run's rule_owner accountable follows whoever last
changed what the rule does.

- Extracted the config-summary + insert into service.RecordAutopilotRuleVersion so
  the handler and the (different-package) failure monitor share one writer; the
  handler's recordAutopilotRuleVersion is now a thin wrapper.
- Trigger edits: UpdateAutopilotTrigger and DeleteAutopilotTrigger republish the rule
  version with the acting member as publisher, ATOMICALLY (tx-wrapped mutation +
  version write, mirroring CreateAutopilot/UpdateAutopilot). CreateAutopilotTrigger
  republishes best-effort — the webhook path mints its token with a retry loop that
  cannot share one tx, and a create is usually initial setup already covered by v1;
  a failed write there is benign (active version stays the current publisher, the new
  trigger fires under it, no immediate daemon claim rides it).
- Archive (DeleteAutopilot) republishes (member, status=archived), tx-wrapped.
- System auto-pause (failure monitor) republishes with a 'system' publisher,
  best-effort — a background sweep to a non-dispatching state (a paused autopilot
  never dispatches; a later member resume supersedes).
- RotateWebhookToken / SetSigningSecret deliberately do NOT version: they rotate
  credentials, not the rule's behavior (not §3.4 substantive).

Semantics: a system-published (no-member) active version degrades dispatch to
unattributed → owner_fallback, never fabricating a human.

Tests: republish-reattributes (member A → member B supersedes → dispatch resolves to
B; system publisher → unattributed). Full service/attribution/handler/migration/
scheduler/cmd suites pass on a DB migrated through 161; build/vet/gofmt clean.
Also merges origin/main (unrelated frontend feature #5074).

Co-authored-by: multica-agent <github@multica.ai>

* fix(attribution): make trigger-create rule-version republish atomic (MUL-4302)

Addresses Elon's final Phase 1 blocking finding: CreateAutopilotTrigger recorded the
rule-version republish best-effort AFTER the trigger insert. If member B added a
schedule/webhook trigger to member A's autopilot and the version write failed, future
schedule/webhook dispatches would keep attributing to A — violating the rule_owner
invariant that the last member to substantively change the rule owns future runs
("no immediate daemon claim" doesn't save it, since the miss surfaces at the LATER
trigger firing).

Both create paths now write the version in the SAME tx as the trigger INSERT:
- schedule create: wrap CreateAutopilotTrigger + recordAutopilotRuleVersion in one tx.
- webhook create: each mint-with-retry attempt runs in its own tx (insert + version
  commit together; a token collision rolls that attempt back and retries with a fresh
  token; a version-write failure rolls the trigger back). Passes ap + the acting
  member id into the helper.
- removed the best-effort recordTriggerRuleVersionBestEffort helper (and the now-unused
  slog import).

Test: TestCreateTrigger_RepublishesRuleVersionAtomically drives both create paths
through the handler and asserts a rule version is published by the acting member.
Existing webhook/trigger/archive handler tests still pass. Also merges origin/main
(unrelated avatar feature #5074). Full service/attribution/handler/migration/
scheduler/cmd suites pass on a DB migrated through 161; build/vet/gofmt clean.

Co-authored-by: multica-agent <github@multica.ai>

* feat(attribution): Phase 2.1 — surface run attribution on the task API (MUL-4302 §9)

First Phase 2 (visibility) increment: the agent-task API now returns the resolved
accountable-human provenance so the UI can render an "on behalf of" badge.

- AgentTaskResponse gains an `attribution` object: source label (never blank —
  pre-migration NULL renders "unattributed") + `precise` flag (false for the degraded
  owner_fallback / backfill / unattributed sources), the initiator (accountable) and
  originator (authorization) user refs, the evidence {kind, ref_id} pointer, and the
  rule_version / delegated / retry / rerun lineage ids.
- The label + evidence + raw ids are built in the PURE taskToResponse (no DB), so
  every task response carries them. Names are hydrated separately, only on the
  user-facing surfaces (ListAgentTasks, ListWorkspaceAgentTaskSnapshot, RerunIssue,
  CancelTaskByUser) — daemon-claim paths stay lean.
- Hydration resolves initiator/originator from the GLOBAL user table (departed-member
  safe) via a new batch GetUsersByIDs query (no N+1); best-effort, so a lookup hiccup
  leaves the raw ids intact.

Tests: pure taskAttributionBase (direct_human / rule_owner NULL-originator /
owner_fallback degraded / pre-migration→unattributed) + DB hydration (fills known
ref, leaves unknown id un-filled, skips nil). Full handler/service/attribution/
migration/scheduler/cmd suites pass on a DB migrated through 161; build/vet/gofmt
clean. The field is additive — the frontend's parseWithFallback ignores unknown keys,
so nothing breaks until the UI increment consumes it.

Also merges origin/main (unrelated editor feature #5090).

Remaining Phase 2 (next increments, same PR): frontend zod schema + "on behalf of"
badge + evidence-chain jump; append-only correction events (write + display).

Co-authored-by: multica-agent <github@multica.ai>

* feat(attribution): Phase 2.2 — on-behalf-of badge in the execution log (MUL-4302 §9)

Surface the accountable human on every agent run row:
- AttributionBadge composes Badge + ActorAvatar, shows "on behalf of <member>"
  with the resolution source as a tooltip; degraded (non-precise) attribution
  gets a warning tone, and an unresolved initiator renders an explicit
  "no responsible member" chip.
- Wire the badge into both active and past rows of the execution log.
- Mirror the attribution shape into AgentTaskResponseSchema (defensive, .loose())
  so the cancel-task path carries it through zod; add parse tests.
- Export TaskAttribution/AttributionUser/TaskEvidence from @multica/core/types
  and add the attribution block to all four issues.json locales.

Co-authored-by: multica-agent <github@multica.ai>

* fix(attribution): hydrate initiator names on issue-facing task endpoints + bound the badge (MUL-4302 §9)

Address Elon's PR #5150 review:
- ListTasksByIssue (the execution-log data source), GetActiveTaskForIssue and the
  issue-scoped CancelTask now call hydrateTaskAttributions, so the "on behalf of
  <member>" badge shows the real member name on issue detail instead of falling
  back to "someone". Mirrors the existing ListAgentTasks / snapshot behavior.
- AttributionBadge: cap width (max-w-40, min-w-0) and truncate the name span so a
  long name / narrow right column can't squeeze out trigger/status/actions; keep
  the avatar shrink-0.
- Add a handler test asserting the issue task list returns a hydrated
  attribution.initiator.name.

Co-authored-by: multica-agent <github@multica.ai>

* fix(attribution): use semantic AvatarSize 'xs' for the badge avatar

main refactored ActorAvatar.size from a raw pixel number to the semantic
AvatarSize union (packages/ui/lib/avatar-size). Switch the on-behalf-of badge
avatar from size={14} to size="xs" (16px) after merging main.

Co-authored-by: multica-agent <github@multica.ai>

* fix(attribution): stage-cascade falls back to parent-issue provenance, not agent owner (MUL-4302)

When closing the last sub-issue in a Stage wakes the parent's assignee agent, the
run was enqueued via a system-authored child-done comment with no actor, which the
resolver classified as unattributed and then degraded to owner_fallback (the agent's
own owner). That is the wrong accountable human: the woken run should be accountable
to whoever caused the parent issue to exist.

attributionForIssueTask now detects a system-authored trigger comment and falls
through to the parent issue's own provenance — the same creator / agent_create-origin
/ autopilot-origin chain a direct enqueue resolves (so an agent-decomposed parent
attributes via delegation to the human who drove it; a member-created parent to that
member; an autopilot parent to the rule publisher). owner_fallback is now only the
last resort when the parent provenance itself has no human.

- Extract attributionFromComment so attributionForIssueTask can inspect author_type
  without a second GetComment; authorization resolution stays byte-identical.
- Add a DB-backed test asserting a system child-done comment resolves to the parent
  issue's origin human (delegation), not owner_fallback.

Co-authored-by: multica-agent <github@multica.ai>

* feat(attribution): autopilot runs attribute to the firing trigger's creator (MUL-4302)

Per Bohan: an autopilot schedule/webhook run should be accountable to the human
who created the SPECIFIC trigger that fired it, not the rule publisher. (Manual
triggers already attribute to the invoking member via direct_human — unchanged.)

- Migration 162: add autopilot_trigger.created_by_type/created_by_id (nullable, no
  FK/cascade). Capture the creating member at both trigger-create sites (schedule +
  webhook).
- New precise source trigger_owner: originator stays NULL (an autonomous fire
  carries no human authorization — same authz-safe divergence as rule_owner),
  accountable = the trigger's member creator.
- triggerOwnerAttribution resolves run.trigger_id → creator; wired into run_only
  dispatch and the create_issue path (bridging issue → active run → trigger_id).
  Legacy triggers with no recorded creator, and agent-created triggers, degrade to
  rule_owner then owner_fallback — nothing regresses.
- Frontend: trigger_owner source label in all four locales + badge switch case.
- Tests: attribution TriggerOwner unit + Precise/invariant; DB-backed resolver
  tests (member creator → trigger_owner; creatorless → rule_owner fallback).

Co-authored-by: multica-agent <github@multica.ai>

* chore(attribution): re-trigger CI (dropped synchronize event on 249090260)

Co-authored-by: multica-agent <github@multica.ai>

* fix(attribution): mirror accountable_user_id on comment-coalescing merge (MUL-4302)

The one-way invariant is 'originator_user_id IS NOT NULL ⟹ accountable_user_id =
originator_user_id', enforced at finalizeAttribution for enqueues and preserved by
the retry-clone (copies both columns). But MergeCommentIntoPendingTask (main #5192)
re-stamps originator_user_id to the newly-coalesced comment's human WITHOUT touching
accountable_user_id — so folding member B's comment into member A's queued task left
originator=B / accountable=A, violating the invariant. Re-stamp accountable to mirror
the new originator (same thing finalizeAttribution does). Add a DB-backed regression
test.

Co-authored-by: multica-agent <github@multica.ai>

* fix(attribution): Elon's 3 must-fixes + DB cross-column invariant CHECK (MUL-4302)

Bohan approved Elon's plan; this closes the three attribution boundaries he flagged
and locks the one-way invariant at the DB.

1. Delegation now inherits the parent's ACCOUNTABLE, not just its originator. An
   autopilot-rooted chain (parent originator NULL, accountable = trigger creator)
   @mentioning an agent / creating a sub-issue used to drop to unattributed →
   owner_fallback and fail-closed workspaces wrongly rejected the fan-out. Added
   ParentAccountable/OriginAccountable to CommentFacts/DirectFacts; ClassifyComment/
   ClassifyDirect copy accountable down (source=delegation, precise, originator NULL)
   so the chain root stays stable at any depth (§3.2).
2. The direct-chat send path (SendDirectChatMessage, MUL-4351) wrote only
   originator_user_id — no accountable/source/evidence, a NULL-source bypass. It now
   stamps the full direct_human attribution like EnqueueChatTask.
3. Comment-coalescing merge re-attribution is now ATOMIC: MergeCommentIntoPendingTask
   re-stamps the whole snapshot (person columns + source + delegation lineage + rule
   version + evidence) of the new comment, not just the two person fields, so a merged
   run never shows B accountable while pointing at A's stale source/evidence.
4. Migration 169: NOT VALID CHECK (originator_user_id IS NULL OR (accountable_user_id
   IS NOT NULL AND accountable_user_id = originator_user_id)). Enforces the invariant
   on every new write (the class of bug #5192 introduced); historical rows not blocked,
   VALIDATE after Phase 3 backfill. Updated test fixtures that seeded originator-only
   rows to also set accountable.

Merged latest main (renumbered attribution migrations 163–168 after main took 161/162;
merged the retry-clone chat_input_task_id + attribution columns). Verified: go build/
vet, attribution/service/handler/cmd-server tests on a migrated DB, frontend
typecheck/lint/tests.

Co-authored-by: multica-agent <github@multica.ai>

* fix(attribution): trigger responsibility transfers to effective publisher; fail-closed comment merge

Elon final-review must-fix 1 (MUL-4302): trigger_owner now resolves to the
member CURRENTLY responsible for the firing trigger's effective config, not the
fixed creator. Per-trigger published_by on autopilot_trigger, seeded to the
creator and re-stamped to the editor on a substantive edit — a trigger-scoped
edit bumps only that row (UpdateAutopilotTrigger), an autopilot-level edit bumps
all its triggers (UpdateAutopilot). Editing one trigger never reassigns another.
Adds real dispatchRunOnly transfer test + resolver-level isolation test.

Must-fix 3: AttributionForMergedComment reuses applyAttributionFallback and
returns ErrAttributionFailClosed; the merge caller refuses on fail-closed,
keeping the queued task's original precise snapshot instead of degrading it to
owner_fallback. Adds regression test.

Renumbered attribution migrations to 166-172 after merging main.

Co-authored-by: multica-agent <github@multica.ai>

* fix(attribution): make migration 172 invariant CHECK upgrade-safe (Option A)

Elon final-review must-fix 2 (MUL-4302), rollout chosen by Bohan. The NOT VALID
CHECK still checks a pre-existing row on any later UPDATE (even one not touching
attribution columns), so cross-deployment stale queued/running tasks (originator
set, accountable NULL from before migration 167) would fail on their next
claim/complete/cancel. Exempt exactly those legacy rows via 'originator_source
IS NULL' — that column was added in 166 with no backfill, so it is NULL only on
pre-migration rows and non-NULL on every attribution-aware write. New writes
stay fully enforced; the #5192 bypass class (source always set) is unaffected.
Phase 3 backfills legacy rows then drops+re-adds the strict form + VALIDATE.

Adds TestAttributionInvariantCheck_ExemptsLegacyRows (legacy row survives a
status UPDATE) and updates the reject-bypass test to the enforced regime.

Co-authored-by: multica-agent <github@multica.ai>

* fix(attribution): pin substantive/cosmetic edit boundary for trigger transfer

Elon re-review must-fix 1: an autopilot-level edit only transfers trigger_owner
responsibility when it changes what the automation instructs or who/whether it
runs. autopilotRuleSubstantiveChange now includes description (the run Prompt)
and issue_title_template; title and project_id stay cosmetic/routing.
UpdateAutopilotTrigger no longer transfers on every PATCH — it compares the
persisted before/after and transfers only on a real cron/timezone/enabled/
event_filters change, not a label-only or no-op PATCH. Adds real handler tests
(prompt->all, title->none, cron->one+isolation, label->none, no-op->none).

Must-fix 3: real merge-path regression (TestMergeCommentIntoPendingTask_
FailClosedKeepsOriginalSnapshot) drives mergeCommentIntoPendingTask and asserts
a fail-closed workspace preserves the queued task's full snapshot; fail-open
control completes the owner_fallback merge.

Docs: migration 172 comment reworded to legacy-writer/unbackfilled-lineage
semantics (source NULL is not strictly pre-migration); PR description synced.

Migration renumber vs latest main (must-fix 2) deferred to pre-launch per Bohan.

Co-authored-by: multica-agent <github@multica.ai>

* chore(attribution): renumber migrations to 167-173 after syncing main

Merged latest main (which took 166_project_dates) and shifted the attribution
migration series off the 166 collision: 166->167 agent_task_attribution,
167->168 accountable_user, 168->169 rule_version, 169->170 rule_version_index,
170->171 fail_closed, 171->172 trigger_publisher, 172->173 invariant_check.
Updated the internal cross-references in the migration comments accordingly.
Fixes TestMigrationNumericPrefixesStayUniqueAfterLegacySet on the merge tree.

Co-authored-by: multica-agent <github@multica.ai>

* feat(admission): unify dispatch outcome + close rerun/chat/autopilot invoke holes (MUL-4525)

P0 first increment toward a platform-wide execution-admission contract so a
user who names an execution target always gets a definite result and never a
silent no-op, and so blocked targets are reported without leaking private-agent
details.

Backend:
- New shared contract (handler/admission.go): DispatchOutcome / DispatchStatus
  (queued/coalesced/deferred/blocked) + stable, enumeration-safe
  DispatchReasonCode set, plus writeDispatchBlocked() whose legacy `error`
  string never reveals target existence.
- Rerun (task.go / task_lifecycle.go): re-validate the operator can invoke the
  RESOLVED target agent (historical agent for a task_id rerun) before any
  cancel/enqueue; blocked returns a structured 403 and mutates nothing
  (ErrRerunInvokeNotAllowed).
- Chat send (chat.go): re-run canInvokeAgent on every send, not just the softer
  canAccessPrivateAgent view gate; a revoked permission blocks before the
  message/attachments/task persist.
- Autopilot manual "run now" (service/autopilot.go): admission now keys on the
  current CLICKER, not the autopilot creator — clicker admission and clicker
  attribution no longer fork. Automation (schedule/webhook) still falls back to
  the creator gate. Added reason_code to the run response for the UI.

Frontend:
- triggerAutopilot response is schema-parsed; handleRunNow branches on run
  status and shows a localized, reason_code-based warning for skipped/failed
  instead of a false-success toast.
- Chat send and rerun surface the structured 403 reason_code as localized
  toasts (dispatchReasonCode helper) instead of a generic failure.
- Additive fields only; older clients keep working. i18n added to all 4 locales.

Tests: rerun fail-before-mutation gate, autopilot clicker-vs-creator fork
(service + handler), reason-code classification, and a malformed-response
schema test. Backend handler+service suites and FE typecheck/lint green.

Co-authored-by: multica-agent <github@multica.ai>

* test(cmd/server): thread nil invoke gate through RerunIssue call sites (MUL-4525)

Co-authored-by: multica-agent <github@multica.ai>

* fix(admission): typed reason codes + run-now whitelist + real security tests (MUL-4525)

Addresses Elon's review of the P0 first increment (must-fix 1–3).

1. Run now no longer has a false-success branch. handleRunNow now classifies on
   a whitelist via a pure runNowToastKind(): only issue_created/running →
   success; skipped → warning; failed and any unknown/future status → error. Add
   run-now-toast unit test over all five status classes plus reason-code → key
   mapping.

2. reason_code is a typed value decided at the admission source, not
   reverse-engineered from English failure text. New leaf package
   internal/dispatch holds the canonical ReasonCode enum (shared by handler +
   service so they can never drift). shouldSkipDispatch / errDispatchSkipped /
   the fail path now carry a typed code through DispatchAutopilotManual straight
   into the response; the substring classifier is deleted. Fixes the two missed
   branches: attribution fail-closed → attribution_blocked (typed errors.Is),
   "agent has no runtime bound" → runtime_offline. Regression tests for both.

3. Security acceptance tests exercise the REAL handlers, not injected callbacks:
   - Chat: create session while invokable → revoke invoke (flip to private, keep
     owner-view) → send returns 403 + reason_code with zero chat_message / task
     writes.
   - Rerun: private historical agent through RerunIssue + canInvokeAgent — a
     non-invoking workspace owner is refused 403 + reason_code and mutates
     nothing (fail-before-mutation); the agent owner is allowed 202.

No migration; reason_code is a decision-time value only the manual "run now"
response carries. Additive on the wire. Backend build/vet/handler+service
suites and FE typecheck/lint/vitest green. (Migration-prefix collision with main
remains the deferred pre-merge renumber.)

Co-authored-by: multica-agent <github@multica.ai>

* test(admission): make must-fix 3 acceptance tests prove the invariant (MUL-4525)

Addresses Elon's round-3 narrow review — the two security tests were not yet
falsifiable against the bugs they must catch.

- Rerun: after enqueuing the historical task, reassign the issue to a SECOND
  agent that the denied user CAN invoke. Now the current assignee and the
  task_id agent differ, so a rerun that wrongly validated the current assignee
  would let the denied user through — the 403 proves the gate is keyed on the
  historical private agent. The allow case now asserts the reran task's agent_id
  is the historical agent, not the current assignee.

- Chat: the blocked send now carries a valid, still-unbound attachment. After
  the 403 the test asserts the attachment's chat_session_id and chat_message_id
  are both still NULL, guarding against anyone moving attachment binding ahead
  of the invoke gate.

Test-only. Full internal/handler Go suite green.

Co-authored-by: multica-agent <github@multica.ai>

* feat(comments): surface blocked @mention trigger_outcomes instead of silent no-op (MUL-4525 §2)

A comment that @mentions an agent/squad the author cannot invoke used to save
with zero feedback — the user assumed a bug. Now the explicit-mention path
reports a per-target outcome on both preview and create/edit.

Backend (server/internal/handler/comment.go):
- resolveMentionedAgentCommentTriggers collects blocked outcomes instead of a
  silent `continue`. The invoke gate is evaluated BEFORE any archived/runtime
  state is read, so a caller who cannot invoke a private target only ever sees
  the generic invocation_not_allowed and can never enumerate its existence.
- enqueueCommentAgentTriggers returns queued/coalesced/deferred/blocked per
  explicit mention; enqueue errors are typed (attribution_blocked via
  errors.Is), not swallowed. Implicit routing (assignee/thread/conversation)
  carries no outcome — the user never named those targets.
- trigger-preview returns `blocked[]`; create/edit return additive
  `trigger_outcomes[]`. One blocked mention never fails the comment.

Frontend:
- Composer shows a warning chip for blocked mentions before sending; after
  sending, a "posted, but N not triggered" toast (blocked-only; coalesced/
  deferred are success-shaped). Additive schema + defensive parse; i18n ×4.

Tests: handler partial-success + enumeration-safety acceptance tests; core
outcome-parse + preview-schema tests; hook/parity updated. Backend
handler+service suites and FE typecheck/lint/vitest green.

Co-authored-by: multica-agent <github@multica.ai>

* i18n(admission): clearer, consistent blocked-trigger copy (MUL-4525)

Reword the awkward "you are not allowed to run this autopilot's assignee" and
polish all MUL-4525 blocked/partial copy across en/zh-Hans/ja/ko:

- Unify on "you don't have permission to use this <agent|target>" (zh: 没有…的
  使用权限) for autopilot Run now, comment mentions, rerun, and chat send —
  replacing the various "not allowed to run/trigger" phrasings.
- Align zh to the glossary term 智能体 (was mixed "Agent"), matching the
  surrounding UI voice (e.g. agent_link_no_access).
- Drop jargon: "blocked by an admission policy"/"被准入策略拦截" → "the run was
  blocked"/"本次运行已被拦截"; "attributed"/"归因" → plainer wording.
- Comment copy counts "mentions" (was "targets") for consistency with the chip.

Backend dispatchBlockedFallbackMessage (old-client English fallback) reworded to
match; its enumeration-safety test assertion updated. Copy-only — no key/logic
changes; parity + typecheck green.

Co-authored-by: multica-agent <github@multica.ai>

* fix(comments): one outcome per explicit mention + FE success whitelist (MUL-4525)

Addresses Elon's round-2 review of the §2 comment trigger_outcomes.

1. Separate execution dedup from per-target outcomes. resolveMentionedAgent-
   CommentTriggers now returns triggers (deduped by executing agent) AND one
   commentMentionTarget per EXPLICIT mention. enqueueCommentAgentTriggers returns
   a per-executing-agent result map; commentTriggerOutcomes fans each agent's
   status to every target that resolved to it. So @Agent A + @Squad S(leader=A)
   coalesces to ONE task but yields TWO outcomes. The squad-leader self-suppress
   branch now returns a definite `deferred` outcome instead of no result. New
   CreateComment test asserts 1 task, 2 outcomes.

2. Frontend no longer treats an unknown status as success. unhandledComment-
   TriggerOutcomes whitelists queued/coalesced/deferred as handled; blocked and
   any unknown/future/empty status warn (mirrors the Run now whitelist). The
   preview schema's `blocked` now drops malformed entries INDIVIDUALLY instead of
   z.array(...).catch([]) discarding the whole set. Regression tests for the
   unknown status and the per-item drop.

Backend handler suite + go vet, FE typecheck/lint/vitest green.

Co-authored-by: multica-agent <github@multica.ai>

* fix(comments): honest role + status in trigger_outcomes fan-out (MUL-4525)

Addresses Elon's round-3 review of the §2 fan-out.

1. Execution merge preserves the squad-leader role instead of first-mention-
   wins. The dedup now UPGRADES an already-added plain @agent trigger to a
   @squad-leader trigger for the same agent, so @Agent A + @Squad S(leader=A)
   always runs as a leader task (is_leader_task + squad_id=S) regardless of
   mention order — the daemon still injects S's briefing. When two DIFFERENT
   squads share one leader, the single run carries one squad's context and the
   other squad is reported `coalesced` (folded), never a second `queued`. The
   enqueue result now records the executed squad so the fan-out can tell them
   apart. Tests assert the task role in both orders and the two-squad split.

2. Squad-leader self-suppression no longer fakes success. The self-trigger
   guard keys on the latest task ROLE with no status filter, so a long-completed
   task also suppresses; reporting `deferred/already_active` when nothing is
   active was a false success. The branch now reports `deferred` only when a
   real non-terminal task is active (its reconcile covers the comment), else a
   non-success `blocked` + new `already_handled` reason. Fixed the reversed
   helper-semantics comment. New handler test covers the completed-task branch.

Backend handler+service suites and go vet green.

Co-authored-by: multica-agent <github@multica.ai>

* fix(comments): fail-closed active-task check, never fake deferred (MUL-4525)

Elon round-4 must-fix: hasActiveTaskForIssueAndAgent swallowed DB errors by
returning true, so both call sites could turn a query failure into a
success-shaped `deferred/already_active` — a silent false success, exactly what
this issue forbids for admission-query failures.

- hasActiveTaskForIssueAndAgent now returns (bool, error).
- Two pure decision helpers govern the branches, fail closed on error:
  - decidePostMergeMiss: on query error, do NOT enqueue a fresh task (duplicate
    concurrent-run risk) AND report non-success blocked/internal_error; a
    confirmed active task defers; a confirmed-none enqueues fresh.
  - decideSuppressedLeaderOutcome: on query error, blocked/internal_error; a
    confirmed active run defers; else self_trigger_suppressed. Never a fabricated
    deferred.
- Renamed reason already_handled -> self_trigger_suppressed (Elon non-blocking
  note): the old name implied the new comment was already processed, but the
  real meaning is a suppressed self-trigger.

Deterministic unit tests cover the query-failure branch at both call sites
(no fresh enqueue, non-success outcome) — a real DB fault can't be forced
through valid handler inputs, and the decision is what governs the behavior.
Backend handler+service suites and go vet green.

Co-authored-by: multica-agent <github@multica.ai>

* fix(comments): honest merge outcome — refused merge is blocked, not fake coalesced (MUL-4525)

A pending-task merge previously reported success (coalesced) even when it
was refused or failed: attribution fail-closed and unknown DB errors both
returned handled=true, so the caller recorded coalesced for a merge that
never happened. mergeCommentIntoPendingTask now returns a distinguishable
commentMergeResult; commentMergeTerminalOutcome maps a real merge to
coalesced, a fail-closed refusal to blocked/attribution_blocked, and any
other failure to blocked/internal_error. Only "no queued task to fold"
falls through to the active-task decision. Adds pure coverage of the
mapping plus a fail-closed regression asserting the non-success outcome
and unchanged task count.

Co-authored-by: multica-agent <github@multica.ai>

* fix(comments): name blocked @mentions in preview + toast instead of a vague count (MUL-4525)

The blocked-trigger preview showed a red "1 mention won't trigger" with no
name, and the post-send toast said "but 1 mention wasn't triggered" — the
user can't tell which target or why. Now each blocked mention renders its own
chip named from the mention markup the user typed ("Go · No permission"),
with an error indicator and a short reason; the toast names the single target
too. The wire outcome still omits the target name (enumeration-safety) — the
label comes from the user's own draft, so nothing new is disclosed.

Shares a blocked-trigger-copy module (long + short reason labels) between the
chip and the toast, and a pure mentionLabelsByTarget/parseMentions helper in
core (fresh regex per call — a shared global leaked lastIndex). Adds core +
chip tests; drops the now-unused trigger_blocked_count keys across locales.

Co-authored-by: multica-agent <github@multica.ai>

* feat(attribution): accountable-member avatar on agent task rows + transcript header (MUL-4302)

Surface who each agent run is on behalf of where runs are actually
browsed:
- Agent detail activity tab: an avatar-only AttributionBadge on every
  task row's meta line (Now + Recent work), tooltip carries the name +
  resolution source.
- Execution-record (transcript) dialog header: the full on-behalf-of
  badge next to the status pill.

Adds a compact variant="avatar" mode to AttributionBadge, reusing its
source-label mapping and degraded-attribution tone. Renders nothing when
a run has no resolved accountable member.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-15 03:09:56 +08:00
YYClaw
9eddcaff10 fix(chat): defer cancellation-time finalization until the task transcript is stable (#5246)
A quick Stop before the agent's first token no longer races a late reply. Started-but-empty cancellations defer the empty/non-empty judgment until the daemon acks its transcript flush (or a grace-period sweeper fires), then settle to a single outcome. Empty outcomes persist a durable, creator-authorized draft restore (fetched/consumed via a dedicated endpoint, reconnect-safe and at-most-once) instead of broadcasting the prompt over the workspace bus.

Closes #5219
2026-07-15 00:52:27 +08:00
Lambda
a847f6f644 fix(properties): address review round 3 — cache reconcile, merged-scope order, GIN-indexable filter, pool loader
- Cache reconciliation: property value writes (mutation settle + WS event)
  now invalidate every issue window whose server-side shape depends on
  property values — queries filtered by `properties` or sorted by
  `property:<id>` (detected via query-key predicate), covering flat lists,
  assignee groups, and my-issues variants. Windows without property params
  keep the cheap in-place patch. Fixes stale ordering/membership/counts
  under staleTime:Infinity.
- My Issues "All" scope: merged assigned/created/involves results are
  re-sorted with a comparator mirroring the server ORDER BY semantics
  (including property sorts and missing-last, created_at DESC tiebreak) in
  both the flat and assignee-grouped merge paths — relation concatenation
  no longer overrides the user's sort.
- Filter predicate rebuilt as plain bind-parameter containment ORs
  (AND across definitions): EXPLAIN now shows BitmapOr over
  idx_issue_properties_gin (the correlated jsonb_array_elements form
  defeated the index). Alternatives capped at 256 bind params.
- Property-grouped board gains a pool loader strip: one sentinel per
  status that still has server rows, keeping every issue reachable until
  per-column pagination lands (MUL-4493).
- Windowing regression test hardened: explicit positions + an assertion
  that the unfiltered first page excludes the target (the old fixture tied
  at position 0 and the created_at DESC tiebreak put the target on page
  one, proving nothing).
- Rollback safety: /api/properties 404 (old server) degrades to an empty
  catalog instead of a query error, which also keeps property params from
  ever being sent to pre-property servers; migration 179's CHECK
  constraints switch to NOT VALID + VALIDATE so the exclusive lock is
  instantaneous.

Co-authored-by: multica-agent <github@multica.ai>
2026-07-14 22:50:44 +08:00
Lambda
8550002878 fix(migrations): renumber issue properties to 179 and build the GIN index concurrently
main's migration sequence advanced twice under this PR (167 collision, then
an upstream renumber wave that claimed 178), so issue properties now sits at
179 — verified against main's current tip by the prefix-uniqueness lint.

The properties GIN index moves to its own single-statement migration (180)
using CREATE INDEX CONCURRENTLY — a plain CREATE INDEX on the hot issue
table would block writes for the duration of the build. Mirrors the
119_user_created_at_index pattern; full-chain dry-run on a fresh database
passes through 180.

Co-authored-by: multica-agent <github@multica.ai>
2026-07-14 17:30:11 +08:00
Lambda
76aaa4e3ee feat(server): custom issue properties — definitions, typed values, CLI (MUL-4463)
Workspace-level property definitions (issue_property table; 7 types:
text/number/select/multi_select/date/checkbox/url) plus a typed value bag
on each issue (issue.properties JSONB keyed by definition UUID, mirroring
the metadata machinery: single-key atomic writes, 16KB cap, GIN index).

- Definitions: owner/admin only; agent actors rejected (agents propose,
  humans confirm). 20 active per workspace, 50 options per select,
  reserved built-in names blocked, archive instead of delete.
- Values: any member or agent; per-type validation with self-correcting
  error messages that enumerate legal option ids.
- API: /api/properties CRUD + PUT/DELETE /api/issues/{id}/properties/{propertyId};
  issue responses always emit the properties bag.
- CLI: multica property list/get/create/update/archive/unarchive and
  multica issue property list/set/unset with name→id translation.
- Events: property:created/updated, issue_properties:changed.

Co-authored-by: multica-agent <github@multica.ai>
2026-07-14 17:29:00 +08:00
yushen
8614b19816 fix(migrations): renumber webhook delivery migrations 2026-07-14 17:19:44 +08:00
Multica Eve
e07b5403ab MUL-4502: make autopilot webhook admission durable (#5386)
* fix(autopilots): make webhook admission durable

Co-authored-by: multica-agent <github@multica.ai>

* fix(autopilots): address webhook delivery review

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-14 16:20:33 +08:00
Jordan
c27919a4d0 feat(agents): add DevEco Code (deveco) runtime agent (MUL-4050) (#4916)
Adds DevEco Code (Huawei's HarmonyOS coding agent, built on the OpenCode engine) as a first-class runtime provider: backend/model parser, daemon discovery (probe + login-shell list + Windows .cmd native resolver), runtime profile migration (protocol_family whitelist), provider UI, metrics, and four-language docs. MCP injection is deferred (UI gates it off). Migration numbered 175.
2026-07-14 15:21:45 +08:00
Bohan Jiang
8567ebffd9 fix(migrations): rebuild legacy label index CONCURRENTLY on rollback (MUL-4479) (#5357)
The 162_resource_labels down migration recreated issue_label_workspace_name_lower_idx
with a plain CREATE UNIQUE INDEX, which takes a blocking lock on the existing
issue_label table during a rollback to v0.3.43 and violates the online-migration
rule that every CREATE INDEX must be CONCURRENTLY.

CREATE INDEX CONCURRENTLY cannot run in a transaction or a multi-command migration,
so the rebuild is split out of 162.down:
- 171.down now rebuilds the legacy index as a single CREATE UNIQUE INDEX CONCURRENTLY
  (the natural inverse of 171.up, which drops it).
- 174 (new, no-op up) deletes the agent/skill label rows in its down so they are gone
  before 171.down rebuilds the workspace-wide unique index. Down migrations apply
  high->low, so 174.down -> 171.down -> 162.down runs in the required order.
- 162.down keeps only the transaction-safe structural teardown.

Validated on an isolated Postgres: full up->down->up chain with a colliding
issue/agent label pair; the concurrent rebuild succeeds after the rows are deleted,
restores the valid pre-162 unique index, and a negative control (rebuild before the
delete) fails on the exact collision.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-13 19:31:27 +08:00
Multica Eve
411a160b99 fix(release): harden v0.3.44 migrations (#5345)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-13 18:13:44 +08:00
Bohan Jiang
1de4b2d688 fix(migrations): make 164_attachment_task_id idempotent to self-heal #5307 renumber drift (#5324)
#5307 renamed 161_attachment_task_id -> 164 (and 162_..._index -> 165) to
resolve a prefix collision. schema_migrations keys on the full stem, so any DB
that applied the migration under its old 161 number does not have "164" in the
ledger and the runner re-applies the renamed file. The bare `ADD COLUMN task_id`
then aborts with 42701 ("column already exists"), blocking every later
migration — this is exactly what crashed the dev deploy of main (bf288349f) at
container startup, before it ever reached 166_project_dates.

Add `IF NOT EXISTS` so the re-run is a harmless no-op on already-migrated DBs
(dev/staging/prod) with no manual schema_migrations surgery, while staying
identical on a fresh DB. Sibling 165 already uses CREATE INDEX ... IF NOT EXISTS
for the same reason; this brings 164 in line. The down file already uses
DROP COLUMN IF EXISTS.

Verified on a throwaway DB reproducing the dev drift (forget 164/165 in the
ledger, keep the column+index): old file reproduces the 42701 crash, the fixed
file self-heals and completes. Migration lint + concurrent migrate tests pass.

Refs #5307

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-13 15:33:52 +08:00
Bohan Jiang
bf288349f6 feat(project): add start_date and due_date fields (MUL-4388) (#5313)
Projects become schedulable planning objects alongside their issues: add
optional start_date / due_date, mirroring issue.start_date / issue.due_date.
This is only the first slice of #5227 — labels, metadata, and the editable
metadata UI are still out of scope.

- migration 166: two nullable DATE columns on `project` (calendar days, no
  FK/index — matches the issue end-state after migration 112)
- sqlc CreateProject / UpdateProject carry the dates; UpdateProject uses
  narg so an explicit null clears
- handler: parse YYYY-MM-DD (400 on bad format), rawFields-presence clear on
  update, and the hand-scanned SearchProjects query returns the columns
- CLI: `project create/update --start-date/--due-date` (empty clears on update)
- frontend + mobile types/zod schemas: the two new schema fields are
  nullable().default(null) so a project from an older backend (frontend
  deploys before backend) parses to null instead of degrading the batch to
  the empty fallback; added a search schema drift test
- projects skill / CLI docs

Part of #5227

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-13 13:22:02 +08:00
Naiyuan Qing
94f9dcaa77 fix(migrations): renumber attachment_task_id to resolve 161/162 prefix collision (#5307)
`161_attachment_task_id` and `162_attachment_task_id_index` (from #5164)
merged after prefixes 161/162/163 were already taken on main by #5277,
#5279, and #5296, leaving two migrations on each of 161 and 162. That trips
TestMigrationNumericPrefixesStayUniqueAfterLegacySet, so the backend job now
fails on every open PR. Renumber to the next free prefixes (164/165); the
file contents are unchanged.

Note for already-migrated databases: any DB that applied 161/162 has those
versions recorded in schema_migrations. After this rename, reconcile the
ledger (rename the recorded versions to 164/165) or reset the dev DB —
otherwise the migrator re-runs the renamed files and the ADD COLUMN fails
because the column already exists.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 11:09:50 +08:00
Naiyuan Qing
a19e60a9e6 feat(chat): support images/files in agent chat replies (MUL-4287) (#5164)
* feat(chat): support images/files in agent chat replies (MUL-4287)

Agents can now attach images/files to their chat replies, matching how
comment attachments already work. The write-side gap was that the assistant
chat_message is synthesized server-side from the completion callback's text
output and never bound any attachments.

Backend:
- migration 150: nullable attachment.task_id (+ partial index), the transient
  handle that ties an agent's in-run upload to the reply it produces.
- POST /api/upload-file accepts task_id: gated to the task's own agent, in
  this workspace, on a chat task; tags the row with task_id + chat_session_id.
- CompleteTask (chat branch) binds the task's still-unclaimed attachments to
  the assistant message via BindChatAttachmentsToMessage (rejects rows already
  owned by an issue/comment/chat_message). An empty-output reply that produced
  files still creates a message so the images have an owner. FailTask binds
  nothing.

CLI:
- `multica attachment upload <path>` uploads a file for the current chat task
  (task from MULTICA_TASK_ID or --task) and prints id / markdown_url / a
  ready-to-paste markdown snippet.

Prompt:
- web/mobile chat prompt tells the agent how to attach a file to its reply.

Mobile:
- chat:done handler now always invalidates the messages list so attachments
  (absent from the event payload) refetch; mirrors web's self-heal.
- chat bubbles render standalone attachment cards via the existing
  CommentAttachmentList (dedup vs inline references), matching web.

Web/desktop needed no change — they already render message.attachments inline
and via AttachmentList, and self-heal on chat:done.

Tests: upload permission/isolation, bind-on-complete, empty-output+attachments,
FailTask no-bind, null task_id untouched, already-owned not stolen, CLI output
contract, mobile refetch-on-done.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): address review blockers on chat reply attachments (MUL-4287)

Two final-review blockers on PR #5164:

1. Mobile inline dedup only checked raw `url`, so an attachment referenced
   inline via `markdown_url` (exactly what the CLI snippet emits) rendered
   twice — once inline, once as a standalone card. Reuse the core
   `contentReferencesAttachment` helper so dedup covers every real reference
   form (stable /api/attachments/<id>/download path, url, download_url,
   markdown_url), matching web's AttachmentList. Extracted the filter into a
   pure `lib/attachment-dedup.ts` so it is unit-testable, and added a
   regression test covering `content` containing `attachment.markdown_url`
   (plus the other URL forms and same-identity sibling dedup).

2. CLI `attachment upload` emitted `![...]` image markdown for every file,
   producing a broken-image snippet for non-images. Emit image markdown only
   for image/* content types and a plain link otherwise, with a CLI contract
   test for both.

Approved scope otherwise unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): renumber attachment_task_id migration 150 -> 157 after main merge (MUL-4287)

Merged latest main; main renumbered its migrations and now occupies 150-156,
so 150_attachment_task_id collided with 150_agent_task_coalesced_comments and
would fail TestMigrationNumericPrefixesStayUniqueAfterLegacySet. Renamed to the
next unique prefix (157). No content change; migrate up applies cleanly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): render agent-produced files as attachment cards, not raw links

The chat upload command handed the agent a bare `[name](url)` markdown
snippet. Pasted mid-sentence it renders as a plain text link (not a card),
and the referenced URL hides the auto-bound standalone attachment — so a
file the agent produced could end up showing as nothing.

Return the block-level `!file[name](url)` card syntax instead (images keep
`![name](url)` inline), and markdown-escape the filename so names with `[`/`]`
don't truncate the label. The prompt and CLI help now state the file
auto-attaches below the reply and the snippet is optional, only for placement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(chat): soften message-list scroll fade (32px → 16px)

The 32px edge fade washed out full-bleed content (HTML / image previews)
at the list edges. Halve the fade distance so it barely grazes previews
while still hinting at more content above/below.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(chat): renumber attachment_task_id migration 157 -> 158

main landed 157_agent_task_delivered_comments while this branch was open,
colliding on prefix 157 and failing TestMigrationNumericPrefixesStayUniqueAfterLegacySet.
Bump this PR's migration to the next free prefix (158). Rename only; the
migration body (nullable attachment.task_id + partial index) is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(chat): pin attachment upload to the token's task; build index concurrently

Two code-review findings on the chat-attachment path (MUL-4287):

- Isolation/privacy: POST /api/upload-file only checked the form task_id
  belonged to the caller's agent, not that it matched the task-scoped token's
  authoritative X-Task-ID. A run authorized for task A could tag an attachment
  onto task B (another chat task of the same agent, possibly another user's
  session), binding it into that reply on completion. Require the form task_id
  to equal the server-set X-Task-ID; add a same-agent/other-task 403 regression.

- Migration: split the task_id lookup index into its own migration (159) built
  with CREATE INDEX CONCURRENTLY (repo convention) — it cannot share a
  multi-command file with the ADD COLUMN in 158.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(chat): enforce task-token source on attachment upload; drop transient task_id FK (MUL-4287)

Addresses the two remaining Preflight BLOCKERs on PR #5164.

Security (file.go): the task_id upload path compared the form task_id to
X-Task-ID but did not require X-Actor-Source=task_token. A normal JWT/mul_ PAT
leaves that header empty and the middleware does NOT strip a client-forged
X-Task-ID; resolveActor's fallback accepts a valid X-Agent-ID+X-Task-ID pair.
So a member who learned a task ID could forge both and inject an attachment
onto another chat task's assistant reply (cross-session/privacy leak). Now the
branch requires X-Actor-Source=task_token first (mirrors chat_history.go's
load-bearing boundary), then pins to the middleware-injected X-Task-ID. Tests
now go through the real task-token headers and add a forged-JWT-403 regression.

Migration (158): task_id is a transient binding handle (written once at upload
against an already-validated task, read only during that task's own
completion; durable owner is chat_message_id). There is no app-layer path that
hard-deletes agent_task_queue rows, and orphan uploads are already reaped by
attachment.chat_session_id's ON DELETE CASCADE — so an FK here would only add a
cascade dependency the app never relies on plus write overhead on the hot
attachment table. Drop the FK; task_id is now a plain UUID column. Added a
regression test that an unbound task-tagged upload is reaped on chat_session
delete. Index (159, CONCURRENTLY) unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(mobile): align !file card preprocess with web parser + CLI escaped labels (MUL-4287)

Howard final-review blocker: mobile's `!file[...]` preprocess didn't keep up
with the CLI's file-card output, so agent-produced non-image files rendered
nowhere on mobile.

- `FILE_LINE_RE` used `[^\]]+` for the label, so the CLI's escaped-bracket
  output `!file[a\]b.pdf](url)` (cmd_attachment.go escapeMarkdownLabel) never
  matched — the line stayed literal AND `standaloneAttachments` still hid the
  fallback card (the URL is in `content`), so the file showed nowhere.
- Align the matcher with web's `packages/ui/markdown/file-cards.ts`: label
  allows backslash-escaped metacharacters (ReDoS-safe class), and the URL is
  restricted to the same allowlist (site-relative /uploads + /api/attachments/
  <UUID>/download, plus absolute http(s)); disallowed schemes stay plain text.
- Unescape the label to the real filename, then re-escape only the chars that
  would break a markdown LINK label (mobile emits `[📎 name](url)`, re-parsed
  by the renderer — unlike web's HTML data-filename), so a raw `]` never
  truncates the link text.

No dedup change: once the inline `!file` renders, hiding the standalone card is
correct. Added focused unit tests covering the escaped-label case, parens/
backslash unescape, the site-relative URL form, and disallowed-scheme rejection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-13 09:15:36 +08:00
Jiayuan Zhang
1427e8abd3 feat(agents): add conversational creation studio (#5296) 2026-07-12 15:40:10 +08:00
Jiayuan Zhang
c377d7fb4f feat(labels): add scoped label management (#5279)
* feat(labels): add scoped label management

* fix(labels): address review feedback

* fix(migrations): use unique label migration prefix
2026-07-12 03:46:08 +08:00
Jiayuan Zhang
a14098288b feat: redesign agent Skills and MCP capabilities (#5277) 2026-07-12 02:53:17 +08:00
Bohan Jiang
cb87dd106b feat(chat): task-owned direct-chat input batches + explicit no_response outcome (MUL-4351) (#5195)
* feat(chat): task-owned direct-chat input batches + explicit no_response outcome (MUL-4351)

Direct (web/mobile) chat no longer uses the last-assistant-row as an implicit
input cursor. Each direct send now owns an immutable input batch:

- agent_task_queue.chat_input_task_id makes a task the owner of the user
  messages it must consume; the send path creates the task + user message +
  attachment bindings + session touch in one transaction, and the daemon is
  notified only after commit. A claim reads exactly that batch, so a message
  that arrives mid-run belongs to the next task and is never absorbed.
- Auto-retry inherits the root input owner and is queued at a bumped priority,
  created inside FailTask's transaction so no newer chat task can jump ahead.
- CompleteTask writes exactly one assistant outcome inside the completion
  transaction: a normal message, or a visible no_response outcome (with a
  non-empty English fallback) when the final output is empty. The write failing
  rolls the completion back and the handler returns 5xx so the daemon retries;
  the status CAS keeps it idempotent. chat:done carries message_kind.
- Web/desktop/mobile render no_response as a localized 'no text reply' state
  (keeping the tool timeline), suppress Copy, keep it unread, and keep the
  session-list preview non-blank.
- Legacy/channel tasks (chat_input_task_id NULL) keep the trailing-message
  selector, so a rolling deploy never replays Slack/Lark history.

Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): scope no_response to direct tasks; don't cancel task on input read error (MUL-4351)

Addresses PR review (Niko):
- writeChatCompletionOutcome only writes a no_response row for task-owned
  direct tasks (chat_input_task_id set). Legacy/channel (Slack/Lark) tasks keep
  the prior behavior: empty output writes no assistant row, so chat:done carries
  empty content and the channel outbound silently drops it — the no_response
  fallback body never reaches an external channel.
- The daemon claim distinguishes a genuine zero-input batch from a failed
  input read: on ListChatInputMessages / ListChatMessages error it returns 5xx
  and preserves the dispatched task for redelivery instead of cancelling a valid
  task on a transient DB error.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-10 16:22:42 +08:00
Multica Eve
bf161f2f9c fix(tasks): preserve merged comment delivery (#5192)
Track actual claim-time delivery, support legacy daemons, and repair comment
batches across claim, retry, edit, and delete races.

MUL-4348

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-10 14:10:10 +08:00
Multica Eve
6a72f248a1 fix: unblock release migrations (#5162)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-09 17:59:19 +08:00
Multica Eve
86c3f30524 fix(server): keep originator on agent-created issues so A2A mentions stay authorized (MUL-4305) (#5149)
* fix(server): attribute agent-created issues so downstream A2A mentions keep the originator (MUL-4305)

An agent creating an issue via the ordinary `issue create` path left the new
issue with no origin link, so resolveOriginatorForIssueTask could not recover
the top-of-chain human. Any assignment / squad-leader run derived from that
issue lost the originator, and A2A @-mentions those runs emitted failed the
canInvokeAgent gate against private agents (after MUL-3963).

Fix (mirrors the comment.source_task_id stamp from MUL-4015):
- CreateIssue stamps origin_type='agent_create' + origin_id=<acting task>,
  resolved from the SERVER-trusted X-Task-ID (never a client-reported field).
- resolveOriginatorForIssueTask inherits the origin task's originator for
  agent_create just like quick_create.
- Align the squad-leader gate originator with the enqueue path via the new
  exported OriginatorForIssueTask, so the gate and the persisted task row
  agree instead of drifting to an empty originator for agent-triggered assigns.
- Migration 149 adds 'agent_create' to issue_origin_type_check.
- Classify agent_create in analytics to avoid the unknown-origin warning.

Tests: agent_create attribution + gate/enqueue consistency (service), and the
HTTP boundary stamp + security regression (member / forged X-Agent-ID must not
smuggle an agent_create origin).

Co-authored-by: multica-agent <github@multica.ai>

* test(server): add end-to-end regression for agent-created issue originator chain (MUL-4305)

Locks the real product path the layered tests could miss, per PR review:

1. CreateAssignSquad_PrivateWorkerTriggered — human H triggers agent A → A
   creates an issue via the ordinary create path AND assigns it to a squad
   whose leader is a private agent owned by H → the leader's assignment run
   @-mentions a second private agent J (owned by H) → asserts the leader task
   carries H and J ends up with a queued task attributed to H. This is the
   exact line-failure shape from the issue.
2. UpdateAssignSquad_HandlerGateAdmitsPrivateLeader — agent A creates an
   unassigned issue then assigns it to a private-leader squad via UpdateIssue,
   exercising the handler enqueueSquadLeaderTask gate (which the create path's
   ungated service enqueue does not hit); asserts the leader task is enqueued
   carrying H.

Both wire handler create stamp → origin resolution → squad-leader gate →
comment source-task stamp → private-worker invocation gate. Verified they FAIL
against a simulated pre-fix resolver (leader originator empty; private worker /
leader get 0 tasks) and PASS with the fix.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-09 15:10:09 +08:00
Multica Eve
75695a2e40 fix(comments): guarantee at-least-once processing of user comments (MUL-4195) (#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 #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 #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 #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 #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>
2026-07-09 12:48:57 +08:00
Jiayuan Zhang
a51ab4d551 feat(chat): Chat V2 — first-class IM-style Chat tab (MUL-4171) (#5076)
* feat(chat): Chat V2 — first-class IM-style Chat tab (MUL-4171)

Replace the floating chat FAB/window with a first-class Chat tab under
Inbox, laid out as an IM-style two-pane surface (thread list + conversation).

Highlights:
- New Chat page (packages/views/chat/chat-page.tsx) with URL-addressable
  session selection; web + desktop routing wired up. Removes the old
  chat-fab / chat-window / resize-handles / context-items paths.
- IM thread list: agent avatar + last-message preview + IM timestamp, red
  unread *count* badge (read-cursor model), presence-gated typing vs waiting.
  Rename lives only in the conversation header ⋯ menu (not the list hover).
- Per-session conversation header (rename / view agent / delete), agent-aware
  empty state (avatar + name + description + starter prompts), and a
  deterministic clean-title derivation from the first message.
- Server: read-cursor unread model (migration 145) and per-user pinned agents
  (migration 146, dedicated chat_pinned_agent table + handler/queries).
  New-agent welcome chat auto-enqueues a real agent run (LLM intro, no
  static template).
- Design: fade the global --border token; borderless list headers on
  Chat/Inbox, kept (faded) on the conversation header.

Verified: pnpm typecheck (all packages), go build ./..., go vet, gofmt.
Co-authored-by: multica-agent <github@multica.ai>

* feat(chat): make new-agent welcome read as an agent-initiated intro (MUL-4230)

The "meet your new agent" chat used to insert a fake user message
("👋 Hi! Please introduce yourself …") and have the agent reply to it, so
the thread looked like the creator prompting the agent.

Drop the persisted user message. Flag the auto-created session
is_agent_intro (migration 147) and drive the intro run server-side: the
daemon builds a proactive self-introduction prompt for such sessions
(buildChatPrompt) instead of a "reply to their message" prompt. The intro
stays LLM-generated; the thread now opens with the agent's own message, as
if it reached out first.

- migration 147: chat_session.is_agent_intro
- CreateChatSession carries the flag; sendAgentWelcomeChat no longer
  persists/publishes a user message
- daemon: ChatIntro threaded from session flag → intro prompt

Co-authored-by: multica-agent <github@multica.ai>

* feat(chat): Settings toggle for the floating chat window (MUL-4235) (#5080)

* feat(chat): Settings toggle for the floating chat window (MUL-4235)

Re-introduce the floating chat overlay on top of Chat V2 as an optional,
Settings-gated surface instead of deleting it outright.

- Settings → Preferences → Chat: a switch (floatingChatEnabled, persisted
  client preference, default ON) to show/hide the floating window.
- FloatingChat wrapper owns the two gates: the preference, and the /chat
  route (hidden on the tab so the same activeSessionId isn't shown twice).
- ChatFab + a compact ChatWindow that reuse the shared useChatController and
  conversation components, so activeSessionId stays in lockstep with the tab.
- Restore use-chat-context-items so the overlay's @ surfaces the current
  issue/project (the 'current context' affordance) — the tab stays manual.
- i18n (en/zh-Hans/ja/ko), store unit tests.

typecheck: core/views/web/desktop green. tests: chat store 9, settings 82,
chat 39 pass.

Co-authored-by: multica-agent <github@multica.ai>

* feat(chat): dedicated Chat settings tab, floating window opt-in (MUL-4235)

Address review: give Chat its own Settings tab instead of a section inside
Preferences, and default the floating window OFF (opt-in).

- New Settings → Chat tab (chat-tab.tsx) under My Account; moves the
  floating-window toggle out of the Preferences tab.
- floatingChatEnabled now defaults OFF — only an explicit enable from the
  Chat tab mounts the FAB/overlay.
- i18n: page.tabs.chat + a top-level chat block (en/zh-Hans/ja/ko);
  revert the Preferences chat section and its test mock; store tests updated
  for the opt-in default.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>

* refactor(chat): drop starter prompts from chat empty state (MUL-4237) (#5081)

The three starter prompts (List my open tasks by priority / Summarize what
I did today / Plan what to work on next) read as filler more than help, so
remove them along with the now-unused returning_subtitle ("Try asking").

The empty state keeps its agent-aware header — avatar + "Chat with {name}"
+ optional description — and the composer stays the entry point. Locale
keys dropped across en/zh-Hans/ja/ko (parity preserved).

Based on the Chat V2 branch (parent MUL-4171, #5076), not main.

Co-authored-by: Lambda <lambda@multica.ai>

* feat(chat): pin a chat to the top of the Chat list (MUL-4240) (#5082)

Builds on Chat V2 (#5076). Adds a per-conversation pin so a user can
keep important chats at the top of the IM-style thread list, above the
activity-sorted rest.

Backend:
- migration 148: chat_session.pinned_at (nullable) + partial index; the
  timestamp doubles as the pinned-group sort key and the boolean flag.
- list queries order pinned-first, then by most-recent activity.
- SetChatSessionPinned query + PATCH /api/chat/sessions/{id}/pin handler;
  pinning never bumps updated_at, so an unpinned chat won't jump the list.
- ChatSessionResponse.pinned + chat:session_updated carries the new state.

Frontend:
- ChatSession.pinned; setChatSessionPinned API + useSetChatSessionPinned
  with optimistic re-sort; shared sortChatSessions comparator.
- thread list: pin indicator on pinned rows + pin/unpin hover action;
  list sorted pinned-first so it stays ordered after cache patches.
- realtime patch re-sorts on pin change; en/ja/ko/zh-Hans strings.

Tests: SetChatSessionPinned handler test, sortChatSessions unit tests.

* feat(chat): round send button, move file upload into a + menu (MUL-4250) (#5088)

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>

* fix(inbox): match chat list selected-item style (inset padding + rounded) (#5093)

Wrap the inbox list in p-1 and give each row rounded-md/px-3 so the
selected bg-accent reads as an inset rounded card — same treatment the
chat thread list already uses — instead of a full-bleed, sharp-cornered
highlight. Content stays 16px-inset (p-1 + px-3 == old px-4).

MUL-4253

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): rename + menu upload item to "Image or files" (MUL-4250) (#5092)

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): stop welcome intro session repeating the same introduction (MUL-4259)

The is_agent_intro flag on chat_session is persistent, so every follow-up turn on a welcome session re-selected the self-introduction prompt in buildChatPrompt and the agent kept replying with the same intro instead of answering the user.

Gate resp.ChatIntro at claim time on the session still having zero human (role='user') messages via a new ChatSessionHasUserMessage query: the first, message-less server-driven turn introduces the agent; once the creator replies, later turns fall back to the normal reply prompt.

Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): address review findings + unbreak CI (MUL-4171)

- task:failed now refreshes the sessions list (invalidateSessionLists), so
  the thread-list preview / unread / sort stays correct after an agent
  failure — FailTask persists a failure chat_message but only broadcasts
  task:failed, mirroring the chat:done success path.
- Self-heal stale chat deep links: once the sessions list has loaded and a
  ?session= id isn't in it (deleted / no access / never existed) with nothing
  in flight, clear the selection instead of rendering an editable empty chat
  that would POST into a nonexistent session. Freshly-created sessions are
  exempt (they carry optimistic messages + a pending task).
- CI: add the new parameterless `chat` route to link-handler's
  WORKSPACE_ROUTE_SEGMENTS and to paths/consistency.test.ts (route set +
  expectedSegments) — keeps the two in sync, fixes the failing @multica/core
  test.
- Fix a MUL-4235/MUL-4237 merge collision that broke @multica/views
  typecheck: chat-window.tsx still passed the removed `onPickPrompt` prop to
  EmptyState.

Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): self-heal dangling session in shared controller, not just ChatPage (MUL-4171)

Re-review follow-up: the stale-session self-heal only lived in ChatPage, so
the floating ChatWindow still entered from a persisted activeSessionId and
would render an editable empty chat (then POST into a nonexistent session)
when the selected session was deleted / lost access off the /chat route.

- Move the self-heal into the shared useChatController so every surface (tab
  and floating window) drops a dangling activeSessionId once the sessions list
  has loaded and doesn't contain it.
- Harden ensureSession: trust the current id only when it's in the loaded list
  or is a just-created session still awaiting the refetch; a dangling id falls
  through to create a fresh session instead of POSTing into a 404.
- Exempt just-created sessions via an OPTIMISTIC-write signal
  (hasOptimisticInFlight: pending task or optimistic- message), not hasMessages
  — a session deleted elsewhere with real cached history stays eligible for
  self-heal. Add a unit test for the discriminator.

Co-authored-by: multica-agent <github@multica.ai>

* test(views): fix app-sidebar useWorkspacePaths mock for the new chat nav (MUL-4171)

The AppSidebar personal nav gained a `chat` item, so it calls
`useWorkspacePaths().chat()` at render. The app-sidebar.test.tsx mock hadn't
been updated, so `p.chat` was undefined and every render threw
`TypeError: p[item.key] is not a function`, failing @multica/views#test in CI.

- Add `chat: () => "/acme/chat"` to the mocked useWorkspacePaths.
- Route the chat-sessions query key through a mutable `chatSessions` fixture.
- Add coverage for the Chat nav: renders the link, badges the summed
  unread_count, and hides the badge when all sessions are read — so this drift
  is caught next time.

Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): use the original floating window, not the rewritten one (MUL-4235) (#5102)

Follow-up to the merged #5080, which shipped a hand-written, simplified
ChatWindow and lost the original's animations / drag-resize / expand-minimize.
The floating window is just a quick entry point — it should be the original
UI, not a rewrite.

- Restore chat-window.tsx, chat-fab.tsx, chat-resize-handles.tsx and
  use-chat-resize.ts verbatim from main (0-diff): motion animations, drag
  resize, expand/minimize and the session dropdown are back.
- Restore the empty_state.returning_subtitle + starter_prompts i18n keys the
  original window renders (V2 had dropped them); drop the now-unused
  window.open_full_tooltip key the rewrite added.
- Settings gating is unchanged: FloatingChat still wraps the original FAB +
  window, gated by floatingChatEnabled (default off) and hidden on /chat.

typecheck: core/views/web/desktop green. tests: chat + settings views 126 pass.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>

* feat(chat): archive chats from the list, delete only from Archived (MUL-4263) (#5098)

Restore an archive flow as the reversible sibling of delete:
- Chat list hover now offers Archive (not Delete); pin/stop unchanged.
- A footer entry ('Archived · N') opens an Archived view listing archived
  chats; hard delete lives only there (hover -> unarchive + delete, with
  the existing inline confirm).
- Conversation header ⋯ menu mirrors this: active chats archive, archived
  chats unarchive/delete.

Backend: PATCH /api/chat/sessions/{id}/archive flips status active<->archived
(SetChatSessionArchived), broadcasts status on chat:session_updated so other
tabs re-sort into the right list. SendChatMessage already refuses archived
sessions, so archived chats stay read-only until unarchived.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>

* feat(chat): handle archived agents in Chat V2 list & conversation (MUL-4265) (#5100)

* feat(chat): handle archived agents in Chat V2 list & conversation (MUL-4265)

Co-authored-by: multica-agent <github@multica.ai>

* refactor(chat): drop chat-list archive marker, keep conversation read-only (MUL-4265)

Co-authored-by: multica-agent <github@multica.ai>

* feat(chat): apply archived-agent read-only to the floating chat window (MUL-4265)

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): address floating-window + archived-agent review blockers (MUL-4171)

Re-review follow-up on the restored floating ChatWindow + archive flow:

1. Floating stale-session self-heal. The restored ChatWindow doesn't use the
   shared controller, so its ensureSession trusted any non-empty
   activeSessionId and there was no dangling-session cleanup — a deleted /
   no-access persisted session could send into a nonexistent session. Ported
   the same guard used for the tab: a self-heal effect that clears a dangling
   activeSessionId once the sessions list has loaded, and ensureSession only
   trusts an id that's in the list or has an in-flight optimistic write
   (hasOptimisticInFlight, reused from use-chat-controller). handleSend seeds
   the optimistic message + pending task before setActiveSession, so a
   freshly-created session is never mis-cleared.

2. Floating dropdown bypassed archive-first safety. Its active rows offered a
   hard-delete, letting the floating window destroy active chats and skip the
   "archive first, delete only from Archived" model. Active rows now ARCHIVE
   (reversible, one-click) like ChatThreadList; the floating window offers no
   hard-delete — unarchive/delete live only in the full Chat page's Archived
   view (reachable via expand). Removed the now-dead delete-confirm machinery.

3. Orphan user message on archived-agent send. SendChatMessage created the
   chat_message before EnqueueChatTask, which rejects an archived / runtime-less
   agent — a stale client would land a user message then get a 500, orphaning
   it. Added a preflight that checks the session agent's archived / runtime
   state and returns 409 before any mutation, plus a handler test asserting the
   send is rejected with no message persisted.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-08 21:58:16 +08:00
Bohan Jiang
fd58e13bec feat(runtimes): custom runtime names + searchable machine-grouped picker (MUL-4217) (#5070)
* feat(runtimes): custom runtime names + searchable machine-grouped picker

MUL-4217. Runtime names were daemon-generated ("Claude (host)") and
uneditable, so picking one at agent-create time was painful once a
workspace had many machines.

Phase 1 — create-agent RuntimePicker: add a search box (>6 runtimes) and
group options by machine (Local/Remote/Cloud, online-first, current
machine first) reusing buildRuntimeMachines/filterRuntimeMachines. Rows
show the provider under a machine header instead of a flat repeated list.

Phase 2 — custom names: new nullable agent_runtime.custom_name column,
never written by the registration/heartbeat upsert so the daemon can't
clobber it; display is coalesce(custom_name, name) via runtimeDisplayName.
PATCH /api/runtimes/:id gains custom_name (+ apply_to_machine to name every
runtime sharing a daemon_id in one action, owner-scoped for non-admins).
Rename UI on the runtime detail page; `multica runtime rename` CLI command.

Verified: go build/vet, sqlc, handler tests (incl. new custom-name single
+ machine-fanout), 1650 views + 764 core TS tests, typecheck, locale parity.

Co-authored-by: multica-agent <github@multica.ai>

* fix(runtimes): address review — persist machine name on new registrations, keep custom_name in register response

Elon's review on #5070 (MUL-4217):

1. Machine name looked "lost" when a new provider registered on an
   already-named machine — the new row landed with custom_name=null and
   broke sharedCustomName. Now a fresh runtime inherits the machine's shared
   custom name at register time (ListDaemonCustomNames + sharedDaemonCustomName),
   so the machine title stays stable as providers come and go.

2. DaemonRegister rebuilt the response row by hand and dropped custom_name,
   so register returned custom_name:null — inconsistent with list/get/update.
   Both branches now carry CustomName.

Also: tighten the updateRuntime patch type to custom_name?: string (drop the
misleading `| null`, since the server treats null as "unchanged", not "clear").

Tests: register response preserves custom_name; new runtime inherits machine name.
Co-authored-by: multica-agent <github@multica.ai>

* fix(runtimes): inherit machine name for failed-profile registrations too

Elon's re-review of #5070 (MUL-4217): the machine-name inheritance added
last round only covered the normal req.Runtimes path. The req.FailedProfiles
branch also upserts a daemon_id-scoped agent_runtime row (offline, profile
registration error), which shows up in the runtime list / machine grouping —
so on a named machine a failed custom-profile row landed with custom_name=NULL
and dragged the machine title back to the hostname.

Extract the inheritance into h.inheritMachineCustomName and call it from both
the normal runtime path and the failed-profile path. Add a test: named daemon
+ failed profile upsert -> the failed row's persisted custom_name is inherited.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-08 16:00:17 +08:00
LinYushen
566d51f1c0 perf(chat): fix pending-task index mismatch + cut aggregate request storm (MUL-4159) (#5018)
* perf(chat): fix pending-task index mismatch + cut aggregate request storm (MUL-4159)

ListPendingChatTasksByCreator was a top DB hotspot. Root cause: the partial
index idx_agent_task_queue_chat_pending (migration 040) only covers
status IN (queued, dispatched, running), but migration 109 added a fourth
in-flight status (waiting_local_directory) that both pending chat queries now
filter on. Postgres can only use a partial index when the query predicate is a
subset of the index predicate, so the 4-status query stopped using it and
degraded to a Seq Scan over the whole agent_task_queue.

Implements the reviewed P0-P3 plan in one PR:

P0 index fix (split single-statement CONCURRENTLY migrations per repo convention)
- 143: CREATE INDEX CONCURRENTLY idx_agent_task_queue_chat_pending_v2 covering
  all four in-flight statuses (same column list, so GetPendingChatTask still
  benefits).
- 144: DROP the superseded 3-status index, in its own migration.

P1 SQL + handler hot path
- ListPendingChatTasksByCreator now returns cs.agent_id and states
  chat_session_id IS NOT NULL so the planner can prove the partial-index subset.
- ListPendingChatTasks filters private-agent access against the already-loaded
  accessible-agent set using the returned agent_id, dropping the extra
  ListAllChatSessionsByCreator scan on the hot path.
- Regenerated sqlc.

P2 frontend request amplification
- FAB uses the new boolean has-any query gated on enabled:!isOpen, so the
  minimised button never holds the full aggregate.
- use-realtime-sync maintains the pending aggregate (list + has-any) in place
  from task lifecycle events (queued/dispatch/running/waiting_local_directory
  -> upsert; completed/failed/cancelled -> remove) instead of invalidating on
  every chat:message/chat:done, with a debounced fallback invalidate for
  reconnect / unknown payloads.

P3 boolean endpoint
- GET /api/chat/pending-tasks/has-any backed by HasPendingChatTasksByCreator
  (EXISTS). Permission filtering is baked in via agent_id = ANY($3); an empty
  accessible-agent set short-circuits to false. The detailed list stays for the
  ChatWindow history / stop-task flows.

Tests: new handler tests cover the private-agent gate on both endpoints
(hidden from a creator who lost access, visible to the agent owner) plus the
boolean status/terminal semantics.

EXPLAIN (ANALYZE, BUFFERS) on a 300k-row reproduction:
- before (3-status index): Parallel Seq Scan, ~300k rows filtered,
  shared hit=3012, 12.1 ms.
- after (v2 index): Index Scan on idx_agent_task_queue_chat_pending_v2,
  shared hit=131, 0.07 ms.

Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): stop optimistic cross-session pending aggregate writes from workspace-fanout task events (MUL-4159)

Review on PR #5018 flagged a real privilege-escalation bug in the P2 change:
use-realtime-sync optimistically upserted the cross-session pending aggregate
(pendingTasks / pendingTasksHasAny) from chat task:* events. Those events are a
workspace fanout delivered to every member (server still BroadcastToWorkspace,
see cmd/server/listeners.go), and the payload carries no creator / agent
visibility. So member B starting a chat task could flip member A's FAB to
has_pending=true, bypassing the server-side permission filter on
/api/chat/pending-tasks[/has-any].

Fix (option 1 from the review — the self-contained one): never optimistically
write the aggregate from task:* events. On every task lifecycle transition,
debounced-invalidate the aggregate so it is refetched through the
permission-filtering endpoint, which only returns the caller's own
creator-owned, accessible-agent tasks. The per-session pendingTask cache is
still written directly — it is keyed by chat_session_id and only rendered for
sessions the user can open (server-gated), so it is not a cross-user leak.
chat:message is still excluded from aggregate refresh, so the MUL-4159 request
storm stays fixed; task transitions are per-task and coalesced by the debounce.

- Removed upsertPendingAggregate / removePendingAggregate.
- Added exported refetchPendingChatAggregate(qc, wsId) — an invalidate, never a
  setQueryData — used by the debounced handler.
- Regression tests: refetchPendingChatAggregate leaves the cached
  has_pending/list untouched (no optimistic write) and only invalidates for an
  authoritative server-filtered refetch; no-ops without a workspace id.

Verified: @multica/core + @multica/views typecheck; full core vitest suite
(752 tests) green including the 2 new guard tests.

Co-authored-by: multica-agent <github@multica.ai>

* chore(chat): address review nits on pending-tasks endpoints (MUL-4159)

- Restore the GetPendingChatTask godoc first line that was clipped when the
  has-any handler was inserted (nit#1).
- ListPendingChatTasks short-circuits to an empty list when the caller has no
  accessible agents, mirroring HasPendingChatTasks — skips the DB round-trip
  (nit#2).
- Add a cross-creator negative test: user A's in-flight task on a
  workspace-visible agent returns has_pending=false / empty list for user B,
  locking the cs.creator_id tenant gate that the agent-visibility filter does
  not cover (nit#3).

Verified: go build ./... and go test ./internal/handler -run PendingChatTasks
(7 tests) green against live Postgres.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-07-07 13:20:30 +08:00
Multica Eve
75e8bd5b64 fix: make release index migrations concurrent (#4995)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-06 19:06:53 +08:00
Bohan Jiang
346f818206 fix(runtime): add traecli to custom runtime profile whitelist (#4972)
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>
2026-07-06 15:03:17 +08:00
Multica Eve
359ef61dc3 fix(search): pg_trgm index fallback + statement_timeout guard (MUL-4059) (#4925)
* fix(search): add pg_trgm index fallback + statement_timeout guard (MUL-4059)

Root cause of the "search freezes with no response" symptom reported in
MUL-4059: the search handler runs LOWER(col) LIKE '%pattern%' queries
that expect a pg_bigm GIN index (migrations 032, 033, 036), but every
migration wraps the CREATE EXTENSION + CREATE INDEX in a DO/EXCEPTION
handler that silently skips when pg_bigm is unavailable. The bundled
self-host / dev / CI Postgres image (pgvector/pgvector:pg17) does not
ship pg_bigm, so on every self-hosted deployment the migrations no-op
and no GIN indexes get built. Every /api/issues/search + /api/projects/search
request then falls back to a Seq Scan on `issue` + correlated Seq Scans
on `comment` — verified with EXPLAIN on the local dev DB, which has zero
title/description/comment search indexes before this change.

Two independent guardrails are added, either of which alone would have
prevented the reported hang:

1) Migration 134 installs pg_trgm (ships in all standard Postgres +
   pgvector images) and builds GIN indexes with gin_trgm_ops on
   `LOWER(title)`, `LOWER(COALESCE(description, ''))`, and
   `LOWER(content)`. The expression signatures match the search
   handler's WHERE clauses exactly, so the planner picks the index
   without further changes. The pg_bigm indexes from 036 are left
   intact — deployments on AWS RDS with pg_bigm 1.2 keep the CJK-friendly
   bigram path; deployments without it get the trigram fallback. Verified
   against a local 25k-row fixture: the description LIKE hits
   `Bitmap Index Scan on idx_issue_description_trgm` in 0.5 ms.

2) runSearchQuery wraps both search handlers in a short-lived read-only
   transaction with SET LOCAL statement_timeout = 3 s. In the pathological
   case where indexes are still missing or the query plan is bad, callers
   see a fast 503 with a descriptive error instead of a stalled request.
   Verified against a live Postgres: a deliberate pg_sleep(2) with the
   test override at 200 ms is cut off in 230 ms with SQLSTATE 57014, as
   asserted by TestRunSearchQuery_StatementTimeoutFires.

Non-goals: this change does not remove the pg_bigm code path, does not
change the SQL the handler builds, and does not change the API response
shape. It is the minimum diff to unblock production while preserving
the CJK-search advantage that pg_bigm provides where it is available.

Co-authored-by: multica-agent <github@multica.ai>

* fix(search): scope comment subqueries to workspace to unblock prd hang (MUL-4059)

Follow-up correction after PRD investigation: pg_bigm IS installed on
prd `multica-prod` and all five bigm indexes exist in the correct
`LOWER(...) gin_bigm_ops` form. The initial "missing index" hypothesis
was wrong; migration 134 (pg_trgm fallback) still helps self-host but
does not touch the production hang path.

Actual prd EXPLAIN (workspace with 60k issues, keyword "search"):

    Index Scan using idx_issue_workspace on issue i
    Rows Removed by Filter: 59123
    SubPlan 2
      Bitmap Heap Scan on comment c
        Rows Removed by Index Recheck: 1928275
        Heap Blocks: exact=48297 lossy=164696
      Bitmap Index Scan on idx_comment_content_bigm
        rows=536761
    Execution Time: 32345.002 ms

Root cause: the correlated `EXISTS` over `comment` gets rewritten by
the planner into a *hashed* subplan. Without a workspace_id filter in
the subquery, that hashed set covers every comment in every workspace
matching the LIKE — 536k rows for "search" — which spills work_mem
into a lossy bitmap and rechecks 1.9M rows.

Two-part fix:

1. Query rewrite. buildSearchQuery now emits
   `c.workspace_id = $wsParam` inside every comment subquery (WHERE
   phrase match, WHERE multi-term match, tier 7 rank, tier 8 rank, and
   the matched_comment_content COALESCE). The same $4 parameter is
   reused so Postgres treats it as a compile-time constant and pushes
   it into the hashed subplan's key, collapsing the set to this
   workspace's comments.

2. Supporting index (migration 135). New
   `idx_comment_workspace ON comment (workspace_id)`. Without it, the
   pushed-down filter still triggers a Seq Scan on `comment` because
   comment has no btree on workspace_id (only the FK constraint and
   composite (issue_id, ...) indexes).

Locally verified against a repro that mirrors prd (5k issues in the
target workspace, 100k comments in a sibling workspace all containing
"search"): the plan drops from 60 ms (hashed global scan, no support
index) to 3 ms (subplan uses idx_comment_workspace). Prd extrapolation
from the same shape: 32.3 s → tens of milliseconds.

Regression test TestBuildSearchQuery_CommentSubqueryWorkspaceScope
asserts every `FROM comment c` in the generated SQL is followed by a
`c.workspace_id = $4` filter, so a future refactor can't silently
regress the plan back to the global-hash pathology.

The statement_timeout guard from the earlier commit in this branch is
kept — it still bounds the worst case if any future query shape
regresses.

Co-authored-by: multica-agent <github@multica.ai>

* fix(search): address PR review — unwrap 135 + add project trigram indexes (MUL-4059)

Both must-fix items from GPT-Boy's review:

1. Migration 135 unwrapped. The previous version buried
   `CREATE INDEX idx_comment_workspace` inside `DO $$ ... EXCEPTION
   WHEN OTHERS $$` — exactly the anti-pattern that caused MUL-4059 in
   the first place. `idx_comment_workspace` is not a CJK-bonus fallback;
   it is the critical support that makes the query rewrite land on an
   Index Scan instead of a Seq Scan. A silent failure (lock timeout,
   disk full, permission denied, schema drift) MUST abort the migration
   and fail deployment, not slip through as green. The unwrapped
   `CREATE INDEX IF NOT EXISTS` now propagates real errors to the
   migration runner, which aborts and does NOT record the version as
   applied. IF NOT EXISTS keeps idempotency for the operator-precreated
   case (`CREATE INDEX CONCURRENTLY ...` before running migrations on
   large prd tables).

2. Migration 134 now covers project search too. SearchProjects reads
   `LOWER(project.title)` and `LOWER(COALESCE(project.description, ''))`,
   and the pg_bigm equivalents in migration 039 silently no-op on
   pg_bigm-less images just like 032/033/036. Without the trigram
   fallback, project searches on self-host would still Seq Scan and hit
   the 3 s statement_timeout guard as a 503 — technically bounded but
   not actually fixed. Added `idx_project_title_trgm` and
   `idx_project_description_trgm`; the down migration drops them too.

Also: fixed the search.go comment that said callers get a "standard
500" — they get a 503 with SQLSTATE-57014 mapping; the comment now
matches reality.

Verified: build clean, vet clean, existing search / timeout tests
still green. Migration 135 dry-run (dropping the index, re-applying
the unwrapped SQL under `ON_ERROR_STOP=1`) creates the index cleanly;
a deliberate `CREATE INDEX` on a non-existent column now aborts psql
with exit 3, confirming the migration runner would fail loudly on any
real error.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-06 12:34:51 +08:00
Jiayuan Zhang
7116691c07 fix(github): hide reference-only PR links from the issue PR list (#4611)
* fix(github): hide reference-only PR links from the issue PR list

A PR that merely mentions an issue key in passing in its description
(e.g. "Related to MUL-3739") was auto-linked and shown in that issue's
right-side PR list as if it were a working PR for the issue.

Add a reference_only flag to issue_pull_request. The webhook keeps
linking generously (so close_intent stays trackable across edits) but
flags a link as reference_only unless the key is a genuine target: a
title prefix, a branch reference, or a body closing keyword
(Closes/Fixes/Resolves). ListPullRequestsByIssue filters
reference_only rows, so passing body mentions are hidden from the CLI
and the UI PR list while real targets remain. reference_only follows
the same terminal preserve gate as close_intent; the auto-advance gate
is unchanged.

Closes MUL-3739

Co-authored-by: multica-agent <github@multica.ai>

* fix(github): exclude reference_only links from the close aggregate

A reference_only link is hidden from the issue PR list, but
GetIssuePullRequestCloseAggregate still counted it toward open_count.
An open body-only mention ("Related to MUL-X") could therefore block
the issue from auto-advancing to `done` after a real closing PR merged,
while being invisible in the right-side PR list.

Filter `AND NOT reference_only` in the aggregate too (reference_only
rows never carry close_intent, so merged_with_close_intent_count is
unchanged). Add TestWebhook_HiddenBodyMentionDoesNotBlockAutoAdvance.

Addresses code review on PR #4611.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-05 13:30:18 +08:00
Bohan Jiang
129efb7688 feat(runtime): allow qoder as a custom runtime profile base (#4883) (#4912)
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>
2026-07-04 23:16:01 +08:00
LinYushen
cb68669c73 feat(composio): gate MCP apps behind feature flag (#4876)
* feat(composio): server-side connect flow + connections REST (Notion MVP) (MUL-3720) (#4608)

* feat(composio): server-side connect flow + connections REST (Notion MVP) (MUL-3720)

Compose the merged server/pkg/composio SDK into a user-facing connection
manager: signed-state connect handshake, local user_composio_connection
mirror, idempotent disconnect, and a per-user MCP session helper (not yet
wired into task dispatch).

- migration 127_user_composio_connection (no FK/cascade, per DB rules)
- sqlc queries: upsert (idempotent on user_id+connected_account_id), list
  active, owner-scoped get, mark revoked
- internal/integrations/composio: signed HMAC-SHA256 state, BeginConnect,
  CompleteCallback (idempotent upsert), ListConnections, Disconnect
  (upstream 404 = idempotent success), CreateMCPSession (no-op when empty,
  pins connected_accounts per toolkit), CallbackRedirect
- REST handlers under /api/integrations/composio (user-scoped, 503 when
  COMPOSIO_API_KEY unset): connect/init, callback (302), connections list,
  delete
- router wiring gated by COMPOSIO_API_KEY; COMPOSIO_AUTH_CONFIGS_JSON maps
  toolkit->auth_config (MVP: notion); state secret from COMPOSIO_STATE_SECRET
  or derived from JWT_SECRET; callback base from COMPOSIO_CALLBACK_BASE_URL
  or MULTICA_PUBLIC_URL
- tests: state (expire/tamper/wrong-secret), service (mapping, callback
  idempotency, non-success, disconnect owner/404 idempotency, MCP pin),
  handlers (httptest), redact regression for Bearer mcp_ tokens

MVP scope: Notion only; no task-dispatch overlay, sharing, or webhook
event handling (later stages).

Co-authored-by: multica-agent <github@multica.ai>

* fix(composio): bind callback account to user + idempotent revoked disconnect (MUL-3720)

Address PR 4608 review (CHANGES_REQUESTED):

- callback: verify connected_account_id with Composio before mirroring it.
  The signed state only proved user/toolkit/exp, so a valid state paired with
  a tampered connected_account_id would be written verbatim. CompleteCallback
  now calls ListConnectedAccounts and fails closed (ErrAccountVerification)
  unless the account belongs to the state's user (composio_user_id == multica
  user id) and was created under the toolkit's auth config. No row is written
  on mismatch / unknown account / upstream error.

- disconnect: short-circuit to a no-op when the local row is already revoked,
  before touching upstream. Previously a second DELETE re-hit Composio and a
  non-404 upstream error surfaced as a 502, breaking the 204-idempotent
  contract.

- CreateMCPSession: document the v1 single-active-connection-per-(user,toolkit)
  constraint and make duplicate selection deterministic (newest-wins, rows are
  connected_at DESC) instead of order-dependent map overwrite. Stage 3 owns the
  real single-account-enforcement vs multi-account-shape decision.

Tests: tampered/wrong-auth-config/unknown-account callback rejection, revoked-row
disconnect no-op (asserts upstream not re-hit). composio pkg 85% coverage; all
green.

Co-authored-by: multica-agent <github@multica.ai>

* feat(composio): list all toolkits + dynamic auth-config resolution (MUL-3720)

Yushen's follow-up to the Notion MVP: surface the full Composio toolkit
catalog, render it in Settings, and drop the static env mapping in favor of
dynamic auth-config discovery.

Config correctness (per Composio docs):
- Remove COMPOSIO_AUTH_CONFIGS_JSON entirely. The toolkit→auth_config mapping
  is now resolved at request time from the project's /auth_configs (cached,
  5-min TTL), so enabling a toolkit is a dashboard action, not a redeploy.
- Do NOT add COMPOSIO_PROJECT_ID. The project API key (x-api-key) authenticates
  to exactly one project; the project is resolved from the key. Only org-level
  endpoints use x-org-api-key, which this integration never calls.

Backend:
- SDK: server/pkg/composio/auth_configs.go — ListAuthConfigs (toolkit_slug,
  is_composio_managed, show_disabled, limit, cursor).
- service: dynamic resolver (authConfigMap cache; betterAuthConfig prefers a
  custom/white-label config over Composio-managed, newest wins); BeginConnect
  and CompleteCallback resolve via it; ListToolkits fetches the full catalog
  (paginated, capped) annotated with connectable = has an enabled auth config,
  connectable-first ordering.
- handler + route: GET /api/integrations/composio/toolkits (user-scoped, 503
  when COMPOSIO_API_KEY unset) returning slug/name/logo/category/connectable.

Frontend:
- core: ComposioToolkit/ComposioConnection types, api client methods, and
  composio query options (@multica/core/composio).
- views: Settings → Integrations now has a Composio section rendering every
  toolkit as a card with search. Connect is gated on `connectable`;
  non-connectable toolkits show a muted "not configured" hint instead of a
  dead button. Connected toolkits show a badge + Disconnect (with confirm).
- i18n: composio block added to en/zh-Hans/ja/ko settings.

Tests: SDK + service (dynamic resolution, custom-over-managed preference,
connectable flag, resolver-error soft-degrade) and handler toolkits endpoint;
composio pkg 85.7% coverage. go build/vet/gofmt clean; core+views typecheck,
core+views lint, and core tests (691) all green.

Co-authored-by: multica-agent <github@multica.ai>

* fix(composio): close cross-toolkit callback fail-open by signing auth_config_id into state (MUL-3720)

Re-review blocker: CompleteCallback resolved the toolkit's auth config at
callback time and ignored a resolve error/empty result, while
verifyAccountOwnership skipped the auth-config comparison when the expected
value was empty. A user could then pass another toolkit's connected_account_id
into this toolkit's callback — the owner check passed and it was written under
the wrong toolkit_slug/account binding.

Fix: the auth_config_id is already resolved in BeginConnect (before the state
is signed), so sign it into the state and compare it exactly at callback. No
re-resolve, no fail-open. verifyAccountOwnership now fails closed when the
expected auth config is empty (rejects instead of skipping) and requires an
exact match — closing the cross-toolkit binding gap.

Tests: state round-trips auth_config_id; BeginConnect signs it; callback
rejects wrong/cross-toolkit auth config and an empty (no-mapping) auth config
fails closed. composio pkg 85.2% coverage, all green.

Frontend (non-blocking): the Composio settings tab now surfaces an error when
the connections query fails instead of silently rendering everything as
unconnected.

Co-authored-by: multica-agent <github@multica.ai>

* fix(composio): hide Settings section entirely when integration unconfigured (MUL-3720)

Decision (option 2, hide-then-merge): don't show a card that leaks the internal
COMPOSIO_API_KEY env-var name to every end user. IntegrationsTab now gates the
whole Composio section (heading + body) on the toolkits query — a 503 means the
key is unset, so the section is withheld instead of rendering the not-configured
card. Admin-only setup guidance is a later, role-gated affordance.

Removed the notConfigured card (and now-unused ApiError import) from
ComposioTab; it only mounts when configured. views typecheck + lint clean.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>

* feat(composio): Stage 2 frontend polish — callback toast, last_used & expired UI, e2e (MUL-3718) (#4688)

* feat(composio): callback toast + refresh, last_used & expired UI, e2e (MUL-3718)

Co-authored-by: multica-agent <github@multica.ai>

* fix(composio): real callback redirect route + StrictMode-safe toast dedup (MUL-3718 review)

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>

* fix(composio): callback endpoint should not require Multica auth (MUL-3843) (#4709)

* fix(composio): move OAuth callback out of the Auth group (MUL-3843)

Composio 302-redirects the browser to /api/integrations/composio/callback
at the end of the OAuth flow, but PR #4608 mounted it inside the cookie-auth
middleware group. When the session cookie is absent (expired session,
SameSite=Strict / Safari ITP, private window, self-hosted callback subdomain)
the Auth middleware returned a hard 401 and a JSON blob instead of the
settings redirect, breaking the flow.

Identity never came from the cookie anyway: it is carried by the HMAC-signed
state param that CompleteCallback verifies (signature, expiry, replay) and
cross-checked by verifyAccountOwnership; h.Composio == nil still 503s. So the
callback is registered alongside the other public OAuth/webhook routes; the
other four composio endpoints stay session-gated.

Refs MUL-3843, MUL-3715.

Co-authored-by: multica-agent <github@multica.ai>

* fix(composio): correct stale callback routing comments (MUL-3843)

The package header and ComposioCallback doc comments still described the
callback as sitting under the Auth middleware group. After the route was
moved out (this PR), update both to state it is a public route whose identity
comes from the signed state — addressing review nit from 张大彪.

Refs MUL-3843.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>

* feat(composio): inject MCP overlay into agent runtime at task dispatch (MUL-3721) (#4704)

Stage 3 of the Composio epic. Wires the per-user Composio MCP session into
every agent task so the agent process sees the initiator's connected tools
without any prompt-time plumbing.

Server side
  - Migration 128 adds agent_task_queue.runtime_mcp_overlay JSONB plus a
    BEFORE-UPDATE trigger that wipes the column on any transition into a
    terminal status (completed / failed / cancelled). A trigger is the single
    source of truth — future queries that flip status cannot bypass it.
  - composio.Service.BuildTaskOverlay(userID) reuses CreateMCPSession and
    emits the Claude-style { mcpServers: { composio: { type: http, url,
    headers } } } shape the daemon's existing sidecar generators consume.
    Returns (nil, nil) on zero active connections so we never burn a
    Composio session for a user with nothing to call.
  - TaskService grows a Composio ComposioOverlayBuilder seam, wired in
    router.go after composiointeg.NewService succeeds. Five enqueue paths
    (issue / mention / quick-create / chat / auto-retry) attach the overlay
    after CreateAgentTask returns and before the daemon is notified — so
    every claim reads a settled row, with no second daemon hop. Best-effort:
    a builder failure logs and proceeds with no overlay.
  - resolveInitiatorFromTriggerComment derives the initiator user from the
    trigger comment when it was authored by a member. Agent-authored
    triggers are not treated as initiators (their connected-apps view is
    empty by construction).

Daemon side
  - handler/daemon.go claim path merges task.runtime_mcp_overlay onto
    agent.mcp_config via mergeMCPOverlay before populating
    TaskAgentData.McpConfig. Overlay wins on server-name collisions
    because it carries the live user-scoped session URL. Errors fall back
    to the agent config unchanged — a bad overlay must not surprise-disable
    saved MCP tools. The existing execenv sidecar generators (cursor /
    codex / openclaw / opencode / hermes-kiro) need no changes: they keep
    consuming the merged result through TaskAgentData.McpConfig.

Tests
  - 9 merge cases (mcp_overlay_test): both-nil short-circuit, agent-only
    pass-through, overlay-only canonicalization, two-side merge, name
    collision (overlay wins), top-level key preservation, malformed agent
    fallback, malformed overlay fallback, non-object server rejection.
  - 4 dispatch cases (composio): zero-connections returns nil without
    CreateSession, happy-path emits the right shape with the right user
    id, empty-URL defensive branch, SDK error surfacing.
  - 4 TaskService helper cases: nil Composio is a no-op (Queries-safe),
    invalid initiator does not call the builder, nil overlay skips the
    UPDATE, builder error swallowed without panic.
  - Migration 128 verified to roll up + down + up cleanly against the test
    database.

Out of scope (deferred): assignment-triggered enqueue paths with no
trigger comment get no overlay attached today (no initiator UUID flows
through enqueueIssueTask in that case). Retry paths recompute the overlay
fresh from the parent's initiator_user_id instead of inheriting the bearer
from the parent row, so a stale token can never resurface on a retry.

Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>

* feat(composio): per-agent allowlist + originator-scoped MCP overlay (MUL-3869) (#4736)

* feat(composio): per-agent allowlist + originator-scoped MCP overlay (MUL-3869)

Stage 3.1 of the Composio epic (MUL-3721 parent). PR #4704 wired in the
runtime_mcp_overlay column and a per-task dispatch hook; this change
inverts the default from "all-on" to opt-in and locks the overlay to the
agent owner's own connected apps:

- Agents carry composio_toolkit_allowlist TEXT[]. NULL or [] => no MCP.
  Owner-only read/write; non-owner GET/PUT silently redacts/drops the
  field (same shape as mcp_config).
- agent_task_queue carries originator_user_id UUID. Set from the
  top-of-chain HUMAN at every enqueue path:
    * issue/mention comment by member  -> author_id
    * issue/mention comment by agent   -> inherit via comment.source_task_id
                                          -> parent task originator_user_id
    * quick-create                     -> requester_id
    * chat                             -> initiator_user_id
    * retry                            -> SQL-inherited from parent row
    * autopilot                        -> NULL (system-driven)
- BuildTaskOverlay (composio dispatch) now takes (ctx, originatorUserID,
  agent) and short-circuits on five gates: invalid originator,
  originator != agent.owner_id, empty allowlist, empty intersection of
  allowlist ∩ active connections, defensive empty session URL. Composio
  CreateSession is called with BOTH `toolkits.slugs` (the intersection)
  AND `connected_accounts` (the pinned account ids), narrowing the
  tool-router twice.
- The originator-vs-owner gate closes the agent-fanout privacy hole: any
  workspace member who can @-mention a public agent used to project the
  owner's connected apps into their run. Now the overlay only mounts
  when the human at the top of the chain IS the agent owner.

Tests:
- dispatch_test.go covers all 5 gates plus uppercase/whitespace slug
  normalisation.
- task_runtime_mcp_overlay_test.go covers the no-op gates of the new
  applyRuntimeMCPOverlay signature.
- agent_composio_allowlist_test.go (handler): owner roundtrip
  (list/empty/null), workspace-admin silent-drop, owner-only GET
  visibility, pure normaliseComposioToolkitAllowlist.
- resolve_originator_test.go (service, DB-backed): member-authored,
  agent-authored inherits via comment.source_task_id, invalid id.

Migration 129 up/down/up verified against docker postgres.

Co-authored-by: multica-agent <github@multica.ai>

* chore(composio): gofmt + regenerate sqlc with v1.31.1 (MUL-3869 review nits)

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>

* fix(composio): accept nested connected account auth config

* feat(views): creator-only MCP tab for per-agent Composio allowlist (MUL-3870) (#4743)

Stage 3.2 frontend on top of the Stage 3.1 backend (MUL-3869, 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>
2026-07-03 14:18:43 +08:00
Bohan Jiang
e4994cd431 fix(github): allow one installation to bind multiple workspaces (#4855)
Connecting the same GitHub App installation in a second workspace silently
overwrote the first workspace's binding: github_installation was
UNIQUE(installation_id) and CreateGitHubInstallation's upsert overwrote
workspace_id on conflict (#4823).

Widen the uniqueness key to (workspace_id, installation_id) so each workspace
keeps its own binding row, and teach the webhook/lifecycle paths to handle N
bindings per installation_id:

- CreateGitHubInstallation upserts per (workspace_id, installation_id).
- Webhook lookup lists all bindings; PR/check_suite routing uses the oldest
  binding as the deterministic fallback and still routes per-repo via the
  existing workspace.repos registry.
- installation.deleted/suspend drops every workspace binding and broadcasts to
  each affected workspace.
- installation.created/unsuspend refreshes account metadata across all bindings.
- Add a standalone index on installation_id (the dropped unique constraint was
  the only index behind the webhook lookup).

MUL-3950

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-02 17:21:50 +08:00
Naiyuan Qing
7b3cad664b revert(self-host): remove source channel reporting (#4799)
* Revert "test(onboarding): cover official source reporting controls (#4782)"

This reverts commit fc88c7720f.

* Revert "fix(self-host): restore official source report endpoint (#4781)"

This reverts commit ad1afdd48d.

* Revert "feat(self-host): collect anonymous source channels mul-3878 (#4741)"

This reverts commit 26142d74aa.
2026-07-01 17:53:16 +08:00
Bohan Jiang
bbd900635e fix(slack): allow slack_chat issue origin so /issue can create issues (MUL-3908) (#4785)
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>
2026-07-01 15:20:22 +08:00
Naiyuan Qing
26142d74aa feat(self-host): collect anonymous source channels mul-3878 (#4741)
* feat(self-host): collect anonymous source channels

* feat(self-host): include other source text

* feat(self-host): report source channel domains

* feat(self-host): add source domain reporting control

* fix(self-host): simplify source reporting copy

* fix(self-host): simplify source channel reporting gate

* fix(self-host): limit source reporting triggers

* chore(self-host): point source reporting at staging
2026-07-01 13:31:25 +08:00
Bohan Jiang
d970b68ce7 feat(autopilot): View/Write permission layer + member access delegation (MUL-3807) (#4695)
* feat(autopilot): add View/Write permission layer

Autopilot write and execute operations were gated only by workspace
membership, so any member could edit, delete, trigger, or rotate the
webhook of any autopilot, and GetAutopilot returned webhook tokens to
every member (a token alone can trigger the autopilot).

- Add canWriteAutopilot / requireAutopilotWrite: update, delete, trigger,
  replay-delivery, and all trigger/secret management now require the
  autopilot creator or a workspace owner/admin.
- Redact webhook_token/path/url in GetAutopilot for callers without write
  access; trigger metadata otherwise stays visible (View default = all
  members). Creating an autopilot stays open to any member.
- ANDs with the existing private-assignee-agent dispatch gate.

MUL-3807

Co-authored-by: multica-agent <github@multica.ai>

* feat(autopilot): delegate write access via collaborators + manage-access UI

Adds an explicit grant primitive so an autopilot's creator/admin can
authorize specific workspace members to manage it, with a frontend entry
point — beyond the implicit creator/owner-admin set from the prior commit.

Backend:
- New autopilot_collaborator table (migration 128, members-only, app-layer
  cleanup, no FK) + sqlc queries.
- memberCanWriteAutopilot now also honors explicit collaborators; the write
  gate, webhook-secret redaction, and a new per-caller can_write flag (on
  list + detail) all flow through it.
- POST/DELETE /api/autopilots/{id}/collaborators (writer-gated); GetAutopilot
  embeds the collaborators list. Delete cleans up grants in its transaction.
- Tests: grant->write->revoke flow, non-writer can't grant, non-member rejected.

Frontend (web + desktop via packages/views):
- ManageAccessDialog: member picker to grant/revoke, current list with remove.
- 'Manage access' entry in the autopilot detail header; edit/run/add-trigger/
  delete and the list-row kebab + per-trigger rotate/delete now gate on
  can_write (absent => allowed, server stays the gate).
- can_write wired through types/schema/api client/mutations; en + zh-Hans copy.

MUL-3807

Co-authored-by: multica-agent <github@multica.ai>

* fix(autopilot): add manage-access i18n keys to ja/ko locales

The locale parity test requires every non-EN bundle to cover every EN
key. The prior commit added detail.manage_access + the access.* block to
en and zh-Hans only, failing parity for ja and ko. Add the translated
keys to both.

Co-authored-by: multica-agent <github@multica.ai>

* fix(autopilot): restrict access-list management to creator/admin only

Final-review fix: AddAutopilotCollaborator/RemoveAutopilotCollaborator
used requireAutopilotWrite, which counts granted collaborators as
writers — so a collaborator could in turn grant/revoke others, a
privilege escalation contradicting the 'collaborators cannot re-grant'
design.

- New requireAutopilotAccessManagement guard uses the narrower
  autopilotWriteByOwnership predicate (creator or workspace owner/admin
  only); swapped into both collaborator endpoints. Collaborators keep
  their edit/trigger/secret write-execute rights.
- GetAutopilot now also stamps can_manage_access (narrower than
  can_write); the detail page gates the 'Manage access' button on it so
  collaborators no longer see an entry that would 403.
- Tests: collaborator grant-others -> 403, revoke-peer -> 403, while
  retaining edit; can_manage_access true for owner, false for collaborator.

MUL-3807

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-30 12:29:11 +08:00
Multica Eve
5d79696fb5 MUL-3794: rewrite comment routing cascade 2026-06-30 12:24:57 +08:00
Naiyuan Qing
b336f07617 Revert "feat(analytics): anonymous self-host onboarding source beacon (MUL-37…" (#4712)
This reverts commit 63eb6f73ad.
2026-06-29 19:01:14 +08:00
Naiyuan Qing
63eb6f73ad feat(analytics): anonymous self-host onboarding source beacon (MUL-3708) (#4691)
* feat(analytics): anonymous self-host onboarding source beacon (MUL-3708)

Production self-host servers now report the anonymous onboarding "how did
you hear about us" channel to Multica's public write-only ingest, so the
self-host source distribution becomes visible alongside official cloud.
Official cloud keeps its existing PostHog capture unchanged; this is a
submit-time beacon, not a background telemetry pipeline.

- server/internal/sourcebeacon: ShouldSend gate (production + non-local +
  non-*.multica.ai app host, fail-closed — judged by the app/frontend host,
  not the backend URL, which official often leaves unset), per-instance
  salted hashing, deterministic event uuid, fire-and-forget sender.
- POST /api/telemetry/self-host-source: public, write-only, per-IP
  rate-limited, 4 KiB body cap, channel allowlist, strict unknown-field
  rejection. Lands in PostHog as self_host_source_channel with a
  deterministic uuid (best-effort dedup), $process_person_profile=false,
  and deployment=self_host — a distinct event name so it never pollutes the
  official onboarding funnel.
- Hook in PatchOnboarding fires once when the source is first set; never
  blocks onboarding. Only channel enum(s) + two per-instance hashes leave
  the box — never user_id/email/name/workspace/org/domain/role/use_case/the
  source_other free-text/IP.
- migration 128: system_settings singleton holding instance_salt.
- frontend: self-host-only anonymous-collection notice on the source step,
  gated by a new /api/config self_host_source_notice flag (en/zh-Hans/ko/ja).
- analytics.Event gains an optional top-level uuid; docs/analytics.md,
  SELF_HOSTING.md and .env.example document exactly what is/isn't sent and
  how to disable it (ANALYTICS_DISABLED). Also fixes the long-standing
  team_size→source drift in docs/analytics.md.

Verified locally: go build/vet, go test (sourcebeacon, analytics, handler),
pnpm typecheck (all packages), locale parity (157), step-source (6) + core
config/schema (69) vitest, lint (0 errors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(analytics): wire self-host source beacon through metrics, guard nil pool (MUL-3708)

Addresses Howard CI blockers on #4691 (no product-direction change):

- loadInstanceSalt returns "" on nil pool; salt is only loaded when
  ShouldSendFromEnv() is true, via a bounded (5s) context — restores the
  "router constructible without a DB" invariant (nil-pool routing tests).
- Add multica_self_host_source_channel_total counter (by source) + an
  IncForEvent case, so every analytics event is paired with a Prometheus
  counter. NormalizeSourceChannel reuses sourcebeacon allowlist (no 3rd copy).
- Beacon handler now builds the event via the analytics.SelfHostSourceChannel
  helper and ships it through obsmetrics.RecordEvent (no naked Capture); not
  IsMetricsOnly, so it still reaches PostHog.
- Prime the new family in the registry-families test.

Verified: go build/vet, go test ./internal/metrics ./internal/sourcebeacon
./internal/handler ./cmd/server (incl. the 3 named blockers + registry +
record-event-helper lints) all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-29 15:56:16 +08:00
LinYushen
3692b6a862 fix(squad): inject leader briefing by task flag, not issue assignee (MUL-3730) (#4606)
* fix(squad): inject leader briefing by task flag, not issue assignee

Key squad-leader briefing injection off task.IsLeaderTask + task.SquadID
instead of issue.AssigneeType=='squad'. The old gate missed the most common
path — an @squad mention in a comment on an issue assigned to a plain agent
(MUL-3724) — so the leader booted with zero squad context and did the work
itself instead of orchestrating.

- migration 127: add agent_task_queue.squad_id (no FK) + partial index
- sqlc: CreateAgentTask stamps squad_id; CreateRetryTask inherits it
- service: thread squadID through EnqueueTaskForSquadLeader(+WithHandoff),
  enqueueMentionTask, and the rerun path; all 5 call sites pass the squad id
- daemon claim: unified injection keyed on leader-task + squad_id, with a
  defensive leader-identity re-check; quick-create block retained (it serves
  issue-less tasks and sets resp.SquadID/SquadName)
- briefing: strengthen leader Operating Protocol opening
- tests: claim-time injection (comment-mention/non-leader/null-squad),
  squad_id enqueue stamping, retry inheritance; existing fixture updated

Co-authored-by: multica-agent <github@multica.ai>

* test+docs(squad): dangling squad_id regression + clarify quick-create path

Address review nits on #4606:
- Add TestClaim_LeaderTaskWithDanglingSquadID_NoBriefing: squad hard-deleted
  after enqueue leaves task.squad_id dangling (no FK); claim still 200 and
  skips injection via the err!=nil guard. This is the load-bearing contract
  for dropping the FK.
- Rewrite the daemon.go injection comment to state quick-create does NOT use
  the is_leader_task/squad_id columns — it routes squad via the context JSON
  branch (qc.SquadID) and must not be folded into the column-based path.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: 魏和尚 <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 16:01:33 +08:00
Bohan Jiang
5e824a94ae chore(channel): remove the one-time MULTICA_LARK_HUB_DISABLED cutover switch (#4527)
The lark_*->channel_* cutover (MUL-3515) is deployed to prod, and the
MULTICA_LARK_HUB_DISABLED park-switch was a one-time scaffold for that
rollout — the end state intentionally does not use it (prod never set the
env). Remove the env-gated branch from cmd/server/main.go so the channel
supervisor always starts when built; its existing nil-guard and shutdown
join are unchanged. Trim migration 124's now-obsolete switch runbook to a
short historical note (comment-only; 124 is already applied, so this does
not re-run).

Refs MUL-3515

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-24 18:18:06 +08:00
Bohan Jiang
76c58a4ee8 MUL-3617: remove Gemini CLI runtime (#4503)
* fix: remove gemini cli runtime

Co-authored-by: multica-agent <github@multica.ai>

* fix: skip unsupported custom runtime profiles

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-24 15:15:42 +08:00
Bohan Jiang
ce28d0aa0e feat(integrations): add platform-agnostic channel foundation (MUL-3515) (#4412)
* feat(integrations): add platform-agnostic channel foundation

Introduce server/internal/integrations/channel — the contract every
inbound IM integration implements, so the core never learns a platform's
event JSON. Four pieces:

- Channel interface (Type/Connect/Disconnect/Send/Capabilities) + Factory
  + Config (channel_type + opaque JSON blob, maps to channel_installation).
- Normalized InboundMessage/OutboundMessage envelopes + Source/MediaRef/
  ReplyCtx/MsgType/ChatType. Envelope holds only cross-platform-true
  fields; platform specifics live in Raw, read only by the adapter.
- Capability bitmask: declaration only, no degrade logic in core.
- Registry: Type->Factory map, last-writer-wins, concurrency-safe.

Pure package (no DB/network/platform deps). Foundation for MUL-3515; the
lark cutover + lark_*->channel_* generalization land in follow-up PRs.

MUL-3515

Co-authored-by: multica-agent <github@multica.ai>

* feat(channel): generalize lark_* tables into channel_* (DB layer)

Migration 123 creates channel_installation / channel_user_binding /
channel_chat_session_binding / channel_inbound_message_dedup /
channel_inbound_audit / channel_outbound_card_message /
channel_binding_token. Each carries a channel_type discriminator and a
JSONB config for platform-specific identifiers/credentials; cross-platform
columns stay flat. Existing Feishu rows are backfilled (channel_type=
'feishu', app_secret_encrypted via base64). NO foreign keys / cascades
(MUL-3515 §4) — integrity moves to the app layer in the cutover.

queries/channel.sql ports the lark query surface to channel_*, JSONB-aware,
plus DeleteChannelUserBindingsByWorkspaceMember /
DeleteChannelChatSessionBindingBySession for the app-layer cleanup that
replaces the removed cascades.

lark_* tables/queries are left in place here and removed once the Go
cutover lands, so this commit ships green on its own.

Verified: sqlc generate, go build ./..., full migrate chain (1..123) on
Postgres 17, and a real-data backfill spot-check (base64 round-trip,
NULL-strip, functional unique index on (channel_type, app_id)).

MUL-3515

Co-authored-by: multica-agent <github@multica.ai>

* fix(channel): name app_id query param + multi-IM install key + null-safe binding merge

Addresses review on MUL-3515 (PR #4412):

- GetChannelInstallationByAppID: explicitly name params and cast app_id to
  ::text so sqlc emits AppID string. A bare $2 next to `config ->> 'app_id'`
  was mis-attributed to the JSONB config column, generating Config []byte.

- channel_installation uniqueness -> (workspace_id, agent_id, channel_type),
  with the UpsertChannelInstallation conflict key matched. Lets one agent
  hold one installation per IM (feishu + slack + ...) instead of a later
  install clobbering an earlier one. Behaviorally identical in the current
  feishu-only world; "one agent, at most one IM overall" stays an app-layer
  rule per MUL-3515 §4, not a DB constraint.

- CreateChannelUserBinding merges jsonb_strip_nulls(EXCLUDED.config) so a
  re-bind carrying {"union_id": null} no longer erases an already-captured
  union_id, restoring the old COALESCE(EXCLUDED.union_id, ...) semantics.

Regenerated with sqlc v1.31.1. Verified on PG17: re-install replaces in
place, feishu+slack coexist, null re-bind keeps union_id, real union_id wins.

Co-authored-by: multica-agent <github@multica.ai>

* feat(lark): channel-backed Feishu store + fix base64 backfill wrapping

Cutover step 1 of switching the lark Go code from lark_* onto the channel_*
tables (MUL-3515). Introduces the JSONB config boundary the rest of the
cutover sits on, and fixes a latent backfill bug surfaced while building it.

- migration 123: strip newlines from the app_secret_encrypted base64 backfill.
  PostgreSQL encode(...,'base64') MIME-wraps at 76 chars, and a secretbox-
  sealed ~72-byte secret exceeds that. Go's encoding/json decodes a JSON
  string into []byte with base64.StdEncoding, which rejects embedded newlines,
  so without the strip every migrated installation would fail to decrypt its
  app secret once reads move to channel_installation.config.

- store.go: flat domain types (Installation / UserBinding / ChatSessionBinding)
  with field parity to the retired db.Lark* rows, plus the feishu config codec.
  Row->domain mappers decode the JSONB config; the secret decoder is
  whitespace-tolerant so legacy MIME-wrapped data still round-trips, while the
  encoder emits unwrapped base64. Binding config encodes an absent union_id as
  "{}" so the upsert's jsonb_strip_nulls merge never clobbers a stored union_id.

- store_test.go: 72-byte secret round-trip, MIME-wrapped tolerance, optional
  null-strip, and flat-column preservation. Verified on PG17.

Field parity keeps the upcoming ~190 db.LarkInstallation call sites a
mechanical rename. No call sites switched yet; behavior unchanged.

Co-authored-by: multica-agent <github@multica.ai>

* feat(lark): route inbound integration onto channel_* + explicit membership checks

Cutover step 2 (MUL-3515): switch the Feishu Go code from the lark_* queries to
channel_* via a ChannelStore adapter, and replace the removed member foreign key
with explicit application-layer membership checks. No user-visible behavior change.

- channel_store.go: ChannelStore embeds *db.Queries and SHADOWS the ~24 lark
  query methods with channel_*-backed equivalents, keeping the db.Lark*
  signatures so the dispatcher/hub/services and their ~20k lines of tests stay
  untouched; the feishu JSONB config is (de)coded by store.go. Adds
  IsWorkspaceMember and a tx-aware WithTx. Only production wiring swaps
  *db.Queries for *ChannelStore.

- Membership re-check (§4 removed the lark_user_binding -> member FK, so a
  binding row no longer proves current membership):
  * the dispatcher inbound identity step verifies membership after the binding
    lookup; a former member's stale binding is dropped as non_workspace_member
    + audited and never reaches chat_session (§4.3 safety property).
  * RedeemAndBind and BindInstallerTx replace the now-dead FK (23503) branch
    with an explicit IsWorkspaceMember gate, preserving the existing
    ErrBindingNotWorkspaceMember outcome without burning the token.

- router wires the ChannelStore into the patcher, typing indicator, dispatcher,
  hub, and the union_id/region backfills; constructor-based services wrap
  *db.Queries internally so their signatures and nil-check tests are unchanged.

Verified: go build ./... ; go vet ; gofmt ; go test -race ./internal/integrations/...
(full lark suite green unchanged + new membership drop/error tests). Adapter
field mappings (secret base64, union_id RMW, chat-id/open-id remaps, dedup,
token, card) checked end-to-end against a PG17 channel_* schema.

lark_* tables and queries remain (unused at runtime) until the S3 cleanup-hooks
and S4 drop-tables/rename commits.

Co-authored-by: multica-agent <github@multica.ai>

* fix(channel): renumber generalization migration 123 -> 124

main merged 123_issue_stage after this branch forked, so the branch's 123_channel_generalization now collides on the migration number. The runner keys schema_migrations by full version string and would still apply both, but a duplicate number is a merge hazard and convention violation, so move the channel migration to the next free slot (124).

issue_stage (ALTER issue ADD COLUMN stage) and the channel generalization touch disjoint tables; verified on PG17 that 123_issue_stage applies cleanly on a DB already carrying 124_channel_generalization, so the two are order-independent. sqlc regenerated (v1.31.1): only the migration-number comment changed.

MUL-3515

Co-authored-by: multica-agent <github@multica.ai>

* feat(channel): prune channel bindings on member removal + chat session delete

MUL-3515 §4 dropped every channel_* foreign key, so the old ON DELETE CASCADE that cleared a user's channel_user_binding when they left a workspace, and a chat's channel_chat_session_binding when its chat_session was deleted, no longer fires. Re-establish that integrity in the application layer, inside the existing transactions: revokeAndRemoveMember -> DeleteChannelUserBindingsByWorkspaceMember, DeleteChatSession -> DeleteChannelChatSessionBindingBySession.

Adds real-DB tests for both paths, including a scoping check that a remaining member's binding survives the prune. Verified on PG17: both new tests plus the existing revocation tests and the full handler package pass.

MUL-3515

Co-authored-by: multica-agent <github@multica.ai>

* fix(channel): scope Lark/Feishu store reads to channel_type='feishu'

The S2 cutover routed the Feishu integration onto channel_*, but the Lark-facing ChannelStore wrappers read installation / chat-session-binding / outbound-card rows across ALL channel_type values. Once a second IM exists, that would let the Lark hub supervise a non-Feishu installation, the Lark install list show it, /lark/installations/{id} revoke another channel's row, and the outbound patcher / typing indicator act on a non-Feishu chat binding or card.

Add a channel_type predicate to the six read/list channel queries and pass channelTypeFeishu from every wrapper: GetChannelInstallation, GetChannelInstallationInWorkspace, ListChannelInstallationsByWorkspace, ListActiveChannelInstallations, GetChannelChatSessionBindingBySession, GetChannelOutboundCardByTask.

The S3 cleanup deletes (DeleteChannelUserBindingsByWorkspaceMember / DeleteChannelChatSessionBindingBySession) stay all-channel on purpose: a member leaving or a chat_session being deleted should clear every IM's binding. Adds a real-DB test that seeds a Slack installation/binding/card next to the Feishu ones and asserts the Lark wrappers never return them.

MUL-3515

Co-authored-by: multica-agent <github@multica.ai>

* refactor(channel): replace db.Lark* translation layer with lark domain types

S2 introduced ChannelStore as a translation layer that read/wrote channel_* but kept the retired db.Lark* struct/param shapes so the dispatcher/hub/services and their ~20k lines of tests did not have to change. This collapses that layer: the store now takes and returns the package's flat domain types (Installation, UserBinding, ChatSessionBinding, InboundMessageDedup, BindingTokenRow, OutboundCardMessage) and the *Params types in params.go, with channel-neutral field names (ChannelUserID / ChannelChatID / ...). All call sites, fakes, and tests move to the domain types.

No behavior change: only channel_* is read/written (as before); db.Lark* is now unused, and the lark_* tables + queries/lark.sql are removed in the next commit. Verified on PG17: go build / vet / gofmt clean, go test -race ./internal/integrations/... green (the ~20k-line fake suite), and the lark + handler suites pass.

MUL-3515

Co-authored-by: multica-agent <github@multica.ai>

* refactor(channel): drop lark_* tables and queries (remove old path)

The Go cutover (previous commit) moved the lark package entirely onto channel_* and the domain types, leaving the lark_* tables, queries/lark.sql, and the generated db.Lark* models unused. Remove them per the design (§5: replace, do not keep both): migration 125 drops the seven lark_* tables (data already lives in channel_* since migration 124), and queries/lark.sql is deleted + sqlc regenerated, removing the db.Lark* models and lark query methods.

The 125 down recreates the authoritative pre-drop schema (bot_union_id, region, per-installation dedup PK, thread-reply columns). Verified on PG17: fresh migrate up ends with lark_* gone + channel_* present; isolated 125 down/up round-trips correctly; go build / vet / gofmt clean; go test -race ./internal/integrations/... and the handler suite pass.

MUL-3515

Co-authored-by: multica-agent <github@multica.ai>

* fix(migrations): remove trailing blank line at EOF of 125 down migration

git diff --check flagged a blank line at EOF of 125_drop_lark_tables.down.sql (a pg_dump-generation artifact). Whitespace only; the recreate SQL is unchanged.

MUL-3515

Co-authored-by: multica-agent <github@multica.ai>

* refactor(channel): defer lark_* table drop to a follow-up migration

Preflight deploy review: dropping lark_* in the same release that cuts over (old migration 125) is not rollback/rolling-safe — the v0.3.27 release still reads lark_*, so a rolling deploy or a post-deploy code rollback would hit "relation does not exist". Remove the drop and keep the old tables for one release (standard expand/contract): migration 124 already backfilled lark_* -> channel_*, the new code reads/writes only channel_*, and the physical drop moves to a separate cleanup migration once this ships and is observed.

The lark_* tables remain in the schema, so sqlc regenerates the (now unused) db.Lark* models; queries/lark.sql stays deleted (the new code uses channel_*). No code path reads lark_* — only the destructive drop is deferred, keeping the design's no-compat-layer / no-dual-write rule while being deploy-safe.

MUL-3515

Co-authored-by: multica-agent <github@multica.ai>

* fix(channel): skip orphaned installations in hub-boot active scan

Preflight deploy review: channel_installation dropped the workspace/agent FK (MUL-3515 §4), so unlike lark_installation it does not cascade away when its workspace is deleted or its agent is hard-deleted (e.g. runtime teardown). The hub-boot query then keeps opening a WebSocket for a bot whose owner is gone.

JOIN ListActiveChannelInstallations to live workspace + agent so an orphaned installation is never connected, uniformly for every deletion path. The JOIN matches the old ON DELETE CASCADE semantics (row existence, not agent archival), so an archived-but-present agent's installation is still listed; the orphaned row's encrypted secret is thereby never decrypted/used.

Tests: a real-DB handler test asserts a deleted-workspace/agent installation and a non-Feishu one are both excluded; the lark scope test's active-list assertion moved there since the JOIN now needs real workspace/agent fixtures. (Physically deleting dormant orphaned channel rows on workspace/agent deletion is a separate app-layer-cleanup follow-up.)

MUL-3515

Co-authored-by: multica-agent <github@multica.ai>

* docs(channel): document non-rolling cutover constraint for the lark->channel migration

Elon deploy review: keeping the lark_* tables (deferred drop) stops old v0.3.27 code from crashing, but is not full expand/contract. Migration 124 is a one-time backfill; afterwards new code runs on channel_* (lease + dedup on channel_*) while pre-cutover code runs on lark_* (lease + dedup on lark_*). If both run concurrently during a rolling deploy, each side claims the same Feishu bot's WS lease on its own table and double-processes inbound events.

This release therefore requires a NON-ROLLING cutover (stop the old hub before applying migration 124 + starting new code; rollback is not lossless once new code writes channel_*). Documented where deployers/reviewers see it: migration 124 header gains a ROLLOUT note; the channel_store.go header is corrected (lark_* tables are retained one release for rollback safety, not "gone"; the store still never touches them). Comment-only — no schema/codegen/behavior change.

MUL-3515

Co-authored-by: multica-agent <github@multica.ai>

* feat(lark): add MULTICA_LARK_HUB_DISABLED switch for the channel cutover

The lark_*->channel_* cutover needs a way to make the Feishu bot briefly unavailable WITHOUT taking down the whole multica-api process — the Lark hub is a goroutine inside it, not a separate Deployment. MULTICA_LARK_HUB_DISABLED=true parks the hub at startup: the API serves HTTP normally but never claims a WS lease or opens a Feishu connection.

Rollout (see migration 124 ROLLOUT note): ship the new release with the flag SET so new pods run API-only while old pods (hub on lark_*) drain during the rolling deploy — the two hubs never overlap. After the old pods are gone and migration 124 has run, flip the flag off; the new hub comes up on channel_*. The old backend does NOT need this switch — its hub stops when k8s terminates the old pods, not via a flag. Nil-ing LarkHub reuses the existing not-configured path so both the startup start and the shutdown join skip it.

MUL-3515

Co-authored-by: multica-agent <github@multica.ai>

* docs(channel): point migration 124 ROLLOUT note at the hub-disable switch

Refine the rollout note to use MULTICA_LARK_HUB_DISABLED for a bot-only cutover (new pods serve API with the hub parked while old pods drain; flip the switch off after the migration), instead of the earlier whole-API recreate. Comment-only.

MUL-3515

Co-authored-by: multica-agent <github@multica.ai>

* docs(channel): fix migration 124 rollout order and document self-host cutover

The previous ROLLOUT note shipped the new (channel_*) build before
running migration 124, so the channel_*-backed HTTP paths (installation
list/install/revoke, chat-session delete, member revoke) would 500 in
the window between new-pod boot and the deferred migration. Restate the
runbook around two explicit invariants — channel_* must exist before the
new build serves those paths, and the old/new hubs must never overlap —
and order the steps so channel_* is created first (park old hub -> snapshot
-> deploy parked new build -> unpark). Document that default self-host
(entrypoint migrate + single-replica Recreate) satisfies both invariants
automatically and needs no manual steps; only prd / multi-replica rolling
self-host needs the switch procedure. Clarify in main.go that the
hub-park switch is generation-agnostic (parks whichever hub the build
carries), which is what enables the preparatory release.

Refs MUL-3515

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-24 12:46:20 +08:00
Multica Eve
eca6102365 refactor(autopilot): autopilot_run.planned_at + DispatchAutopilotForPlan (2/3 MUL-3551) (#4443)
* refactor(scheduler): add PlansForScope hook for non-cadence jobs

The current Manager.plansForTick assumes a uniform Cadence grid:
plan_times are derived via FloorPlan(db_now, Cadence). That works for
rollup_task_usage_hourly but not for the upcoming Autopilot schedule
dispatch job, where each trigger has its own cron expression and the
plan_times do not snap to a single global grid.

This change adds an optional JobSpec.PlansForScope hook. When set:

  * Manager loads the latest stored plan for (job, scope) and passes
    a new LatestPlanInfo to the hook (exported from the previously
    private latestPlanInfo). The hook returns the plan_times to attempt
    this tick.
  * Cadence, CatchUpMode and CatchUpWindow are bypassed; the hook is
    in full control of plan_time selection.
  * MaxPlansPerTick still acts as a safety cap on the hook's output.
  * All other timing fields (RunTimeout / StaleTimeout /
    HeartbeatInterval / MaxAttempts / RetryBackoff / AllowStaleReentry)
    and the lease/heartbeat/terminal-write SQL primitives are reused
    unchanged.

JobSpec.validate now allows Cadence=0 when PlansForScope is set, and
makes the every_plan MaxPlansPerTick > 0 invariant fire only on
Cadence-driven every_plan jobs. Existing rollup_task_usage_hourly
behaviour is unchanged — that JobSpec leaves PlansForScope nil.

Tests:
  * TestJobSpecValidatePlansForScopeRelaxesCadence — validate() rules.
  * TestManagerPlansForScopeHookDrivesPlans — end-to-end hook delegation
    through the manager (DB-backed), proving that hook-returned
    plan_times go through the same tryClaim path, MaxPlansPerTick
    truncates without erroring, and LatestPlanInfo is populated on the
    second tick.
  * TestManagerPlansForScopeHookEmptyIsNoOp — empty hook output is a
    valid no-op.

No behaviour change for callers that don't set PlansForScope.

Refs MUL-3551

Co-authored-by: multica-agent <github@multica.ai>

* refactor(autopilot): add planned_at + DispatchAutopilotForPlan for occurrence idempotency

PR 2 of 3 for the scheduled-Autopilot refactor on MUL-3551.

Adds dispatch-layer idempotency for scheduled triggers. This is the
second line of defence behind the primary uq_sys_cron_execution
guarantee in sys_cron_executions: if a runner crashes between
"create autopilot_run" and "write SUCCESS in sys_cron_executions",
the next stale-steal retry re-enters dispatch with the SAME
(trigger_id, planned_at). Without a row-level guard, that retry
would create a duplicate autopilot_run, issue, and task.

Changes:

  * Migration 124: ALTER TABLE autopilot_run ADD COLUMN planned_at
    TIMESTAMPTZ + partial unique index on (trigger_id, planned_at)
    WHERE both are NOT NULL. Manual / webhook / api dispatch leaves
    planned_at NULL so they keep the existing semantics unchanged.

  * autopilot.sql: CreateAutopilotRun now takes planned_at;
    GetAutopilotRunByTriggerAndPlanned is the fast-path lookup used
    by DispatchAutopilotForPlan to detect a prior attempt's row
    without burning an INSERT.

  * service.DispatchAutopilotForPlan: new entry point for scheduled
    triggers that already know the canonical UTC plan_time of the
    occurrence they are firing. Looks up an existing run for
    (trigger_id, planned_at) and reuses it on a stale-steal retry;
    otherwise dispatches normally with planned_at stamped on the
    new run.

  * service.DispatchAutopilot keeps its current signature for
    manual / webhook / api callers (planned_at stays NULL).

  * recordSkippedRun also threads planned_at so the skip path
    participates in the same partial-unique guarantee.

  * sqlc v1.31.1 regenerated autopilot.sql.go + models.go.
    Unrelated workspace.sql.go drift restored.

Tests (against local Postgres):

  * TestDispatchAutopilotForPlanIsIdempotent — first call creates a
    run; second call with same (trigger, planned_at) reuses it
    (autopilot_run row count stays at 1); third call with a different
    planned_at on the same trigger creates a second run (proves we
    are not collapsing legitimate occurrences).

  * TestDispatchAutopilotForPlanRejectsZeroArgs — invalid trigger_id
    and zero planned_at both fail loudly so callers cannot silently
    disable the idempotency guard.

  * Existing autopilot listener / squad / dispatch tests all still
    pass.

This PR has no scheduler / handler / UI behaviour change on its own:
the new entry point exists but is not yet wired into the schedule
goroutine. PR 3 will register the autopilot_schedule_dispatch
JobSpec that consumes it and remove the legacy
cmd/server/autopilot_scheduler.go path.

Refs MUL-3551

Co-authored-by: multica-agent <github@multica.ai>

* fix(autopilot): DispatchAutopilotForPlan recovers partial-state runs

Review fix for #4443 (MUL-3551).

Before this change, DispatchAutopilotForPlan returned ANY existing
autopilot_run for (trigger_id, planned_at), including the
half-written rows produced when a runner crashed between
"CreateAutopilotRun" and "create downstream issue/task". The
scheduler handler would then write SUCCESS in sys_cron_executions
even though no issue or agent task was ever created, silently
losing the scheduled occurrence.

Fix:

  * New isAutopilotRunComplete helper classifies an existing run:
      - terminal status (completed / failed / skipped) → reuse.
      - issue_created with valid issue_id → reuse (the issue
        listener owns task creation from here).
      - running with valid task_id → reuse (the task is queued).
      - anything else → partial; must NOT short-circuit.

  * New SQL RecoverPartialAutopilotRun marks a partial row FAILED
    with a recovery reason AND clears its planned_at. The cleared
    planned_at releases the partial-unique slot in
    uq_autopilot_run_trigger_planned, letting the fresh dispatch
    INSERT a new row at the same (trigger_id, planned_at) without
    conflict.

  * DispatchAutopilotForPlan now branches on the lookup:
    complete run → return; partial run → recover-then-fresh-
    dispatch; not-found → fresh dispatch. The fresh dispatch path
    still goes through dispatchAutopilot, so the new row carries
    the real issue_id / task_id by the time the handler returns.

  * Tests: TestDispatchAutopilotForPlanRecoversPartialRun seeds a
    partial run (status='running', task_id=NULL for run_only;
    status='issue_created', issue_id=NULL for create_issue) and
    asserts the retry:
      - returns a DIFFERENT run row (no false reuse);
      - leaves the partial row in status='failed', planned_at=NULL,
        with a non-empty failure_reason for ops;
      - produces a fresh row with planned_at preserved AND the
        appropriate downstream linkage (task_id for run_only,
        issue_id for create_issue);
      - exactly one live row at (trigger_id, planned_at) after
        recovery, so the partial-unique constraint is honoured.

Existing TestDispatchAutopilotForPlanIsIdempotent and
TestDispatchAutopilotForPlanRejectsZeroArgs still pass — the
complete-reuse path is unchanged for the realistic SUCCESS-state
case.

Refs MUL-3551

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-24 12:01:10 +08:00
Bohan Jiang
5038c983c0 MUL-3281: Add daemon skill bundle refs (#4445)
* feat: add daemon skill bundle refs

Co-authored-by: multica-agent <github@multica.ai>

* fix: tighten skill bundle resolve safeguards

Co-authored-by: multica-agent <github@multica.ai>

* feat: add task prepare lease

Co-authored-by: multica-agent <github@multica.ai>

* fix: isolate prepare lease concurrent index migration

Co-authored-by: multica-agent <github@multica.ai>

* fix: keep prepare lease active through start

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-23 16:19:16 +08:00
Naiyuan Qing
4ab335b8a5 MUL-3416: Issue pre-trigger preview + Handoff Note (#4383)
* feat(issues): unify run-enqueue decision behind WillEnqueueRun + preview endpoint

Collapse the issue update/batch enqueue copies into one service predicate
service.IssueService.WillEnqueueRun, shared verbatim with a new dry-run
endpoint POST /api/issues/preview-trigger so the four entry points stop
drifting (squad/self-loop/batch omissions, MUL-3375). The private-agent gate
stays at the HTTP boundary: write paths inject allow-all, preview injects the
real gate so it never leaks a private agent's readiness.

Add suppress_run to issue update/batch: the change applies but no run starts.
Remove the now-dead handler mirrors shouldEnqueueSquadLeaderOnAssign /
isSquadLeaderReady. service.Create and the comment trigger chain are untouched.

Tests: preview behavior, preview<->write-path match, batch aggregation,
member no-trigger, suppress_run skip, malformed-body 400.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(issues): inject handoff note into assigned runs via first-class task field

Add an optional handoff_note carried by issue assign/promote into the run's
opening prompt and issue_context.md, via a dedicated agent_task_queue column
(migration 122) and a daemon assignment-handoff render branch — never a
fabricated comment, never trigger_comment_id (MUL-3375 §6.1).

Thread the note through enqueueIssueTask/enqueueMentionTask + WithHandoff
public variants and dispatchIssueRun; suppress_run or a parked write drops it
(no run = nothing to inject). Soft version gate: MinHandoffCLIVersion +
HandoffSupported, surfaced per-trigger as handoff_supported in the preview so
the UI can gray the note box on old daemons; the assignment never hard-fails.

Tests: daemon prompt + issue_context render via the assignment branch (not
quick-create/comment), version helper matrix, note persists on the task,
suppressed assign enqueues nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(issues): leave a display-only handoff record on the timeline

When an assign/promote with a handoff note starts a run, write one
type='handoff' timeline record via TaskService.RecordHandoff — a direct
Queries.CreateComment + timeline event that bypasses Handler.CreateComment, so
it never reaches triggerTasksForComment and cannot start a second run
(MUL-3375 §6.2, the must-not-retrigger invariant). Author is the actor who
handed off; body is the note. Migration 123 admits the 'handoff' comment type.
Recorded only on a real run start: suppress_run or a parked write writes
nothing. enqueueSquadLeaderTask now reports whether it enqueued so the trace
is gated on an actual dispatch.

Test: exactly one handoff record on assign-with-note, exactly one task (no
re-trigger), and no record when suppressed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(issues): frontend plumbing for issue-trigger preview + handoff (core)

Add api.previewIssueTrigger + IssueTriggerPreviewSchema (zod parseWithFallback),
the use-issue-trigger-preview hook, issueKeys.issueTriggerPreview(+All) with WS
queue-state invalidation, suppress_run/handoff_note on UpdateIssueRequest, the
'handoff' CommentType, and stripping of the control fields from optimistic
update/batch cache patches (MUL-3375 §9).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): exclude handoff records from new-comment counting

type='handoff' is a display-only timeline record, not conversation. Exclude it
from CountNewCommentsSince so a handoff note never inflates the count of
"new comments to catch up on" fed to a claiming agent (MUL-3375 §12). Analytics
already excludes it (RecordHandoff is a direct write that emits no analytics
event), and the comment-trigger path is already bypassed.

Test: a handoff record does not bump the new-comment count; a real comment does.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(issues): pre-trigger preview UI, handoff note, timeline card (web/desktop)

Wire the §9 frontend onto the preview endpoint + handoff fields:
- Delete the backlog blocking dialog (backlog-agent-hint*) and its modal type;
  the over-eager nag is gone. Backlog awareness is now a passive label.
- RunConfirmModal: single assign + batch assign/status route here. Shows the
  backend predicate's verdict ("将启动 @X" / "将启动 N 个" / parked), an optional
  handoff note (assign only, soft-gated by handoff_supported), and 暂不启动 —
  then applies via update/batch. No frontend guessing.
- create modal: passive CreateRunHint ("将启动 @X" / backlog parked).
- single status change stays a direct apply (unchanged).
- timeline: render type='handoff' as a distinct, non-interactive handoff card.
- i18n run_confirm + handoff_card across en/ja/ko/zh-Hans; drop backlog action
  keys; locale parity green.

Tests: use-issue-actions (assign → run-confirm modal, member → direct),
create-issue + comment-card suites updated/green; views typecheck + lint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* test(issues): use a valid anchor in the handoff count-exclusion test

CountNewCommentsSince filters id <> @anchor_id; SQL id <> NULL is NULL and
excludes every row, so an empty anchor made the control assertion read 0. The
production caller always passes a real anchor — mirror that with a non-matching
sentinel uuid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* test(issues): RunConfirmModal apply logic (start/suppress/note-gate/batch)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* test(core): preview schema malformed/missing/null fallback coverage

Cover IssueTriggerPreviewSchema via parseWithFallback (MUL-3375): well-formed
parse, top-level + item default fills (empty/older backend), and fallback to
{ triggers: [], total_count: 0 } for malformed shapes, a dropped required
issue_id, a wrong-typed total_count, and null/non-object bodies — so the four
entry points degrade to "nothing will start" instead of throwing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* refactor(issues): remove display-only handoff timeline record (留痕)

The handoff "留痕" timeline record (type='handoff' comment written on run
start) was judged superfluous and dropped per product call. This removes
only the display-only trace; the handoff NOTE injection into the run's
opening prompt + issue_context.md is untouched.

- backend: drop RecordHandoff + its call in dispatchIssueRun
- db: drop the `type <> 'handoff'` exclusion in CountNewCommentsSince and
  migration 123 (comment_type_check reverts to the 4-type set from 001);
  no production data exists for this unreleased feature
- frontend: drop the "handoff" CommentType, HandoffCard, and handoff_card
  i18n (all locales)
- tests: drop handoff_count_test.go and the record-write assertions in
  issue_trigger_preview_test.go (note-injection tests retained)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(issues): dismissable run-confirm modal + team-handoff copy

Two fixes to the pre-trigger confirm modal (MUL-3375).

1. Dismissable: switch RunConfirmModal from AlertDialog to the standard
   shadcn Dialog so it has the close (X) button + Esc + click-outside.
   Previously the only choices were "start" / "don't start now" with no
   way to abort the action entirely; dismissing now cancels with no write.

2. Copy: rework the action-surface wording away from the backend term
   "run" toward team-handoff voice — 指派 / 开始 / 交接 (run stays only on
   record surfaces). Unifies the note's three names to "交接说明", and
   parallels the rewrite across en/ja/ko.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* chore(agent): bump handoff note min CLI version to 0.3.28

The daemon release that renders handoff notes ships in 0.3.28 (0.3.27
was the prior tag), so move the soft-gate threshold up. Below this the
note is silently dropped and the frontend grays the note box — assignment
is never blocked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(issues): skip run-confirm when batch-moving issues to backlog

A move into backlog never starts a run (service/issue_trigger.go), so the
pre-trigger confirm modal degenerated to an empty "won't start" box with a
single Apply button — pure friction. Apply directly instead, matching the
single-issue status path. Other target statuses still route through the
modal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(issues): refine pre-trigger preview hint and copy

- Move the create-issue run hint to a reveal band (grid 0fr→1fr) above the
  property toolbar. It was sharing the footer button row and, lacking a
  width constraint, reflowed the submit buttons whenever it appeared.
  Restyle to a borderless, comment-style avatar+caption that is purely a
  caption (non-interactive avatar).
- Distinguish squad from agent in the pre-trigger copy: a squad's leader
  evaluates and delegates rather than "starting work" itself. Add
  will_start_named_squad / will_start_squad / create_will_start_squad across
  en/zh/ja/ko (reusing the squad_leader_* evaluate→arrange vocabulary) and
  branch run-confirm + the create hint on squad assignees.
- Bold the assignee name in the run-confirm headline via a language-safe
  sentinel split (no per-language prefix/suffix keys).
- Align zh "开始处理" → "开始工作" on the single-assign copy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(issues): stub ActorAvatar in create-issue suite

CreateRunHint now renders an ActorAvatar for agent/squad assignees, which
pulls in getActorInitials/getActorAvatarUrl + the workspace/presence/navigation
hook tree. This form-focused suite only stubbed getActorName, so the
squad-forwarding test crashed with "getActorInitials is not a function". Stub
the avatar inert — its own behavior is covered elsewhere.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Walt <walt@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-23 13:17:13 +08:00
Bohan Jiang
a123dfc2df MUL-3508: stage sub-issues so the parent wakes per stage, not per child (#4410)
* feat(issues): stage sub-issues so the parent wakes per stage, not per child

Sub-issues under a parent can be grouped into ordered stages (issue.stage).
The child-done -> parent notification + assignee wake now fire only when a
stage barrier closes: every sub-issue in the lowest unfinished stage has
reached a terminal status (done/cancelled). An unstaged sibling set is one
implicit stage, so the parent is woken once when the last sub-issue finishes
instead of on every child — the default fix for the fire-on-every-child
cascade reported in discussion #4320 / MUL-3508.

Stage advancement stays agent-driven: the server only detects the closed
barrier and wakes the parent assignee, who decides whether to promote the
next stage.

- DB: nullable issue.stage (CHECK >= 1) + sqlc regen
- API: stage on issue create/update/response and batch update
- CLI: `issue create`/`issue update` --stage; new `issue children` command
  that lists sub-issues grouped by stage (table + json)
- stageBarrierClosed / stageProgressSummary in issue_child_done.go, with the
  wake comment now stage-aware, plus unit tests
- skill docs (multica-working-on-issues SKILL.md + source map)

Web UI (create-form stage picker, sidebar edit, group-by-stage display) is a
follow-up; the API already returns stage for it to consume.

MUL-3508

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): address review on stage barrier (cancel, batch, unstaged)

Resolves the three blockers from the PR review:

1. Cancel can close a stage. The child-done barrier now fires on any
   non-terminal -> terminal transition (done OR cancelled), not just done.
   isTerminalChildStatus already treats cancelled as terminal (a cancelled
   sibling never finishes, so it must not hold a stage open), so a cancelled
   last-open child now closes its stage and wakes the parent. Keying on the
   transition also makes a later cancelled -> done edit a no-op, avoiding a
   lagging duplicate wake.

2. Batch update of stage no longer no-ops. `hasMutation` now includes
   "stage", so `{"updates":{"stage":N}}` persists instead of returning
   {"updated": 0}.

3. Unstaged children no longer participate in the staged frontier. In a
   staged sibling set, NULL-stage children neither hold a stage open nor fire
   on their own completion, and the wake comment no longer renders "Stage 0".
   This matches migration 123 ("NULL does not participate in staged
   grouping") and the CLI's separate unstaged group, removing the footgun
   where an unstaged backlog child silently blocked Stage 1.

Tests: cancellation closes a stage (staged + unstaged), unstaged ignored in a
staged set, stage summary skips unstaged, and a stage-only batch update
persists.

MUL-3508

Co-authored-by: multica-agent <github@multica.ai>

* feat(web): stage UI — create picker, sidebar edit, group sub-issues by stage

Frontend for the sub-issue stage feature (web + desktop, shared via packages):

- core: `stage` on the Issue type + create/update request types; zod
  IssueSchema parses it (defaults to null for older backends) with schema
  tests for the numeric and omitted cases.
- StagePicker component (mirrors the other property pickers): "No stage" +
  Stage 1..N, offering one beyond the current/sibling max.
- Create-issue modal: a Stage pill, shown only when a parent is selected,
  threaded into the create payload.
- Issue detail sidebar: an editable Stage row + "add property" entry, gated to
  sub-issues (issues with a parent).
- Sub-issue list grouped by stage with per-stage headers (flat when unstaged).
- i18n: stage keys across en / zh-Hans / ja / ko (parity test passes).

Verified: full typecheck (6/6), core (591) + views (1433) vitest suites,
lint clean (no new findings). Backend/CLI shipped earlier in this PR.

MUL-3508

Co-authored-by: multica-agent <github@multica.ai>

* test(issues): add stage to Issue fixtures merged from main

The merge brought in new Issue fixtures that predate the required `stage`
field: core issues/batch.test.ts, views batch-action-toolbar.test.tsx, and
the mobile EMPTY_ISSUE_FALLBACK sentinel. Add `stage: null` so they satisfy
the Issue type (mobile reuses core's IssueSchema for parsing, so only the
sentinel needs it).

MUL-3508

Co-authored-by: multica-agent <github@multica.ai>

* fix(web): feed StagePicker the sibling max stage so higher stages stay selectable

The StagePicker accepts maxStage to extend its option list beyond the
floored Stage 1-3, but neither call site passed it, so a parent with an
existing Stage 4/5 child could not pick that stage when creating a new
sub-issue or editing one in the sidebar.

- Compute the sibling max stage at both call sites: the create modal now
  loads the parent's children (childIssuesOptions) and the detail sidebar
  reuses the already-loaded parentChildIssues.
- Extract maxSiblingStage + stageOptions as pure helpers on stage-picker
  and unit-test them (the regression: a Stage 5 sibling keeps Stage 5
  selectable and offers Stage 6).

MUL-3508

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-23 00:14:42 +08:00
lethean-kun
0aa3b53c25 MUL-3378 feat(lark): reply inside the originating thread (话题) instead of the group (#4262)
* feat(lark): reply inside the originating thread (话题) instead of the group

When a user @-mentions the bot inside a Lark topic/thread, the bot now
replies back into that thread rather than posting a fresh message at the
chat level. Behavior is automatic and scoped: only triggers that were
themselves inside a thread get a threaded reply, so normal group/p2p
chats are unchanged.

The outbound path is event-driven and decoupled from the inbound
message, so the trigger message_id + thread_id are persisted on
lark_chat_session_binding (migration 122) at ingest time. The patcher
then routes the agent reply (text / markdown card / error card) and the
OutcomeReplier notices (/issue confirmation, offline/archived) through
Lark's reply endpoint with reply_in_thread=true when a thread is present,
falling back to a chat-level send if the threaded reply fails.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(lark): classify thread-reply failures before chat-level fallback

Only retry a threaded reply at the chat level when Lark returns an
explicit "this message/topic cannot receive a threaded reply" error
(recalled trigger, topic gone, topics disabled, aggregated message,
etc.). Transport errors, 5xx, timeouts, rate limits, and ambiguous
failures are now logged and returned as failures instead of being
retried, so we never duplicate a reply or leak a thread-only reply
into the main group chat.

The three reply-capable send methods now return a structured *APIError
carrying the Lark business code, and isThreadReplyUnsupported drives
the fallback via an allowlist. sendWithThreadFallback is promoted to a
package-level function so the immediate OutcomeReplier sends (/issue
confirmation, offline/archived notices) share the same classified
fallback path instead of silently swallowing thread-reply failures.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: kun <kuen@micous.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 13:34:40 +08:00
Bohan Jiang
e3a829f05e feat(daemon): disk-usage cross-root aggregation + migration FK/readiness fixes (MUL-3404) (#4290)
Bundles the MUL-3404 disk-usage feature with the two Preflight BLOCK fixes.

- feat(daemon): `disk-usage --all-profiles` aggregates across every workspace
  root (default + each ~/.multica/profiles/* root, incl. the Desktop app's),
  with a per-root breakdown and combined grand total; the cross-root hint now
  also fires when the current root is non-empty.
- fix(db): drop DB-level foreign keys/cascades from the new autopilot_subscriber
  and comment.source_task_id migrations (resolved in the app layer — autopilot
  delete now removes subscribers in a transaction); the autopilot_subscriber
  down-migration relabels reason='autopilot' to 'manual' instead of deleting.
- fix(server): readiness verifies every required migration is applied, not just
  the lexically-last one, so an out-of-order migration can't be masked.

MUL-3404.
2026-06-18 13:33:14 +08:00