Commit Graph

456 Commits

Author SHA1 Message Date
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
Multica Eve
12ea1f6a8c MUL-3495: support custom runtime args and registration errors (#4408)
* feat: support custom runtime args

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

* fix: address custom runtime 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>
2026-06-23 14:20:18 +08:00
Reinhard Schneidewind
7008f4276d MUL-3558 feat: add 'issue usage' CLI command for aggregated issue token usage
`GET /api/issues/:id/usage` endpoint (handler `GetIssueUsage`). Returns the
aggregated token usage for an issue, summed across all of its task runs.

The usage is already captured server-side and shown in the issue detail view,
but was not reachable from the CLI, so it couldn't feed billing/cost scripts.
This closes that gap with no backend changes.

$ multica issue usage MUL-139
INPUT_TOKENS  OUTPUT_TOKENS  CACHE_READ  CACHE_WRITE  RUNS
5625          83880          9190806     154078       1

$ multica issue usage MUL-139 --output json
{ "total_input_tokens": 5625, ... "task_count": 1 }

Accepts an issue key (`MUL-139`) or UUID, mirroring `issue runs`.

- Table cells use the existing `formatMetadataValue` helper so large cache-token
  counts render as plain integers (not scientific notation).
- Scope is the per-issue aggregate the endpoint already returns (`task_count` =
  run count); a per-run token breakdown is out of scope.

- `go test ./cmd/multica/ -run TestRunIssueUsage` (added) 
- `go vet ./cmd/multica/` 
- Verified against a live self-hosted server; numbers match the issue UI.

- `server/cmd/multica/cmd_issue.go` — command + handler
- `server/cmd/multica/cmd_issue_test.go` — unit test
- `CLI_AND_DAEMON.md` — docs
2026-06-23 14:13:57 +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
honki12345
4679217586 feat(cli): STR-208 오토파일럿 구독자 플래그 추가 (#4438)
* feat(cli): STR-208 오토파일럿 구독자 플래그 추가

* test(core): Issue fixture stage 기본값 추가

* test(views): Issue fixture stage 기본값 추가
2026-06-23 11:56:09 +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
Bohan Jiang
637b6ee433 feat: add CLI comment resolve commands (#4404)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-22 18:16:38 +08:00
YOMXXX
737c976b0d fix(cli): guide remote setup callbacks (#4360)
## Summary
- expose `--callback-host` on `multica setup` and `multica setup cloud`, reusing the existing login callback override
- print an SSH tunnel hint when browser login runs in an SSH session with a loopback callback
- show local flags in parent-command help so `multica setup --help` documents the callback option

Fixes #4357

## Tests
- `go test ./cmd/multica -run 'TestCallbackHostFlagValueReadsParentSetupFlag|TestSetupHelpShowsCallbackHostFlag|TestSetupCallbackHostFlagWiring|TestBrowserLoginInstructionsSSHRemoteHint'`
- `go run ./cmd/multica setup --help`
- `go run ./cmd/multica setup cloud --help`
- `git diff --check`
- `go test ./...`
2026-06-22 00:06:14 +08:00
LinYushen
e1c6754304 Revert "fix: gzip daemon claim responses (#4259)" (#4307)
This reverts commit 2f398c36ad.
2026-06-18 17:58:43 +08:00
Bohan Jiang
2d1313c5a4 MUL-3264: add Codex usage cache backfill
Adds a dry-run-first operator command for historical Codex usage cache correction, packages it in the backend image, documents the operator-job flow, and covers execution with DB-backed tests.
2026-06-18 13:34:10 +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
Bohan Jiang
2f398c36ad fix: gzip daemon claim responses (#4259)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 18:48:33 +08:00
LinYushen
77ac17ef49 Make custom runtimes appear immediately (#4234)
* Make custom runtimes appear immediately

* Scope daemon profile refresh by authorized runtimes

* Relay runtime profile refresh hints

* Localize runtime profile close label
2026-06-17 16:00:22 +08:00
Matt Voska
6f2e9aa7a8 feat(cli): add --thinking-level to agent create and update (#4170) (#4207)
The agent record already carries a top-level `thinking_level` field —
exposed by `agent get --output json`, settable from the web inspector,
and accepted/validated by `PUT /api/agents/{id}` — but the CLI had no
flag to write it. Scripted or version-controlled agent management could
set `--model` but not the thinking depth, forcing a drop to raw HTTP.

Add `--thinking-level` to `agent create` and `agent update`, mirroring
`--model`: a thin pass-through to the top-level `thinking_level` field.
On update an empty string clears it back to the runtime default (the
server reads it as a tri-state pointer: omitted = no change, "" = clear,
value = set). The CLI deliberately does not enumerate valid levels —
they are runtime/model-specific and the server already owns the catalog
(`agent.IsKnownThinkingValue`, `server/pkg/agent/thinking.go`), returning
a 400 for an unknown value or a runtime with no thinking concept, which
the CLI surfaces verbatim.

- server/cmd/multica/cmd_agent.go: register the flag on both commands,
  Changed-gate it into the request body, add it to the no-fields error.
- server/cmd/multica/cmd_agent_test.go: cover create/update send,
  unset-omission, empty-clears, the flag-exposed guard, and that a
  server-side rejection surfaces to the user.
- multica-creating-agents builtin skill + source map: document the new
  CLI write surface and re-derive shifted cmd_agent.go line numbers.

Closes #4170

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Matt Voska <voska@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-17 14:47:59 +08:00
LinYushen
1f5cb51d4e MUL-3284: Web UI + CLI (custom runtime PR3) (#4177)
* MUL-3284 PR3 (CLI): multica runtime profile subcommands + local path override

- cmd_runtime_profile.go: `multica runtime profile` group — list / create /
  update / delete against /api/workspaces/{id}/runtime-profiles, plus set-path
  / unset-path for a per-machine command override. protocol-family validated
  client-side via agent.IsSupportedType / agent.SupportedTypes; visibility
  validated; update only sends changed flags (protocol_family immutable);
  delete surfaces the server 409 body when agents are still bound.
- internal/cli/config.go: ProfileCommandOverrides map[string]string on
  CLIConfig (omitempty), through the existing marshal/unmarshal so set/unset
  round-trips without dropping other fields.
- internal/daemon: Config.ProfileCommandOverrides, loaded from CLIConfig;
  appendProfileRuntimes now prefers an override path when set AND executable,
  else falls back to exec.LookPath(command_name), else skips+logs as before.
- Tests: cmd_runtime_profile_test.go (registration, create/update/delete incl.
  bad-family + missing-flag + 409 surfacing, set/unset path round-trip,
  relative-path rejection, config preservation); cli/config round-trip;
  daemon prefers-override / falls-back-when-not-executable.

Verified: go build ./..., go vet, go test ./cmd/multica/... ./internal/daemon/...
./internal/cli/... all pass.

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

* MUL-3284 PR3 (Web): custom runtime profiles in the Runtime page

Single-list integration — no new page, no tabs/grouping. Built-in protocol
families and custom profiles render mixed in one catalog, each row badged
built-in vs custom (progressive disclosure).

- packages/core: RUNTIME_PROFILE_PROTOCOL_FAMILIES (single-source 13-family
  whitelist, matches server agent.SupportedTypes + migration 120 CHECK) and
  RuntimeProtocolFamily / RuntimeProfile types; api client
  list/get/create/update/deleteRuntimeProfile against
  /api/workspaces/{id}/runtime-profiles; runtimes/profiles.ts query +
  mutation hooks and a 409 "agents still bound" conflict parser.
- packages/views/runtimes: runtime-profile-catalog (mixed built-in+custom
  rows), runtime-profiles-dialog (header "+ Add runtime" → step 1 pick
  protocol family → step 2 display_name/command_name/description; edit form
  for custom; admin-gated), delete-runtime-profile-dialog (confirm + graceful
  409), runtimes-page / runtime-list integration.
- i18n: new strings added to all four locales (en, zh-Hans, ja, ko).
- a11y: dialogs are focus-trapped, Esc-closable, labelled; full
  create/edit/delete flow is keyboard + screen-reader operable.

Iron rule honored: no generic per-agent args UI here (those stay on Agent
config). fixed_args is not surfaced as a general args field.

Verified: turbo typecheck + lint + test pass for @multica/core, @multica/views,
@multica/web; the @multica/web production build succeeds.

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

* MUL-3284 PR3: hide fixed_args from Web + CLI (not yet wired to launch)

Review fix. fixed_args was surfaced as a working feature, but the daemon does
not splice it into the agent launch command — exposing it promised admins a
no-op. Per the call, remove it from every user-facing surface while keeping the
underlying column/struct "carried but not exposed".

- Web (runtime-profiles-dialog.tsx + runtime-profile-catalog.ts): drop the
  detail row, the create body field, the update patch field, and the form
  textarea; remove the parseFixedArgs/fixedArgsToText helpers and the
  fixedArgs form value. Left a NOTE pointing at the daemon TODO.
- i18n: removed the fixed_args strings from all four locales (en/zh-Hans/ja/ko).
- CLI (cmd_runtime_profile.go): removed the `--fixed-arg` flag from create and
  update and stopped sending `fixed_args`; updated the "no fields" message.
  Test now asserts the CLI never sends fixed_args.

Untouched (the carried-but-not-exposed layer): the runtime_profile.fixed_args
column, the server handler's accept/return, and the daemon's RuntimeProfile
field — all keep the existing TODO(MUL-3284) to wire it into the launch path
(with a test proving args reach the backend) before any UI/CLI re-exposes it.

Verified: turbo typecheck+lint+test pass for @multica/core and @multica/views;
go build/vet/test pass for ./cmd/multica/.

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

* MUL-3284 PR3: stop exposing profile visibility=private (server forces workspace)

Double-review (Eve) caught a fixed_args-shaped hole: visibility=private was a
user-facing toggle (Web form + detail + CLI), but the three server read paths
(ListRuntimeProfiles, daemon ListEnabledRuntimeProfilesForWorkspace,
DaemonRegister) never enforce it — so a "private" profile's name/command would
leak to other members and could be registered by other machines' daemons
(lateral data leak). Same "don't paint a pie" fix as fixed_args: hide the
control everywhere and force the stored value.

- Server (runtime_profile.go): drop `visibility` from the create + update
  request structs; CreateRuntimeProfile always stores 'workspace'
  (runtimeProfileDefaultVisibility); UpdateRuntimeProfile no longer accepts it;
  removed validRuntimeProfileVisibility. The column + response field stay
  (always 'workspace') as the carried-but-not-exposed layer.
- Web (runtime-profiles-dialog.tsx): removed the visibility form fieldset,
  the VisibilityOption component, the detail row, the visibility state, and the
  create/update submit fields.
- i18n: removed the profile visibility strings from all four locales
  (profiles.detail.visibility, profiles.visibility.*, profiles.form.visibility_*).
  Top-level runtime/agent visibility strings are untouched.
- CLI (cmd_runtime_profile.go): removed `--visibility` from create/update and
  the VISIBILITY list column; removed validateVisibility; stopped sending the
  field.
- Tests: new TestCreateRuntimeProfile_ForcesWorkspaceVisibility (POST
  visibility:"private" -> response and DB row are 'workspace'); CLI create test
  now asserts visibility is never sent.

Follow-up MUL-3308 tracks implementing real creator-visibility (and wiring
fixed_args to the launch path); TODOs left in server/Web/CLI point to it.

Verified: turbo typecheck+lint+test pass (@multica/core, @multica/views);
go build/vet pass; go test ./cmd/multica/... and the full ./internal/handler/
suite pass against a migrated Postgres 17.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 11:38:17 +08:00
LinYushen
52e76e7b23 MUL-3284: server API + daemon (custom runtime PR2) (#4149)
* MUL-3284: add runtime_profile schema (custom runtime PR1)

Schema-only foundation for custom runtimes. Additive migration 120:

- New workspace-level `runtime_profile` table: the shared, team-visible
  definition of a custom runtime (e.g. an in-house Codex wrapper).
  protocol_family is CHECK-constrained to the exact backend list in
  agent.New() (server/pkg/agent/agent.go). The only args column is
  `fixed_args` (args every agent on the runtime must inherit); there is
  deliberately no generic per-agent args field — those stay on
  agent.custom_args.
- `agent_runtime.profile_id` (nullable, FK -> runtime_profile ON DELETE
  CASCADE): NULL = built-in runtime, non-NULL = a registered instance of
  a custom profile.
- Partial unique index agent_runtime_workspace_daemon_profile_key on
  (workspace_id, daemon_id, profile_id) WHERE profile_id IS NOT NULL.

The legacy UNIQUE (workspace_id, daemon_id, provider) constraint is left
INTACT so the existing registration upsert
(ON CONFLICT (workspace_id, daemon_id, provider) in runtime.sql) keeps
resolving its arbiter and the server stays green. Converting that key to
a partial (WHERE profile_id IS NULL) index and making the upsert
profile-aware is PR2's registration work, not this migration.

Verified up + down against Postgres 17: full `migrate up` applies 120;
schema shows the table, column, partial index and intact legacy
constraint; functional checks pass (partial index blocks dup
(ws,daemon,profile), allows same profile on another daemon; CHECK and
display_name uniqueness reject bad input; legacy ON CONFLICT still
resolves; profile delete cascades to instances); down/up round-trip is
clean.

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

* MUL-3284: drop DB FKs/cascade from runtime_profile migration (review fix)

Per review (house rule: no new database foreign keys / cascades; relational
integrity lives in the application layer):

- runtime_profile.workspace_id: drop REFERENCES workspace ON DELETE CASCADE
  -> plain UUID NOT NULL.
- runtime_profile.created_by: drop REFERENCES "user" ON DELETE SET NULL
  -> plain UUID.
- agent_runtime.profile_id: drop REFERENCES runtime_profile ON DELETE CASCADE
  -> plain UUID.

CHECK constraints, UNIQUE (workspace_id, display_name), the workspace index,
and the partial unique index agent_runtime_workspace_daemon_profile_key are
unchanged. The legacy UNIQUE (workspace_id, daemon_id, provider) constraint
remains untouched.

Behavioral consequence: the database no longer auto-removes a profile's
agent_runtime instance rows on profile delete. That cleanup moves into PR2's
profile-delete path. Up-migration comments document this; down-migration
comment no longer references FKs/cascade.

Re-verified on Postgres 17: migrate up applies 120; no FK constraints exist on
the new columns; partial index still blocks dup (ws,daemon,profile_id); CHECK
and display_name uniqueness still reject bad input; deleting a profile now
leaves the runtime row orphaned (proving cascade is gone); down/up round-trip
clean with the legacy constraint intact.

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

* MUL-3284 PR2 (server): runtime_profile CRUD + profile-aware registration

Server/DB half of the custom-runtime feature.

- Migration 121: convert the legacy UNIQUE (workspace_id, daemon_id, provider)
  constraint on agent_runtime into a partial unique index scoped to built-in
  rows (WHERE profile_id IS NULL). With 120's partial index on profile_id this
  lets one daemon host the built-in provider AND custom profiles of the same
  protocol family without collision.
- Queries: runtime_profile CRUD; ListEnabledRuntimeProfilesForWorkspace
  (daemon-facing); CountAgentsByProfile + DeleteAgentRuntimesByProfile for the
  app-layer cascade; profile-aware UpsertAgentRuntimeWithProfile; the built-in
  UpsertAgentRuntime ON CONFLICT now spells out WHERE profile_id IS NULL so it
  targets the right partial index. sqlc regenerated.
- agent.SupportedTypes / IsSupportedType: single-source protocol_family
  whitelist, in lockstep with agent.New and the migration 120 CHECK.
- Handlers + routes: runtime_profile CRUD (member-read, admin-write) with
  protocol_family whitelist validation, display_name uniqueness (409), and
  fixed_args validation (no generic per-agent args — iron rule); a
  daemon-token endpoint GET /api/daemon/workspaces/{id}/runtime-profiles;
  DeleteRuntimeProfile does the app-layer cascade (delete instance rows then
  profile, in one tx) and refuses (409) while active agents are bound.
- DaemonRegister accepts an optional per-runtime profile_id: validates the
  profile belongs to the workspace and is enabled, registers via the
  profile-aware upsert, and skips legacy hostname merge for custom rows.
  AgentRuntimeResponse now carries profile_id.

Verified on Postgres 17: migrate up through 121; built-in + custom codex
coexist on one daemon; both upsert arbiters are idempotent; delete-by-profile
cascade removes only the custom instance; migrate down reverses 121 then 120
and replays clean. go build ./... and go vet pass; handler test package
compiles.

Daemon-side wiring (fetch profiles, PATH-resolve command_name, register with
profile_id, exec uses command_name) lands in a follow-up commit on this branch.

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

* MUL-3284 PR2 (daemon): pull profiles, PATH-resolve, register, exec command

Daemon-side half of custom runtime profiles, against the server contract on
this branch.

- client.go: GetRuntimeProfiles(workspaceID) -> GET
  /api/daemon/workspaces/{id}/runtime-profiles (mirrors GetWorkspaceRepos);
  RuntimeProfile / RuntimeProfilesResponse types.
- types.go: Runtime gains profile_id (parsed from the register response so
  runtimeIndex carries it).
- daemon.go:
  * appendProfileRuntimes — called inside registerRuntimesForWorkspace before
    the empty-runtimes guard. Best-effort fetch (older server 404s are logged
    and swallowed; never fails registration). Per enabled profile: resolve
    command_name via PATH (exec.LookPath, behind a `lookPath` test hook),
    skip+log when absent, best-effort version probe, record the resolved
    absolute path keyed by profile_id, and append a registration entry
    {name, type=protocol_family, version, status:online, profile_id}. A
    custom-only host (no built-in agents) still registers.
  * profileCommandPaths map (guarded by d.mu) + recordProfileCommandPath /
    customCommandPathForRuntime helpers.
  * runTask: looks up the claimed task's RuntimeID -> profile command path and
    overrides the executable path, synthesizing an AgentEntry so a custom
    runtime runs even when the host has no built-in agent of the same
    provider. provider (=protocol_family) is unchanged so agent.New still
    selects the right backend.
- Tests: GetRuntimeProfiles request shape; profile runtime appended + path
  recorded (custom-only host); profile skipped when command not on PATH;
  profiles-fetch-404 is best-effort; customCommandPathForRuntime bookkeeping.
- agent: lockstep test pinning SupportedTypes to agent.New and the migration
  120 protocol_family CHECK.

Iron rule honored: profile carries no generic per-agent args. fixed_args are
parsed and carried but intentionally NOT wired into the launch command yet
(optional/best-effort; explicit TODO(MUL-3284) in appendProfileRuntimes).

Verified: go build ./... clean; go vet ./internal/daemon/... clean;
go test ./internal/daemon/... pass (existing + 5 new); full
go test ./internal/handler/ suite passes against a migrated Postgres 17;
agent lockstep test passes.

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

* MUL-3284 PR2: profile delete runs full archived-agent cascade (fix 500)

Review fix. DeleteRuntimeProfile previously guarded only on ACTIVE agents, but
agent.runtime_id is ON DELETE RESTRICT — a profile whose runtimes had only
ARCHIVED agents passed the guard, then DeleteAgentRuntimesByProfile hit the FK
and the handler 500'd.

Now it mirrors the mature runtime-delete cascade (DeleteAgentRuntime): in one
transaction it enumerates the profile's runtime rows, refuses (409) any with
active agents or active squads led by archived agents, then for each runtime
pauses autopilots pinned to its archived agents, drops archived squads led by
them, and hard-deletes the archived agents before removing the runtime rows
and the profile. No code path can now fall through to a raw FK error.

- queries: ListAgentRuntimeIDsByProfile (sqlc regen). Reuses the existing
  per-runtime teardown queries (CountActiveSquadsWithArchivedLeadersByRuntime,
  ListArchivedAgentIDsByRuntime, PauseAutopilotsByAgentAssignees,
  DeleteSquadsByArchivedAgentsOnRuntime, DeleteArchivedAgentsByRuntime).
- tests: TestDeleteRuntimeProfile_ArchivedAgentCascade (archived-only profile
  deletes cleanly: 204, runtime + archived agent + profile gone) and
  TestDeleteRuntimeProfile_ActiveAgentBlocks (active agent → 409, survives).

Verified against Postgres 17: both new tests pass; full handler suite, daemon
tests, and agent lockstep test pass; go vet clean.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 11:33:09 +08:00
Wes
4f1797598e MUL-3321: Add runtime delete CLI command
Adds a command-line runtime delete flow with strict default behavior and explicit cascade support.\n\nFixes #3909.
2026-06-16 16:58:10 +08:00
Bohan Jiang
241a3582cf fix: validate issue status and priority (#4156)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-16 12:26:44 +08:00
Bohan Jiang
f9c193e06b fix: fail closed on agent task auth tokens (#4142)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-15 16:34:35 +08:00
Bohan Jiang
7db3e507d1 feat(cli): manage workspace repo registry (#4067)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-13 01:22:45 +08:00
Bohan Jiang
c8ab73d38d MUL-3244: Bind quick-create attachments to created issues (#4062)
* fix: bind quick-create attachments to created issues

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

* test: use real image markdown in quick-create attachment test

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-12 16:45:38 +08:00
Bohan Jiang
c510515da7 fix: suggest daemon profiles for empty disk usage
- suggest other profile workspace roots when disk-usage sees an empty selected root
- include the default profile in reverse suggestions and shell-quote profile arguments
- keep JSON output and explicit --workspaces-root behavior unchanged

MUL-3232
2026-06-12 13:37:35 +08:00
Bohan Jiang
e4ec9dc425 MUL-2802: add skill import conflict strategies (#3997)
* feat(skills): structured conflict + overwrite path for local skill re-import

Local-skill re-import previously failed (or silently skipped) on a same-name
collision and, on delete+reimport, changed the skill UUID and dropped agent
bindings. This adds a structured conflict result and a creator-only overwrite
write path so a re-import can update the existing skill in place.

- New terminal import status `conflict` carrying { existing_skill_id,
  existing_created_by, can_overwrite }; can_overwrite = requester is the
  skill creator (canOverwriteSkillByLocalImport — intentionally narrower than
  canManageSkill: admins edit in-app, not via re-import).
- Conflict is detected at daemon-report time (the effective name is only known
  once the bundle arrives) via GetSkillByWorkspaceAndName, with the unique
  constraint as a race backstop.
- Import requests carry action=overwrite + target_skill_id, persisted through
  both the in-memory and Redis LocalSkillImportStore (the heartbeat → daemon
  payload is unchanged; overwrite is resolved server-side).
- overwriteSkillWithFiles updates by target_skill_id in one tx: re-checks
  existence (workspace-scoped) and creator permission, then replaces
  description/content/config and fully replaces files (pruning files absent
  from the new bundle). Preserves id, created_by, created_at, name, and
  agent_skill bindings. Publishes skill:updated (not skill:created).
- Boundaries: target deleted or permission lost → failed (no fallback to
  create-by-name); any mid-write error rolls back the tx, leaving the original
  skill untouched. Retrying a terminal request is a no-op.

Tests cover: creator/non-creator conflict (can_overwrite), overwrite preserves
UUID + agent binding + prunes removed files, non-creator overwrite fails,
deleted target fails without create fallback, retry idempotency, and Redis
round-trip of the new fields.

Backend half of MUL-2701. Contract change: same-name local imports now return
status `conflict` instead of `failed` — the Desktop/core client must be updated
to consume it (sibling task).

MUL-2800

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

* fix(skills): gate structured conflict behind client opt-in; guard overwrite target name

Addresses review feedback on PR #3498 (MUL-2800).

Backward compatibility: a same-name local import now returns the new `conflict`
status only when the initiating client opts in via `supports_conflict` (an
overwrite request implies it). Older clients — already-installed Desktop builds
whose poll loop only understands `failed`/`timeout` — keep the legacy `failed`
+ "a skill with this name already exists" behavior, so upgrading the backend
ahead of the client no longer regresses the import UX. This is the installed-app
API-compat boundary the repo's CLAUDE.md calls out.

Also: the overwrite write path now verifies the incoming effective name matches
the target skill's current name (errSkillOverwriteNameMismatch -> failed),
preventing a stale/wrong target_skill_id from writing one skill's content onto
another. Creator-only + workspace scoping already prevent privilege escalation;
this narrows the API so it can't be misused.

Refactored LocalSkillImportStore.Create to a LocalSkillImportRequestInput params
struct (the signature had grown to 8 positional args; the opt-in flag pushed it
over). supports_conflict is persisted in both the in-memory and Redis stores.

Tests: conflict tests now opt in; added a legacy-client test (no flag ->
failed + legacy message) and an overwrite name-mismatch test.

MUL-2800

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

* feat(skills): resolve local import conflicts in desktop

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

* fix(skills): preserve bulk flow after conflict resolution

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

* feat(cli): add skill import conflict strategies

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

* fix(i18n): sync skill import locale keys

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

* docs: explain skill import conflict handling

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

* docs: refresh skill import source map anchors

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-11 13:00:56 +08:00
Naiyuan Qing
906f70a3e2 Add comment trigger preview suppression (#3792)
* Add comment trigger preview suppression

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

* Use TanStack Query for trigger preview

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

* Test note comments skip create triggers

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

* feat(issues): redesign comment trigger chips as avatar chips

Single agent renders as avatar + presence dot + full sentence; several
agents collapse to an overlapping stack + active count, mirroring the
header working chip. Per-agent skip moves into a click-opened popover
(hover layers stay read-only tooltips); suppression reads as brightness,
not a ban glyph. Loading and preview errors render nothing.

Also: share one tooltip body across chip and popover rows, invalidate
cached previews after a comment lands (the enqueued task changes the
dedup answer), move the preview query key into issueKeys, and drop the
now-unconsumed status field from useCommentTriggerPreview.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(server): drop comment trigger wrappers kept only for tests

enqueueMentionedAgentTasks and shouldEnqueueSquadLeaderOnComment had no
production callers after the compute/enqueue split — the comment path
goes through computeCommentAgentTriggers. Tests now exercise the compute
functions directly via package-local helpers, so the legacy adapters
cannot drift from the real path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(skills): sync mentioning/squads source maps with shared trigger computation

The squads source map still pointed the comment-trigger contract at the
pre-refactor call chain (comment.go:940 -> shouldEnqueueSquadLeaderOnComment),
and the mentioning skill referenced the deleted wrapper. Re-anchor both
to computeCommentAgentTriggers / computeAssignedSquadLeaderCommentTrigger
/ computeMentionedAgentCommentTriggers with current line numbers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:27:07 +08:00
LinYushen
70ccbd9bce Revert "MUL-3132: harden /uploads/* (auth, no listing, nosniff, tight CSP) (#…" (#3944)
This reverts commit 8ff68502fc.
2026-06-09 14:50:56 +08:00
Bohan Jiang
998ebe97e4 fix(autopilot): fail create-issue runs on any terminal task failure (#3943)
Generalize SyncRunFromLinkedIssueTask beyond Codex no-progress: any
terminal create-issue task failure with no retry still in flight now fails
the linked autopilot run, so it can no longer hang in issue_created
(invisible to the failure-rate auto-pause monitor).

- fail the linked run for any terminal task failure, gated by the existing
  HasActiveTaskForIssue wait-for-retry guard
- remove the isNoProgressTaskFailure classifier (subsumed; drops duplicated
  pkg/agent marker literals)
- drop the redundant GetIssue/origin lookup; GetAutopilotRunByIssue leads
  and short-circuits ordinary failures in one query
- tests: keep no-progress regression, add agent_error (non-retryable) and
  retry-pending cases

Follow-up to #3927. VEN-661 / VEN-662 / MUL-3164
2026-06-09 14:48:20 +08:00
Multica Eve
13e9485a3b MUL-3130: persist stable /api/attachments/<id>/download URL in comment markdown (#3937)
* MUL-3130: persist a stable attachment download URL in comment markdown

Comment image attachments rendered as broken placeholders ~30 minutes
after upload because the editor was persisting a short-lived
HMAC-signed URL into the comment body. After PR #3903 (MUL-3132)
hardened /uploads/* with auth, `attachmentToResponse` started signing
`attachment.url` as `/uploads/<key>?exp=<unix>&sig=<HMAC>` for
LocalStorage so token-auth clients could keep loading inline images.
The signature has a 30-min TTL by design — but `useFileUpload` was
returning that signed value as `link` and the editor was writing
`![file](signed-url)` straight into the markdown, so the comment
permanently captured a URL that stopped working as soon as the
signature expired.

The fix is to persist a stable per-attachment URL that the server can
re-sign on every request:

* `useFileUpload` now returns `link = /api/attachments/<id>/download`
  (avatar uploads without an id still fall back to `att.url` so the
  pre-attachment-row code paths keep working).
* `DownloadAttachment` self-resolves the workspace from the attachment
  row instead of reading X-Workspace-Slug / X-Workspace-ID headers,
  and the route is registered under the auth-only group so a native
  browser <img>/<video> resource load (which cannot attach those
  headers) succeeds. Membership is checked inside the handler with
  a 404 deny shape so the route does not act as an IDOR oracle.
* A new `GetAttachmentByIDOnly` SQL query supports the workspace-
  derivation step.
* `AttachmentDownloadProvider` now extracts the attachment id from
  the stable URL when matching markdown refs to attachment records,
  with a fallback to the existing url-equality check for legacy
  comments (and S3/CloudFront markdown that points straight at the
  CDN).
* `contentReferencesAttachment` covers both URL shapes for the
  composer / standalone-list dedup paths so an attachment uploaded
  before the fix and one uploaded after both deduplicate cleanly.

Tests:
- New unit tests for the URL helpers (16 tests, packages/core).
- Backend regression test: bare `<img src>`-style request without
  workspace headers now succeeds for a member (200) and 404s for a
  non-member, replacing the previous "400 without workspace context"
  contract.
- Existing TestDownload*, TestServeLocalUpload*, TestAttachmentTo
  Response* and the 1220 frontend views tests all pass.

Refs: MUL-3130, GitHub issue #3891
Co-authored-by: multica-agent <github@multica.ai>

* MUL-3130: address PR review — split markdown link from upload link, swap render src

Two follow-ups from GPT-Boy's review on PR #3937.

(1) Don't reroute every upload consumer through the workspace-gated
    download endpoint.

    The previous change made `useFileUpload`'s `link` field unconditionally
    return `/api/attachments/<id>/download` whenever the upload had an id.
    But `useFileUpload` is also used by avatar / logo pickers
    (account-tab, workspace-tab, agents/avatar-picker, squads/squad-detail-page)
    that persist `result.link` directly into `avatar_url`. Avatars are
    referenced cross-workspace (mention chips, member lists, inbox
    items), so binding their URL to a workspace-membership-gated
    endpoint would silently break cross-workspace avatar visibility.

    The fix splits the URL into two semantically distinct fields:

      - `link`         — same as `att.url` (legacy contract). Avatar /
                          logo callers continue to use this and remain
                          on whatever URL semantics the storage backend
                          dictates.
      - `markdownLink` — the stable per-attachment URL
                          `/api/attachments/<id>/download`. Only the
                          editor's markdown-persisting flow consumes
                          this. Falls back to `link` for the
                          no-workspace upload branch (where there is
                          no attachment-row id to address).

    `editor/extensions/file-upload.ts` switches `image.src` and
    `fileCard.href` to `markdownLink ?? link` so comment markdown gets
    the stable shape while avatar callers stay on `link` unchanged.

(2) Make the render-time img src loadable for token-mode clients.

    Persisting the stable `/api/attachments/<id>/download` URL fixes the
    expiry problem but the path itself sits behind `middleware.Auth`,
    which expects either a `multica_auth` cookie or a Bearer token in
    `Authorization`. Native `<img>`/`<video>` resource loads from
    token-mode clients (Electron's default mode, the mobile app,
    legacy-token web sessions) cannot attach the Authorization header,
    so the bare URL would 401 immediately rather than 30 minutes later.

    `Attachment.normalize` now runs the resolved record through a new
    `pickInlineMediaURL` helper that returns:

      - `record.download_url` when it's an absolute URL with a
         recognised CDN signature query (CloudFront-signed
         `Signature` / `Expires` / `Key-Pair-Id`, or
         `X-Amz-Signature` for raw S3 presigns) — these load as
         native resource src in any client.
      - else `record.url`, which on the LocalStorage backend carries
         a freshly-minted `/uploads/<key>?exp&sig` query whose
         signature IS the auth (token-mode-loadable). On non-CF S3
         backends this is the raw stored URL — same behaviour as
         today.
      - else the original input URL (legacy / unresolved markdown
         keeps its existing path).

    This gives the same effect for both `kind: "record"` and
    `kind: "url"` attachment inputs: once a record is in hand, the
    rendered media src is whichever URL the current backend exposes
    a working signature on.

Tests:

  - New `file-upload.test.ts` regression pinning that `markdownLink`
    is what lands in the markdown body when the upload result returns
    both a short-lived storage URL and a stable download path.
  - Updated `attachment.test.tsx` to reflect the new render-time
    swap (the rendered img src now follows the freshly signed URL,
    not the raw storage URL) and added a record-mode regression
    pinning the LocalStorage default — when `download_url` is the
    bare /api/attachments/<id>/download path, the renderer must fall
    through to the signed `record.url`.
  - Updated `chat-input.test.tsx` makeUpload helper for the new
    `markdownLink` UploadResult field.
  - 1222 frontend views tests + 507 core tests + typecheck across
    @multica/{core,ui,views} all pass.

Refs: MUL-3130, GitHub issue #3891. Builds on a740f7a35.
Co-authored-by: multica-agent <github@multica.ai>

* MUL-3130: chat upload map keys on persisted markdownLink, not the short-lived link

GPT-Boy's second-round review on PR #3937 caught a chat-only blocker
left over from the previous fix.

After the previous commit split `UploadResult.link` into `link`
(legacy avatar/logo URL) and `markdownLink` (stable per-attachment
URL persisted into markdown), the comment editor's image src + file
card href correctly switched to `markdownLink ?? link`. But chat
input still kept the upload-map key on the old `link`:

  uploadMapRef.current.set(result.link, result.id)
  …
  if (content.includes(url)) activeIds.push(id)

In the LocalStorage backend `link` is the short-lived
`/uploads/<key>?exp=&sig=` URL. The editor persists the stable
`/api/attachments/<id>/download` URL into the message body, so
`content.includes(url)` never matches and the send call drops
`attachment_ids`. The attachment ends up bound only to the chat
session, not to the message — agents reading message-level metadata
see no attachments.

Fix: key the upload map on the same value the editor actually wrote
into the markdown body (`markdownLink || link`). The
`content.includes(url)` check then matches and the attachment id is
correctly forwarded on send.

Tests:

- Updated the chat-input mock editor to insert `markdownLink || link`
  into its value, mirroring the real editor's persisted-URL choice
  (uploadAndInsertFile in editor/extensions/file-upload.ts). Without
  this the mock would silently paper over the bug.
- Added a regression test where the upload result returns a
  short-lived `link = /uploads/...?exp&sig` and a stable
  `markdownLink = /api/attachments/<id>/download`. Asserts (a) the
  message body carries the stable URL and never the signed query,
  and (b) the bound `attachment_ids` includes the attachment id.

All 1223 frontend views tests pass (was 1222, +1 new regression).
Typecheck and 507 core tests still green.

Refs: MUL-3130, PR #3937 review by GPT-Boy. Builds on f66a522d0.
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-09 14:26:36 +08:00
stevenayl
ee6200de25 fix(autopilot): fail no-progress issue runs (#3927)
Fail create-issue autopilot runs that hang in issue_created after a Codex
no-progress / semantic-inactivity task failure, so they surface as failed
and count toward the failure-rate auto-pause monitor.

- route failed create-issue issue tasks (no direct autopilot_run_id) into linked run sync
- fail linked runs only for Codex no-progress / semantic-inactivity failures
- wait when an active retry task still exists for the issue
- add classifier coverage + a DB-backed listener regression

VEN-661 / VEN-662 / MUL-3164
2026-06-09 14:25:04 +08:00
Bohan Jiang
42251b42fc fix(cli): honor MULTICA_SERVER_URL in setup self-host (#3912) (#3938)
* fix(cli): honor MULTICA_SERVER_URL in setup self-host

`multica setup self-host` resolved the backend URL only from the
--server-url flag, falling back to http://localhost:8080 when the flag
was absent. It never consulted MULTICA_SERVER_URL, even though that env
var is documented on the root --server-url flag and in `multica --help`,
and is honored by every other command via resolveServerURL. A self-host
user who set the env var instead of the flag still hit localhost and got
"Server at http://localhost:8080 is not reachable".

Route server-url and app-url through cli.FlagOrEnv so the documented env
vars (MULTICA_SERVER_URL / MULTICA_APP_URL) are honored when the matching
flag is not set, with the flag still taking precedence. userProvided now
reflects flag-or-env, so an env-sourced remote URL still triggers the
explicit app_url prompt. Not platform-specific despite the report.

Fixes GitHub #3912.

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

* fix(cli): normalize MULTICA_SERVER_URL in setup self-host

MULTICA_SERVER_URL is documented as a ws:// daemon address
(ws://localhost:8080/ws) and every other command normalizes it via
NormalizeServerBaseURL before use. setup self-host consumed the resolved
value raw and probed <url>/health, so a self-hoster who set the
documented ws:// form would still fail the reachability check.

Run the flag/env value through normalizeAPIBaseURL (ws->http, wss->https,
strip /ws) so the documented form works and the stored server_url stays a
clean http(s) base. Add a normalization test case and a focused test for
the MULTICA_APP_URL env path (review nit).

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

* docs(self-host): note setup self-host honors MULTICA_SERVER_URL / MULTICA_APP_URL

Document that `setup self-host` reads the env vars when the matching flag
is omitted (flag wins), and that MULTICA_SERVER_URL accepts the ws://…/ws
daemon form. Added to en/zh/ja/ko quickstart for parity.

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-09 14:02:06 +08:00
Bohan Jiang
7dc05d28bc fix(projects): validate project status/priority — return 400 instead of 500 (#3925) (#3939)
* fix(projects): return 400 (not 500) for invalid project status/priority

CreateProject/UpdateProject passed an unvalidated status/priority straight to
the INSERT, so an unknown value (e.g. --status active) tripped the table's
CHECK constraint and surfaced as a blanket 500 'failed to create project'
with no server-side log to diagnose it (#3925).

Pre-validate both enums against the column CHECK lists and return a 400 with
the allowed values. Back it with isCheckViolation -> 400 for any other
constrained column, and log the underlying error on genuine 500s so transient
DB failures are diagnosable.

MUL-3153

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

* fix(cli): validate project --status in create/update

project create and project update forwarded --status to the server without
checking it, while project status already validated. Share a single
validateProjectStatus helper across all three so a typo fails fast with the
valid list instead of a server round-trip.

MUL-3153

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-09 13:54:53 +08:00
LinYushen
9ff801f926 docs(cli): error-message conventions + sign-in copy (PR3, MUL-3104) (#3900)
* docs(cli): add Error Messages conventions + refine sign-in copy (PR3)

Final pass of the CLI error-message work (MUL-3104).

- CLI_AND_DAEMON.md: new "Error Messages" section documenting the user-facing
  contract — friendly single-line messages, server validation passthrough,
  English default with automatic Chinese on a zh locale, the tiered exit codes
  (0/1/2/3/4/5), --debug / MULTICA_DEBUG for the full chain, and
  MULTICA_HTTP_TIMEOUT.
- cmd_auth.go: clarify three high-frequency sign-in errors so the message
  states what failed and the next step — local login-callback server start
  (hints at port/firewall), access-token creation, and token verification
  (suggests retrying `multica login` and checking the token is valid/not
  expired). All keep %w so exit-code tiering and --debug detail are preserved.

cmd_id_resolver.go is left as-is — its not-found / ambiguous-prefix messages
already point at `list --full-id` and need no change. The user-facing
FormatError layer is unchanged, so its existing PR1/PR2 test coverage still
applies; no test asserted the old verb strings.

Refs MUL-3104. PR3 of 3 (final).

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

* fix(cli): make login failure guidance visible via typed user-message wrapper

Addresses 张大彪's PR3 review: the refined sign-in copy was wrapped with %w,
so FormatError returned the centralized *HTTPError/*NetworkError copy and the
new guidance only appeared under --debug.

- Add cli.UserMessageError + cli.WithUserMessage: a typed wrapper carrying a
  user-facing message that FormatError surfaces by default, recognized before
  the network/http branches. Unwrap() is preserved, so ExitCodeFor still
  classifies by the underlying typed error and --debug still prints the full
  original chain.
- cmd_auth.go: wrap the OAuth access-token-creation and PAT-verification
  failures with WithUserMessage (OAuth copy no longer mentions a passed token,
  since that flow has none), and move the token-specific 'valid / not expired'
  hint to the real Enter your personal access token:  verification site (was the generic
  'invalid token: %w').
- Focused tests: under a wrapped *HTTPError(401) the default FormatError shows
  the login hint, ExitCodeFor returns ExitAuth, and --debug retains the raw
  chain; a wrapped *NetworkError still classifies as ExitNetwork.
- CLI_AND_DAEMON.md: narrow 'every error' to command errors returned to the
  top-level handler, noting commands like setup's fast /health probe bypass it.

Refs MUL-3104, PR #3900.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-06-09 13:15:51 +08:00
Multica Eve
8ff68502fc MUL-3132: harden /uploads/* (auth, no listing, nosniff, tight CSP) (#3903)
* MUL-3132: harden /uploads/* (auth, no listing, nosniff, tight CSP)

Closes the open hardening items from the SVG XSS disclosure
(security-findings-2026-06-02). The primary chain (PR #3023 / #3050)
is intact; this PR addresses every remaining recommendation from the
disclosure's hardening list except 'serve uploads from a separate
origin' (a structural change beyond this fix).

Changes:

- /uploads/* now requires authentication. The route is wrapped in
  middleware.Auth so anonymous internet users can no longer fetch
  workspace attachments by guessing the URL. A new ServeLocalUpload
  handler then enforces the second layer:
    - workspaces/{wsID}/* paths require membership in wsID (uses
      MembershipCache for the hot path);
    - users/{userID}/* paths allow any authenticated user (avatars
      are referenced cross-workspace);
    - any other prefix returns 404, so a future feature cannot drop
      content under /uploads/<other-prefix>/ and inherit a relaxed
      policy by accident.
  Non-members see 404 (not 403) so the route does not act as an IDOR
  oracle for workspace IDs.

- Directory listing on /uploads/* is rejected at the storage layer:
  empty keys, trailing-slash keys, and any key that resolves to a
  directory return 404 before http.ServeFile would render an HTML
  index. UUID filenames were obscurity, but enumerating them
  shouldn't be free.

- Every successful /uploads/* response carries
  X-Content-Type-Options: nosniff and a tight per-response CSP
  (default-src 'none'; sandbox; frame-ancestors 'none'), overriding
  the application-wide CSP. This is belt-and-suspenders if a future
  regression weakens the Content-Disposition: attachment path.

- UploadFile rejects HTML-family uploads at the edge (.html, .htm,
  .xhtml, .shtml, .xht, .phtml, plus a content-type denylist for
  text/html and application/xhtml+xml so renamed payloads cannot
  bypass the extension check). SVG and JS remain allowed because
  their existing serve-side defenses neutralize them and source-code
  attachments preview as text/plain via /api/attachments/{id}/content.

Tests:

- storage: TestLocalStorage_ServeFile_RejectsDirectoryListing,
  TestLocalStorage_ServeFile_HardeningHeaders.
- handler: TestIsUploadDenied (pure), TestUploadFile_RejectsHTMLByExtension,
  TestUploadFile_RejectsHTMLByContentType, TestUploadFile_AllowsLegitimateImage,
  and the full ServeLocalUpload matrix (RequiresAuth, MemberCanRead,
  NonMemberDenied, RejectsDirectoryInPath, UnknownPrefixDenied,
  UserPrefixAllowsAnyAuthedUser).
- Full server test suite passes.

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

* MUL-3132: HMAC-signed query auth for /uploads/* (token-auth client compat)

Addresses J's Request Changes review on PR #3903.

Problem: PR #3903 wrapped /uploads/* in middleware.Auth, but native
<img>/<video>/<iframe> resource loads cannot attach Authorization
headers. Token-auth clients (Desktop default, legacy-token Web
sessions, mobile) were breaking on inline attachment rendering even
though the API itself authenticated fine.

Fix: implement HMAC-signed query parameters for /uploads/*, mirroring
S3 + CloudFront presigned URLs.

- storage.SignLocalUploadURL(rawURL, key, secret, expiry) appends
  '?exp=<unix>&sig=<HMAC-SHA256(key|exp)>' query params; signature
  is bound to one specific key, has a TTL matching CloudFront mode
  (defaultAttachmentDownloadURLTTL = 30 min), constant-time compared
  on verify.
- storage.VerifyLocalUploadSignature(key, exp, sig, secret, now)
  rejects expired, tampered, wrong-secret, and key-mismatched
  signatures.
- ServeLocalUpload now has two auth paths: signed-query (no Auth
  middleware needed; signature itself is the authority) and
  Bearer/cookie (membership-gated as before). Partial signed-query
  fails closed.
- The route in router.go dispatches between the two: if both exp+sig
  query params are present, route to inner handler unwrapped; else
  wrap in middleware.Auth.
- attachmentToResponse appends signed query to URL when the storage
  backend is *LocalStorage. CloudFront-signed download URLs and S3
  paths are unchanged.

Tests:
- storage: TestSignAndVerifyLocalUploadURL_RoundTrip,
  TestVerifyLocalUploadSignature_RejectsExpired, _RejectsTamperedSig,
  _BoundToKey, _RejectsWrongSecret,
  TestSignLocalUploadURL_PreservesExistingQuery,
  TestLocalUploadSignatureFromQuery_EmptyOnAbsence (7 pure tests).
- handler: TestServeLocalUpload_{SignedQueryBypassesAuth,
  SignedQueryRejectsExpired, SignedQueryRejectsTampered,
  SignedQueryBoundToOneKey, PartialSignedQueryFailsClosed},
  TestAttachmentToResponse_LocalStorageMintsSignedURL.

Full server test suite passes.

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-09 11:59:00 +08:00
chyax98
26ca943d45 feat(lark): add typing indicator lifecycle for inbound messages (#3860)
When a message is successfully ingested, send a Typing reaction to
the user's message. When the agent replies (EventChatDone) or fails
(EventTaskFailed), clear the reaction before the reply is visible.

- Add AddMessageReaction / DeleteMessageReaction to APIClient
- Implement reaction HTTP calls in httpAPIClient
- Introduce TypingIndicatorManager for per-session state tracking
- Wire into Hub (add on ingest) and Patcher (clear before reply)
- Skip typing for messages older than 2 minutes (WS replay guard)

Co-authored-by: miaolong001 <miaolong@xd.com>
2026-06-08 19:27:08 +08:00
LinYushen
b83b41ff44 feat(cli): per-status error copy with actionable hints (PR2, MUL-3104) (#3897)
* feat(cli): refine per-status error copy with actionable hints (PR2)

Builds on PR1's translation layer. Each HTTP-status message now carries an
actionable next step, in both English and Chinese:

- 401: run `multica login`; plus a self-hosted / non-OAuth fallback telling
  the user to ask their administrator for valid credentials
- 403: check the workspace / ask an admin to grant access
- 404: check the ID or run the matching `list` command
- 409: re-fetch the latest state and retry
- 422: check values / run with --help
- 429: wait and retry; reduce call frequency if it persists
- 5xx: retry, contact support, and re-run with --debug for the raw response

Also adds ErrorKind.String() (stable snake_case identifiers) and uses it in
--debug output instead of the raw int, and clears the pre-existing gofmt dirt
Eve flagged in cmd_config.go, cmd_version.go, and help.go.

Tests: TestErrorKindString (all kinds + uniqueness + out-of-range fallback)
and TestFormatErrorActionableHints (locks the per-status hints in EN and ZH).

Refs MUL-3104. PR2 of 3.

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

* test(cli): cover validation (400/422) actionable hint

TestFormatErrorActionableHints omitted KindValidation, so deleting the 400/422
hint would have gone unnoticed. Add 400 and 422 cases (no server message, so
the generic validation copy is used) asserting EN contains --help / expected
format and ZH contains --help / 格式 / 参数.

Refs MUL-3104, PR #3897.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-06-08 16:02:09 +08:00
LinYushen
28de8b8bde feat(cli): central error translation layer (PR1, MUL-3104) (#3892)
* feat(cli): add central error translation layer (PR1)

Introduce server/internal/cli/errors.go, a single user-facing error
translation layer that collapses raw transport errors, HTTP status
errors, and internal verb-wrapped chains into clear, localized messages.

- ErrorKind classification (network timeout/DNS/refused/TLS/offline,
  401/403/404/409/400+422/429/5xx, unknown)
- NetworkError wraps transport errors and strips the raw URL from the
  user-facing message; classifyNetworkError categorizes via errors.As/Is
  with string fallbacks
- HTTPError.Kind() maps status codes onto ErrorKind
- FormatError: bilingual output (English default, auto-switch to Chinese
  on a zh LC_ALL/LC_MESSAGES/LANG locale), validation errors surface the
  server message; --debug / MULTICA_DEBUG appends the full raw chain
- ExitCodeFor: tiered exit codes (network=2, auth=3, 404=4, validation=5,
  other=1)
- client.go: default HTTP timeout 15s -> 30s, overridable via
  MULTICA_HTTP_TIMEOUT; wrap every transport Do() error as *NetworkError
- main.go: route errors through FormatError + ExitCodeFor, add persistent
  --debug flag

Unit tests cover every ErrorKind, classification, language detection,
exit codes, server-message extraction, and timeout parsing.

Refs MUL-3104. PR1 of 3; PR2/PR3 (status-code copy refinement and
per-command customization) follow separately.

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

* fix(cli): address review — unify command timeouts and classify all helper errors

Must-fix 1: command-level contexts no longer truncate MULTICA_HTTP_TIMEOUT.
Added cli.APITimeout/AtLeastAPITimeout/APIContext (budget = transport timeout
+ small grace, honoring MULTICA_HTTP_TIMEOUT) and replaced the hardcoded 15s
context.WithTimeout in every API command (14 files, 92 sites) with
cli.APIContext. The issue-create/comment path now uses APITimeout() with a
60s floor for attachment uploads.

Must-fix 2: all API helpers now return *HTTPError on status >= 400. Added a
shared newHTTPError(method, path, resp) and routed GetJSON, GetJSONWithHeaders,
PostJSON, PutJSON, PatchJSON, DeleteJSON, DeleteJSONWithBody, UploadFile,
UploadFileWithURL, DownloadFile (and HealthCheck) through it, so issue
update/status/metadata (PUT), comment list (GetJSONWithHeaders), project/label/
comment delete (DELETE) and agent/workspace/autopilot update (PUT/PATCH) all
get HTTPError.Kind() classification, friendly copy, and the tiered exit code
instead of the raw string + exit 1.

Tests: new errors_integration_test.go drives the real helpers against a fake
server and asserts FormatError copy + ExitCodeFor for 401/403/404/422/500
across all 10 helpers, plus a slow-server test proving the command context
does not cancel before the transport timeout. Updated the UploadFileWithURL
assertion to check for *HTTPError.

Refs MUL-3104, PR #3892.

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

* fix(cli): make remaining fixed-timeout API commands honor MULTICA_HTTP_TIMEOUT

Closes out the timeout work: the last API command paths still used a
hardcoded context deadline that capped MULTICA_HTTP_TIMEOUT. Converted them
to cli.AtLeastAPITimeout(<original floor>) so the env override scales them up
while preserving each original lower bound:

- cmd_autopilot.go  autopilot trigger      30s -> AtLeastAPITimeout(30s)
- cmd_attachment.go attachment download    60s -> AtLeastAPITimeout(60s)
- cmd_agent.go      avatar upload           60s -> AtLeastAPITimeout(60s)
- cmd_skill.go      skill import / search    60s -> AtLeastAPITimeout(60s)
- cmd_runtime.go    runtime update         150s -> AtLeastAPITimeout(150s)
- cmd_login.go      workspace-creation poll 10s -> AtLeastAPITimeout(10s)

The login poll keeps a short 10s floor to stay responsive within its 5-minute
loop, but it is NOT a silent exception: AtLeastAPITimeout means it still scales
with MULTICA_HTTP_TIMEOUT. Documented in code and covered by a new subtest in
TestAPITimeoutRespectsEnv.

Refs MUL-3104, PR #3892.

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

* style(cli): gofmt cmd_attachment.go to unblock backend CI

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-06-08 15:34:59 +08:00
xiaoyue26
10076ae773 MUL-3123 fix(realtime): support X-Forwarded-Host in WebSocket checkOrigin 2026-06-08 14:43:20 +08:00
LinYushen
b89b9cb4d6 test(migrate): concurrent migration race test using real Postgres (MUL-2956) (#3712)
* test(migrate): add concurrent migration race test using real Postgres (MUL-2956)

Follow-up to MUL-2923 / #3658, which added a Postgres advisory lock to
serialize the migration loop across concurrent runners (multi-replica
backend startup, scale-up, manual `migrate up` overlap). That PR shipped
without a test because cmd/migrate/ had no harness; this commit adds it.

Refactor: extract runMigrations(ctx, pool, runOptions) from main(), with
the lock key, the bookkeeping table, and the file list now injectable.
main() behavior is unchanged. Identifier interpolation goes through
pgx.Identifier{}.Sanitize so callers can pass "schema.schema_migrations"
safely.

Tests (cmd/migrate/migrate_concurrent_test.go) — every case isolates
itself in a unique throwaway schema and a unique lock key, so they
never touch the real schema_migrations table or block real production
runners that share the database. Skip cleanly when DATABASE_URL is
unreachable, matching the pattern already used in
internal/handler/handler_test.go and internal/metrics/business_sampler_pgsleep_test.go.

  - TestRunMigrationsConcurrentPending: 16 goroutines apply 5
    deliberately non-idempotent migrations (bare CREATE TABLE +
    ALTER TABLE ADD COLUMN). Without the lock, concurrent CREATE TABLE
    races trip "duplicate key value violates unique constraint
    pg_type_typname_nsp_index" — proving the lock is doing its job.
  - TestRunMigrationsConcurrentAlreadyApplied: 16 goroutines hit the
    EXISTS no-op path against a pre-populated bookkeeping table; the
    state must be unchanged.
  - TestRunMigrationsAdvisoryLockSerializes: an external connection
    holds the same advisory lock; we assert that zero of the 16
    runners complete during a 1 s observation window, then release
    the side lock and let them all finish. Catches the original
    MUL-2923 bug where the lock got attached to a random pooled
    connection.
  - TestRunMigrationsConcurrentMixedPoolStress: same pending case but
    with a deliberately small pool (runners/2), forcing pgxpool.Acquire
    contention to overlap with pg_advisory_lock contention.

Verified locally: `go test -race -count=10 ./cmd/migrate/` passes in
~15 s. Mutation test (lock acquire/release replaced with `SELECT 1`)
confirms the pending and lock-serializes tests both fail loudly,
catching the regression they were written to detect.

go.mod tidy promotes golang.org/x/sync to a direct dependency
(now imported by the test for errgroup) and incidentally fixes a
stale `// indirect` annotation on prometheus/client_model, which is
already imported directly by internal/metrics/testutil.go.

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

* test(migrate): gofmt + address review nits (MUL-2956)

- gofmt -w cmd/migrate/migrate_concurrent_test.go: fixture struct field
  alignment.
- quoteQualifiedIdentifier: actually reject identifiers with more than
  one dot (the previous version split on the first dot only and would
  silently sanitize "a.b.c" into "a"."b.c", contradicting the comment).
  Inline the splitter via strings.Split now that we explicitly check the
  component count.
- Soften the test's lock-key comment from "never collide" to the
  accurate probabilistic statement (~1 in 2^62 collision odds with the
  production constant).

go test -race -count=10 ./cmd/migrate/ still passes (~15 s).

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

* test(migrate): direction whitelist + tidy go.mod (MUL-2956)

Address two follow-ups from review:

- runMigrations now whitelist-checks opts.Direction up-front and
  returns an error for anything that is not "up" or "down". The
  previous shape relied on `opts.Direction == "up"` and an else branch,
  so a typo or empty string would silently fall through to the
  rollback path. Add TestRunMigrationsRejectsInvalidDirection covering
  the empty string, "UP"/"DOWN" case mismatches, "rollback", and a
  whitespace-padded value; the check fires before any pool work, so
  the test runs without Postgres.
- go mod tidy: promotes google.golang.org/protobuf to a direct
  dependency (it is imported directly elsewhere in the module and was
  stale-marked indirect).

go test -race -count=10 ./cmd/migrate/ green (~15.7 s, 50/50).

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

---------

Co-authored-by: wei-heshang <wei-heshang@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-08 13:33:16 +08:00
Prince Pal
d6e00e0909 fix(daemon): fail loudly when self-restart spawn fails (#2503)
* fix(daemon): fail loudly when self-restart spawn fails

* fix(daemon): surface log reopen failures on restart
2026-06-08 13:02:31 +08:00
Xinmin Zeng
270d177475 fix: broken "Add a computer" command on Multica Cloud + two CLI amplifiers (MUL-3087) (#3817)
* fix(server): recognize official cloud by frontend host in daemon setup config

The 'Add a computer' dialog builds its command from /api/config's
daemon_server_url/daemon_app_url, falling back to 'multica setup' when
both are empty. The official cloud is meant to omit them, but the
omission only fired when MULTICA_PUBLIC_URL=https://api.multica.ai. When
that env is unset the server URL defaults to the frontend origin and the
old guard (which required serverURL host == api.multica.ai) didn't match,
so the dialog emitted 'multica setup self-host --server-url
https://multica.ai' — pointing the daemon backend at the frontend (no
/health, no WebSocket proxy).

Identify the official cloud by its frontend host alone (multica.ai /
app.multica.ai) so a missing or misconfigured MULTICA_PUBLIC_URL can no
longer leak the broken self-host command. Regression from #3474.

* fix(cli): probe before persisting self-host config to preserve auth on failure

setup self-host wrote a fresh CLIConfig{ServerURL, AppURL} (a full
overwrite that drops the saved token) and only then probed the server,
returning early on failure. A failed probe therefore logged the user out
and left them unconnected, with no recovery in the same command.

Probe first via persistSelfHostConfigIfReachable: an unreachable server
leaves the existing config — and its token — untouched (failed setup =
no-op). The prober is injected so both branches are unit-tested.

* fix(daemon): serve health before preflight so daemon start readiness is accurate

The CLI's 'daemon start' polls the health endpoint for 15s expecting
status=running, but the daemon only began serving health after
preflightAuth, whose initial workspace sync detects every configured
agent's version by exec'ing it (~20s cold with 8 agents). Health served
too late, so a perfectly healthy daemon printed 'may not have started
successfully'.

Start the health server right after resolveAuth (which still fails fast
on a missing token) and before the slow preflight, so readiness reflects
the daemon core being up rather than agent-version detection finishing.

* fix(daemon): gate /health readiness so daemon start can't report a false start

Serving health before preflightAuth fixed the false-negative (a healthy
daemon printed "may not have started"), but health still returned
status:"running" unconditionally — before preflight (PAT renew + workspace
sync + runtime registration) had completed. `daemon start` and the desktop
treat "running" as ready, so a slow or *failing* preflight could be
misreported as a started daemon: setup prints "connected", then the process
exits or hangs in agent-version detection with no runtime registered. That
is harder to diagnose than the original false-negative.

Split liveness from readiness: bind/serve the health port early (so callers
see a live "starting" daemon instead of connection-refused), but report
status:"starting" until d.ready is set after preflight, then "running".

- daemon.go: add d.ready (atomic.Bool); set it true after the background
  loops launch, before pollLoop.
- health.go: healthHandler reports "starting" until ready, else "running".
- cmd_daemon.go: `daemon start` waits for "running" with a deadline raised
  to 45s (covers cold-start agent detection) and a clearer "still starting"
  message; new daemonAlive() helper treats both "running" and "starting" as
  a live daemon, so the already-running guard, restart, and stop act on a
  starting daemon and don't double-spawn or race its listener; `daemon
  status` shows "starting" distinctly.

Older CLIs/desktop that only know "running" safely treat "starting" as
not-ready (status != "running"), so no boundary break.

Tests: health reports starting-then-running; daemonAlive truth table.
Co-authored-by: multica-agent <github@multica.ai>

* fix(desktop): handle daemon "starting" health status in lifecycle

The daemon now reports /health status:"starting" until preflight completes
(liveness/readiness split). That made "starting" a new external contract of
/health, but the Desktop daemon-manager only knew "running", so the readiness
fix would have moved the CLI's false-negative into a Desktop start regression:

- `daemon start` now blocks up to 45s waiting for readiness, but the Desktop
  spawned it via execFile({ timeout: 20_000 }). On a cold start (the ~20s agent
  detection this PR targets) Electron killed the CLI supervisor at 20s and
  reported a start failure, even though the detached daemon child kept booting —
  the UI flashed "stopped" then "running". Raise the timeout to 60s (must exceed
  the CLI's 45s startupTimeout).
- The Desktop treated only raw status === "running" as a live daemon, so a
  daemon that was still "starting" (booting on its own or started via the CLI)
  showed as "stopped", and startDaemon() would spawn a second one — which the new
  CLI rejects as "already running", surfacing as a start error.

Add daemonStatusAlive() (shared, pure, unit-tested) mirroring the Go daemonAlive()
and use it for liveness: fetchHealth() surfaces a daemon-reported "starting" as
state "starting" regardless of our own currentState; startDaemon()'s
already-running guard and the restart-on-user-switch guard treat "starting" as an
existing daemon. version-decision stays gated on "running" (readiness, not
liveness) — unchanged.

Verified: desktop typecheck, eslint, full vitest suite (193 tests) all pass.
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 17:01:23 +08:00
Bohan Jiang
6d0b9e3918 feat(lark): prefetch surrounding group context on @-mention (MUL-3084) (#3819)
* feat(lark): prefetch surrounding group context on @-mention (MUL-3084)

In Feishu group chats the Bot only saw the single message that @-mentioned
it — never the surrounding conversation — because the inbound enricher only
inlined context the user explicitly attached (a quoted reply or a
merge_forward), and the API client had no way to list a chat's history.

Add APIClient.ListChatMessages (GET /open-apis/im/v1/messages,
container_id_type=chat, ByCreateTimeDesc, page_size clamped to Lark's 50
cap) and, for a group message addressed to the Bot, prefetch a bounded
window of recent messages and inline them as a <recent_context> block
ahead of the user's own message. The trigger and any quoted parent are
excluded so nothing is duplicated; speakers are labeled positionally
(User 1/2 / Bot); failures degrade to a visible placeholder and never
block ingestion. Window size is configurable via
InboundEnricherConfig.RecentContextSize (<=0 disables); production wires
DefaultRecentContextSize (20). One list call per addressed turn keeps the
fetch within the inbound ACK / EnrichTimeout budget.

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

* feat(lark): anchor group context window to trigger time, default 10

Address review feedback on MUL-3084:
- Anchor the recent-context prefetch to the trigger message's time:
  thread the message create_time through InboundMessage and pass it as
  the list end_time (millis -> seconds), so the window is the
  conversation up to the @-mention rather than whatever is newest when
  the slightly-later prefetch HTTP call runs. end_time is omitted when
  the time is missing/unparseable (falls back to newest N).
- Lower DefaultRecentContextSize from 20 to 10.

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

* docs(lark): clarify recent-context persistence stance and fetch-window semantics

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

* fix(lark): region-aware doJSON for ListChatMessages after rebase

origin/main merged #3815 (Lark dual-region support), which changed
doJSON to take a per-call baseURL resolved via resolveBaseURL(creds).
Adapt the new ListChatMessages call to that signature so the backend
build passes against latest main, and refresh the now-stale
ListMessagesParams comment (EndTime is exposed).

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-05 16:37:49 +08:00
Bohan Jiang
6ac8314711 feat(lark): support both Feishu and Lark from one deployment (MUL-3083) (#3815)
* feat(lark): serve Feishu and Lark from one deployment, per installation

The Lark integration was locked to a single open-platform host chosen
deployment-wide (MULTICA_LARK_HTTP_BASE_URL / _CALLBACK_BASE_URL,
defaulting to open.feishu.cn), so one deployment could talk to only the
mainland Feishu cloud OR Lark international — never both. Teams on the
other tenant could not use the integration at all.

Make the host per-installation. The device-flow installer already
auto-detects the tenant (Lark emits tenant_brand="lark" mid-poll); we now
persist that as lark_installation.region, carry it on
InstallationCredentials.Region, and resolve the open-platform host per
call (REST + WS bootstrap) from the region. An explicit cfg.BaseURL
(env / httptest) still overrides every region, so existing tests and
staging/proxy setups keep working.

- migration 116: lark_installation.region TEXT NOT NULL DEFAULT 'feishu'
  CHECK (region IN ('feishu','lark')) — existing rows are all mainland.
- lark.Region enum + OpenPlatformBaseURL/RegionOrDefault helpers.
- registration: thread the detected region into finishSuccess so the
  install-time GetBotInfo hits the right cloud AND the row records it.
- every credential-build site (patcher, replier, WS provider, union_id
  backfill) copies region off the installation row.
- region is part of the WS supervisor fingerprint so a re-install that
  switches cloud restarts the connection.
- API: surface region on the installation listing DTO.

MUL-3083

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

* feat(lark): surface installation region in settings UI

Read the per-installation region off the listings response: build the
"Manage in Lark" dev-console host from it (open.feishu.cn vs
open.larksuite.com instead of a hardcoded mainland host) and render a
Feishu / Lark badge on each connected bot. The field is optional and
defaults to Feishu when an older server omits it (API-compat). Adds the
region_feishu / region_lark labels to all four locales.

MUL-3083

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

* docs(lark): document simultaneous Feishu + Lark support

The cloud each bot belongs to is now auto-detected at install and stored
per installation, so one deployment serves both. Replace the old
"point MULTICA_LARK_HTTP_BASE_URL at larksuite for international tenants"
guidance (now just an optional override) in all four locales.

MUL-3083

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

* fix(lark): repair legacy Lark-international installs on upgrade

Review follow-up (MUL-3083). Migration 116 backfilled every existing
lark_installation to region='feishu', assuming all historical rows were
mainland. But self-host deployments could already run Lark international
via the deployment-wide MULTICA_LARK_HTTP_BASE_URL override, so those
rows are really Lark — clearing the override after upgrade (which the new
docs invite) would route them to open.feishu.cn and break them.

Add a one-shot startup repair, BackfillRegionFromLegacyOverride, fired
off the hot path like BackfillBotUnionIDs: when the deployment's global
base-URL override targets open.larksuite.com, relabel the still-default
'feishu' rows to 'lark'. Gating on the deployment-wide override is what
makes it safe — every pre-existing install on such a deployment was Lark.
Idempotent; no-op on mainland / fresh deployments. Verified end-to-end
against a scratch DB (flip then 0-row idempotent re-run).

Also document that a Lark/飞书 app_id is globally unique across both
clouds, which is what makes the app_id-keyed token cache and the
UNIQUE(app_id) constraint safe across regions (review nit).

MUL-3083

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

* docs(lark): fix ops guidance to match auto per-installation region

Review follow-up (MUL-3083). .env.example and docker-compose.selfhost.yml
still told operators that international Lark requires pointing both base
URLs at open.larksuite.com — now wrong, and it would push a fresh
deployment back into a single-cloud override. Rewrite them: the base
URLs are optional deployment-wide overrides; normal dual-cloud operation
keeps them empty. Document the first-boot auto-relabel for deployments
migrating off the old single-cloud override, across the integration docs
(en/zh/ja/ko).

MUL-3083

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-05 16:03:13 +08:00
Bohan Jiang
3708fb0f07 fix(daemon): inactivity-based agent run timeout, no wall-clock guillotine (MUL-3064)
Active long-running sessions are no longer killed by a fixed wall-clock deadline. Liveness is delegated to the idle watchdog (MULTICA_AGENT_IDLE_WATCHDOG, default 30m) with a larger in-flight-tool budget (MULTICA_AGENT_TOOL_WATCHDOG, default 2h). MULTICA_AGENT_TIMEOUT is an opt-in absolute cap (default 0 = no cap). The server-side 2.5h sweeper is unchanged as a coarse backstop.

Fixes #3745.
2026-06-05 15:06:07 +08:00
Bohan Jiang
62925b97f1 chore(cli): remove the --from-template flag from agent create (#3805)
* chore(cli): remove the --from-template flag from agent create

The `--from-template` CLI flag was an untaught, immature surface (the
built-in skill's source-map explicitly marked the template path "out of
scope"). It also silently ignored sibling create flags (--custom-env,
--mcp-config, etc.) by short-circuiting before body assembly. Remove the
flag and its runAgentCreateFromTemplate handler from the CLI.

Scope is CLI-only. The agent-template product feature stays intact:
- registry server/internal/agenttmpl/ (embedded curated templates)
- handler server/internal/handler/agent_template.go
- routes GET /api/agent-templates, GET /api/agent-templates/{slug},
  POST /api/agents/from-template
- the onboarding "create from template" flow (packages/views/onboarding)

The onboarding flow calls the API directly and does not depend on the
CLI flag, so removing the flag does not affect it.

Updates the multica-creating-agents source map accordingly.

MUL-3070

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

* fix: correct source-map note on agent-template usage + guard --from-template

Review of #3805 (MUL-3070) flagged a factual error in the source-map note:
it claimed onboarding uses the agent-template backend. It does not.
`packages/views/onboarding/steps/step-agent.tsx` builds four hardcoded
local presets (i18n-resolved) and creates via plain `POST /api/agents`
(`createAgent`), never `POST /api/agents/from-template`. The whole
agent-template stack (registry, handler, routes, `packages/core` client +
query wrappers) is orphaned — the removed CLI flag was its only non-test
caller. Rewrite the note to say so.

Also add a regression test asserting `agent create` exposes no
`--from-template` flag, so it can't be silently re-added.

MUL-3070

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-05 14:21:11 +08:00
LinYushen
3caba86b09 feat(scheduler): DB-backed execution-record scheduler [MUL-2957] 2026-06-05 13:46:26 +08:00
Bohan Jiang
18a5224fe8 feat(cli): add --mcp-config flags to agent create/update (#3799)
Agents already support an mcp_config field (consumed by the daemon →
provider at task time) and the agent-settings UI exposes an MCP tab, but
the CLI had no way to set it. This adds the missing CLI surface, mirroring
the existing custom-env pattern:

- `agent create` and `agent update` gain --mcp-config / --mcp-config-stdin
  / --mcp-config-file. The stdin/file channels keep MCP server tokens out
  of shell history and 'ps'; the three channels are mutually exclusive.
- The value is validated as a JSON object (or the literal `null` to clear,
  on update), matching the agent-settings MCP tab. Empty stdin/file input
  errors instead of silently clearing a secret-bearing field.
- Unlike custom_env, mcp_config IS settable via `agent update` — it is
  persisted through the generic UpdateAgent endpoint (no dedicated audited
  endpoint), so both create and update expose the flags.

Adds parser/resolver unit tests (incl. secret-leak sanitization) and
updates the multica-creating-agents built-in skill + source map.

MUL-3070

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 13:39:38 +08:00
Bohan Jiang
f3ab29cdfc fix(lark): publish lark_installation:created at row-commit, not on status poll (#3770)
The agent Integrations tab's "已连接到飞书" connection badge only updated after
a manual page refresh. lark_installation:created had a single emit site — the
status-poll handler GetLarkInstallStatus — so it only fired while a browser was
actively polling the install dialog to success. Every other surface (a second
admin, the inspector sidebar, the Settings panel, or the installer whose dialog
closed before the success poll) never received the invalidation frame, and under
the QueryClient defaults (staleTime: Infinity) the installations cache stayed
stale until a full page refresh.

Publish the event from RegistrationService.finishSuccess at the row-commit point,
mirroring the already-correct revoke path, so every workspace client refreshes
the moment the install lands. Wire the bus via an optional SetEventBus (keeps the
constructor and its validation tests untouched, nil-safe) and remove the now-
redundant poll-handler emit.

MUL-3059

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-04 19:29:01 +08:00
LinYushen
5e1a6c4853 fix(cli): degrade 'issue metadata list' to {} on /metadata 404 (#3757)
Self-hosted backends without the per-issue metadata route (older builds,
unapplied 105_issue_metadata migration, or proxy/ingress misroutes) reply
404 to GET /api/issues/:id/metadata. The agent runtime bootstrap calls
'multica issue metadata list <issue> --output json' best-effort, but a
non-zero exit was being escalated by Hermes into a failed agent run even
when the rest of the work succeeded.

This makes only the 'list' verb best-effort: a 404 from /metadata now
prints {} (or an empty table) and exits 0. Other status codes (401, 500,
etc.) keep real error semantics, and 'metadata get / set / delete' are
unaffected — those represent explicit caller intent.

To support the status-code check without changing the user-facing error
string, GetJSON now returns *cli.HTTPError on HTTP failures (the format
'GET <path> returned <code>: <body>' is preserved by HTTPError.Error()).

Refs GitHub issue #3711.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-04 16:13:27 +08:00
Multica Eve
ae27058b0a fix(attachments): unified download endpoint with mode + presign + proxy (MUL-2976) (#3747)
Fix attachment download for self-hosted deployments using private S3-compatible buckets without CloudFront. Closes #3721.

**Server**

- New unified `GET /api/attachments/{id}/download` endpoint that picks CloudFront / S3 presign / server proxy at request time.
- `ATTACHMENT_DOWNLOAD_MODE=auto|cloudfront|presign|proxy` and `ATTACHMENT_DOWNLOAD_URL_TTL` env knobs; `auto` routes Docker hostnames / localhost / private IPs through the proxy and public S3 endpoints through presign.
- `Storage.PresignGet` capability; S3 implementation generates presigned GET URLs.
- `attachmentToResponse` returns the unified relative endpoint instead of leaking raw unsigned S3 URLs when CloudFront is not configured. Proxy path streams via `io.Copy` with `Content-Disposition` / `Content-Length` / `Cache-Control: no-store` / `X-Content-Type-Options: nosniff`.

**Clients**

- CLI / Desktop / Mobile resolve relative `download_url` values against the configured API base. Desktop covers the Electron native download bridge and the media preview modal; Mobile covers `Linking.openURL`, the markdown image RN loader, and the composer's completed non-image file chip.
- Mobile gains a minimal Node-environment vitest lane wired into `mobile-verify.yml`.

**Docs**

- `.env.example`, `docker-compose.selfhost.yml`, `SELF_HOSTING_ADVANCED.md`, and the `environment-variables` doc set updated with the new env keys and the `ATTACHMENT_DOWNLOAD_MODE=proxy` recommendation for Docker / VPC-internal object stores.

**Tests**

- `internal/storage`, `internal/cli`, `internal/handler` (download endpoint, mode selection, proxy header, `/content` non-regression), `cmd/server` (trusted proxy parser).
- `packages/views/editor/use-download-attachment.test.tsx` and `attachment-preview-modal.test.tsx` exercise relative URL resolution + absolute pass-through.
- `apps/mobile/lib/attachment-url.test.ts` covers every helper branch plus the composer non-image chip case.
2026-06-04 14:52:57 +08:00
Bohan Jiang
6e004149a8 feat(lark): debounce inbound run trigger per chat session (MUL-2968) (#3742)
A forwarded transcript plus a follow-up note arrive as two separate Lark
messages, each of which synchronously called EnqueueChatTask — so the bot
ran twice (once on the bare forward, before the note arrived). The chat
task already reads the whole session history at run time, so the messages
never needed stitching; only the run TRIGGER did.

Introduce pendingBatcher: a per-chat_session debouncer that collapses a
burst into one agent run on a 3s silence window. Each message is still
appended, deduped, and ACKed synchronously and individually; step 8 of the
dispatcher now schedules a debounced flush instead of enqueuing inline.

Because EnqueueChatTask's agent-offline / agent-archived verdict is now
only known at flush, the dispatcher emits that notice itself via an
injected FlushReply (wired to OutcomeReplier.Reply) rather than returning
it synchronously to the hub. Infra failures are logged, not surfaced — the
inbound frame was ACKed long ago. The hub drains the batcher on graceful
shutdown so a normal restart does not drop a pending window.

Out of scope (owner-aligned): group-chat multi-speaker batching, restart
recovery for the in-process window, and forwarded-sender real-name
resolution.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-04 12:48:02 +08:00
Bohan Jiang
5eba94ee25 feat(lark): inbound context enrichment — post / merge_forward / quoted-reply (MUL-2951) (#3724)
Expand an inbound Lark bot message's body before dispatch with the context
a user explicitly attached, so the agent sees a semantically complete
conversation instead of a bare "@bot 总结一下".

- post: flatten rich-text (title + paragraphs, links, @-mentions) to plain
  text synchronously in the decoder.
- merge_forward: inline the forwarded transcript via a single GetMessage —
  GET /open-apis/im/v1/messages/{id} returns the forward sentinel plus the
  bundled children. (The issue's container_id_type=merge_forward query is
  undocumented; this avoids it and also handles a forwarded quoted parent.)
- quoted reply: prepend the parent_id message as a <quoted_message> block;
  a parent that is itself a forward nests a <forwarded_messages> block.
- new InboundEnricher runs in the WS connector between decode and emit,
  bounded by EnrichTimeout and degrading to "[unable to fetch]" placeholders
  so it never blocks the ~3s long-conn ACK budget.

/issue stays parseable on a quote-reply by parsing the command from the
user's own text (CommandBody) rather than the enriched body.

Short-window debounce batching (issue item #4) is tracked as a follow-up.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-04 11:58:16 +08:00