mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
d276991c603e6041d20fb5c1e65df8756d35d2ae
117
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
731af7ccd6 |
MUL-7002: push runtime deletion invalidation to active daemons (#7969)
* fix(runtime): invalidate deleted active runtimes Co-authored-by: multica-agent <[email protected]> * fix(runtime): separate invalidation delivery metrics Co-authored-by: multica-agent <[email protected]> * fix(runtime): complete invalidation metric separation Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
0431218061 |
MUL-6833 fix(web): localize auth callback and error pages (#7752)
Localize the web auth callback, 404 page, and root error boundary, plus the shared section-level ErrorBoundary. Also fixes several pre-existing auth-path bugs surfaced during review: - access_denied was unreachable (the !code branch preceded the error-param check) - UserLocaleSync's reload could replay a consumed OAuth code and show 'Login Failed' after a successful sign-in - Google token-exchange and userinfo provider failures were misreported as user-actionable causes - checkSignupAllowed reported 'registration is disabled' for an allowlist miss |
||
|
|
bfe32f5e39 |
fix(runtime): wake daemon for local skill requests (#7713)
Co-authored-by: Janez Stupar <[email protected]> |
||
|
|
5fa65bd125 |
feat(billing): enforce Cloud issue count limits (#7619)
* feat(billing): enforce Cloud issue count limits * fix(billing): address issue limit review feedback * test(billing): exercise validation as manager * fix(billing): resolve second review feedback * fix(core): preserve entitlement limit discrimination |
||
|
|
3b1018e9c9 |
Revert "MUL-6016: feat(task): retry exact Codex capacity failures (#6747)" (#7586)
This reverts commit
|
||
|
|
8fda4f22d5 |
MUL-6016: feat(task): retry exact Codex capacity failures (#6747)
* docs: record Codex capacity retry design * docs: add Codex capacity retry implementation plan * fix(taskfailure): recognize exact Codex capacity error * feat(task): retry exact Codex capacity failures * feat(server): configure Codex capacity retries * docs: expose Codex capacity retry setting * fix(task): align capacity retry comments --------- Co-authored-by: cuijs <[email protected]> Co-authored-by: Claude <[email protected]> |
||
|
|
baf1bbf340 |
fix(billing): make Cloud seat enforcement automatic (#7542)
Replace the separate Fleet/capacity URLs and seat-capacity switches with the single managed MULTICA_CLOUD_URL connection. Strict seat admission turns on whenever Multica is Cloud-connected and fails closed when the capacity machine token is absent or invalid; the recovery worker starts only for a valid executor so live intents keep their retry budget during credential outages. Split invitation throttling into a non-consuming precheck and post-reservation consumption. Persistent capacity_full and capacity_overcommitted rejections charge only the actor budget; transient failures charge none. Preserve every Cloud/proxy HTTP 429 as retryable with both Retry-After forms, defer outbox rows without spending attempts, and use Cloud's rate-limit scope so a workspace 429 delays only that tenant while global or unscoped 429s stop the current batch. BREAKING: delete/unset MULTICA_FLEET_URL, MULTICA_CLOUD_FLEET_URL, MULTICA_CLOUD_FLEET_TIMEOUT, MULTICA_SUBSCRIPTION_CAPACITY_ENABLED, MULTICA_SUBSCRIPTION_CAPACITY_URL and MULTICA_SUBSCRIPTION_CAPACITY_WORKER_ENABLED before rollout. The startup guard rejects any non-empty value, so MULTICA_SUBSCRIPTION_CAPACITY_ENABLED=false still blocks boot. Configure MULTICA_CLOUD_URL, MULTICA_CLOUD_TIMEOUT and MULTICA_SUBSCRIPTION_CAPACITY_SERVICE_TOKEN instead; entitlements keep their independent MULTICA_ENTITLEMENT_POLICY_* configuration. Companion: multica-ai/multica-cloud#57 |
||
|
|
60a55ec57b |
feat(billing): purchase a seat from the invite flow (#7538)
* feat(billing): purchase seats from full invite flow * fix(billing): harden full-capacity invite flow * fix(billing): explain overcommit with cloud seat occupancy Cloud refuses a reserve on used + reserved, but the overcommit toast filled members_over_capacity_description with the product member count. The most common trigger is a first ledger snapshot whose members alone still fit inside the purchased seats, so the message read "3 members but only 3 purchased seats" and did not explain the rejection. Add occupancy_over_capacity_description, which names occupied seats, purchased seats, members and pending invitations, and feed it from the Cloud summary. members_over_capacity_description stays as-is: the billing tab banner is genuinely member-count driven. |
||
|
|
da7131ab59 |
MUL-6625: enforce prepaid member seat capacity (#7491)
* MUL-6625: enforce prepaid member seat capacity
Co-authored-by: multica-agent <[email protected]>
* fix(seat-capacity): address review findings
Co-authored-by: multica-agent <[email protected]>
* fix(seat-capacity): close retry and worker races
Co-authored-by: multica-agent <[email protected]>
* fix(seat-capacity): bound lock waits during rollback
Co-authored-by: multica-agent <[email protected]>
* fix(seat-capacity): pin the cross-repo confirm settlement budget
Multica Cloud must wait longer than this worker's whole retry budget before it
treats a member row as one its ledger never recorded; adopting a member whose
confirm is still retrying counts one person as two seats, and an over-estimate
is what asks a customer to buy seats they do not owe.
That budget was only implicit here — 10 attempts of exponential backoff plus a
tick and a lock wait each — and Cloud's horizon had been set from a single
retry. Extract the backoff into retryBackoff, expose SettlementBudget, and
assert the worst case (29m15s) stays inside Cloud's 45m horizon, so raising
maxAttempts or the backoff cap fails review here instead of widening the window
in which Cloud cannot quantify a ledger.
Co-authored-by: multica-agent <[email protected]>
* Revert "fix(seat-capacity): pin the cross-repo confirm settlement budget"
This reverts commit
|
||
|
|
8c563b49be |
MUL-6555 feat(issues): preserve source context for sub-issues (#7461)
* feat(issues): preserve source context for sub-issues * fix(issues): address source context review findings * fix(migrations): guard source context rollback preflight |
||
|
|
bedad9e222 |
MUL-6485: isolate hosted plugin surfaces (#7329)
* feat(plugins): host plugin artifacts, bind installs to immutable versions A plugin used to be a URL. The manifest was frozen at install, but the surface script was fetched from the author's server every time a panel opened, with no integrity check — so an administrator consented to a manifest while the browser ran whatever that host served that day, inside the scopes already granted. The author's uptime was our uptime, every panel open leaked the reader's IP and "who read which issue" to the author, and publishing at all required a public HTTPS domain, which is why the repo's own deploy-sentinel example never ran. The author now uploads an artifact bundle and Multica stores it: - plugin_package / plugin_package_version / plugin_package_file. A version is insert-only, and the (package_id, version) unique index is what makes immutability a database rule rather than a convention. - An installation names one version. Publishing another changes nothing for a workspace until an administrator upgrades, which is a second consent. - The bundle is validated once, at publish: the manifest parses, every file it declares is present and loadable, and a surface entry with a top-level import is refused with the line named. That failure used to surface months later in a reader's browser. - The host inlines the surface script into the document it generates, so the CSP names no remote script origin at all. `connect-src 'none'` now means it: a surface with no net: scope cannot reach anywhere, including its author. - Install-by-URL is gone rather than kept alongside. Two paths would make "is this code frozen?" depend on how the plugin was installed. - MULTICA_PLUGIN_DIR stays the development channel, but publishes an ordinary immutable version instead of being a second render path; re-publishing an unchanged version lands as `+dev.N`. Hook endpoints and MCP servers remain on the author's own infrastructure. Migration 376 drops existing installations: they name a source URL that is no longer a concept, and the code they would run was never published here. plugins_v1 has never left its feature flag and holds no production data — the same call migration 344 made. Closes MUL-6469 Co-authored-by: multica-agent <[email protected]> * fix(plugins): serialize publish/install/delete, correct the isolation claim Review of #7321 found five problems. Four are fixed here; the fifth is real, needs its own design pass, and is now tracked and described honestly in the code instead of claimed away. Migration prefix collision. main took 376 for agent_task_durable_work_dir while this branch was open, so `migration prefix 376 is reused` failed backend-tests. Rebased and renumbered the six migrations to 377-382. TOCTOU between delete and install/publish. Relationships are application-owned by repository policy, so nothing made "the version this installation names still exists" true across statements: `delete counts zero installs` -> `install reads the version` -> `delete commits` -> `install commits` left an installation whose panel 404s forever, and a publish racing a delete left a version whose package row was gone. Publish, install and delete now hold a (workspace, plugin key) advisory lock for their whole transaction, and delete counts installations inside it. Both interleavings have regression tests that reproduce the bug with the lock removed. Failed publishes moved package state. upsertPackage ran before the version transaction, so a republish that lost the unique index had already renamed the package, and a first publish that failed afterwards left a plugin with zero versions. It now runs inside the same transaction. The top-level import check was wrong in BOTH directions. It read line prefixes, so it missed ` import x` and `/* c */ import x` — and it refused a valid file whose template literal contained the word at a line start, which is ordinary in a surface that renders code samples. A refusal blocks a publish with no way around it, so that false positive was the worse half. Replaced with a scanner that skips comments and string/template literals; where it is imprecise it is imprecise safely (`/` always reads as division), so a mistake can only cost a detection, never invent one. Sandbox self-navigation is NOT fixed. `sandbox="allow-scripts"` permits `_self` navigation by design, no shipped CSP directive covers it, and contentWindow is unchanged across it — so a hostile artifact can still reach its author once and hand the replacement document the bridge port. Closing it needs an embedder frame-src policy or a handshake a navigated document cannot complete; both are decisions of their own. What lands here is damage control, labelled as such: the generated document beacons on pagehide from a listener the plugin cannot detach, and the embedder drops the bridge and unmounts the frame. The "a surface cannot reach its author" wording is removed from the SDK README and the tests. Closes MUL-6469 Refs MUL-6485 Co-authored-by: multica-agent <[email protected]> * feat(plugins): isolate hosted surfaces (MUL-6485) Co-authored-by: multica-agent <[email protected]> * feat(plugins): host plugin artifacts, bind installs to immutable versions A plugin used to be a URL. The manifest was frozen at install, but the surface script was fetched from the author's server every time a panel opened, with no integrity check — so an administrator consented to a manifest while the browser ran whatever that host served that day, inside the scopes already granted. The author's uptime was our uptime, every panel open leaked the reader's IP and "who read which issue" to the author, and publishing at all required a public HTTPS domain, which is why the repo's own deploy-sentinel example never ran. The author now uploads an artifact bundle and Multica stores it: - plugin_package / plugin_package_version / plugin_package_file. A version is insert-only, and the (package_id, version) unique index is what makes immutability a database rule rather than a convention. - An installation names one version. Publishing another changes nothing for a workspace until an administrator upgrades, which is a second consent. - The bundle is validated once, at publish: the manifest parses, every file it declares is present and loadable, and a surface entry with a top-level import is refused with the line named. That failure used to surface months later in a reader's browser. - The host inlines the surface script into the document it generates, so the CSP names no remote script origin at all. `connect-src 'none'` now means it: a surface with no net: scope cannot reach anywhere, including its author. - Install-by-URL is gone rather than kept alongside. Two paths would make "is this code frozen?" depend on how the plugin was installed. - MULTICA_PLUGIN_DIR stays the development channel, but publishes an ordinary immutable version instead of being a second render path; re-publishing an unchanged version lands as `+dev.N`. Hook endpoints and MCP servers remain on the author's own infrastructure. Migration 376 drops existing installations: they name a source URL that is no longer a concept, and the code they would run was never published here. plugins_v1 has never left its feature flag and holds no production data — the same call migration 344 made. Closes MUL-6469 Co-authored-by: multica-agent <[email protected]> * fix(plugins): serialize publish/install/delete, correct the isolation claim Review of #7321 found five problems. Four are fixed here; the fifth is real, needs its own design pass, and is now tracked and described honestly in the code instead of claimed away. Migration prefix collision. main took 376 for agent_task_durable_work_dir while this branch was open, so `migration prefix 376 is reused` failed backend-tests. Rebased and renumbered the six migrations to 377-382. TOCTOU between delete and install/publish. Relationships are application-owned by repository policy, so nothing made "the version this installation names still exists" true across statements: `delete counts zero installs` -> `install reads the version` -> `delete commits` -> `install commits` left an installation whose panel 404s forever, and a publish racing a delete left a version whose package row was gone. Publish, install and delete now hold a (workspace, plugin key) advisory lock for their whole transaction, and delete counts installations inside it. Both interleavings have regression tests that reproduce the bug with the lock removed. Failed publishes moved package state. upsertPackage ran before the version transaction, so a republish that lost the unique index had already renamed the package, and a first publish that failed afterwards left a plugin with zero versions. It now runs inside the same transaction. The top-level import check was wrong in BOTH directions. It read line prefixes, so it missed ` import x` and `/* c */ import x` — and it refused a valid file whose template literal contained the word at a line start, which is ordinary in a surface that renders code samples. A refusal blocks a publish with no way around it, so that false positive was the worse half. Replaced with a scanner that skips comments and string/template literals; where it is imprecise it is imprecise safely (`/` always reads as division), so a mistake can only cost a detection, never invent one. Sandbox self-navigation is NOT fixed. `sandbox="allow-scripts"` permits `_self` navigation by design, no shipped CSP directive covers it, and contentWindow is unchanged across it — so a hostile artifact can still reach its author once and hand the replacement document the bridge port. Closing it needs an embedder frame-src policy or a handshake a navigated document cannot complete; both are decisions of their own. What lands here is damage control, labelled as such: the generated document beacons on pagehide from a listener the plugin cannot detach, and the embedder drops the bridge and unmounts the frame. The "a surface cannot reach its author" wording is removed from the SDK README and the tests. Closes MUL-6469 Refs MUL-6485 Co-authored-by: multica-agent <[email protected]> * fix(plugins): harden surface validation and error reporting * fix(migrations): renumber plugin package migrations * fix(plugins): validate classic surfaces and reset failures * fix(plugins): refresh issue-scoped surface launches Co-authored-by: multica-agent <[email protected]> * feat(plugins): isolate hosted surfaces (MUL-6485) Co-authored-by: multica-agent <[email protected]> * fix(plugins): refresh issue-scoped surface launches Co-authored-by: multica-agent <[email protected]> * fix(plugins): make surface termination event-driven Co-authored-by: multica-agent <[email protected]> * feat(plugins): isolate hosted surfaces (MUL-6485) Co-authored-by: multica-agent <[email protected]> * fix(plugins): refresh issue-scoped surface launches Co-authored-by: multica-agent <[email protected]> * fix(plugins): make surface termination event-driven Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Lambda <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
1dc135598e |
MUL-6343: Enforce recently created issue windows (#7305)
* MUL-6343: enforce recently created issue windows Co-authored-by: multica-agent <[email protected]> * fix(issues): address creation window review Co-authored-by: multica-agent <[email protected]> * fix(issues): bound table window ID cache Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
67c6aa05c4 |
MUL-6166: feat(telegram): add Telegram channel integration (#6944)
* feat(telegram): add Telegram channel integration Co-authored-by: multica-agent <[email protected]> * chore(telegram): rebase onto main and renumber migrations to 366-367 349/350 collided with main's agent_task_queue_chat_terminal_resume_index and agent_task_queue_chat_retired_session_index. Renumber the Telegram origin pair to the next free prefixes, update the cross-references inside the SQL comments, and point telegram_origin_migration_test.go at the new filenames. Contributor-facing behavior is unchanged. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: multica-agent <[email protected]> Co-authored-by: Bohan-J <[email protected]> |
||
|
|
5d5fe64672 |
MUL-6343: Add semantic issue activity timestamps (#7193)
* feat: add semantic issue activity timestamps Co-authored-by: multica-agent <[email protected]> * fix(migrations): register issue activity index cleanup Co-authored-by: multica-agent <[email protected]> * fix(issues): address last activity review Co-authored-by: multica-agent <[email protected]> * fix(issues): close last activity review gaps Co-authored-by: multica-agent <[email protected]> * fix(issues): harden activity rollout safeguards Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
24d8c521a6 |
MUL-6364: make the LLM client retry budget explicit, wired, and validated (#7201)
* feat(llm): make the LLM client retry budget explicit, wired, and validated Config.MaxRetries was a bare int that nothing outside tests ever set. The production wiring in handler.New never passed it and no MULTICA_LLM_* env var fed it, so the field was configurable in name only and every deployment ran on the SDK default. Its semantics were also inverted. Because the zero value and an explicit 0 are the same int, "0" fell into the unset branch and silently produced 2 retries, while a negative — clamped to 0 to dodge the panic in option.WithMaxRetries — was the only value that actually disabled retries. - MaxRetries becomes *int so unset, 0 (disabled) and N (exact) stay distinct. New always passes the budget to the SDK explicitly, including the default, so the enforced policy and the reported one cannot drift apart. - Add MULTICA_LLM_MAX_RETRIES as the single configuration source. Non-numeric, negative and above-ceiling values fail the boot instead of being corrected into something that looks configured. The ceiling of 5 is a latency budget: backoff reaches ~21s at 6 retries while the internal callers of this layer time out after 8s and 20s. - Report the effective policy at startup via Client.RetryBudget, whose fields are counts and a fixed enum so the line cannot carry a key or gateway URL. - Document the four states in .env.example and the environment-variable docs, and record what the budget does not cover: the GenerateJSON parameter negotiation and a stream that breaks after ChatStream returns. Tests pin every state by upstream request count, so the unset-versus-zero collapse cannot come back unnoticed. Refs MUL-6364, closes #7154 Co-authored-by: multica-agent <[email protected]> * fix(llm): reject invalid retry budgets at the client boundary and wire self-host Review found the retry budget still reachable in two invalid shapes, and never reachable at all on the main self-hosted deployment path. - docker-compose.selfhost.yml declares the backend environment as an explicit allowlist and mapped none of the MULTICA_LLM_* variables, so every value .env.example and the docs told a self-hoster to set was dropped before the container: the LLM layer read as unconfigured no matter what they wrote. Map all four, and assert propagation in scripts/selfhost-config.test.sh, plus a drift guard so a future MULTICA_LLM_* documented without a mapping fails the script instead of silently going nowhere. - Config.MaxRetries took a *int, so a negative still reached llm.New, which warned and used the default instead. Warning is not silence, but substituting a different budget is still the implicit correction #7154 asked to remove. It now takes *RetryOverride, whose field is unexported and whose only constructor rejects negatives — the invalid state is unrepresentable, so New has no correction branch left to take. - The budget is a ceiling, not a quota: only retryable failures consume it, and a success or the caller's deadline can end a call sooner. Say "at most N" rather than "exactly N" in the field docs, .env.example and all four environment-variable docs. The negative-value test now asserts rejection instead of pinning the fallback as contract. Refs MUL-6364, closes #7154 Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
4b8873df3a |
feat(issues): add revision-aware concurrency guards (#7038)
* feat(issues): add revision-aware concurrency guards * fix(concurrency): decouple text edits from aggregate revisions |
||
|
|
e3a40b0b8d |
feat(MUL-6243): custom issue status UI — picker, board, filters, settings (#7103)
* refactor(views): widen IssueStatus to open key, resolve category at render sites Board columns, swimlane columns and hidden-column panels are CATEGORIES, not status keys, so adding a custom status never adds a column. Retyped those to IssueStatusCategory and made every presentation lookup (STATUS_CONFIG, i18n labels, status icons) resolve the key to its category first. Status filters stay in concrete keys and map back to columns via the catalog. MUL-6243 Co-authored-by: multica-agent <[email protected]> * feat(views): catalog-driven status picker and per-status labels The picker now offers the workspace catalog's active statuses grouped by category, instead of the 7 hardcoded built-ins, and falls back to the built-ins until the catalog lands so a cold render is unchanged. Built-in names always resolve through i18n, never through the catalog's server-seeded English name — otherwise every non-English workspace would see 'In Progress' where it used to see '进行中'. Custom names come from the catalog. That rule lives in one place: useStatusLabel. Applied it to every surface that shows an issue's OWN status (detail activity feed, table group headers and CSV export, hover card, inbox), which previously showed the category label and so could not tell two statuses in the same category apart. MUL-6243 Co-authored-by: multica-agent <[email protected]> * feat(views): show custom statuses on cards and in filters Board columns and list sections are categories, so two issues in the same column can sit on different statuses with nothing to tell them apart. CustomStatusChip is that signal, and it renders nothing for a built-in — a workspace with no custom statuses looks unchanged. The status filter now lists the workspace catalog instead of the 7 hardcoded built-ins. Without this an issue moved onto a custom status could not be narrowed to at all. Picker and filter share one useStatusOptions hook so the two lists cannot drift apart. MUL-6243 Co-authored-by: multica-agent <[email protected]> * feat(settings): issue status catalog management with drag reorder Owner/admin CRUD for custom statuses, organised by CATEGORY rather than as one flat list. Category is not decoration here — it is the behavior a status inherits, and picking it is the one moment an admin can, since it is immutable afterwards. Built-ins are shown but locked: each is its category's canonical definition. Drag reorder writes intra-category positions starting at 1, because the built-in is seeded at 0 and PATCH on it is a 403, so it always heads its category. The optimistic cache write re-SORTS rather than only writing positions, or the drag would visually undo itself until the refetch. custom_issue_statuses is now published to the frontend, so the tab can hide creation when the deployment has the rollout gate off instead of offering a button that 403s. MUL-6243 Co-authored-by: multica-agent <[email protected]> * fix(issues): close the category contract end to end Elon's review found the board only half-converted: the UI targeted categories while the server still paged by concrete status key, so a card on a custom status was dropped twice — it never came back from the server, and the row filter would have discarded it if it had. 1. New `status_category` group kind (and compound secondary axis). Grouping rewrites the column with a static CASE built from the catalog; paging expands the category to concrete keys and stays on `status = ANY(...)`, so (workspace_id, status) is still usable. A workspace with no custom statuses gets exactly `i.status` back. Row filter, facet totals and swimlane cells all fold by category now. 2. Column visibility split from the status filter. Hiding a column used to write the other six built-in keys into statusFilters, so hiding Backlog silently dropped every CUSTOM status in other categories. Display state is now its own field. The saved-view baseline also stopped deleting custom status filters on reopen. 3. The board/list right-click menu reads the shared catalog options, so every entry point that can set a status offers the same set. 4. Remaining presentation consumers resolve a category instead of guessing: gantt bars were keyed by IssueStatus and went colorless on any custom key; hover card, sidebar, search, tab strip, inbox row, create toast and the activity feed all resolve explicitly now. 5. Reorder is one atomic statement behind a new endpoint, and the client submits ACTIVE rows only. The per-row PATCH loop half-committed when an archived row rejected mid-sequence. Also: one catalog observer per card instead of three, and the stale tasks/todo.md draft is gone. MUL-6243 Co-authored-by: multica-agent <[email protected]> * fix(issues): atomic reorder, category behavior sweep, rollout-safe grouping Second review round. 1. Reorder is now one transaction holding the catalog's SHARED lock. The old shape validated ids, THEN ran the UPDATE — an archive committing in that window left the UPDATE silently skipping the archived row while the earlier rows were rewritten, and still returned 200. The authoritative active set is read under the lock and the payload must match it EXACTLY: a partial order would assign positions from the array index and collide with the rows it left out. Affected rows are checked against the payload before commit. 2. Swept the Web behavior consumers that still compared raw keys, so a custom category actually inherits behavior: gantt hide-completed, batch backlog parking, done/cancelled strikethrough, the mark-done button, sub-issue progress, search cancelled-demotion and mention demotion/dimming. issueBehavesAs is the one predicate they share, and it fails toward showing more and asking more. Two glyph sites (gantt row, swimlane lane) were still painting custom in_review as Todo. 3. Rollout safety. status_category is a contract this PR introduces, and the default-off creation flag does not guard the request path, so a new Web build would have 400'd against any un-upgraded pod — for every workspace. The client now sends it only once the catalog confirms the workspace HAS a custom status, which can only be true if the fleet already serves this version. Everyone else keeps the exact request they made before. That also uncovered a real defect: plain status grouping 500'd on a workspace holding custom statuses, because the descriptor rejected any non-built-in value and failed the whole response. 4. One catalog read per resolve instead of three, and the common path reads none at all. Pinned with observable scan-count tests rather than an assertion that the SQL string matches. 5. A custom-key filter no longer routes on a guess. categoryOf answers todo for anything unloaded, so a saved qa filter paged the todo column while the query still restricted status=qa — empty on every cold load, permanent if the catalog fetch failed. Unresolved custom keys now contribute no column until the catalog is authoritative; facet counts drop unknown keys rather than inflating todo. MUL-6243 Co-authored-by: multica-agent <[email protected]> * fix(issues): swimlane category axis, catalog tri-state, tests with teeth Third review round. 1. Swimlane was only half-converted. The compound spec sent secondary=status_category unconditionally — the same rolling-deploy hole the board/list branches had — and the client row matcher then compared the raw key, so a qa row the server correctly folded into the in_review cell was thrown away again. Both now follow the same hasCustomStatuses switch, and the matcher knows which axis it is on. 2. The cold-load fix did not actually make the surface wait. Returning an empty column set is indistinguishable from "narrow to nothing", so the surface fetched nothing and rendered an empty board with no spinner; on catalog failure that was permanent, with no retry. Resolution is now pending / resolved / error: pending and error suspend fetching AND hold loading, error surfaces isStatusCatalogError. Fetching is suspended rather than left to fall back to all columns, which would briefly show the UNFILTERED board to someone opening a saved qa view. The group contract also joins the branch cursor identity, so a status cursor cannot ride into a status_category request. 3. Two tests were false-green and are replaced: - The reorder race archived BEFORE calling reorder, so the active-set check caught it even with the lock deleted. It now reproduces the real interleaving (reorder parked on the lock, archive commits, then reorder proceeds) and fails without the shared lock — with a 200, which is the actual hole. - The catalog read count read pg_stat_user_tables, whose counters flush asynchronously, and asserted delta <= 1 — vacuous at zero. It now counts through an injected querier and asserts exactly 1 read and 0 category-expansion reads. Also: plain status grouping 500'd on a workspace holding custom statuses (the descriptor rejected non-built-in values and failed the whole response) — that is the path the Table view and every client fallback use. And the chat mention items dropped status_category, so a custom done status in Current/Recent rendered as an active Todo. MUL-6243 Co-authored-by: multica-agent <[email protected]> * fix(issues): give the status catalog failure a real user path Fourth review round. One group, both halves of it. A failed status catalog suspends row fetching, because a custom status filter cannot be routed to a column without it. That left isLoading and isEmpty both false, so IssueSurface fell straight through to the content branch and rendered an empty view with no explanation and no way out — the controller's isStatusCatalogError had no consumer. The catalog now carries its own `retry` (React Query's refetch) rather than just a failure flag, the controller re-exposes it, and IssueSurface renders an explicit error state ahead of loading/empty/content. Component test covers the full loop: catalog rejects, error appears with no row requests issued, clicking retry succeeds, fetching resumes. The blocking condition is also narrowed. A BACKGROUND refetch can fail while the last successful catalog is still cached, and error-before-loaded meant that perfectly serviceable snapshot got a retry screen in front of it. Only a failure with no data behind it blocks now; otherwise the last successful catalog keeps resolving. That rule lives in status-behavior.test.ts, where it can be asserted directly. The component test asserts the wiring — it was originally written to claim the rule too, but reverting the narrowing did not fail it, so the claim moved to the layer that actually holds it. MUL-6243 Co-authored-by: multica-agent <[email protected]> * fix(issues): accept custom status keys in table filters.statuses Filtering a board, list or table by a custom status 400'd the whole request: `filters.statuses` carries the exact keys the user picked, and the table query still validated that list against the 7 built-ins. Every surface errored out instead of narrowing — the one thing the filter work in this PR exists to do. The predicate is a parameterized `status = ANY(...)`, so an unknown key simply matches nothing; the check is now non-empty and length-bounded. Found while writing the post-deploy verification steps, which is where the gap became obvious: "filter by QA" was step one and it 400s. MUL-6243 Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
2832f003ae |
[MUL-6269] Use Redis for channel WebSocket leases (#7055)
* feat: move channel websocket leases to Redis Co-authored-by: multica-agent <[email protected]> * fix: address Redis lease review feedback Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
0a1357498a |
MUL-6247: rate limit workspace invitations (#7030)
* feat(workspaces): rate limit invitations (MUL-6247) Co-authored-by: multica-agent <[email protected]> * fix(workspaces): preserve invitation rate budgets Co-authored-by: multica-agent <[email protected]> * fix(workspaces): avoid partial limiter failures Co-authored-by: multica-agent <[email protected]> * fix(workspaces): relax recipient invitation limit Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
4d495056e8 |
[MUL-6139] Expand Plugin skills and hosted MCP connections (#6920)
* feat(plugins): add remote MCP runtime Co-authored-by: multica-agent <[email protected]> * chore(plugins): add private Exa example Co-authored-by: multica-agent <[email protected]> * docs(plugins): remove private plugin guide Co-authored-by: multica-agent <[email protected]> * fix(plugins): stabilize remote MCP tool digests Co-authored-by: multica-agent <[email protected]> * fix(plugins): preserve remote MCP notification status Co-authored-by: multica-agent <[email protected]> * fix(plugins): make Exa example runnable end to end Co-authored-by: multica-agent <[email protected]> * fix(codex): record MCP tool calls in agent log Co-authored-by: multica-agent <[email protected]> * chore(plugins): add community skills example Co-authored-by: multica-agent <[email protected]> * feat(plugins): support multi-file skill bundles Co-authored-by: multica-agent <[email protected]> * feat(plugins): add Mobbin MCP example Co-authored-by: multica-agent <[email protected]> * chore(plugins): bundle full Matt Pocock skill set Co-authored-by: multica-agent <[email protected]> * feat(plugins): add remote MCP OAuth connections Co-authored-by: multica-agent <[email protected]> * fix(plugins): gate enable on remote MCP readiness Co-authored-by: multica-agent <[email protected]> * fix(plugin): authenticate remote mcp credential broker Co-authored-by: multica-agent <[email protected]> * fix(plugins): harden remote mcp oauth flow Co-authored-by: multica-agent <[email protected]> * refactor(plugins): use a single feature flag Co-authored-by: multica-agent <[email protected]> * fix(plugins): clean up remote MCP OAuth state Co-authored-by: multica-agent <[email protected]> * chore(plugins): remove Matt Pocock example Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Lambda <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
d467cc9069 |
MUL-6075: complete Plugin V1 execution chain (#6853)
* feat(server): add Plugin V1 lifecycle foundation Co-authored-by: multica-agent <[email protected]> * feat(server): complete Plugin V1 execution chain Co-authored-by: multica-agent <[email protected]> * feat(web): add temporary plugin settings demo Co-authored-by: multica-agent <[email protected]> * refactor(plugin): keep foundation free of demo content Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Lambda <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
5e569fa38a |
fix(workspace): derive new issue prefixes from the slug, editable at create (#6815)
Every workspace named only in Chinese (or Japanese, Korean, emoji…) was
created with the issue prefix "WS": the server derived it from the
workspace NAME by stripping non-ASCII letters, and a CJK name strips to
nothing. Not a collision risk — a certainty, for every such team, with no
way to change it in the create flow.
New workspaces now derive their default prefix from the slug, which the
same form already forces the user to pick in ASCII, and the onboarding
field is editable instead of a read-only preview. The client preview and
the server derivation are now the same algorithm, so the create screen
stops promising an identifier the user won't get.
Existing workspaces are untouched by design: no backfill, and the
read-time fallback for rows with an empty prefix keeps the old
name-based derivation (now legacyIssuePrefixFromName, frozen and
test-guarded). Identifiers are computed at read time, so moving that
path would have rewritten every historical issue identifier in those
workspaces.
Also validates issue_prefix as ^[A-Z0-9]{1,10}$ on create and update.
Settings guarded this client-side, but the API took anything — CJK text
or a 100-character string went straight into the column.
Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
|
||
|
|
b4692a192d |
MUL-6048 fix(github): withhold close intent when a PR identifier is ambiguous across workspaces (#6808)
* fix(github): withhold close intent when a PR identifier is ambiguous across workspaces Issue prefixes are deliberately not globally unique (#2797) and issue numbers restart at 1 in every workspace, so once #5183 fanned PR webhooks out to every workspace bound to an installation, "Closes ABC-100" could resolve to a genuine — but different — issue in each of them. Each workspace then acted on it independently, silently advancing an issue in a workspace that had nothing to do with the PR (#6804). Resolve the collision before any workspace links: count how many bound workspaces resolve each closing identifier, and where more than one does, write the link rows but withhold close_intent, which keeps the auto-advance gate shut. Links stay visible so a human can see and unlink the wrong one; only the irreversible half is suppressed. The workspace that legitimately owns the PR also loses auto-complete for that identifier — deliberate, since nothing in the event says which one that is. A warn log names the colliding identifier, because an issue moving to done for no visible reason is otherwise untraceable back to GitHub. The pre-pass only runs for closing identifiers on installations with more than one binding, so the single-workspace case does no extra work. Co-authored-by: multica-agent <[email protected]> * fix(github): make the close-intent verdict authoritative and fail closed Review found the ambiguity scan could fail open, and that its verdict was not the same state the write path acted on. Fail open: the scan reused helpers that fold read failures into permissive defaults — workspaceAutoLinkPRsEnabled returns true when the settings blob cannot be read or parsed, getIssuePrefix returns "" on a failed load, and lookupIssueByIdentifier reports any query error as "no such issue". One transient failure could count two real resolvers as one, and both workspaces would then advance to done on merge — the exact outcome the change exists to prevent. Not authoritative: the scan produced a denylist, then mirrorPullRequest- ForWorkspace independently re-read the toggle, the prefix and the issue and decided close intent from those reads. Anything changing in between (an admin enabling auto-link, a prefix edit, a same-numbered issue created) reopened the hole. Replace the denylist with closeIntentPolicy, an allowlist recording which single workspace was proven to own each closing identifier. Absence denies, so the zero value returned by every indeterminate read permits nothing, and the mirror pass can only narrow the verdict, never grant what the scan withheld. Recording the owner rather than a bare "allowed" also closes the scan-to-write window: a workspace that grows a same-numbered issue in between is not the recorded owner and still cannot act. Surface the reads the scan depends on instead of swallowing them: autoLinkPRsEnabledForWorkspace now returns a parse error, and the scan distinguishes pgx.ErrNoRows (a proven miss) from an error it cannot interpret. Existing callers keep the permissive default. Extract issuePrefixForWorkspace and issueNumberForPrefix so the scan derives both from the row it already holds, which also drops a redundant workspace read per binding. Tests: a table test pinning that the policy denies by default, an integration test that an unreadable bound workspace withholds close intent (fails with the old fail-open behavior), and one that a proven unique resolver among several bindings still auto-completes, so the fix cannot over-suppress. Co-authored-by: multica-agent <[email protected]> * docs(handler): split the merged getIssuePrefix / issuePrefixForWorkspace doc comments Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
b904e6b71c |
MUL-6029: docs(daemon): correct two comments #6604 made false, and enforce the Slack/Lark brief policy (#6771)
Three follow-ups to #6604: two comments the merged diff made false, and one policy the tests stated but did not enforce. - `handler.go` said the brief turns `chat_channel_delivers_files` into upload-or-describe copy. After #6604 the per-turn prompt does, and the brief states no verdict at all — it is the prompt cache prefix, and a per-turn verdict there renders two briefs (MUL-5377). - `outbound_media.go` cited `TestDeliverAttachments_AdmissionBoundsTheLookupStage` for a sentence about turns carrying no file; that test's turns all carry one. The paragraph now states both ways admission is held without a pending slot and cites the one the test drives. - `TestBriefSurfaceDeliveryPolicy`'s `chat_slack` and `chat_feishu` rows denied two phrases where the WeCom rows deny four, so a denial worded around "does NOT apply" passed. Both rows now carry the same four. Review follow-up (tests only): `TestBriefChannelDeliveryCopyIsPlatformNeutral` holds the same policy structurally — with the display name substituted out, every channel-backed brief must be byte-identical, so a per-platform position fails whatever wording it uses. Slack/Lark `mustHave` reaches parity with WeCom, `mustNot` failures quote the real match instead of a section it may not be in, and the admission test asserts `pending == 0` rather than implying it. No behaviour change: two comments and test coverage. |
||
|
|
90eadd940a |
MUL-5905: feat(wecom): send the files an agent produced into the chat (#6604)
* feat(wecom): send the files an agent produced into the chat An agent that produced a file and answered in WeCom delivered only the sentence. `multica attachment upload` had already run, the object was in storage, and CompleteTask had bound the row to the assistant message — but nothing downstream of that bind knew how to reach a WeCom conversation, so the file stopped there. The user read "here is the forecast" and got no forecast, with no error anywhere to explain it. Worse when the agent said nothing at all: an empty completion with a file bound to it returned early, so the artifact was thrown away silently. This adds the last hop. media_upload.go carries a file up the long connection — WeCom has no REST endpoint for this, so it goes as aibot_upload_media_init / _chunk / _finish over the same socket, and all three read their verdict through the request() layer #6590 generalized. outbound_media.go is the caller: after the answer is out, it reads the bound attachment out of object storage, decides what to call it, and pushes it in as its own message. Three decisions worth naming. The answer goes first and the file never blocks it. An upload is megabytes and round trips and it can fail; delivery runs after the words are out, on its own goroutine and a budget of its own, and its worst outcome is one extra line saying a file did not make it. A lost ack and a refusal are opposite failures. A chunk whose verdict never came is offered once more — the protocol makes re-sending idempotent, and losing one slice of a hundred must not cost the file. A chunk the server refused is not, because the refusal is its answer and will be its answer again. The push of the finished media follows the same rule in reverse: a timeout is reported as-is rather than retried, because the same media_id sent twice puts the file on the user's screen twice with nothing to undo. WeCom validates bytes against the msgtype, so what to call a file is a decision. A kind past its ceiling is demoted to a plain file rather than sent and refused — a card the user can open beats a photo the server rejected. WithAttachments is what turns this on, and router.go passes it only when the deployment configured object storage. Without it the adapter behaves exactly as it did before. * fix(daemon): stop telling a WeCom agent its conversation is text-only The per-turn chat prompt and the runtime brief both decided file delivery by asking "is there a channel at all". Any non-empty ChatChannelType got the text-only paragraph: `attachment upload` does not apply here, describe the file in words, never write its local path. That was true of every IM channel when it was written, and it stopped being true for WeCom, whose adapter now reads the bound attachment out of object storage and sends it into the room. So an agent on WeCom was instructed not to use the one command that would have delivered its file. The capability shipped and the agent was told it did not exist. The delivery question is per-adapter, not "is there a channel", and ChannelCarriesFiles is where that answer now lives. Both call sites branch three ways instead of two. The new copy is deliberately built from main's existing web-chat branch rather than written fresh. Both open on the same clause — "To include a file or image you produced in your reply, run `multica attachment upload <local-path>`" — and diverge exactly where the platforms diverge: web chat says the file "appears as an attachment card below it" and offers a markdown snippet to place it inline; WeCom says Multica "sends it into the WeCom conversation as a separate message right after your text" and that "there is no way to place it inline". That last clause is the one worth having. An agent told only that files work here writes "see the chart below" and then nothing appears below, because the long connection has no msg_item and a picture cannot be embedded in the reply. Slack and Lark keep their existing text-only wording untouched. Two comments are rewritten rather than carried over, because they asserted the binary the same commit falsifies — "keys off 'is there a channel at all'" in prompt.go, and "any non-empty channel type means the reply leaves Multica for an external IM platform, where `attachment upload` has nothing to bind to" in the brief. A comment that contradicts the code beneath it is worse than none. ChannelCarriesFiles names every channel type this package knows rather than leaving Slack and Lark to the default, so a fourth adapter surfaces here as a decision to make. An unrecognized type answers false: the agent is told to describe its file in words, and the worst case is a deliverable that could have been sent was not. False the other way would have it promise a delivery nothing performs. * fix(wecom): stop claiming more certainty than the system has Three review blockers, one shape: a promise made in one place that another place cannot keep. Capability came from the channel type. The daemon answered "this conversation takes files" from `chat_channel_type == "wecom"`, but the last hop only exists where the server wired object storage — so a WeCom deployment without storage had its agents told to send files nothing would carry, and a new daemon on an older server inferred a capability that build has no code for. The server now answers it per turn on the claim (`chat_channel_delivers_files`), declared by the same `if store != nil` branch that gives the adapter the storage, and absent/false everywhere else. Upload limits came from the framing. 50 MB is what 100 chunks of 512 KB can describe; WeCom's own OpenClaw plugin puts the hard file ceiling at 20 MiB, so that is now the cap, enforced against the attachment's recorded size before any byte is read. Concurrency was a fixed 4 regardless of size; WeCom's own SDK steps it down — all at once up to 4 chunks, 3 up to 10, 2 past that, because their backend answers a burst of chunks on a large upload with a system error. Failure handling had two words for three outcomes. A send whose ack never came was reported as a definite failure although the file may well be in the chat, and the local failures — a full pending queue, a slot wait that expired, a lookup that errored — were reported to nobody at all. There are now three states: delivered says nothing, definitely_failed keeps the definite wording, and unknown gets wording that holds either way and is never resent, since a duplicate file cannot be taken back. The attachment lookup moved ahead of the rationing so a shed delivery knows there was a file to apologise for, and a turn with nothing attached still says nothing. * fix(wecom): bound the attachment lookup, and stop calling an attempted write a failure Two gates, not one better place for one gate. The pending counter is claimed after the lookup so a shed can name a real file, which is also why it cannot bound the lookup: at the point it would have to be claimed, whether the turn carries a file is exactly what is not yet known. admitAttachmentDelivery is claimed before the spawn and bounds the goroutines and the queries they run. Its cap is twice the pending cap, so a backlog meets the honest post-lookup shed first and only reaches the silent one when the system is already well past it. A refusal there is logged and not spoken — telling a user their file was dropped on a turn that may have carried none is the false alarm the post-lookup gate exists to prevent. Separately, a WebSocket write error stopped being read as proof of non-delivery. wsSender.write already distinguished the stages for tracing; it now carries that out in the error. Past WriteMessage the frame may be at the peer — a half-closed connection reports "broken pipe" to the writer for bytes the reader has — so those classify as unknown and are never resent. What fails before the write is provably local and stays definite. Tests drive the real path rather than the counters: one blocks the lookup, submits past the cap, and counts the goroutines and queries that actually happen; one hands the frame to a connection that records it and then fails. Both fail without the change. fakeObjectStore takes a lock, because it is no longer only ever driven inline. * docs(wecom): cite the size limits to WeCom's docs, not a third-party constant The upload ceilings were sourced to Tencent's OpenClaw plugin. That file carries the right numbers but cites nothing itself, so it corroborates rather than establishes them. WeCom documents all four on aibot_upload_media_init's body.total_size row, and repeats them on the classic media upload API. Point at those. * fix(wecom): drop the two media helpers #6605 brought in ahead of this branch This branch carried its own cleanShort filename and extension helpers because nothing else in the package had them when it was written. #6605 has since merged, and it brings both — so the package now declares each twice and does not compile against current main. GitHub still reported the branch mergeable and CI was green, because both were measured against a main that predated that merge by six hours. Upstream's copies are the ones to keep: its cleanMediaFilename also strips control characters out of a name, and its mediaExtension drops a charset parameter before matching. Neither of those was in the copies here. * fix(daemon): keep the file-delivery verdict out of the cached brief chat_channel_delivers_files arrives on every claim and answers a deployment question: does this server carry a file the last hop into the room. Upgrading a server so it starts sending the field, or turning object storage on or off, flips it under a chat session that is already running — and the brief branched on it, so one resumed session rendered two different briefs. The brief is the prompt-cache prefix (MUL-5377), which makes that a miss on every turn after the flip. The two rows in the delivery test asserting different briefs for the two verdicts were the exposure written down: they are what one session sees in sequence. The brief's channel-backed chat copy now names the platform and says the answer is stated per turn. It holds on either verdict and contradicts neither. The verdict has a home already — the per-turn chat prompt, whose branch was correct and is untouched — and that message is emitted on every turn, so nothing is lost by the brief declining to answer. Slack and Lark take the same copy. Scoping it to WeCom would put a channel-type decision back into the brief, which is what ChannelCarriesFiles exists to prevent, and would hardcode a denial a server-side change can falsify. Coverage moves rather than goes. TestBriefByteIdenticalAcrossRunsForEveryKind gains a WeCom kind and a variant that flips the field; both fail without this change, and the existing Slack kind fails on the variant too. The delivery test's two WeCom rows assert the inverse property now — one brief for both verdicts — and the upload/describe split they used to hold is pinned by TestBuildChatPromptTwoLayerChannelPolicy on the per-turn prompt, which has covered both branches all along. * docs(wecom): correct what the admitted cap implies, and note that delivery cannot answer back The comment on the two counters said reaching the admitted cap means the pending cap is full and as many turns again are still in their lookup. It does not. Admission is held for a goroutine's whole life, including turns whose lookup finds no file and which therefore never claim a pending slot — TestDeliverAttachments_AdmissionBoundsTheLookupStage fills admission with pending at zero. The two-counter split and the ordering it buys are unchanged: a backlog of file-carrying turns still meets the honest post-lookup shed first. The package doc gains the fact that an agent cannot learn its delivery was refused. `multica attachment upload` returns once the object is in storage and bound to the reply, while the send into the room runs on EventChatDone, after the run has ended, so everything the adapter knows about a failed delivery goes to the person in the chat and to nobody else. |
||
|
|
0db0055964 |
fix(wecom): stop blaming an admin's credentials when the fault is ours (#6589)
* fix(wecom): stop blaming an admin's credentials when the fault is ours RegisterWecomBYO's default branch answered 400 with "check the Bot ID and secret from the WeCom admin console". Every failure that is not one of the three ownership conflicts lands there — a Postgres blip, a bad encryption key, a bug of ours — and all of them tell the admin their credentials are wrong. That is not a cosmetic mistake. It sends them to the WeCom console to rotate a secret that was fine, and a rotated WeCom long-connection secret cannot be recovered: the console shows it once. A thirty-second database failover costs them a working bot. Split by whose fault it is. validateInstallationParams now wraps ErrInvalidInstallationParams, so a missing field is identifiable as the caller's to fix and keeps its 400. Everything else falls to a 500 that says so plainly and states that the credentials were not changed, logged at Error with the workspace and agent rather than Warn. Each branch also carries a stable code next to the English sentence, through a new writeErrorCode helper. The settings tab reads it (errorCode, alongside the existing dispatchReasonCode) and renders its own localized string, so a zh-Hans admin stops being toasted English. The sentence remains the fallback for a server that sends no code. Rebased onto main, which brought #6594's credential probe into the same switch. The two are complementary and both are kept whole: #6594 decides whether WeCom refused the pair (ErrCredentialsRejected) or could not be reached at all (ErrCredentialsUnverifiable); this change decides what the admin is told about each. Both now carry their own stable code — wecom_credentials_rejected (400) and wecom_credentials_unverifiable (503) — rather than folding into wecom_install_rejected, which means "you left a field out" and must keep meaning only that: the three call for three different actions from the admin. The full matrix is now one named function, writeWecomInstallError, instead of a switch inline in the handler: bot already owned (same ws / archived agent / other ws) → 409 required field missing → 400 WeCom refused the credentials → 400 WeCom unreachable, credentials unverified → 503 anything else (DB, encryption key, a bug) → 500 Seven new keys in en/ja/ko/zh-Hans, key sets verified identical across all four at 45 keys each. Tests. At the handler level, a table-driven test pins HTTP status + error + code for all seven outcomes, plus a wrapped sentinel and a second test that every outcome's code is distinct — the layer where a bad merge lands and where nothing was asserting before, since the wecom-package test only proves sentinel matching and the frontend test only mocks the code. Dropping either credential arm fails the matrix test; giving two outcomes one code fails the distinctness test even though both are 400 and the status assertions still pass. In the wecom package, every missing field is identifiable as a caller error and names itself, complete params pass, and an infrastructure error does NOT match the sentinel. Four frontend tests cover the localized render, both new codes, and the no-code fallback. * test(wecom): pin each install outcome's own fallback sentence The matrix test's header said it pinned status + error + code, but the table carried no expected sentence and the assertion was only that one was non-empty. That leaves the case it was written to catch: a resolution that keeps every status and code correct and hands one outcome its neighbour's sentence still passes, and a client too old to know the codes then shows the admin the wrong next action — "check your credentials" for an outage, or "wait and retry" for a pair WeCom actually refused. The sentence is a contract with those clients as much as the status is, so the table now carries it and the test compares it exactly. Verified by crossing the rejected/unverifiable sentences in writeWecomInstallError: before, green; now, three rows fail with the status and code still right. Co-authored-by: multica-agent <[email protected]> * docs(wecom): un-orphan the validateInstallationParams comment, drop the stale 500 claim Two problems in the same block. ErrInvalidInstallationParams was declared between validateInstallationParams's doc comment and the function, so godoc read "validateInstallationParams is a lightweight pre-write check" as documentation of the error value and left the function undocumented. And the text said everything that is not one of these belongs on a 500, which stopped being true when #6594 added ErrCredentialsRejected (400) and ErrCredentialsUnverifiable (503). Left as-is it invites the next reader to collapse exactly the distinctions this change spent its effort drawing. Comment split back onto what each one documents, and the classification now names all three outcomes and points at writeWecomInstallError, which holds the matrix. No behaviour change. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
02d06229db |
fix(wecom): prove control of the bot before the install reclaim destroys somebody’s row (MUL-5891) (#6594)
* fix(wecom): prove control of the bot before the install reclaim destroys somebody's row idx_channel_installation_type_appid is UNIQUE on (channel_type, config->>'app_id') with NO workspace in it, so a bot id's routing slot is global. ReclaimDeadChannelInstallationByAppID then hard-deletes the row holding that slot along with every channel_user_binding, channel_chat_session_binding and pending binding token beneath it. Nothing verified that the caller has anything to do with that bot. Bot ids are not secret — every member using the bot can see one — so an admin of ANY workspace could type a known bot id with a junk secret and destroy a revoked owner's installation and all of its bindings. The reclaim query's own comment states the premise this restores: "workspace B, which proves control by holding the same app credentials, rebinds." That sentence describes a check that did not exist. Upsert now probes first: one connection, one subscribe, no read loop, no registration, nothing else in the process learns it happened. A rejection (ErrCredentialsRejected) is the one answer entitled to blame the admin's credentials. Everything else is ErrCredentialsUnverifiable and answers 503 — reporting an unreachable WeCom as a wrong secret sends the admin to rotate one that was fine, and a rotated WeCom secret cannot be recovered. A nil probe keeps the old behaviour, so a deployment that cannot reach WeCom at install time can still install. DingTalk already validates credentials at install; WeCom was the outlier. Four tests. Removing the probe fails two of them with the cost stated: "Upsert reached the write path with an unproven credential — the reclaim would have destroyed the current holder's installation and its bindings". * fix(wecom): decide who owns the bot before the probe touches WeCom Review follow-up on the install control check. The probe is not a read. It subscribes, and WeCom allows exactly one live subscriber per bot, so subscribing displaces whoever is connected. Firing it at the top of Upsert meant a request that was about to be REFUSED still knocked the rightful owner offline first: their supervisor reconnects, the caller repeats the request, and an install nobody was permitted to make becomes a denial of service against a live bot. The active / archived / other-workspace conflicts were only detected further down, at the unique index. Upsert now runs the whole sequence in one transaction, in order: take a transaction-level advisory lock on (channel_type, bot_id), read the current owner inside it, refuse every live owner other than the caller's own row, and only then probe, reclaim and write. A refused install reaches WeCom zero times and leaves the owner's row byte-for-byte where it was. The lock closes the TOCTOU window a pre-read outside the transaction would leave open: a racing install parks on the lock instead of reading a stale "nobody owns this". Two supporting changes: Only the codes WeCom documents as a refusal of the pair (40001 invalid secret, 40013 invalid corpid) are reported to the admin as a wrong credential. Everything else non-zero — 45009/45033 throttling, platform failures, codes added since — fails closed as unverifiable, with the raw code and message logged. WeCom guarantees nothing about non-zero codes except that they are not success, and telling an admin to rotate a long-connection secret that was fine destroys it: it cannot be recovered. Codes only, never errmsg text. Proof of control no longer has an off switch. NewInstallationService defaults to the real handshake probe and returns ErrProbeRequired on an explicit nil, and Upsert refuses to run without one, so no wiring mistake can reopen the destruction path. Tests inject a fake explicitly. New sqlc queries: LockChannelInstallationAppIDSlot (the serialization boundary) and GetChannelInstallationSlotOwnerByAppID (LEFT JOINs so an orphan row survives the read and stays distinguishable from a live owner). Rebased onto main. |
||
|
|
eb59e24010 | fix(wecom): cap the request bodies both WeCom JSON endpoints read (#6586) | ||
|
|
040bb97b0f |
feat(wecom): WeCom (企业微信) smart-bot integration via the Channel engine (MUL-5226) (#5833)
Adds the WeCom (企业微信) smart-bot integration built on the unified Channel engine — inbound chat, /issue, inbox notifications, install/revoke UI and an explicit user-binding flow. Co-authored-by: seacen <[email protected]> Follow-up: #6547 |
||
|
|
c3577cb04b |
feat(dingtalk): add DingTalk bot integration (MUL-3958) (#4829)
Adds a DingTalk (钉钉) bot integration on the bring-your-own-app model: a workspace admin creates their own Stream-mode robot and pastes its AppKey / AppSecret, so no public webhook or OAuth redirect is required. Each agent gets its own bot identity, so several agents can be distinct, separately @-mentionable contacts in one DingTalk organization. Supports DMs, @-mentions in groups, inbound images, /issue quick-create, and /new. Built on the shared channel engine (ForceFresh/BareFresh, MediaResolver / MediaRef and the intent ledger) rather than a private implementation. Off unless MULTICA_DINGTALK_SECRET_KEY is set. Docs in en/zh/ja/ko. Closes #4791. Community-maintained: @yyclaw is the code owner for server/internal/integrations/dingtalk/. |
||
|
|
f13969b996 |
refactor(chat): generate quick actions server-side via the LLM layer (MUL-5573) (#6214)
* refactor(chat): generate quick actions server-side via the LLM layer (MUL-5573)
Follow-up suggestions were produced by a second, full provider CLI invocation
per chat turn: the daemon resumed the just-finished session and ran a
suggestion-only pass. That pass inherited the main turn's exec options, so its
20s budget had to cover process spawn, every MCP handshake, session replay, and
model reasoning at the agent's own thinking level — typically 8-15s of visible
skeleton, and every turn paid two provider cold starts.
Generate them here instead, through the same pkg/llm layer that backs chat
auto-titling. Suggestions need no tools, workdir, or agent identity — only the
tail of the conversation — so a bounded 8s call on the deployment's small model
replaces the whole resumed turn.
Quality changes that came with the move:
- The prompt now states the frame explicitly ("you write FOR THE USER"). The
old pass ran inside the agent's session and inherited the runtime brief's
identity, which drifted suggestions toward agent-operations actions.
- Previously-offered labels are replayed as ALREADY SUGGESTED. The old
architecture had the opposite effect: on providers that append on resume,
each pass saw its predecessor's JSON and anchored on it.
- A failed generation broadcasts failed=true. Before, a timeout delivered an
empty array — indistinguishable from "nothing worth suggesting", so every
slow pass read as a quality problem.
- The in-band footer is still stripped from replies but its actions are now
discarded, so a pre-upgrade session is not pinned to the retired
suggestions with the replacing pass suppressed.
The refresh path no longer enqueues an agent task: it validates the target and
calls the same generator, which also drops the not-resumable refusal — a session
whose runtime was rebound can now be refreshed. Client contract is unchanged
(chat:done pending flag, chat:quick_actions supplement); the only frontend
change is the pending window, resized from 30s to 12s to match the new budget.
Also removes the daemon's TMPDIR-after-cleanup hazard by construction: the old
pass started after runTask's defers had already deleted the temp dir it was
still pointed at.
Co-authored-by: multica-agent <[email protected]>
* refactor(chat): drop the quick-actions opt-out setting (MUL-5573)
Suggestions are always on. The Settings → Chat toggle is removed along with
the whole per-turn opt-out path it fed: the persisted client preference, the
quick_actions_enabled send field, the quick_actions_disabled task stamp, and
the eligibility gate that read it.
The toggle predates server-side generation, when it could only hide chips a
provider pass had already paid for. Now that generation is a bounded call the
server decides on, an off switch buys nothing a user would miss, and it was
the last piece of UI implying the feature might be unavailable.
agent_task_queue.quick_actions_disabled is no longer written (dropped from
CreateChatTask's INSERT; the column keeps its false default). Left in place
alongside regenerate_quick_actions_for for a later drop migration — removing
columns an already-running binary still inserts would break mid-deploy.
Co-authored-by: multica-agent <[email protected]>
* fix(chat): address quick-actions review findings (MUL-5573)
Four defects from review of the server-side generation change.
1. Automatic failures were reported as refresh failures. The generator
broadcast failed=true on any LLM error, but the client turns every
failed=true into a "couldn't refresh" toast — so an automatic timeout
popped a toast for an action the user never took. This also contradicted
ChatQuickActionsPayload.Failed, which documents false for the automatic
pass. The caller now passes its origin; only an explicit refresh reports.
2. Generation context was not bound to the target turn. The pass re-read the
session's newest messages while always writing to the task it was handed,
so a turn landing between the completion callback and the detached read
supplied the context for a reply it did not belong to. Worse, a user
typing a follow-up in the second after a reply left the window ending on
a user row, which the old code treated as "nothing to build on" — that
turn silently never got pills. The window is now anchored on the target
assistant message and queried strictly before it.
3. No concurrency or idempotency bound on generation. Refresh stopped
creating a task, so the busy check could not see a pass already running:
two refreshes both returned 202, spent two upstream calls, and raced to
write one row. Nothing bounded generation process-wide either. Adds a
per-session in-flight guard (refresh now 409s on a duplicate) and a
process-wide ceiling; a shed pass still resolves the client placeholder
so no skeleton hangs on work that never started.
4. A new daemon could not safely talk to an older server. The refresh task
discriminator was deleted, so a regenerate task from such a server fell
through to the ordinary chat path: no user message, but the agent would
answer anyway and the server would persist it as a real reply. The field
is restored as a refusal marker only — the task completes empty, which is
the shape the retired pass produced and which that server writes no row
for. Not a restored execution path.
Co-authored-by: multica-agent <[email protected]>
---------
Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
|
||
|
|
577018649e |
feat(issues): support human-readable issue URLs using issue keys (MUL-5354) (#6117)
* feat(issues): support human-readable issue URLs using issue keys (MUL-5354) Closes #5987. `/{ws}/issues/MUL-123` now opens the issue, the copy-link action shares that form, and a UUID URL rewrites itself to it. Existing UUID links keep working. Backend already resolved identifiers on `GET /api/issues/{id}`, but it compared the number only — every prefix with the right number opened the same issue, so no identifier URL could be canonical. Resolution now validates the prefix against the workspace's own (case-insensitively, matching `lookupIssueByIdentifier`), and the number parser bails on int32 overflow instead of truncating a digits-only UUID group into a plausible issue number. On the client the identifier stays a presentation concern: the route resolves it to the UUID before rendering, because the realtime updaters patch `issueKeys.detail(wsId, issue.id)` with the UUID from the websocket payload. A view keyed on the identifier would sit on a cache entry no realtime event can reach and silently stop updating. Resolution reuses the request the detail view would have made anyway and seeds the UUID-keyed entry, so an identifier URL costs no extra round trip. The desktop tab title/status glyph hops through the same resolution for the same reason. The URL rewrite lives in the new route wrapper rather than IssueDetail: the inbox renders IssueDetail in a side panel, where replacing the URL would navigate the user out of the inbox. No migration — `issue (workspace_id, number)` is already unique/indexed. Co-authored-by: multica-agent <[email protected]> * refactor(issues): make the single-request guarantee for identifier URLs explicit Review flagged that opening `/{ws}/issues/MUL-123` fires two detail requests. It does not, under the app's own QueryClient — but the guarantee was resting on something implicit, so make it structural. The old shape seeded the UUID-keyed entry from a `useEffect` after resolution, while the route enabled the UUID query in the same render. That held only because the seed effect happened to be declared before the UUID query's own effect, and because `createQueryClient` sets `staleTime: Infinity` so a seeded entry is never refetched. Neither is obvious from the code, and a diagnostic run under a bare `new QueryClient()` (staleTime 0) does show two calls — the second being a staleness refetch of an already-seeded entry, i.e. a harness artifact. `useCanonicalIssueId` becomes `useCanonicalIssue`, which owns both the resolution query and the canonical detail query and hands the resolution response to the latter as `initialData`. That is applied while the observer is created, so the canonical query never observes an empty cache and never starts a fetch of its own — no dependency on effect ordering, and no cache write that could race a realtime patch (`initialData` is ignored once the entry holds data). Callers collapse to one hook each: the route no longer runs its own detail query, and the desktop page drops its duplicate. Tests now build the client with `createQueryClient()` rather than a bare `new QueryClient()`, so request-count assertions measure production behavior instead of the harness, plus a direct assertion that an identifier URL costs exactly one request. Co-authored-by: multica-agent <[email protected]> * fix(issues): stop the request loop when an identifier names no issue Opening `/{ws}/issues/ZZZ-134` never reached "not found". It spun an unbounded request loop and left the UI on the loading skeleton forever. The route treated a failed resolution as "nothing resolved" and handed the raw identifier down to IssueDetail. IssueDetail mounted a second observer on the query that had just failed; `retryOnMount` refetched it, which flipped the resolve hook back to pending, which unmounted IssueDetail, which remounted it when the refetch failed — and around again. Measured with retry disabled to isolate it: 8,192 requests at 300ms, 32,768 at 600ms. Under the app's `retry: 1` the backoff only paces the loop, it still never converges. `useCanonicalIssue` now reports a terminal `notFound` read from the resolution query's own error state, rather than leaving callers to infer failure from "not resolving and no id" — an inference that cannot distinguish failed from in-flight. `IssueDetailRoute` renders the not-found UI itself and never hands an unresolved segment to a view that would query it again, so no second observer exists to restart the cycle. Same measurement after the fix: 1 request, settled, "not found" on screen. The not-found UI moves out of IssueDetail into a shared `IssueNotFound` so both render the identical state. Regression tests at both levels, with retry off so any count above 1 can only be a remount refetch: the hook settles a failed resolution without looping, and the real IssueDetailRoute holds at one request across waits and rerenders. Both fail against the previous code. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
c25a82eee0 |
perf(agents): fast model discovery on runtime switch (MUL-5444) (#6098)
* perf(agents): make runtime model discovery fast on runtime switch (MUL-5444) Switching runtime in the agent creation form left the model picker spinning for ~8-20s. Two costs stacked up: - the list-models request sat in the store until the daemon's next scheduled heartbeat (0-15s, avg 7.5s of pure dead wait), and - the daemon then enumerated the catalog locally (static for claude, but a CLI/ACP round trip up to ~15s for everyone else). Both are addressed with the two standard techniques for a slow, low-frequency, read-only operation: push instead of poll, and stale-while-revalidate. Push (removes the heartbeat wait): - new additive `daemon:pending_work` hint, runtime-scoped, delivered through the existing daemon WS hub and the Redis relay so the API node holding the socket does the delivery. - the daemon answers a hint with ONE immediate heartbeat and dispatches what it claimed. The hint deliberately carries no work, so nothing has to be un-claimed when delivery fails and a duplicate hint cannot duplicate work - PopPending stays the atomic claim. - per-runtime coalescing plus a 1s floor keeps a caller-triggered hint from becoming a heartbeat amplifier. Cache (removes the discovery wait on repeat opens): - server-side per-runtime catalog cache (in-memory single-node, Redis multi-node) written on every successful report. - a snapshot younger than 15min answers the POST immediately as an already-completed request; older than 60s it also enqueues a background refresh that only warms the cache. - only supported, non-empty catalogs are cached; a completed-but-empty report invalidates instead, while a failed report keeps serving the last known good list. Frontend: staleTime 60s -> 5min and gcTime 30min, so a runtime revisited in the same session renders from cache and revalidates in the background instead of showing the spinner again. Compatibility: every wire change is additive. Old daemons ignore the unknown hint type and keep using the scheduled heartbeat; new daemons against an old server simply never receive one. The cached response is shaped exactly like a completed live discovery apart from the optional `cached` / `cached_at` markers. Co-authored-by: multica-agent <[email protected]> * fix(agents): address review on model discovery SWR (MUL-5444) Sol-Boy's review on #6098 found the client cache could outlive the server's own staleness promise, and that the two changed endpoints were still cast rather than validated. Must-fix 1 — client freshness now derives from the served answer. `staleTime` was a flat 5min, so a 14-minute-old snapshot (which the server returns while queueing its own refresh) was held as fresh for another 5min: observable staleness became server window + client window, and the refreshed catalog never reached the tab that triggered the refresh. `staleTime` is now a function of the query data: a `cached` answer is stale on arrival (bound stays the server's window alone, and the next mount/focus picks up the refreshed snapshot), while a live discovery — which just measured the truth — is trusted for the full 5min so a cold runtime is never re-enumerated inside one form session. `gcTime` stays 30min, so a revisited runtime still renders from cache and revalidates in the background; the pickers gate their spinner on `isLoading`, which stays false throughout. Must-fix 2 — both model-discovery responses go through a zod schema. `POST /api/runtimes/{id}/models` and its poll companion were casting network JSON to `RuntimeModelListRequest`, which the root CLAUDE.md API-compatibility rules forbid. Added a lenient schema (`status` stays `z.string()`, `supported` defaults to true, `.loose()` keeps unknown fields) plus a fallback record whose `status` is `failed`: a malformed body now surfaces "discovery failed" with manual entry still usable instead of a fabricated empty catalog or an endless spinner. `resolveRuntimeModels` was tightened to match — only an explicit `completed` is a catalog, so an unrecognised status is an error rather than a silent empty list, and `supported` can no longer be `undefined`. Nit — the in-memory catalog cache now deep-copies each entry's `Thinking` (and its level slice) and `ServiceTiers`, so it delivers the independent value its comment promises and matches the Redis backend's JSON round-trip semantics. Tests: staleTime policy for cached/live/no-data; a QueryObserver test proving the refreshed catalog reaches the same client with no blank loading state; unknown-status and omitted-`supported` handling; schema tests for live, cached, old-backend and nine malformed shapes; client tests that both endpoints degrade to an explicit failure; nested-field mutation isolation for the cache. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Eve <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
60048172a7 |
fix(lark): ingest inbound images and videos as chat attachments (MUL-4934) (#5580)
* fix: ingest feishu media as chat attachments * fix: ingest feishu post embedded media * fix(lark): make inbound media retries safe * fix lark media resource limit * fix(lark): move inbound media off ack path * fix(channel): make inbound media runs durable * fix(channel): close enqueue-vs-append race on media deferral EnqueueChatTask read the session-wide media deadline in one statement and sealed the input batch in a later one. Under READ COMMITTED a media message committing between the two got sealed into a task the deadline read had already decided was 'queued', so the daemon could claim it before its attachment bound — the agent received the bare placeholder, and the later media-ready promotion was a no-op against a non-deferred task. After the seal, re-derive the deferral from the sealed batch itself in the same transaction (DeferChatTaskForSealedPendingMedia): if any sealed message still carries an unexpired media marker, flip the task to deferred with fire_at aligned to the latest marker. The existing post-commit promote fence already covers the opposite direction (marker cleared mid-transaction). Adds a deterministic regression test that injects the media append between the deadline read and the seal via a wrapped pgx.Tx. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(channel): keep committed chat task out of enqueue error path The post-commit media-ready fence returned its error from EnqueueChatTask even though the deferred task was already durably committed. The router flush treats any enqueue error as "no task exists": it clears the typing indicator and logs an enqueue failure while the run still happens at its fire_at deadline. Log the fence failure instead — the claim-path deferred promoter re-queues the task regardless. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(channel): cap global media resolution concurrency Media jobs were serialized per session but unbounded across sessions: a burst could open arbitrarily many concurrent 45s Lark downloads, and each unknown-length upload may buffer up to the 100 MiB resource cap in memory. Gate resolveAndBindMedia behind a global slot semaphore (default 8, RouterConfig.MediaConcurrency). Per-session ordering is unchanged; on shutdown a job cancelled while waiting for a slot proceeds straight to the bounded DB finalize so marker clearing stays prompt. Also document that the per-message media budget spans queue/slot waits (it must match the persisted fire_at) and why timed-out uploads cannot leak unbounded orphans. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(chat): keep channel-sealed user messages on task cancel Sealing the channel input batch stamps task_id onto channel user messages, which exposed them to the cancel draft-restore path: an empty-transcript cancel would DeleteUserChatMessageByTask the sealed Feishu/Slack messages and detach their attachments. Those messages are the durable record of what the platform sender wrote — the sender has no Multica composer to restore a draft into. Gate the restore-delete on ChatSessionHasChannelBinding in both the synchronous finalize and the deferred finalize (the latter covers markers left by an older replica during a rolling deploy); a bound session now settles as "Stopped." instead. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(channel): skip the media pipeline for messages without media Every inbound message on a Media-enabled platform persisted a 45s media deadline and queued a resolution job, so a plain text message could wait behind the global media semaphore (its task deferred while other sessions download 100 MiB videos) and a crash between append and clear delayed a pure-text run to the full 45s fallback. Add MediaResolver.HasMedia — a pure in-memory probe the Router calls on the ACK path — and only persist the deadline / enqueue the job when the message actually references platform media. The Feishu resolver decodes the already-received payload and reports standalone image or video keys and post-embedded img/media spans. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(chat): gate cancel restore on immutable channel provenance The previous guard keyed the cancel restore-delete off ChatSessionHasChannelBinding, but a binding only proves routing exists right now: archiving a session and rebinding an installation both delete the binding while preserving chat history, so a still-cancellable sealed task could again restore-delete the original inbound messages. Persist provenance on the message instead: migration 203 adds chat_message.channel_ingested, stamped inside the channel append transaction and never mutated, and both cancel finalize paths now gate on TaskHasChannelIngestedMessages over the task's sealed batch. The binding-existence query is removed. Regression tests cover ingest -> archive/unbind -> cancel for a queued and a started task. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(channel): reclaim media uploads that never gain an attachment row Deadline expiry dropped already-resolved refs and a BindMedia failure was log-only, leaving uploaded objects with no attachment row and no reclaim path — the dedup mark commits with the message before media runs, so a redelivery is dropped as a duplicate and never re-resolves (and thus never overwrites) those keys, and workspace/session deletion only enumerates the attachment table. Add MediaResolver.DiscardMedia — a best-effort delete by StorageKey — and call it from both failure paths in resolveAndBindMedia. The Feishu resolver forwards to the storage backend's Delete. Tests cover a partial upload discarded at the deadline, discard on bind failure, and key-level deletion in the resolver. Co-Authored-By: Claude Fable 5 <[email protected]> * docs(server): refresh comments stale after detached media ingestion Channel tasks now seal a self-owned input batch, media ingestion is no longer out of scope for the flattener, and MediaRefs are filled by the detached resolver after append rather than by feishuChannel pre-engine. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(chat): stop keying channel empty-completion silence off chat_input_task_id Sealing gave channel tasks a self input-owner, which broke writeChatCompletionOutcome's discriminator: it treated any owned task as direct, so an empty channel completion wrote the no_response fallback row and the outbound patcher — which forwards any non-empty chat:done content verbatim — pushed the English fallback body to Feishu/Slack, violating the MUL-4351 contract. Silence is now decided by the immutable channel_ingested provenance of the task's input batch, looked up by the batch OWNER id (chat_input_task_id): auto-retry clones inherit the owner while their sealed messages stay tagged with the parent's id, so keying off the task's own id would misread a channel retry as direct. The cancel-path provenance gates switch to the same owner key via chatInputOwnerID. chat_input_task_id is back to meaning only "input batch owner". Co-Authored-By: Claude Fable 5 <[email protected]> * chore(migrations): renumber to 203/204 after upstream took 202 Upstream main merged 202_runtime_profile_add_qwen while this branch held 202/203, tripping TestMigrationNumericPrefixesStayUniqueAfterLegacySet on the CI merge tree. channel_media_pending becomes 203 and channel_ingested becomes 204; no content changes. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(channels): gate outbound delivery on channel provenance, not owner Merging main brought #5645 (keep direct chat replies in Multica), whose outbound gate assumed channel tasks leave chat_input_task_id NULL. Sealed channel tasks own an input batch too, so on the merge tree every channel reply and failure notice was classified as direct and silently dropped — agents stopped replying in Feishu/Slack. Both outbound gates now call engine.TaskInputIsChannelIngested: a NULL owner keeps #5645's deliver-by-default for pre-sealing tasks, an owned batch delivers only when it carries the immutable channel_ingested stamp (keyed by the owner id, so auto-retry clones inherit the verdict). Direct replies stay in Multica; sealed channel replies reach the platform. Tests cover both directions on both platforms. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(channel): discard media orphans on a fresh context, after finalize DiscardMedia shared finalizeCtx with BindMedia, so a bind that failed because the finalize deadline expired handed the storage deletes an already-dead context — the compensation silently no-opped and the orphans leaked anyway. The deadline path also ran S3 deletes before the marker clear, eating the same 5s budget the user-facing bind/promotion needed. Collect the refs from both failure paths, run bind + promotion on the finalize budget first, then delete on a fresh discard context. The bind-failure test now pins that discard receives a live context. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(channel): compensate result-uncertain media uploads and commits The compensation protocol treated "the call returned an error" as "the side effect did not happen", which is wrong in both directions across the result-uncertain windows: - An upload error can follow a server-side write (lost response, deadline mid-write). The attempted key never reached the router, so nothing could reclaim it and dedup guarantees no re-resolve. The resolver now idempotently deletes the deterministic key on a fresh budget right at the failure site. - A commit error is not a rollback guarantee: a lost ack can report failure after Postgres durably committed the attachment rows, and the router's discard would then delete objects those rows reference. BindMediaRefs now converges the ambiguity on a fresh budget — any of the batch's URLs present proves the atomic commit landed (bind reports success); none proves the rollback (discard stays safe); a failed verification returns ErrMediaBindResultUnknown and the router keeps the uploads, preferring a rare orphan over a broken attachment. Fault-injection coverage: an upload error deletes the attempted key; a lost-ack commit keeps the bound attachment and reports success; a verified rollback stays a discardable error; the router keeps uploads on the unknown-outcome sentinel. Co-Authored-By: Claude Fable 5 <[email protected]> * chore(migrations): renumber to 207/208 after upstream took 203-206 Co-Authored-By: Claude Fable 5 <[email protected]> * docs(channel): note DiscardMedia self-invocation and the unknown-outcome skip Co-Authored-By: Claude Fable 5 <[email protected]> * chore(migrations): renumber to 212/213 after upstream took 207-211 Co-Authored-By: Claude Fable 5 <[email protected]> * feat(channel): replace inline media compensation with an intent ledger and reconciler Inline best-effort compensation cannot answer "did my side effect happen?" at the moment it needs the answer — the DELETE/PUT reordering and the empty-read-vs-in-flight-COMMIT gaps were both instances of the same two-system atomicity problem. Persist the intent instead and let an asynchronous reconciler settle it: - channel_media_pending_object (migration 214; claim index 215 as its own single-statement CONCURRENTLY migration): a state machine row ('pending' -> 'deleting') with lease, attempt, and backoff columns. - The resolver upserts the row BEFORE each PUT, state-guarded so a key the reconciler owns is never resurrected (the resource is skipped). ObjectURL is a pure function of configuration, so the row carries the attachment URL pre-upload. - BindMediaRefs deletes the batch's rows INSIDE the attachment-insert transaction: commit landed <=> intents gone, atomically, so an ambiguous COMMIT never needs adjudication. A key already claimed to 'deleting' is skipped (placeholder stays). - Nothing is ever deleted inline. The reconciler — an independent worker so storage latency cannot starve other sweepers — claims due rows ('pending' past the settle delay, or expired leases) under a fresh lease, checks for a durable attachment reference only AFTER the claim (race-free: bind can no longer succeed on the key), deletes unreferenced objects outside any transaction, and backs off failed deletes with attempt-based retry. Crash windows converge for free. - The settle delay is a fixed constant carrying NO correctness weight; invariant tests pin it at >=10x every pipeline budget. Metrics cover deletes, referenced clears, delete failures, and ledger backlog. Removed: MediaResolver.DiscardMedia, ErrMediaBindResultUnknown, the post-commit verification, and both router discard branches. Tests: intent-before-upload ordering; upload error leaves the row and deletes nothing; bind-wins vs reconciler-wins on the same key; lost-ack and rolled-back commit injections (intent cleared iff the attachment landed); reconciler three-state settle; expired-lease reclaim; delete failure backoff and retry; settle invariants. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(channel): never build or sweep the media reconciler without storage store is nil when S3 is unconfigured AND the local upload dir fails to initialize, but the reconciler was constructed unconditionally and main only gates the goroutine on the reconciler pointer — the first unreferenced ledger row (rows can pre-exist from a boot where storage worked) would nil-pointer panic a bare goroutine and take down the process. Construct the reconciler only when a storage backend exists, and guard RunOnce defensively: with no deleter it skips the sweep without claiming, so rows are not stranded in 'deleting' until lease expiry. Test covers the pre-existing-row + missing-storage boot. Co-Authored-By: Claude Fable 5 <[email protected]> * chore(migrations): renumber to 213-216 after upstream took 212 Co-Authored-By: Claude Fable 5 <[email protected]> * refactor(channel): remove the dead pre-resolved MediaRefs ingress path lark.InboundMessage.MediaRefs and the resolver's early-returns for pre-populated refs were vestiges of the pre-detached synchronous design — no producer fills them before the router anymore. Worse, the intent ledger made the path actively misleading: refs arriving without ledger rows would be silently skipped at bind (with a log blaming the reconciler), contradicting the field's "already persisted" contract. Delete the field, its channelMessageFromLark mapping, and both early-returns; channel.InboundMessage.MediaRefs is now documented as what it actually is — ResolveMedia's output channel, always empty on ingress, attachable only through a claimed ledger intent. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(channel): enforce workspace tenancy on every ledger query The intent-ledger upsert's conflict branch guarded only on state, so a cross-workspace storage_key collision could rewrite the row's workspace/message/url ownership; release and delete keyed on (storage_key, lease_token) alone. The derived key embeds the workspace UUID so none of this is reachable today — but tenancy must be enforced by the workspace column in every query, never derived from the key string (MUL-3515 rule, restated in this PR's review). The upsert now updates only within the same workspace (a cross-tenant conflict updates nothing, returns no row, and the resolver skips the upload — the fail-safe direction), and release/delete take (workspace_id, storage_key, lease_token). Tests pin that a foreign workspace can neither steal, release, nor delete a row. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(migrations): build the ledger primary key via a concurrent index storage_key TEXT PRIMARY KEY created its unique index implicitly at CREATE TABLE, against the repo convention that every migration index — including a new table's unique index — is built CONCURRENTLY in its own single-statement migration (the exact three-step pattern client_usage_daily shipped in 207-209). The table now declares storage_key NOT NULL, 216 builds the unique index concurrently, and 217 attaches the primary key USING INDEX; the claim index moves to 218. ON CONFLICT (storage_key) still resolves against the constraint, and the full down/up round-trip is verified. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(channel): bound each reconciler object delete with its own timeout DeleteObject ran on the worker-lifetime context and the SDK's default HTTP client has no overall request timeout, so one black-holed connection would wedge the sequential sweep loop — and with it every later batch and the backlog gauge — forever; a single-replica deployment has no other worker to reclaim the lease. Each delete now gets a 30s timeout (well under the 2min lease), and a timed-out delete takes the existing release/backoff path. Covered by a blocking-deleter test with an injectable timeout. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(channel): anchor the media deadline to the DB clock and bound queue waits by it Two deadline gaps from review: - The persisted marker was an application-clock timestamp compared against SQL now() everywhere it is read, so a skewed app node could shrink the fallback window and hand the agent a placeholder before the resolver's local budget ended. The append transaction now anchors a relative budget (MediaPendingSeconds) with now() + make_interval, writer and readers sharing one clock; the local resolve budget stays monotonic app-side. A DB test pins that the remaining budget measured by the DB clock equals the requested one. - enqueueMedia's waits (per-session order, global slot) only watched shutdown, so in a burst an already-expired job kept its goroutine and payload until it reached the front. Both waits now also watch the message's deadline; on expiry the job skips the resolver entirely and runs only the empty finalize (marker clear + promotion), which also unblocks the session's later messages. Covered by a queued-expiry test that finalizes while the only slot is deterministically held. Co-Authored-By: Claude Fable 5 <[email protected]> * chore(migrations): renumber to 216-221 after upstream took 213-215 Co-Authored-By: Claude Fable 5 <[email protected]> * fix(channel): start the local media budget before the append transaction The DB anchors the durable fallback at insert-time now(), but the local monotonic budget started only after AppendMessage returned — so the resolver outlived the fallback by the append/commit latency, a window where the deferred task is already claimable while the resolver still runs and the agent reads a placeholder that binds moments later. Capture the local deadline before calling AppendMessage, restoring the ordering local-gives-up <= durable-fallback-fires. A slow-append test pins that the resolver's context deadline is measured from the pre-append instant. Co-Authored-By: Claude Fable 5 <[email protected]> * chore(migrations): renumber to 224-229 after upstream took 216-223 Verified against the merged tree: the numeric-prefix uniqueness test passes and the full migration set applies cleanly from scratch. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(channel): heartbeat the reconciler lease per row One claim covers up to 50 rows under a single 2-minute lease, but the batch is processed sequentially and each delete may run its full 30s timeout — a few stalled deletes could outlive the lease mid-batch, letting another replica reclaim the tail: duplicate concurrent deletes, inflated attempt/backoff on rows whose owner was alive, and skewed metrics. The lease is now renewed before EACH row's settle work, so it only ever needs to cover one row's worst case (invariant-tested: lease >= 2x the per-delete timeout). A renewal that matches no row means another worker reclaimed it after a genuine expiry — the row is skipped, leaving the new owner's state untouched. Test simulates a mid-batch reclaim and pins the skip. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(channel): dedup post media resources and make local writes atomic A rich post may reference the same image_key/file_key in several spans. The object key derives from (message, type, key), so duplicates uploaded to the SAME key twice: LocalStorage.UploadStream truncated the destination up front and removed it outright on a copy error, so a second failing attempt destroyed the object the first success had produced — leaving an attachment row pointing at nothing. A second succeeding attempt instead produced two attachment rows for one object. Collapse duplicate spans by (fetch type, platform key) before the upload loop, and write local uploads through a temp file renamed into place so a failed write can only discard its own temp file. Tests cover a duplicated span uploading once and a failed re-upload leaving the previous object intact. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(channel): fence late-materializing PUTs with a tombstone schedule A DELETE cannot be ordered against a PUT the client already abandoned: the store may materialize the object after the delete completes. The reconciler cleared the ledger row right after deleting, so such an object had no row and nothing to reclaim it — which made the settle delay the de-facto correctness barrier for the PUT/DELETE race, exactly what the design says it must not be. The row is now kept as a tombstone ('tombstoned' state, migration 226's CHECK) and re-deleted on a widening schedule (15m, 1h, 6h, 24h, the pass index carried in last_error), so a late materialization is reclaimed by a later pass; only after the schedule is exhausted is the row dropped. Claim, heartbeat, lease, and tenancy predicates are unchanged — a tombstone is claimed exactly like any other due row. A separate gauge reports tombstones so they cannot be mistaken for a backlog of objects awaiting reclaim, and the header comment now states precisely what state fences (bind/commit) versus what the schedule fences (late PUTs). Tests: the reviewer's interleaving — DELETE completes, the abandoned PUT materializes right after, and the object is gone by the end of the schedule — plus a full schedule walk asserting the object is counted once and the row clears at the end. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(channel): tombstones must re-delete, not re-ask the reference question A tombstone revisit ran the same reference check as a first settle, so an attachment carrying the same URL — a re-ingested copy of the object — sent the row down the "referenced, keep it" branch: the object was kept and the row cleared, abandoning the re-delete schedule that fences the ORIGINAL object against an abandoned PUT. A tombstone has already been judged unreferenced and deleted; it exists only to re-delete whatever materializes later, so it now goes straight to the delete + schedule tail (extracted as settleDeletedObject, shared with the first settle). Co-Authored-By: Claude Fable 5 <[email protected]> * fix(channel): keep the tombstone schedule position in its own column The re-delete pass index was encoded into last_error, which the failure path also writes: one failed re-delete erased the position and restarted the walk. A store failing intermittently could therefore keep a tombstone alive indefinitely — every recovery would resume at pass 1 and the row would never reach the end of the schedule to be dropped. tombstone_pass is now its own column (the table is introduced in this PR, so migration 226 carries it), advanced only by a successful delete, and the tombstone write clears the now-stale last_error. Test walks the schedule across a failed re-delete and asserts it resumes rather than restarts, and that the row still terminates. Co-Authored-By: Claude Opus 5 <[email protected]> * fix(lark): derive media object keys per chat message The object key was derived from the platform message alone, so a second ingest of the same Feishu message reused the first ingest's ledger row. That row can be a tombstone (up to ~31h while the re-delete schedule runs), and the intent upsert refuses anything that has left 'pending', so the second ingest skipped the upload and silently produced a placeholder with no attachment. A re-ingest is reachable: the inbound dedup claim is reclaimable once 60s stale and the dedup row is only vacuumed after 24h. Keying on the chat message the object will attach to keeps the two ingests independent, and nothing leaks: each one's objects are covered by its own ledger row. Co-Authored-By: Claude Opus 5 <[email protected]> * refactor(storage): route both local upload paths through one atomic write UploadStream wrote through a temp file and renamed into place, but the buffered Upload path still truncated the destination up front — the destructive shape the stream path exists to avoid, one caller away from coming back. Both now share writeAtomic, which also restores the 0644 the direct write used (CreateTemp makes files 0600). Co-Authored-By: Claude Opus 5 <[email protected]> * chore(channel): gofmt the media-pending append fields Co-Authored-By: Claude Opus 5 <[email protected]> * fix(storage): keep the local upload chmod best-effort The rename-into-place rewrite made a failed chmod fail the whole upload. CreateTemp's 0600 has to be widened to the 0644 the direct write used, but an upload dir on a mount that ignores chmod (SMB/NFS/FUSE) accepted the old direct write fine — turning those deployments' uploads into hard errors would be a regression for a cosmetic property. Log and continue. Tests pin 0644 on both upload paths, and that a failed buffered upload leaves no temp litter and no damage to a previous object. Co-Authored-By: Claude Opus 5 <[email protected]> * fix(channel): never re-delete an object an attachment references The tombstone pass skipped the reference check and deleted unconditionally, so a durable attachment carrying that URL lost the only object it can read — the dangling attachment the intent ledger exists to prevent, and the opposite of the posture every other path here takes ("a reclaimable orphan beats a broken attachment"). The check now runs on every pass. A positive result on a tombstone is unreachable by design — keys are per (chat message, resource) and a bind cannot attach a key that has left 'pending' — so reaching it means an invariant broke: keep the object, clear the row, log it, and count it on a dedicated reconciler_tombstone_referenced_total counter. The test's contract is flipped to assert the referenced object survives. Co-Authored-By: Claude Opus 5 <[email protected]> * fix(storage): make the local staging file reclaimable after a crash os.CreateTemp's random suffix meant a crash between the staging write and the rename left a file nothing could name: the ledger records only the final storage key, and DeleteObject removed only the object and its sidecar. Each leftover can approach the 100 MiB resource cap and they accumulate without bound. The staging path is now derived from the object key, so DeleteObject removes it alongside the object — which makes the media reconciler reclaim it too, since the intent row is written before the upload. Opening it 0644 directly also drops the chmod the previous commit had to make best-effort. Both read paths refuse the staging name (keys come from the request URL, and a half-written body should not be readable); a user-supplied ".tmp" extension is unaffected, since object keys are generated and never dot-prefixed. Co-Authored-By: Claude Opus 5 <[email protected]> * chore(channel): renumber the media migrations after merging main main took 224 (agent_task_session_rollout_missing), so the ledger group moves to 225-230 and the cross-references inside the table migration follow. main's CompleteTask also grew a sessionRolloutMissing parameter; the three call sites this PR added to chat_input_ownership_test.go pass false. Verified the way the numbering is meant to be verified: full migration set applied from scratch on the merged tree, and the whole server suite run against that database. Co-Authored-By: Claude Opus 5 <[email protected]> * fix(channel): let Postgres compute every reconciler deadline The reconciler built settle cutoffs, lease expiry, backoff and re-delete times from the process clock and compared them against the database's now(). A replica whose clock had drifted would therefore settle rows whose upload was still in flight (the object is deleted and the bind then refuses to attach — media silently lost), hand out leases that are born expired (rows churn between workers, attempt/backoff inflate), or compress the tombstone schedule that fences a late-materializing PUT. The four settle queries now take durations and derive their timestamps from now(), so every replica reads one clock. The parameter types are the guard: an app-side timestamp can no longer be passed. Test asserts the persisted lease, backoff and re-delete deadlines all track the database's now(). The generated code also picks up main's new agent_task_queue column in the two RETURNING task.* queries this PR adds. Co-Authored-By: Claude Opus 5 <[email protected]> * chore(lark): drop unrelated gofmt-only churn from this PR Six files carried whitespace/comment-reformatting with no functional change, unrelated to the inbound media pipeline. Reverting them to the base revision keeps the diff focused on the feature (75 -> 69 files): server/internal/service/empty_claim_cache.go server/internal/integrations/lark/markdown_detect.go server/internal/integrations/lark/ws_chunk_assembler.go server/internal/integrations/lark/ws_chunk_assembler_test.go server/internal/integrations/lark/ws_frame_test.go server/internal/integrations/lark/registration_test.go Verified: `git diff -w` against these files was already empty, so no behavior is affected. go vet clean; tests covering these files pass. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Claude Fable 5 <[email protected]> Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
ecce589867 |
MUL-5265: GitHub API-snapshot PR cards — CI status + mergeability (#5889)
* feat(github): API-snapshot PR cards — CI status + mergeability (MUL-5265) Fetch each linked PR's CI checks and mergeability from the GitHub GraphQL API as the single source of truth (Plan C). Webhooks, page visits and a bounded TTL sweep are refresh triggers only; nothing is inferred from webhook payloads anymore. Backend (server/internal/integrations/ghsnapshot): - installation-token cache + GraphQL client (private key / tokens never logged) - one paginated pullRequest query -> normalized per-check snapshot - outbound queue: (installation,repo,PR) dedup + single in-flight per PR, bounded worker pool, Retry-After / rate-limit backoff, jitter - head-SHA-guarded atomic batch replace (a slow response for an old head can never overwrite a newer head's snapshot) - bounded chase window (30s->5m, stops on terminal/closed) + page-visit + TTL refresh; clean degradation when no App private key is configured Removes the old suite-level webhook aggregation display path (query + handlers + tests). check_suite / check_run / status are now pure triggers. Frontend: PR card shows two independent tri-state elements (CI status + mergeability). "Ready to merge" only when merge state is clean; no-checks and unknown-mergeable never assert a positive verdict; progress strip removed; four locales; stale marker. Docs: github-integration + environment-variables (four languages) — now required App private key, read-only Checks/Commit-statuses permissions, new event subscriptions, capability boundaries and troubleshooting. Co-authored-by: multica-agent <[email protected]> * fix(github): address PR snapshot review blockers Co-authored-by: multica-agent <[email protected]> * fix(github): bound snapshot refresh scheduling Co-authored-by: multica-agent <[email protected]> * fix(github): concurrent check-run index migration + singleflight token mint Address Elon's third-round review on the MUL-5265 PR snapshot pipeline. Must-fix — migration built a non-concurrent index. The github_pull_request_check_run table declared PRIMARY KEY (pr_id, ordinal) inside CREATE TABLE, which builds a unique index synchronously and violates the repo rule that every migration-created index (including on a new table) use CREATE UNIQUE INDEX CONCURRENTLY in its own single-statement file. Split: 222 now creates the table without a primary key; new 223 adds the (pr_id, ordinal) unique index CONCURRENTLY. The atomic delete-all/insert write path already guarantees ordinal uniqueness, so a plain unique index is sufficient; the index also serves the pr_id-prefix list aggregation and the workspace/PR cleanup deletes. Nit — token mint now singleflights per installation. installationToken released the lock before minting, so the N workers of one installation could mint N tokens on a cold cache or a simultaneous renew. Concurrent callers for the same installation are now collapsed via singleflight into one HTTP mint; added a -race concurrent-mint test asserting a single mint under 16 callers. Verified: fresh DB migrates through 223 (table has no PK, concurrent unique index present); ghsnapshot suite + new test pass under -race; migration lint and handler github/workspace-delete tests pass; sqlc produced no diff; go build / vet / gofmt / git diff --check clean. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
73b0015475 |
feat(vcs): make self-hosted Git providers self-host-only (MUL-3772, MUL-5138) (#5888)
* feat(vcs): gate self-hosted Git providers to self-host deployments only (MUL-3772) The Forgejo/Gitea/GitLab integration is intended for self-hosted Multica, where Multica can reach a Git instance on the operator's own network. On the managed multi-tenant cloud it adds an SSRF surface (connect validates a user-supplied instance URL from the server) and would store third-party Git tokens for all tenants under one key, while only serving the small subset of users whose instance is publicly reachable. Product decision: offer it on self-host only. - Add an explicit deployment switch MULTICA_VCS_INTEGRATION_ENABLED (default off). Connect, rotate, and webhook now require BOTH the switch on AND a valid MULTICA_VCS_SECRET_KEY — the switch is the product boundary, not key presence alone. When off, connect/rotate return 404 and the webhook returns a bare 404 (no config leak), independent of the frontend. - /api/config exposes vcs_integration_available (mirrors the switch, omitted when false) so the Settings UI hides the whole "Git providers" section on cloud instead of surfacing an operator-only "missing key" hint. - docker-compose.selfhost.yml defaults the switch on; .env.example documents it. - Docs (en/zh) lead with a callout: available on self-hosted Multica only, not Multica Cloud, and clarify "self-hosted" means Multica itself, not just Git. #5006 / #5883 stay in place — the schema and backend capability are retained; this only gates availability. No cloud VCS connection can exist (connect always required the key, which the cloud never set), so nothing needs migrating. Verified: go build/vet + VCS/config handler tests on a fresh migrated DB (incl. a new disabled-deployment 404 test); pnpm typecheck (core + views) and the integrations-tab + core schema/config vitest suites pass. Co-authored-by: multica-agent <[email protected]> * fix(vcs): complete self-host integration gating (MUL-5138) Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
581d9527ba |
feat(vcs): self-hosted Git providers (Forgejo, Gitea, GitLab) alongside GitHub (MUL-3772) (#5006)
Adds self-hosted Git provider support (Forgejo, Gitea, GitLab) alongside GitHub: per-workspace token connection, a provider-dispatched webhook, PR/MR and CI mirroring, and the shared issue auto-link / auto-close machinery. Off until MULTICA_VCS_SECRET_KEY is set, so existing deployments are unaffected. Co-authored-by: Bohan <[email protected]> |
||
|
|
7985699df9 |
feat(help): surface the running server version in the Help popover (#4959)
Surface the running server build version in the Help popover so self-hosted operators can confirm what's deployed and include it in bug reports. - Backend exposes it via /api/config's server_version (from main.version), omitempty so older/unstamped builds omit the field. - Unstamped "dev" builds are normalized to empty and the row stays hidden. - The row is suppressed on the managed cloud (frontend host multica.ai) and shown only on self-hosted deployments. - Frontend renders a muted footer row in the Help popover only when the value is non-empty; i18n added for en/ja/ko/zh-Hans. |
||
|
|
e07b5403ab |
MUL-4502: make autopilot webhook admission durable (#5386)
* fix(autopilots): make webhook admission durable Co-authored-by: multica-agent <[email protected]> * fix(autopilots): address webhook delivery review Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Eve <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
ac62f72c2a |
MUL-4480: make daemon workspace sync event-driven (#5354)
* feat(daemon): make workspace sync event-driven Co-authored-by: multica-agent <[email protected]> * fix(daemon): preserve trailing workspace changes Co-authored-by: multica-agent <[email protected]> * fix(workspace): reconcile failed creates Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Eve <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
220fa58264 |
fix: guide SSH installs to token login (#5318)
Co-authored-by: Eve <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
1427e8abd3 | feat(agents): add conversational creation studio (#5296) | ||
|
|
bf161f2f9c |
fix(tasks): preserve merged comment delivery (#5192)
Track actual claim-time delivery, support legacy daemons, and repair comment batches across claim, retry, edit, and delete races. MUL-4348 Co-authored-by: Eve <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
619b1b78e7 |
feat(server): remove generic LLM passthrough endpoints (MUL-4309) (#5154)
* feat(server): remove generic LLM passthrough endpoints (MUL-4309) Remove the OpenAI-compatible passthrough HTTP handlers LLMChatCompletions / LLMChatCompletionsStream and their two routes (/api/llm/v1/chat/completions[/stream]) plus their tests. Exposing a generic LLM proxy backed by the deployment key let any logged-in user run arbitrary completions on our dime. pkg/llm and the MULTICA_LLM_* config are kept unchanged as the server-internal LLM entry point, so chat title generation (maybeGenerateChatTitleAsync -> h.LLM.GenerateText) continues to work untouched. Updated the handler.go and .env.example comments to reflect internal-only usage. Co-authored-by: multica-agent <[email protected]> * docs(server): fix stale comments referencing removed LLM passthrough handlers (MUL-4309) Address GPT-Boy review nits: three doc comments still described the deleted OpenAI-compatible HTTP proxy handlers / 503 behavior. Update pkg/llm/client.go (package doc + ErrNotConfigured) and the Handler.LLM field comment to describe the internal-only usage. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Eve <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
0c2e48ded2 |
refactor: retire FF_RUNTIME_BRIEF_SLIM, make slim runtime brief the only path (MUL-4297)
The runtime_brief_slim feature flag has burned in; the slim runtime brief is now the sole path. - execenv: buildMetaSkillContent / BuildCommentReplyInstructions delegate to the slim assembler unconditionally; delete the legacy verbose brief body and writeBackgroundTaskSafetyInstructions. - Remove the runtime_brief_slim flag and the daemon-bound flag delivery subsystem built solely for it: execenv flag wiring (runtime_config_flag.go, server_snapshot_provider.go), the featureflagdispatch package, the DaemonFeatureFlagSnapshot heartbeat protocol field, and the server/daemon wiring in router.go, handler, daemon.go, main.go, cmd_daemon.go. - Keep the generic server/pkg/featureflag engine (still used by composio_mcp_apps). - Update tests to slim-only expectations and docs/feature-flags.md. Co-authored-by: Eve <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
75695a2e40 |
fix(comments): guarantee at-least-once processing of user comments (MUL-4195) (#5068)
* fix(comments): guarantee at-least-once processing of user comments (MUL-4195) Consecutive comments on an issue were silently dropped: a new comment that arrived while the agent already had a queued/dispatched task was discarded by the HasPendingTaskForIssueAndAgent dedup, losing the user's follow-up instruction with no visible trace. Comments — unlike chat — are deliberate, addressed, persisted input and must never vanish. This makes comment handling at-least-once while keeping concurrency bounded to one run per (issue, agent): - Merge, don't drop (PR1): a comment landing while a not-yet-started task exists is folded into that task — the prior trigger becomes a coalesced comment and the new one becomes the trigger, so a single run still covers every deliberate comment. Falls back to a fresh enqueue if the pending task was claimed mid-flight, so nothing is lost in the race. - Completion reconciliation (PR2): on task completion, a member comment newer than the run's started_at schedules exactly one follow-up via the normal trigger pipeline. Loop-safe: member-authored only, capped by the existing per-(issue,agent) dedup, and terminating. - Visibility (PR3): coalesced_comment_ids is surfaced on the task API and in the run prompt so the covered comments are explicit. Migration 145 adds agent_task_queue.coalesced_comment_ids UUID[]. Tests: merge-not-drop preserves all three of a rapid burst and repoints the trigger to the newest; reconciliation query gates on member/since; e2e CompleteTask enqueues a follow-up for a mid-run member comment and does not for none. Co-authored-by: multica-agent <[email protected]> * fix(comments): address review — originator gate, agent-scoped reconcile, cross-thread coalesced prompt (MUL-4195) Resolves GPT-Boy's Request-changes review on PR #5068. Must-fix #1 — merge no longer inherits a stale originator/runtime context. MergeCommentIntoPendingTask now only folds a comment into a pending task whose originator_user_id IS NOT DISTINCT FROM the new comment's originator. runtime_mcp_overlay / runtime_connected_apps are a pure function of (originator, agent) and the agent is fixed, so a matching originator keeps the stored overlay/attribution valid; a differing originator (e.g. user B commenting on a task originated by user A) matches no row and the caller enqueues a fresh follow-up with B's own context instead of reusing A's. trigger_summary is refreshed to the new trigger comment. Must-fix #2 — completion reconcile no longer re-wakes unrelated agents. reconcileCommentsOnCompletion computes the latest member comment's triggers and keeps ONLY the agent that just completed, instead of fanning the comment out through the full pipeline. An @-mention of agent B during agent A's run is triggered once at creation time and is no longer replayed (double-run) when A completes. Should-fix #3 — coalesced-comment prompt no longer assumes a single thread. The claim response now carries each folded comment's thread id / author / created_at / content (CoalescedCommentData); the prompt embeds them directly so the agent addresses cross-thread folded comments without the wrong "they are in the triggering thread" hint. Old servers that ship only ids fall back to an issue-wide fetch, still without the same-thread assumption. Tests: TestMergeCommentIntoPendingTask_OriginatorGate (query gate), TestCompleteTask_DoesNotReTriggerOtherAgentMentionedDuringRun (reconcile scoping), TestBuildCommentPromptCoalescedCrossThread / IDsOnlyFallback (prompt). Existing MUL-4195 suites still pass. Co-authored-by: multica-agent <[email protected]> * fix(comments): close unique-index drop + dispatched-window race in comment coalescing (MUL-4195) Second-round review follow-up on PR #5068. Must-fix #1 — originator-mismatch no longer drops the comment. The previous originator gate returned ErrNoRows on a different originator and the caller fell through to a fresh enqueue, which collided with the idx_one_pending_task_per_issue_agent unique index (one queued/dispatched task per (issue, agent)) — silently dropping the second user's comment. Replaced the gate with recompute-on-merge: MergeCommentIntoPendingTask now re-stamps originator_user_id, runtime_mcp_overlay, runtime_connected_apps and trigger_summary to the new comment's originator. A different member's comment folds into the single coalescing run carrying the latest instruction's own identity/overlay (no cross-user capability bleed, no drop, no collision). Must-fix #2 — comment arriving in the claim→StartTask window is no longer lost. Merge now targets only PRE-CLAIM states ('queued','deferred'); a dispatched/running task is never a merge target, so a post-claim comment is never falsely stamped into coalesced_comment_ids as "delivered". Completion reconcile is re-anchored on dispatched_at (the moment the claim response is built) instead of started_at, and sweeps ALL undelivered member comments since that anchor — replaying each through the normal enqueue path so they coalesce into one bounded, agent-scoped follow-up run. This covers the dispatch→start window a started_at anchor missed. Enqueue path: on a merge miss the caller no longer blindly fresh-enqueues (which could collide with a dispatched sibling); it defers to the active task's completion reconcile via HasActiveTaskForIssueAndAgent, and only fresh-enqueues when no active task exists. Tests: rewrote the query test to TestMergeCommentIntoPendingTask_RecomputesOriginatorAndSkipsDispatched; added TestConsecutiveCommentsDifferentOriginatorsFullEnqueuePath (full handler enqueue path, two distinct originators) and TestCompleteTask_ReconcilesDispatchedWindowComment (claim→start window). All existing MUL-4195 handler/cmd-server/daemon/service suites still pass. Co-authored-by: multica-agent <[email protected]> * fix(comments): catch pre-dispatch merge-race comment in completion reconcile (MUL-4195) Third-round review follow-up on PR #5068. Race: a member comment is created while the task is still queued, but its merge loses the race to the daemon claiming the task (queued→dispatched). The merge then finds no pre-claim row (ErrNoRows), the enqueue path defers to reconcile — but the comment's created_at is BEFORE dispatched_at, so the dispatched_at-anchored reconcile skipped it and the comment vanished with no task coverage. Fix: anchor completion reconcile on the task's created_at (which always precedes dispatch) instead of a dispatch/start timestamp, and exclude the run's DELIVERED SET — trigger_comment_id ∪ coalesced_comment_ids. Because merges only ever touch pre-claim rows, that set is exactly what the claim response carried, so any member comment created since the task was made that is NOT in it was genuinely undelivered and earns a bounded follow-up. This catches the pre-dispatch merge-race comment and the dispatch→start comment, while never re-firing a comment that was delivered as a pre-claim coalesced entry. Test: TestCompleteTask_ReconcilesPreDispatchMergeRaceComment reproduces the race (comment created pre-dispatch, task dispatched before merge, plus a delivered coalesced comment) and asserts exactly one follow-up, triggered by the race comment, with the delivered coalesced comment excluded. Existing reconcile fixtures updated to set a realistic created_at (the production invariant that created_at is the earliest task timestamp). Co-authored-by: multica-agent <[email protected]> * fix(comments): merge only into the queued task, never a deferred fallback (MUL-4195) Fourth-round review follow-up on PR #5068. MergeCommentIntoPendingTask targeted status IN ('queued','deferred') ordered by created_at DESC. When a (issue, agent) pair had both an older queued task (the run about to be claimed) and a newer deferred assignee-fallback task, a new comment merged into the deferred row instead of the queued one — so the comment missed the imminent run and the deferred fallback could later promote into a duplicate/conflicting run. This merge is only ever reached when HasPendingTaskForIssueAndAgent matched a queued/dispatched task (it never inspects deferred), so the coalescing target must be the queued row. Restricted the merge target to status = 'queued' (the unique index guarantees at most one). Deferred fallbacks keep their own fire_at/promotion escalation lifecycle and are never a merge target. Test: TestMergeCommentIntoPendingTask_TargetsQueuedNotDeferred seeds an older queued task + a newer deferred fallback for the same (issue, agent), merges a new comment, and asserts it lands on the queued task (trigger repointed, old trigger coalesced) while the deferred fallback is left untouched. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Eve <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
22a71bafe3 |
feat(server): add basic LLM API layer with OpenAI-compatible endpoints (#5138)
Integrate the official openai-go SDK (v3) as a thin, reusable LLM layer (pkg/llm) backing lightweight utility calls that do not need the agent runtime (chat titles, quick-create drafts, ...). Expose two user-authenticated, OpenAI-compatible chat-completions endpoints: - POST /api/llm/v1/chat/completions (JSON response) - POST /api/llm/v1/chat/completions/stream (SSE stream) Requests decode directly into the SDK's ChatCompletionNewParams and responses are relayed via RawJSON() for byte-exact OpenAI-format compatibility. Base URL and API key are configurable (MULTICA_LLM_*), and the model is taken from the request with a configurable default fallback (MULTICA_LLM_DEFAULT_MODEL, else gpt-4o-mini). When unconfigured the endpoints return 503. Co-authored-by: Eve <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
b6adf23f91 |
feat(api): emit Content-Length header on JSON responses (#5021)
The core writeJSON helpers streamed the body via json.NewEncoder(w).Encode after WriteHeader, which forces net/http into chunked transfer encoding and omits Content-Length. Buffer the marshaled body first, set an accurate Content-Length, then write — so API (and health) JSON responses advertise their exact size. writeMeasuredJSON gets the same header. Adds a test asserting the header matches the on-wire body length. Co-authored-by: Eve <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
cb68669c73 |
feat(composio): gate MCP apps behind feature flag (#4876)
* feat(composio): server-side connect flow + connections REST (Notion MVP) (MUL-3720) (#4608)
* feat(composio): server-side connect flow + connections REST (Notion MVP) (MUL-3720)
Compose the merged server/pkg/composio SDK into a user-facing connection
manager: signed-state connect handshake, local user_composio_connection
mirror, idempotent disconnect, and a per-user MCP session helper (not yet
wired into task dispatch).
- migration 127_user_composio_connection (no FK/cascade, per DB rules)
- sqlc queries: upsert (idempotent on user_id+connected_account_id), list
active, owner-scoped get, mark revoked
- internal/integrations/composio: signed HMAC-SHA256 state, BeginConnect,
CompleteCallback (idempotent upsert), ListConnections, Disconnect
(upstream 404 = idempotent success), CreateMCPSession (no-op when empty,
pins connected_accounts per toolkit), CallbackRedirect
- REST handlers under /api/integrations/composio (user-scoped, 503 when
COMPOSIO_API_KEY unset): connect/init, callback (302), connections list,
delete
- router wiring gated by COMPOSIO_API_KEY; COMPOSIO_AUTH_CONFIGS_JSON maps
toolkit->auth_config (MVP: notion); state secret from COMPOSIO_STATE_SECRET
or derived from JWT_SECRET; callback base from COMPOSIO_CALLBACK_BASE_URL
or MULTICA_PUBLIC_URL
- tests: state (expire/tamper/wrong-secret), service (mapping, callback
idempotency, non-success, disconnect owner/404 idempotency, MCP pin),
handlers (httptest), redact regression for Bearer mcp_ tokens
MVP scope: Notion only; no task-dispatch overlay, sharing, or webhook
event handling (later stages).
Co-authored-by: multica-agent <[email protected]>
* fix(composio): bind callback account to user + idempotent revoked disconnect (MUL-3720)
Address PR 4608 review (CHANGES_REQUESTED):
- callback: verify connected_account_id with Composio before mirroring it.
The signed state only proved user/toolkit/exp, so a valid state paired with
a tampered connected_account_id would be written verbatim. CompleteCallback
now calls ListConnectedAccounts and fails closed (ErrAccountVerification)
unless the account belongs to the state's user (composio_user_id == multica
user id) and was created under the toolkit's auth config. No row is written
on mismatch / unknown account / upstream error.
- disconnect: short-circuit to a no-op when the local row is already revoked,
before touching upstream. Previously a second DELETE re-hit Composio and a
non-404 upstream error surfaced as a 502, breaking the 204-idempotent
contract.
- CreateMCPSession: document the v1 single-active-connection-per-(user,toolkit)
constraint and make duplicate selection deterministic (newest-wins, rows are
connected_at DESC) instead of order-dependent map overwrite. Stage 3 owns the
real single-account-enforcement vs multi-account-shape decision.
Tests: tampered/wrong-auth-config/unknown-account callback rejection, revoked-row
disconnect no-op (asserts upstream not re-hit). composio pkg 85% coverage; all
green.
Co-authored-by: multica-agent <[email protected]>
* feat(composio): list all toolkits + dynamic auth-config resolution (MUL-3720)
Yushen's follow-up to the Notion MVP: surface the full Composio toolkit
catalog, render it in Settings, and drop the static env mapping in favor of
dynamic auth-config discovery.
Config correctness (per Composio docs):
- Remove COMPOSIO_AUTH_CONFIGS_JSON entirely. The toolkit→auth_config mapping
is now resolved at request time from the project's /auth_configs (cached,
5-min TTL), so enabling a toolkit is a dashboard action, not a redeploy.
- Do NOT add COMPOSIO_PROJECT_ID. The project API key (x-api-key) authenticates
to exactly one project; the project is resolved from the key. Only org-level
endpoints use x-org-api-key, which this integration never calls.
Backend:
- SDK: server/pkg/composio/auth_configs.go — ListAuthConfigs (toolkit_slug,
is_composio_managed, show_disabled, limit, cursor).
- service: dynamic resolver (authConfigMap cache; betterAuthConfig prefers a
custom/white-label config over Composio-managed, newest wins); BeginConnect
and CompleteCallback resolve via it; ListToolkits fetches the full catalog
(paginated, capped) annotated with connectable = has an enabled auth config,
connectable-first ordering.
- handler + route: GET /api/integrations/composio/toolkits (user-scoped, 503
when COMPOSIO_API_KEY unset) returning slug/name/logo/category/connectable.
Frontend:
- core: ComposioToolkit/ComposioConnection types, api client methods, and
composio query options (@multica/core/composio).
- views: Settings → Integrations now has a Composio section rendering every
toolkit as a card with search. Connect is gated on `connectable`;
non-connectable toolkits show a muted "not configured" hint instead of a
dead button. Connected toolkits show a badge + Disconnect (with confirm).
- i18n: composio block added to en/zh-Hans/ja/ko settings.
Tests: SDK + service (dynamic resolution, custom-over-managed preference,
connectable flag, resolver-error soft-degrade) and handler toolkits endpoint;
composio pkg 85.7% coverage. go build/vet/gofmt clean; core+views typecheck,
core+views lint, and core tests (691) all green.
Co-authored-by: multica-agent <[email protected]>
* fix(composio): close cross-toolkit callback fail-open by signing auth_config_id into state (MUL-3720)
Re-review blocker: CompleteCallback resolved the toolkit's auth config at
callback time and ignored a resolve error/empty result, while
verifyAccountOwnership skipped the auth-config comparison when the expected
value was empty. A user could then pass another toolkit's connected_account_id
into this toolkit's callback — the owner check passed and it was written under
the wrong toolkit_slug/account binding.
Fix: the auth_config_id is already resolved in BeginConnect (before the state
is signed), so sign it into the state and compare it exactly at callback. No
re-resolve, no fail-open. verifyAccountOwnership now fails closed when the
expected auth config is empty (rejects instead of skipping) and requires an
exact match — closing the cross-toolkit binding gap.
Tests: state round-trips auth_config_id; BeginConnect signs it; callback
rejects wrong/cross-toolkit auth config and an empty (no-mapping) auth config
fails closed. composio pkg 85.2% coverage, all green.
Frontend (non-blocking): the Composio settings tab now surfaces an error when
the connections query fails instead of silently rendering everything as
unconnected.
Co-authored-by: multica-agent <[email protected]>
* fix(composio): hide Settings section entirely when integration unconfigured (MUL-3720)
Decision (option 2, hide-then-merge): don't show a card that leaks the internal
COMPOSIO_API_KEY env-var name to every end user. IntegrationsTab now gates the
whole Composio section (heading + body) on the toolkits query — a 503 means the
key is unset, so the section is withheld instead of rendering the not-configured
card. Admin-only setup guidance is a later, role-gated affordance.
Removed the notConfigured card (and now-unused ApiError import) from
ComposioTab; it only mounts when configured. views typecheck + lint clean.
Co-authored-by: multica-agent <[email protected]>
---------
Co-authored-by: multica-agent <[email protected]>
* feat(composio): Stage 2 frontend polish — callback toast, last_used & expired UI, e2e (MUL-3718) (#4688)
* feat(composio): callback toast + refresh, last_used & expired UI, e2e (MUL-3718)
Co-authored-by: multica-agent <[email protected]>
* fix(composio): real callback redirect route + StrictMode-safe toast dedup (MUL-3718 review)
Co-authored-by: multica-agent <[email protected]>
---------
Co-authored-by: multica-agent <[email protected]>
* fix(composio): callback endpoint should not require Multica auth (MUL-3843) (#4709)
* fix(composio): move OAuth callback out of the Auth group (MUL-3843)
Composio 302-redirects the browser to /api/integrations/composio/callback
at the end of the OAuth flow, but PR #4608 mounted it inside the cookie-auth
middleware group. When the session cookie is absent (expired session,
SameSite=Strict / Safari ITP, private window, self-hosted callback subdomain)
the Auth middleware returned a hard 401 and a JSON blob instead of the
settings redirect, breaking the flow.
Identity never came from the cookie anyway: it is carried by the HMAC-signed
state param that CompleteCallback verifies (signature, expiry, replay) and
cross-checked by verifyAccountOwnership; h.Composio == nil still 503s. So the
callback is registered alongside the other public OAuth/webhook routes; the
other four composio endpoints stay session-gated.
Refs MUL-3843, MUL-3715.
Co-authored-by: multica-agent <[email protected]>
* fix(composio): correct stale callback routing comments (MUL-3843)
The package header and ComposioCallback doc comments still described the
callback as sitting under the Auth middleware group. After the route was
moved out (this PR), update both to state it is a public route whose identity
comes from the signed state — addressing review nit from 张大彪.
Refs MUL-3843.
Co-authored-by: multica-agent <[email protected]>
---------
Co-authored-by: multica-agent <[email protected]>
* feat(composio): inject MCP overlay into agent runtime at task dispatch (MUL-3721) (#4704)
Stage 3 of the Composio epic. Wires the per-user Composio MCP session into
every agent task so the agent process sees the initiator's connected tools
without any prompt-time plumbing.
Server side
- Migration 128 adds agent_task_queue.runtime_mcp_overlay JSONB plus a
BEFORE-UPDATE trigger that wipes the column on any transition into a
terminal status (completed / failed / cancelled). A trigger is the single
source of truth — future queries that flip status cannot bypass it.
- composio.Service.BuildTaskOverlay(userID) reuses CreateMCPSession and
emits the Claude-style { mcpServers: { composio: { type: http, url,
headers } } } shape the daemon's existing sidecar generators consume.
Returns (nil, nil) on zero active connections so we never burn a
Composio session for a user with nothing to call.
- TaskService grows a Composio ComposioOverlayBuilder seam, wired in
router.go after composiointeg.NewService succeeds. Five enqueue paths
(issue / mention / quick-create / chat / auto-retry) attach the overlay
after CreateAgentTask returns and before the daemon is notified — so
every claim reads a settled row, with no second daemon hop. Best-effort:
a builder failure logs and proceeds with no overlay.
- resolveInitiatorFromTriggerComment derives the initiator user from the
trigger comment when it was authored by a member. Agent-authored
triggers are not treated as initiators (their connected-apps view is
empty by construction).
Daemon side
- handler/daemon.go claim path merges task.runtime_mcp_overlay onto
agent.mcp_config via mergeMCPOverlay before populating
TaskAgentData.McpConfig. Overlay wins on server-name collisions
because it carries the live user-scoped session URL. Errors fall back
to the agent config unchanged — a bad overlay must not surprise-disable
saved MCP tools. The existing execenv sidecar generators (cursor /
codex / openclaw / opencode / hermes-kiro) need no changes: they keep
consuming the merged result through TaskAgentData.McpConfig.
Tests
- 9 merge cases (mcp_overlay_test): both-nil short-circuit, agent-only
pass-through, overlay-only canonicalization, two-side merge, name
collision (overlay wins), top-level key preservation, malformed agent
fallback, malformed overlay fallback, non-object server rejection.
- 4 dispatch cases (composio): zero-connections returns nil without
CreateSession, happy-path emits the right shape with the right user
id, empty-URL defensive branch, SDK error surfacing.
- 4 TaskService helper cases: nil Composio is a no-op (Queries-safe),
invalid initiator does not call the builder, nil overlay skips the
UPDATE, builder error swallowed without panic.
- Migration 128 verified to roll up + down + up cleanly against the test
database.
Out of scope (deferred): assignment-triggered enqueue paths with no
trigger comment get no overlay attached today (no initiator UUID flows
through enqueueIssueTask in that case). Retry paths recompute the overlay
fresh from the parent's initiator_user_id instead of inheriting the bearer
from the parent row, so a stale token can never resurface on a retry.
Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* feat(composio): per-agent allowlist + originator-scoped MCP overlay (MUL-3869) (#4736)
* feat(composio): per-agent allowlist + originator-scoped MCP overlay (MUL-3869)
Stage 3.1 of the Composio epic (MUL-3721 parent). PR #4704 wired in the
runtime_mcp_overlay column and a per-task dispatch hook; this change
inverts the default from "all-on" to opt-in and locks the overlay to the
agent owner's own connected apps:
- Agents carry composio_toolkit_allowlist TEXT[]. NULL or [] => no MCP.
Owner-only read/write; non-owner GET/PUT silently redacts/drops the
field (same shape as mcp_config).
- agent_task_queue carries originator_user_id UUID. Set from the
top-of-chain HUMAN at every enqueue path:
* issue/mention comment by member -> author_id
* issue/mention comment by agent -> inherit via comment.source_task_id
-> parent task originator_user_id
* quick-create -> requester_id
* chat -> initiator_user_id
* retry -> SQL-inherited from parent row
* autopilot -> NULL (system-driven)
- BuildTaskOverlay (composio dispatch) now takes (ctx, originatorUserID,
agent) and short-circuits on five gates: invalid originator,
originator != agent.owner_id, empty allowlist, empty intersection of
allowlist ∩ active connections, defensive empty session URL. Composio
CreateSession is called with BOTH `toolkits.slugs` (the intersection)
AND `connected_accounts` (the pinned account ids), narrowing the
tool-router twice.
- The originator-vs-owner gate closes the agent-fanout privacy hole: any
workspace member who can @-mention a public agent used to project the
owner's connected apps into their run. Now the overlay only mounts
when the human at the top of the chain IS the agent owner.
Tests:
- dispatch_test.go covers all 5 gates plus uppercase/whitespace slug
normalisation.
- task_runtime_mcp_overlay_test.go covers the no-op gates of the new
applyRuntimeMCPOverlay signature.
- agent_composio_allowlist_test.go (handler): owner roundtrip
(list/empty/null), workspace-admin silent-drop, owner-only GET
visibility, pure normaliseComposioToolkitAllowlist.
- resolve_originator_test.go (service, DB-backed): member-authored,
agent-authored inherits via comment.source_task_id, invalid id.
Migration 129 up/down/up verified against docker postgres.
Co-authored-by: multica-agent <[email protected]>
* chore(composio): gofmt + regenerate sqlc with v1.31.1 (MUL-3869 review nits)
Co-authored-by: multica-agent <[email protected]>
---------
Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* fix(composio): accept nested connected account auth config
* feat(views): creator-only MCP tab for per-agent Composio allowlist (MUL-3870) (#4743)
Stage 3.2 frontend on top of the Stage 3.1 backend (MUL-3869,
|
||
|
|
7b3cad664b |
revert(self-host): remove source channel reporting (#4799)
* Revert "test(onboarding): cover official source reporting controls (#4782)" This reverts commit |