mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-05 09:30:05 +02:00
v0.4.17
4493 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
6b56bc05a4 |
docs(changelog): add v0.4.17 release entry (2026-08-03) (MUL-5655) (#6311)
* docs(changelog): add v0.4.17 release entry (2026-08-03) (MUL-5655) Co-authored-by: multica-agent <github@multica.ai> * docs(changelog): drop the MCP allowlist fix, reverted in #6314 (MUL-5655) Co-authored-by: multica-agent <github@multica.ai> * docs(changelog): add the Kimi resume fix from #6308 (MUL-5655) Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>v0.4.17 |
||
|
|
6890812ae4 |
MUL-5657 fix(agent): add missing streamingCurrentTurn gate to kimi ACP backend (#6308)
kimi.go was the only ACP backend missing the streamingCurrentTurn gate. Without it, history replay emitted by the Kimi CLI during session/resume contaminates Result.Output and the message stream with previous-turn content — the user sees the old answer duplicated alongside the new one. The root cause is chronological: the gate was introduced for Hermes in PR #2024 (2026-05-03) but kimi already existed at that point and was not updated. Later backends (grok, traecli) were written after the fix and included the gate from day one. Add the same atomic.Bool gate + acceptNotification callback pattern used by hermes, grok, traecli, kiro, and qoder. Pin with TestKimiBackendDropsHistoryReplayOnResume. |
||
|
|
4fe94a6d40 |
revert: "MUL-5637 fix(mcp): agent mcp_config is an authoritative allowlist (#6292)" (#6314)
This reverts commit
|
||
|
|
a54662f30e |
fix(daemon): provision config-referenced Codex instruction files into task home (#6291)
* fix(daemon): provision config-referenced Codex instruction files into task home A per-task CODEX_HOME copies the user's config.toml verbatim, so a model_instructions_file reference survived the move while the file it names did not. Codex resolves the relative value against CODEX_HOME — now the task home — and failed loading its configuration before the task prompt was ever delivered (#6271). Generalize the existing model_catalog_json provisioning into a keyed table of path-valued config keys and add model_instructions_file plus its deprecated experimental_instructions_file alias. Semantics are unchanged per key: absolute/~ values are left for Codex to read directly, relative values must stay inside the task home, the copy refreshes on reuse, and a missing source fails during environment preparation with a diagnostic naming the key instead of an opaque os error 2 at thread/start. Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): root-scope task-home writes for config-referenced Codex files Review must-fix: filepath.IsLocal only proves the config string has no lexical '..'. A task home is reused, and the task that ran in it can replace an intermediate directory of the copy destination with a symlink, which made the daemon's next MkdirAll/Remove/copy follow that link and delete or overwrite a file outside the task home. Do the mkdir, stale-copy removal, and write through os.OpenRoot(codexHome) so links leaving the task home are rejected (links staying inside it are harmless), and refuse outright when codexHome itself is a symlink, since OpenRoot would resolve it before confining anything below. The pre-existing model_catalog_json path is covered by the same helper. Also stop reporting every source stat failure as a missing file — a permission or IO error now says so — and document the source-side symlink policy plus the stale-copy contract. Co-authored-by: multica-agent <github@multica.ai> * test(daemon): pin stale-copy contract when a referenced Codex file is repointed Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): bind codex-home root check to the opened handle Review must-fix: checking the path with os.Lstat and then opening it with os.OpenRoot leaves a window where the directory is swapped for a link to somewhere else. OpenRoot resolves the path it is given, so the resulting root is confined to the wrong tree and every root-scoped write lands outside the task home without ever escaping its root. Task homes are reused and Windows cannot confirm descendant cleanup, so a leftover process that knows its old CODEX_HOME can create that window. Open first, then prove identity against the handle: compare root.Stat(".") with a no-follow os.Lstat of codexHome via os.SameFile, and reject a symlink outright. A swap before the open now fails the check; a swap after it cannot matter because all writes go through the verified handle. verifyCodexHomeRoot is split out so the swap is tested deterministically instead of raced, plus an end-to-end test for a task home that is already a link to an outside directory. Co-authored-by: multica-agent <github@multica.ai> * docs(daemon): scope the codex-home symlink test name and root-handle claim Review nit: the end-to-end test asserted only that the referenced-file copy refuses a symlinked task home, but its name read as though the whole prepare were safe. Rename it accordingly and state in both the test and openVerifiedCodexHomeRoot that the earlier path-addressed steps of prepareCodexHomeWithOpts are out of scope, tracked in MUL-5647. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
aa349fed02 |
MUL-5637 fix(mcp): agent mcp_config is an authoritative allowlist (#6292)
* fix(mcp): treat agent mcp_config as an authoritative allowlist
An agent's saved mcp_config was silently widened with the runtime host's
own user-level MCP servers, so an explicitly empty `{"mcpServers":{}}`
resolved to the COMPLETE host set instead of no servers at all — the
opposite of what the operator configured (GitHub #6283).
`--strict-mcp-config` was being passed correctly; the merge happened
before it, in the daemon, so strict mode constrained an already-widened
set. Introduced by #5277 and present in v0.4.16 through main.
Restore the three-state contract in resolveEffectiveMcpConfig:
null / unset -> inherit the provider's native MCP configuration
{"mcpServers":{}} -> strict empty, no host servers
non-empty object -> strict allowlist, exactly those servers
Two explicit inherit paths keep the additive behaviour reachable without
weakening the default:
- runtime_config.mcp.inherit_runtime = true opts an agent back in.
- The claim response now carries mcp_config_overlay_only so the daemon
can tell an agent-authored config from a per-task Composio overlay.
Without it, enabling an integration on an agent that never configured
MCP would have stripped the host servers it was already inheriting.
Both decode paths fail closed: malformed runtime_config never enables
inheritance, and a failed runtime merge falls back to the agent's own
config.
The web MCP tab and the `agent create/update --mcp-config` help text
described the old additive behaviour, which is how a tightened config
could look correct while exposing every host server; both now state
which mode is in effect.
Note for rollout: the fix lives in the daemon, so self-hosted users must
upgrade the daemon — a server/UI upgrade alone does not apply it.
Co-authored-by: multica-agent <github@multica.ai>
* fix(mcp): close review gaps in the authoritative mcp_config change
Addresses the four must-fix findings from review of #6292.
1. Deleting the last managed server no longer widens access.
removeManagedMcpServer cleared the config to null, which now means
"inherit the host's MCP servers" — so a delete took the agent from one
allowed server to every server on the host. It now leaves an explicit
`{"mcpServers":{}}`. Restoring inheritance moved to a separate
clearManagedMcpConfig action behind its own confirmation that states the
widening. The delete dialog no longer claims "Runtime servers are not
affected", which was the opposite of the truth.
2. The UI no longer promises a boundary an old daemon does not enforce.
The strict semantics live in the daemon, so a config saved against an
older daemon is not yet in effect. Adds the authoritative-mcp-v1 daemon
capability:
- The daemon advertises it and reports authoritative_mcp on the
runtime-capabilities response.
- The claim path fails closed: a managed, non-inheriting mcp_config
claimed by a daemon without the capability cancels the task and
returns 412 with an actionable message, instead of letting that daemon
merge the host's servers in. runtime_config.mcp.inherit_runtime is the
documented escape hatch, and it is honest — it declares that the
operator accepts the host's servers.
- The MCP tab shows "needs upgrade" rather than "Not exposed" while the
bound runtime lacks the capability.
3. Saving OpenClaw settings no longer drops the inherit opt-in.
parseOpenclawRuntimeConfig discarded unknown keys and the tab persisted
the result as the whole runtime_config, so one unrelated routing save
silently deleted mcp.inherit_runtime. Unknown keys now round-trip
through OpenclawRuntimeConfig.passthrough, excluded from the dirty check
so they cannot make the form look edited.
4. Documents the new semantics in the built-in creating-agents skill and
its source map: the three states, the persisted
runtime_config.mcp.inherit_runtime field, and the claim-time capability
gate.
Also corrects the PR's rollout claim: there is no database migration, but
this does add a persisted JSON field and change the meaning of an existing
one.
Co-authored-by: multica-agent <github@multica.ai>
* fix(mcp): stop the authoritative-daemon gate from blocking valid claims
CI's backend job failed three handler claim tests with the new 412. Two
distinct problems, both real:
1. The gate fired for a non-object mcp_config. 66 handler fixtures seed
`[]`, which is not a valid MCP config and cannot carry `mcpServers`, so
it expresses no boundary to protect. An old daemon does not widen it
either: mergeRuntimeAndAgentMcpConfig fails to unmarshal a non-object
and falls back to the agent config alone (verified directly). Gating
these blocked tasks with no security benefit, so the gate now requires a
JSON object.
2. The shared daemon test-request helper advertised no capabilities, so
every claim test was accidentally simulating a pre-#6283 daemon. It now
defaults authoritative-mcp-v1 on, matching what every current daemon
sends. Only that capability — skill-bundles / coalesced-comments / rpc
are feature negotiations whose absence tests real legacy behaviour, so
they stay opt-in per test.
Adds claim-level coverage for the gate itself, which is what the unit tests
alone could not catch: an outdated daemon gets 412 with an actionable
message and the task is cancelled; a capability-advertising daemon gets
200; the inherit_runtime opt-in lets an outdated daemon through; and an
unmanaged or non-object config is never gated.
Verified against a real migrated schema this time (throwaway Postgres),
which is how the three failures were reproduced locally and confirmed
fixed: `go test ./internal/handler ./internal/daemon` both ok.
Co-authored-by: multica-agent <github@multica.ai>
* fix(mcp): surface the daemon-upgrade refusal and stop gating safe providers
Addresses the second review round on #6292.
1. The refusal is now visible wherever the operator looks. The default
claim path is the machine-level BATCH endpoint, which skips build
failures and still answers 200 {"tasks":[]}, so the previous bare
CancelTask showed a task that vanished with no stated reason — turning an
explicit upgrade requirement into an unexplained failure. The claim path
now fails the task with a new classified reason,
mcp_config_daemon_outdated, plus the actionable message. That reaches the
user on all three claim paths and on any daemon version, which a new
response field could not: the audience is by definition a daemon too old
to read one. The per-runtime path keeps its 412.
The reason is deliberately not auto-retryable — the same outdated daemon
would claim the retry and fail it again.
2. The gate no longer cancels safe tasks. It applied to every provider, but
only claude / codebuddy / codex / cursor / opencode / openclaw were ever
merged with host MCP by an old daemon (loadRuntimeMcpServerConfigs).
Qwen was never merged and already had strict semantics, so its tasks were
being failed for a risk that does not exist. Scoped via
providersOldDaemonsMergedRuntimeMcp; an unknown provider does not gate,
because the gate should only fire where the old behaviour is concrete.
3. The new authoritative_mcp flag now goes through the API schema layer.
Both local-skills responses were returning raw network JSON, so the flag
that decides whether the UI may assert an MCP boundary rested on an
unchecked type assertion. Adds RuntimeLocalSkillListRequestSchema with
authoritative_mcp and mcp_supported defaulting to FALSE — the fail-closed
direction — and a MALFORMED_ fallback that cannot express a guarantee.
Claim-level tests now cover all three paths, which is what the previous
helper-only tests missed: per-runtime 412, batch recording the refusal on
the task while still delivering the healthy tasks in the same batch, WS RPC
refusing and accepting, the qwen negative case, the inherit_runtime escape
hatch, and unmanaged / non-object configs.
Verified against a real migrated schema (throwaway Postgres):
go test ./internal/handler ./internal/daemon ./pkg/agent ./pkg/taskfailure
./internal/service all ok.
Co-authored-by: multica-agent <github@multica.ai>
* fix(mcp): register the new failure reason and wire its copy into the UI
Addresses the third review round on #6292.
1. mcp_config_daemon_outdated was declared but never registered in
taskfailure.allReasons, so metrics.NormalizeFailureReason missed the
known-value map and fell through to free-text Classify() — relabelling a
platform-side refusal as `agent_error.unknown` (verified directly) and
leaving the Prometheus series un-pre-warmed. Registered it, canonical
count 22 → 23 (platform 8 → 9), with the wire value and IsAgentError split
pinned. New test pins the WHOLE canonical set through
NormalizeFailureReason so forgetting the next reason fails a test instead
of quietly mislabelling a metric; NormalizeFailureReason had no coverage
at all before.
2. The upgrade copy was dead. The locale strings landed last round but
neither consumer mapped the reason: chatFailureCopy fell back to generic
failure text with the actionable detail buried in the collapsed raw
error, and task-failure.ts rendered the bare wire value
`mcp_config_daemon_outdated` in the agent activity list and issue
execution log. Both are mapped now, with regression tests, plus the
runtime class pinned in failure-class.test.ts. This directly contradicted
the claim in the claim-path comment that every path reaches the user, so
that is now actually true.
3. providersOldDaemonsMergedRuntimeMcp is documented as what it is: a FROZEN
record of what pre-capability daemons merged, not a mirror of the daemon's
current provider switch. The old "keep the two lists in lockstep" note was
actively harmful advice — runtime MCP discovery for a new provider can only
ship in a daemon that already advertises the capability (never gated), so
adding it here would fail tasks on old daemons that never merged for it,
re-creating the qwen false-positive. Pinned with a test.
Also corrects a stale count in task-failure.ts (7 → 9 platform reasons).
Verified against a real migrated schema (throwaway Postgres): full backend
suite green apart from the pre-existing environmental cmd/multica guard; all
9 TestMcpGate_* integration tests pass.
Co-authored-by: multica-agent <github@multica.ai>
* docs(taskfailure): correct taxonomy counts and finish the reason registration
Non-blocking nits from the fourth review round on #6292.
- Taxonomy counts now say 23 reasons / 9 platform-side. Registering
mcp_config_daemon_outdated last round updated the assertions but not the
prose. Swept the whole repo rather than only the flagged lines, which
turned up four more that were already stale at 21 and drifted further:
handler/dashboard.go, daemon/poisoned.go, core/types/agent.ts, and the
db/queries/task_usage.sql comment sqlc copies into the generated file.
The generated file's comment was updated by hand to match its source.
Running `sqlc generate` churned 58 lines across 47 unrelated files — the
local sqlc version differs from the one that produced the checked-in
output — so that churn was reverted and only the one intended line kept.
- failure_test.go's `required` list now includes
ReasonMcpConfigDaemonOutdated. Length and label assertions already covered
the reason, but the list is documented as the complete canonical set, so
the omission contradicted its own comment.
- Restored the line break in chat-message-list.test.tsx that a previous edit
of mine collapsed.
Comment, test-fixture and formatting only; no behaviour change.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
|
||
|
|
f1fc33f458 |
fix(autopilots): save the first schedule added from the edit dialog (MUL-5649) (#6303)
Editing a manual-only autopilot showed the schedule panel seeded with the editor's default — 09:00 every day, next runs and all — as though that were the autopilot's schedule. It was not: the autopilot had no trigger. Saving then compared that default against itself, found no change, wrote nothing, and toasted "Autopilot updated" while the detail page went on reading "No triggers configured". The dirty check is right when a schedule exists (it stops a re-picked, unchanged cron from being rewritten) and meaningless when none does, because the panel is showing a proposal rather than stored state. So the panel now says what is true — a dashed card matching the detail page's empty state, "No schedule — this autopilot only runs when triggered manually" — and asks for the schedule explicitly. Adding one writes it on save whether or not the user touched the default; leaving it alone keeps the autopilot manual, so a title-only edit can no longer put it on a daily cron by accident. The footer's auto-run promise drops out while that empty state is up. The schedule write also targets the first `schedule` trigger rather than `triggers[0]`, which on an api-triggered autopilot was a row a cron could have been patched into. Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
76fbd48849 |
fix(skills): measure draft dirtiness against a seeded baseline (MUL-5645) (#6294)
* fix(skills): measure draft dirtiness against a seeded baseline (MUL-5645) The detail page inferred "the user edited something" from "the draft differs from the latest server skill". That difference has two independent causes — the user typed, or the server moved — and the page could not tell them apart, so it read both as a local edit. Two user-visible failures came out of it: - A description ending in whitespace (what `description: |` frontmatter yields, so every imported skill) compared unequal to itself, because the dirty check trimmed the draft side and not the server side. The page opened permanently dirty and Discard reseeded the same value, so the save bar could not be dismissed at all — only Save cleared it, by rewriting the stored description. - Any remote update to an open skill was taken for a local edit, so the page raised the conflict banner and refused to reseed. The editor stayed frozen on pre-update text with no way to see what had changed, and saving from there pushed the stale draft back over the newer version. Record the seeded snapshot in `baselineRef` and compare against that instead. With a baseline the two causes separate: `draft !== baseline` is a local edit, and a new `updated_at` with no local edits is just a remote update, which now reseeds silently. The conflict banner is left for the case it was written for — a remote update landing on real unsaved work. `toDraft` also trims name and description at the single seam where server data becomes a draft, matching what Save persists, so later comparisons are plain equality rather than a trim both sides have to remember. Content and file bodies are not normalized: whitespace in a SKILL.md body is content. File sets are compared through a path-sorted signature, since GET sorts files by path while PUT echoes request order and that difference is not a content change. Four of the five regression tests fail against the previous implementation; the fifth covers the true-conflict path, which was already correct and must stay that way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(skills): trim frontmatter name and description at the parse seam (MUL-5645) Both fields are single-line labels everywhere they are consumed, but YAML clip chomping gives `description: |` and `description: >` a trailing newline, so imports stored a description that differed from its own trimmed form. The detail page no longer depends on this — it normalizes when it seeds a draft — but leaving it means every new import keeps writing the padded value, and any future consumer that compares a stored description to a trimmed one inherits the same trap. Trimming here covers all four import paths (GitHub, skills.sh, archive, runtime-local) in one place rather than asking each to remember. Callers that need the raw SKILL.md still have it: `content` is stored untouched. Defensive only. Reverting this commit alone does not reintroduce the bug. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(skills): release the conflict when the user reverts their own edits (MUL-5645) Review catch on the previous commit. Once a conflict was raised, the seed effect only reconsidered when a new server version arrived, so a user who resolved the conflict by hand — retyping the field back to what it was — got stuck: the draft was clean again, and because the save bar renders only while dirty, its Discard button unmounted. That left the banner sitting above stale text with no control left to dismiss it, and no way forward short of a reload. This state is a regression from measuring dirtiness locally. Previously the draft was compared against the moved server value, so reverting still counted as dirty and Discard stayed on screen. Re-run the decision on draft changes too. When the local edits go away there is nothing left to protect, so the page adopts the server version and clears the banner — the same outcome as the never-edited case, reached a moment later. The true-conflict path is unchanged, and its regression test still passes, which is what keeps this from over-correcting into "always release". Reseeding also grew a third caller, so the four pieces that have to move together — draft, baseline, seeded key, conflict flag — are now assigned in exactly one place, `adoptServerVersion`, used by first load, silent refresh, Save and Discard alike. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
69d3208586 |
fix(channels): handle /new in shared router (#6251)
* fix(channels): handle /new in shared router * test(channels): cover /new provider reset end to end * fix(channels): preserve command source across rewrites --------- Co-authored-by: 无瑕 <yaowangjue.ywj@antgroup.com> |
||
|
|
0af0c6e27e |
docs(cli): tighten the issue search help text (#6300)
The help text added in #6293 is correct but runs 19 lines of prose, the longest Long in cmd_issue.go. Its job is only to correct two wrong expectations — that comments are not searched, and that an external identifier implies a cross-tracker link — and each paragraph carried a sentence that did not serve that job: - the first paragraph stated the comment-body scope twice; - the second explained, via LIKE-pattern set theory, that "412" also matches "1412" while "AGE-412" does not. That is reviewer-grade precision, not user-grade; simply not claiming the two forms are equivalent conveys it; - the third explained the number-only fallback before saying the part that is actionable. Down to 11 lines with no fact dropped: the fallback caveat is kept in short form, since without it "strongest field that matched" reads as wrong for a number-only hit. Co-authored-by: J <agent@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
20d2dd8cef |
MUL-5620: fix(daemon): close the repo-eviction liveness window and stop GC walking internal caches (#6299)
* fix(daemon): close the repo-eviction liveness window and stop GC walking internal caches Two review follow-ups from #6297. 1. The live-repo set was snapshotted once at the start of the .repos walk, then consulted per repo after running git and filesystem work on each one in turn. A workspace re-attaching a repo inside that window could see the cache evicted anyway. Replace the snapshot with a per-path query and ask twice: once as a cheap early-out so an attached repo never pays for the git work, and again immediately before RemoveAll. Re-reading in-memory state costs one mutex and no network, and shrinks the window from the whole walk to a few adjacent statements. This narrows the race rather than eliminating it — attachment updates workspaceState without taking the repo lock, so a sufficiently unlucky interleaving is still possible. It stays benign: a freshly attached repo has no last-used stamp and takes the backfill-and-skip path, and a wrong eviction costs one re-clone via ensureRepoReady. 2. runGC skipped only .repos, so it walked .skill-cache as if it were a workspace. Its "v1" directory then looked like a task dir with no .gc_meta.json and the orphan path deleted the entire bundle cache once its mtime went GCOrphanTTL without a new bundle — a few hundred KB reclaimed in exchange for a full re-download. Skip every dot-directory, matching what ScanDiskUsage already does; workspace directories are always UUIDs, so a dot-prefixed entry is one of our own caches. The skill cache has no lifecycle of its own, but it is measured in hundreds of KB, so leaving it unmanaged is clearly better than deleting it wholesale on an unrelated TTL. Giving it a real lifecycle is separate work if it ever grows. Steve's third observation — repoCacheSize measuring any second-level directory while eviction only handles isBareRepo ones — is left as is on purpose: measuring more widely than we delete is the right asymmetry for a visibility feature, so a corrupted leftover stays visible instead of silently uncounted. Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): measure repo size before the final liveness check, not after Review follow-up: the second liveness check was not actually adjacent to the delete. dirSize walks every file in the bare repo and ran between the check and RemoveAll, so on a multi-GiB cache — the size that motivated this work in the first place — a workspace re-attaching during that walk would still lose its cache. Measure first, then check, then delete. That leaves only the window between two adjacent statements, which is what the comment claimed all along, and it keeps the slow filesystem walk outside the section the check is meant to protect. No behaviour change beyond the ordering: bytes_reclaimed still reports the size measured just before deletion, which TestEvictRepoCache_RemovesIdleDetachedRepo already asserts is non-zero. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
091d6e7c23 |
MUL-5648: docs(cli): issue search searches comments too, and its number match ignores the prefix (#6293)
* docs(cli): say that `issue search` searches comments too
`multica issue search --help` claimed "Search issues by title or
description", but the server has always matched comment bodies as well
(`server/internal/handler/issue.go` WHERE clause + rank tiers 7/8) and
returns `match_source: "comment"` for those hits.
On a real workspace that is not an edge case: over a 20-query sample,
52% of hits came from comments vs 35% description and 13% title. Readers
who trusted the help text concluded that a decision recorded in a comment
thread was unfindable via search, and fell back to reading whole comment
histories instead.
Fix the one-liner and add a Long that also documents the `match_source`
value set (`title` / `description` / `comment`), including that `comment`
doubles as the fallback for a number-only match — so it reads as a
display hint rather than a filter.
Docs only; no behavior change.
* docs(cli): spell out that identifier-shaped queries ignore the prefix
`parseQueryNumber` accepts a bare number OR anything matching
`(?i)^[a-z]+-(\d+)$`, and never checks the prefix against this
workspace. So "MUL-412" and "ZZZ-412" both match local issue 412, and
because a number match is rank tier 0 it lands at the top of the results
labelled `comment` with an empty snippet.
That matters in practice: external tracker ids appear in our own docs
and in code comments, so pasting one into search returns a confident,
wrong top hit. The previous wording ("a numeric query also matches an
issue by its number") read as bare-digits-only and hid it.
Documents the existing behavior; the loose prefix match is plausibly
deliberate (paste an id from anywhere and still find something) and
tightening it is a product decision, not a bug fix. Still docs only.
* docs(cli): don't claim "AGE-412" and "412" are equivalent
Only the number match is the same for both forms. The text search still
uses the query as written, so a bare number matches a strictly wider set
of text than the identifier form ("412" hits "1412", "AGE-412" does not).
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Liaoyuan Ning <truetalents.lynn@gmail.com>
Co-authored-by: J <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
|
||
|
|
a735e4808d |
feat(daemon): account for and evict the bare repo cache (#6297)
The .repos bare-clone cache was excluded from disk-usage and never reclaimed, so it grew monotonically and was invisible while doing it. On the machine in #6265 it was 3.78 GB — 29% of the workspaces root, and exactly the difference between what disk-usage reported (9.1 GiB) and what the user's file manager showed (12.88 GiB). Accounting: .repos is now measured and reported on its own line rather than skipped. It stays out of the task totals on purpose — every task in a workspace checks out from this one shared cache, so folding it into per-task numbers would attribute it to directories that do not contain it. Other daemon-internal dot-directories (.skill-cache) are no longer counted as workspaces, which is what produced bogus rows like '.skillca'. Eviction: a bare repo is removed only when all four hold — GCRepoTTL > 0, no watched workspace still claims it, no worktrees remain, and no task has created a worktree from it within the TTL (default 30d). Two decisions worth calling out: - The workspace check is a RETAIN predicate, not a delete predicate. Sync re-clones every listed repo that is missing whenever a workspace registers, which happens on every daemon start, so evicting a repo the workspace still claims just buys a full re-clone on the next restart — that moves disk cost, it does not reclaim it. Because the set only prevents deletion, a stale or empty one cannot widen what we delete. - Idleness is an explicit stamp written by CreateWorktree, not directory mtime. Restarts re-fetch every cached repo, refreshing the mtime of repos no task has checked out in months; atime is unavailable in practice (noatime on Linux, off by default on Windows). A cache with no stamp reports unknown and gets its clock started, never treated as ancient — otherwise the first cycle after an upgrade would wipe every cache on the machine. Evicting wrongly costs a re-clone, not a failure: the next task that needs the repo takes the cache-miss path in ensureRepoReady. Verified on a live runtime: both table views now show the .repos line (242.8 MiB across 3 repos) and no longer list .skill-cache as a workspace. Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
10746ad3af |
docs(license): state that free public hosting still needs a commercial license (MUL-5558) (#6296)
* docs(license): state that free public hosting still needs a commercial license (MUL-5558)
Condition 1a's operative sentence restricts providing a hosted service to
third parties without qualifying it by commerciality, but clause 1's
preamble frames the whole section around commercial use, so a reader can
argue that a free public instance escapes (a). A downstream fork asked
exactly this question, which is evidence enough that the ambiguity is real.
Say it directly: hosting for third parties is restricted whether or not a
fee is charged. Free hosting is the substitution risk the condition exists
to price, so leaving it to inference is the wrong default.
Also state the other half explicitly: publishing a fork's source is not a
hosted service and needs no commercial license, but it conveys no hosting
right downstream — each operator needs its own.
This ports the net content of commit
|
||
|
|
eb14e73a20 |
MUL-5617: fix(storage): disable request checksum in buffered S3 Upload (#6261)
* fix(storage): disable request checksum in buffered S3 Upload
* test(storage): make the checksum-trailer regression test actually fail without the fix
The test built its client with s3.New(s3.Options{...}), which leaves
RequestChecksumCalculation unset. An unset value emits no checksum at all,
so the assertions passed with or without the fix in Upload(). Production
builds the client via config.LoadDefaultConfig, which resolves the field to
WhenSupported.
Pin that production default in the test client and serve over TLS, since the
SDK only switches to a trailing checksum when the request is HTTPS. Removing
the option from Upload() now fails buffered_upload on the exact
STREAMING-UNSIGNED-PAYLOAD-TRAILER value Aliyun OSS rejects, while
streaming_upload still passes.
Also gofmt s3.go: the added option callback used spaces instead of tabs.
Co-authored-by: multica-agent <github@multica.ai>
* fix(storage): scope the checksum workaround to non-AWS endpoints
Downgrading RequestChecksumCalculation to WhenRequired is what makes Aliyun
OSS and Tencent COS accept the buffered upload, but it drops the client-side
checksum entirely rather than moving it out of the trailer: on TLS the request
goes out as UNSIGNED-PAYLOAD with no x-amz-checksum-* at all. Applying that to
every backend would change requests that work today, and AWS buckets with a
default Object Lock retention require a checksum to be present.
Gate the option on the endpoint instead. Real AWS S3 — no AWS_ENDPOINT_URL, or
an explicit amazonaws.com host — keeps whatever the SDK resolved, including any
operator-set AWS_REQUEST_CHECKSUM_CALCULATION. Only S3-compatible endpoints get
WhenRequired.
Adds TestS3StorageAWSUploadKeepsChecksumTrailer so the AWS path is pinned from
the other side: making the workaround unconditional now fails that test, while
removing it entirely still fails buffered_upload.
Also drops the incorrect claim that the seekable body stays covered by a real
SigV4 payload hash.
Co-authored-by: multica-agent <github@multica.ai>
* fix(storage): match the AWS endpoint on the host label boundary
usesAWSEndpoint substring-matched "amazonaws.com" anywhere in the configured
endpoint, which got the decision wrong in both directions: an uppercase
"https://S3.US-EAST-1.AMAZONAWS.COM" was treated as third-party and lost its
checksum, while "https://notamazonaws.com", "https://s3.amazonaws.com.evil.net"
and any URL merely carrying the string in its path or query were treated as AWS
and never got the fix they needed.
Parse the endpoint and compare the hostname on the label boundary instead,
covering the China partition. Scheme-less values are retried as https, since
url.Parse otherwise reads the whole value as a path.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: renjianjun <renjianjun@angelalign.com>
Co-authored-by: Bohan-J <bohan.optimism@gmail.com>
Co-authored-by: multica-agent <github@multica.ai>
|
||
|
|
cb04226fd4 |
MUL-5632: fix(engine): broadcast the full issue payload for chat-created issues (#6278)
* fix(engine): broadcast the full issue payload for chat-created issues
An issue opened with /issue from a chat channel broadcast
{"issue_id": <uuid>} — the minimal fallback IssueService.Create emits
whenever a caller leaves IssueCreateOpts.BroadcastPayload nil, which the
engine did. Every issue:created consumer reads payload["issue"], so the
missing key meant extractIssueFields (cmd/server/subscriber_listeners.go)
failed its map assertion and returned false before it ever reached the
id / creator_id check. The listener returned early and no auto-subscribe
rule ran, so the person who typed /issue was never subscribed to the
issue they had just filed and got no notifications for it. Both channels
that register with the engine — Feishu/Lark and Slack — were affected.
The HTTP handler already supplies a BroadcastPayload and autopilot
publishes its own event through issueToMap; only the engine path was
short. Export issueToMap as IssueToMap and have the engine use it, so a
single builder is the one source of truth for that shape instead of
three descriptions drifting apart. The workspace issue prefix comes from
the GetWorkspace call the /issue path already made for the chat reply's
identifier, hoisted so it is read once and used for both.
No regression — the payload only gains keys, and consumers read named
fields. The activity and notification listeners type-assert
payload["issue"] to handler.IssueResponse and still skip a map, exactly
as they already do for autopilot-created issues. The subscriber listener
accepts either shape. The workspace WS fanout marshals the payload
as-is, so open clients now render a chat-created issue live instead of
waiting for a refetch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(service): complete the issue:created payload contract
IssueToMap omitted project_id, stage, metadata and properties, all of
which handler.IssueResponse — the other rendering of the same event —
always emits. Clients type both as a complete Issue and insert the
object straight into the list cache without runtime validation, so an
issue created by autopilot, quick-create or the chat /issue command
appeared in open clients missing its project and custom properties
until the next refetch. Broadcasting the full issue from the chat path
would otherwise have spread that existing defect to a third entry
point.
Fill in the missing keys and pin the contract with a test that fails if
the two renderings ever drift apart again, so adding a field to
IssueResponse without adding it here is caught in CI rather than in the
UI. metadata and properties render as {} when unset, matching the
"always present" part of the contract.
Also route both identifier renderings through service.IssueIdentifier,
so a workspace lookup that degrades the prefix cannot show "#42" in the
chat reply while the realtime list shows "-42".
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* docs(service): name the actual IssueToMap call sites
The doc comment and the shape test listed quick-create among the
non-HTTP publishers of the issue payload. It is not one: the three call
sites are autopilot and the channel engine on issue:created, and the
background stuck-issue status reset on issue:updated.
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
|
||
|
|
c037c134ad |
MUL-5620: fix(daemon): make disk-usage report what is actually on disk (#6270)
* fix(daemon): make disk-usage report what is actually on disk Two defects made `multica daemon disk-usage` misreport, both of which matter when a user is trying to work out why their workspaces root is large (#6265). 1. The STATUS column was dead code. TaskDiskUsage.ParentStatus was declared and rendered but never assigned anywhere, so the column always printed "-" and parent_status was always "" in JSON. The GC is unaffected — it resolves status independently via gcDecisionIssueResult — but the one column that answers "which of these issues are still open?" was blank. ScanDiskUsage stays network-free; ResolveParentStatuses is an opt-in second pass wired into the CLI, backed by the same batch gc-check endpoint the GC loop uses, so the column reports exactly the status the GC would act on. The daemon already authenticates with the profile's CLI token, so this needs no new API surface. Best-effort: offline or logged out, the column stays blank and the command still works. Only issue-kind dirs are resolved — they dominate, and they are the only kind with a batch endpoint. Chat / autopilot-run / quick-create keep an empty status rather than costing one request each. 2. taskSize skipped .git entirely, so size_bytes under-reported every task dir holding a real git checkout. That disagreed with both the user's file manager and the GC itself: a full gcActionClean removes .git with the rest of the dir, and dirSize (which reports bytes_reclaimed there) counts it. Now counted wholesale into totalBytes, still never descended into, so artifact accounting stays aligned with cleanTaskArtifacts. Verified against a live runtime: STATUS resolves (in_review on the largest dirs, i.e. not done — which is exactly the question that prompted this), and top dirs went from 104.9 MiB to 132.6 MiB reported, closing most of the gap against du. Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): skip disk-usage status resolution when nothing renders it Review follow-up on #6270. The --by-workspace table has no STATUS column, but the CLI resolved statuses unconditionally. With credentials on disk and an unreachable server that cost a full timeout before printing a purely local report — and --all-profiles paid it once per root, serially — then warned about a column the output does not contain. Gate resolution on whether task rows are actually rendered; JSON keeps resolving because it always carries the task array. Also correct two contract statements that the .git change had made false: the command's Long help still claimed 'The walk skips .git', and ScanDiskUsage's doc comment still claimed it never enters .git. Both now state the real rule (counted toward the total, never toward the artifact subset, symlinks still never followed), and the help now says STATUS needs the network and is left blank when it cannot be reached. Adds the CLI-level regression tests the wiring was missing: the by-workspace table makes no request, the per-task table resolves and renders the status, a failing server still exits 0 with valid JSON on stdout, and --all-profiles resolves each root with its own profile's token. Verified the no-request test fails when the gate is removed. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
d38da27ed4 |
MUL-5619: fix(cli): surface the server's 409 message instead of the generic conflict template (#6267)
* fix(cli): show the server's conflict message instead of the generic 409 template
Every 409 this API returns is a deterministic refusal that names its own fix
("a skill with this name already exists", "set parent_id (--parent) to <id>").
The CLI replaced all of them with a template that says the opposite — that the
state changed underneath you and you should re-fetch and retry. Agents took the
retry hint literally: GH #6264 reports 15+ identical retries over 10 minutes
followed by hours spent chasing an optimistic-concurrency theory that never
existed, and GH #5948 is a second user misdiagnosing the same way. MUL-4417 had
already written the useful message server-side; it just never reached anyone.
Route 409 through the same server-message extraction 400/422 already uses, so
roughly forty hand-written conflict messages across skills, agents, runtimes,
labels, projects and comments become visible by default. A body we cannot
recognize still falls back to the template, so this never dumps a raw response.
extractServerMessage now prefers prose over a bare identifier, because a few
endpoints put a stable code in "error" and the sentence in "message".
MUL-5619
Co-authored-by: multica-agent <github@multica.ai>
* fix(comments): stop telling a wrong --parent that it posted a top-level comment
The reply guard returns one message for two different mistakes. A resumed
session that carries a previous turn's --parent forward (GH #6264) did not ask
for a top-level comment, but is told it did — which sends it looking for a
new-thread opt-in (GH #5383) instead of correcting the parent it already passed.
Split the copy: name the rejected parent when one was supplied, and keep the
existing top-level wording for the parentless case. Both still point at the
trigger comment to use.
MUL-5619
Co-authored-by: multica-agent <github@multica.ai>
* fix(runtime): return 500, not a 409 echo, when the update store fails
InitiateUpdate answered every UpdateStore.Create failure with a 409 carrying
err.Error(). The in-memory store only ever returns errUpdateInProgress, so this
looked safe — but the Redis store also wraps infrastructure failures as
"reserve active update: <dial error>" and "persist update request: <error>".
Surfacing 409 bodies in the CLI turns that into a user-visible leak of internal
addresses, and labels an outage as a conflict the caller could fix by retrying.
Classify instead: errUpdateInProgress keeps its 409 and its actionable message,
everything else is logged and answered with a 500 and fixed copy.
Also pins the prose-over-machine-code preference for validation bodies, which
the shared extractor applies to 400/422 as well as 409. Only the issue-table
endpoints are shaped that way and none is reachable from the CLI today, but the
change is intentional and should fail loudly if reverted.
MUL-5619
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
|
||
|
|
b06af2ae17 |
feat(runtime): unbind agents on runtime delete instead of destroying them (#6220)
* feat(runtime): unbind agents on runtime delete instead of destroying them Deleting a runtime archived its agents and then hard-deleted the rows, so the agents and every conversation with them disappeared — while the confirmation dialog said "archive", which a user reasonably reads as recoverable. Retiring a laptop is an ordinary action; losing the agents configured on it is not an ordinary consequence. An agent is now a persistent business object and a runtime is replaceable execution capacity: deleting a runtime unbinds its agents. `runtime_id IS NULL` means unbound — orthogonal to archived — and the agent keeps its instructions, skills, chats, labels, channel installations, autopilots and task history. service.AgentReadiness already refused an agent with no runtime, so the scheduling safety gate needed no change. Two columns become nullable, not one. Without `agent_task_queue.runtime_id`, deleting the runtime still cascades the task history away (and task_message / task_usage / task_token with it), so the agents would survive with no record of anything they did — the same class of loss. A NOT VALID CHECK keeps NULL confined to history: an active task must always have a runtime, so claim / dispatch / delivery-CAS paths can never observe one without. It is written against completed_at rather than a status list so a future non-terminal status fails closed instead of slipping through. Two prerequisites this depends on: - 'deferred' (migration 128) was missing from CancelAgentTasksByRuntimeOrAgent. It went unnoticed because the delete used to cascade those rows away; with the new CHECK it would abort the delete and make the runtime undeletable. - The channel-installation / label / chat-pin / invocation-target / draft-restore cleanups were scoped to "archived agents on this runtime". Archived user agents now survive, so that scope is narrowed to kind='system' — otherwise the fix would produce a subtler loss: agent alive, configuration wiped. Also removes the squad guard that refused (409) when an active squad's leader was an archived agent on the runtime, plus the archived-squad delete that existed only to get past squad.leader_id's RESTRICT FK. The leader is no longer deleted, so nothing needs to be given up to retire a machine. Autopilots are no longer paused either: their assignee survives, and a rebind restores them without the owner having to remember to re-enable. Reason codes: an unbound agent reports agent_runtime_required, not runtime_offline. The copy for runtime_offline tells users to reconnect a machine; an unbound agent has no machine to reconnect, and the fix is to bind a runtime. Chat's bare 409 string gains the same code so the composer can offer that action. API: agents gain runtime_bound. runtime_id stays a string (empty when unbound) so installed clients keep parsing and no gated two-release rollout is needed. The confirmed-delete endpoint is /unbind-agents-and-delete; /archive-agents-and-delete still routes to it, and the compared expected_active_agent_ids set is unchanged — widening it would 409 every older client forever. Co-authored-by: multica-agent <github@multica.ai> * fix: make runtime unbinding recoverable Co-authored-by: multica-agent <github@multica.ai> * fix: address runtime unbind review nits Co-authored-by: multica-agent <github@multica.ai> * fix: resolve runtime unbind review blockers Co-authored-by: multica-agent <github@multica.ai> * fix(migrations): renumber runtime unbind after main merge Co-authored-by: multica-agent <github@multica.ai> * test(daemon): avoid late-request lease flake Co-authored-by: multica-agent <github@multica.ai> * test(autopilots): bind validation fixture runtime Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
829a4e5af9 |
fix(daemon): honour HTTP(S)_PROXY when dialing the wakeup WebSocket (#6279)
runTaskWakeupConnection built its dialer by hand:
dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second}
A zero-value Proxy field means "dial direct and ignore the
environment". websocket.DefaultDialer sets Proxy to
http.ProxyFromEnvironment; a dialer built this way gets nothing, and
gorilla skips the CONNECT wrapper entirely.
This is the daemon's control connection to the Multica server, so in
the SaaS deployment it dials out to the public internet. On a machine
whose only egress is a corporate proxy the handshake can never succeed.
Nothing points at the cause either: the loop logs "task wakeup
websocket unavailable; polling fallback remains active" at debug level
and never mentions a proxy. The daemon then runs permanently degraded —
task pickup waits for the HTTP poll (PollInterval, 30s by default)
instead of a server push, heartbeats stay on HTTP, and the WS-first
batch claim path (MUL-4257) falls back to HTTP on every task.
Set Proxy: http.ProxyFromEnvironment. gorilla rewrites wss:// to
https:// on the parsed URL before it calls Proxy, so HTTPS_PROXY — and
NO_PROXY — apply to this dial the same way they apply to every other
HTTPS client in the process. The lark connector fixed the same defect
the same way in #4165; this was the last bare dialer left in non-test
code.
No regression where no proxy is configured: ProxyFromEnvironment
returns a nil URL, gorilla leaves netDial untouched, and the dial is
byte-for-byte the direct dial it was before. Everything downstream of
the handshake — headers, heartbeat writer, RPC attach, teardown — is
unchanged.
The regression test drives the dial from a child process. net/http
resolves the proxy environment once per process and caches the result
(envProxyOnce), so by the time a test in this package runs, an earlier
test has already primed that cache with "no proxy" and t.Setenv can no
longer reach it. The child starts with a clean environment pointing at
a stub CONNECT proxy, and the parent asserts the CONNECT for the wss
target arrived. Without the fix the stub proxy sees nothing.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
3d0e1324c6 | fix(mobile): recover from zombie WebSocket connections | ||
|
|
0398d29675 |
docs: four-language end-to-end tutorial with 41 screenshots (#6268)
* docs(tutorial): add the 41 walkthrough screenshots Screenshots for the end-to-end tutorial, captured against the English UI and shared by all four locales. Compressed with pngquant (18 MB -> 4.8 MB) to stay in line with the existing docs images. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(tutorial): add a four-language end-to-end tutorial A hands-on walkthrough that builds one personal website project from an empty workspace: connect a computer, create agents through chat, deliver the first issue, review and merge it, form a squad so the leader handles coordination, attach imported skills, and set up a scheduled autopilot. Sits in Get started between the quickstart and the concept pages, so a reader who finished the 5-minute path can walk the full flow before moving on to reference material. UI labels, statuses and feature names follow each locale's product strings, matching how the rest of the docs are written. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(tutorial): move the tutorial below How Multica works Places it after the concept pages rather than straight after the quickstart, so a reader meets the core objects before the full walkthrough. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9b013e34e8 |
feat(inbox): move the selection with the arrow keys (MUL-5622) (#6269)
Up/Down inside the inbox list scrolled the container instead of walking the selection, so a notification could only be opened with the mouse. The list's scroll container now owns the arrow keys: it moves the selection by one row, scrolls the new row into view through Virtuoso (never the DOM's scrollIntoView, which also scrolls ancestors), and claims the keypress so the native scroll cannot pull the viewport off the selected row. Keyboard focus is parked on the container rather than a row, because virtualization unmounts rows as they scroll out. Scoping the handler to the container keeps Down from swapping the row out while the user is reading the issue detail. Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
37f3bb7dd9 |
MUL-5587: fix(autopilots): tell the user which required field blocks Create (#6231) (#6237)
* fix(autopilots): tell the user which required field blocks Create (MUL-5587) The create dialog gated its submit button with native `disabled` on an empty title, an unpicked assignee, or a server-rejected schedule. A natively disabled button is neither hoverable nor focusable, so a user who had not picked an assignee got a dead grey control and no hint about what was missing (GitHub #6231) — the assignee picker looks exactly like the optional Project and Subscribers pickers beside it, and nothing said only that one was required. An unmet requirement now uses `aria-disabled` instead: the button still takes the hover that shows a tooltip naming the missing field, and still takes the click, which reveals an inline error under the field at fault and focuses it. `handleSubmit` is the real gate either way, so nothing can be submitted that could not be before. The assignee section also carries a required marker up front, so the dead end is avoided rather than only explained. The three states come from one `submitBlock` value shared by the button, the tooltip, the inline errors and `handleSubmit`, so a rendered affordance cannot disagree with what submitting actually does. Co-authored-by: multica-agent <github@multica.ai> * refactor(autopilots): stop dimming Create at all — the click is the feedback (MUL-5587) Follow-up on review. The previous commit kept the button greyed via `aria-disabled` and explained the grey with a tooltip. Since a blocked click now intercepts and points at the offending field, the greying earns nothing: drop `aria-disabled`, the dimming classes and the tooltip, and let the button be an ordinary live button whenever a save isn't already in flight. The schedule case no longer scrolls to the editor's inline error and returns. It falls through to `scheduleGate.ensureAccepted`, which re-asks the server and toasts its actual reason — visible feedback where the scroll could be a no-op, and it self-heals when a stored expression the server once rejected is accepted again. That leaves nothing in this dialog reading the gate's `scheduleValid`, so its `onValidityChange` / `clearRejection` wiring goes too rather than sitting inert. The shared hook keeps them for the detail page's add-trigger dialog, which still gates on it. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
8df7549d84 |
refactor(issues): move sidebar Details section below the execution log (MUL-5599) (#6244)
Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
3cab03cc36 |
feat(labels): remove agent labels from the product (MUL-5600) (#6245)
Label Settings no longer exposes an Agents catalog, so agent labels can no longer be created, renamed, recolored, or deleted from the UI. Removing only that catalog would have left the agent detail settings form with an attach-only label picker pointing at a catalog the user can no longer populate, so the Labels row there goes too. That picker was the only other agent-label surface in the product — the CLI never managed them, and no list, filter, or chip renders them anywhere else. The backend still models the `agent` resource type and keeps its endpoints; this change removes the product surface, not the API. Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai>v0.4.16 |
||
|
|
bdd90dd9ca |
docs(changelog): add v0.4.16 release entry (2026-07-31) (MUL-5596) (#6242)
* docs(changelog): add v0.4.16 release entry (MUL-5596) Co-authored-by: multica-agent <github@multica.ai> * docs(changelog): rewrite v0.4.16 entries for clarity (MUL-5596) Each line now states the user-visible result instead of the mechanism: name the agents whose replies were truncated, say what the Linux Codex change actually gives you, drop WebSocket/migration internals, and use the same word for link chips across all four languages. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai> Co-authored-by: Bohan-J <bohan@devv.ai> |
||
|
|
33a2743f93 |
fix(llm): make quick actions GPT-5.6 compatible (MUL-5573) (#6243)
* fix(llm): use max completion token limits Co-authored-by: multica-agent <github@multica.ai> * fix(llm): harden GPT-5.6 JSON generation Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
28b6105edc |
fix(subscribers): notify the human an agent files sub-issues for (MUL-5483) (#6209)
When an agent created a sub-issue while working on a human's behalf, that human received no notifications for it at all. issue_subscriber modelled ACTOR identity, so an agent-created, agent-assigned issue had a full subscriber list and zero members to deliver to. The platform already knew who the work was for (agent_task_queue.originator_user_id, MUL-4302); notification never asked. - attribution.DelegatedSubscriber: one shared rule over the same origin waterfall ClassifyDirect uses. agent_create subscribes the originator as 'delegated'; quick_create keeps the direct 'creator' tier; autopilot and degraded attribution subscribe nobody. - Delegated is a reduced delivery tier: in_review/done/cancelled/blocked plus failures and mentions. Routine churn is suppressed, and the parent bubble cannot re-deliver what the tier dropped. - Unsubscribe becomes stateful: an unsubscribed_at tombstone survives later rule passes, and opt_out_scope distinguishes "this issue" from "this subtree" so a narrow opt-out no longer silently suppresses future children. - Subtree unsubscribe is its own endpoint. A body flag cannot fail loudly against an older backend (Go drops unknown fields); an unknown route 404s, which the UI now surfaces with a distinct message. - Eligibility and the write share one statement under a (workspace, user) advisory lock that subtree unsubscribe and member revoke also take, closing the check-then-insert races. Revoke additionally clears the departing member's subscriptions in the same tx. - UI explains a delegated subscription and offers both unsubscribe scopes. Migrations 249/250 add the delegated reason, the opt-out tombstone, and the opt-out scope, using NOT VALID + VALIDATE CONSTRAINT so the widened CHECK does not scan issue_subscriber under an exclusive lock. Reviewed across eight rounds; an earlier write-time subtree roll-up was built and then removed in full once it proved unfixable without serializing every topology mutation. The parent's own status transition already carries that signal. Closes MUL-5483. |
||
|
|
9daa291d00 |
fix(migrations): resolve duplicate migration number (#6239)
Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
d4ae220cc1 |
feat(rich-content): render bare in-app project/issue URLs as chips (MUL-5499) (#6141)
* feat(rich-content): render bare in-app project/issue URLs as chips (MUL-5499) A project has no `MUL-123`-style identifier — only a UUID and a free-text title — so there is nothing for the bare-identifier autolink preprocessor to detect, and the link copied out of the app is how people actually reference one. It rendered as a raw URL. RichLink now unfurls a bare in-app entity URL into the same chip the `mention://project/<uuid>` form already produces (issue URLs go through the same path for symmetry). Render-only: stored markdown is untouched, and the editable Tiptap path is deliberately unaffected. Three guards, each load-bearing: the link must be bare (an authored label is never discarded), same-workspace (a chip resolves its title in the current workspace only), and address exactly one entity page by UUID with no query or fragment. Also: - mobile: tapping a `mention://project/` link navigated nowhere despite the `project/[id]` route existing — it now pushes the project detail. - agents had no documented way to emit a clickable project reference: add the link form to the runtime brief's Mentions section and to the projects skill, and record in the mentioning skill why `project` sits outside `MentionRe` (render-only, enqueues nothing). Co-authored-by: multica-agent <github@multica.ai> * fix(rich-content): unfurl issue URLs in identifier form The unfurl required a UUID id, on the stated grounds that "every link the app itself produces carries a UUID". That holds for a project but not for an issue: `copyLink` and `openInNewTab` both build `paths.issueDetail(issueIdentifier || issueId)`, and the issue route rewrites a UUID URL back to the identifier — so `MUL-123` is the shape a user actually copies, out of the app or out of the address bar. The issue half of the feature could not fire on the links people paste, while bare `MUL-123` prose did become a chip: the fuller reference lost to the shorter one. `parseWorkspaceEntityLink` now accepts an issue identifier as well as a UUID. A project still requires a UUID — it has no shorthand, so an identifier-shaped id under /projects/ addresses nothing. An identifier needs a lookup, which means it can miss, and the miss has to differ by entry point. `AutolinkedIssueMentionLink` degraded to plain text, which is right for autolinked prose and wrong for a URL: the author wrote a link, and an issue this workspace cannot see must not cost them the only pointer to it. The fallback is now a prop — plain text for the autolink path, the original anchor for a URL. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(editor): stop drawing link chrome over mention chips A mention chip already carries its own affordance — border, icon, hover background — so the generic `.rich-text-editor a` color and underline draw a second, competing one straight through the card. `.issue-mention` reset it; `.project-mention` never did, so project chips shipped with a brand-coloured underline through them. The rule belongs to the chip shape rather than to one entity, so both selectors now share it and a future chip is one line. The hover card had the same gap: it skipped `.issue-mention` only, so hovering a project chip opened a URL card offering to copy `/{slug}/projects/{uuid}` — an in-app path, not the shareable link that wording implies. Both are pre-existing, but a bare project URL now renders as a chip, so what used to surface on hand-written mentions alone shows up on ordinary pasted links. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(rich-content): decide in-app by resolving the URL, not by its prefix `href.startsWith("/")` was standing in for "this deployment". It is not: a browser reads `//other.example/x` and `/\other.example/x` as another host and goes there, and both start with a slash. The parser skipped the origin check for exactly the hrefs that most needed it. Nothing shipped from this: an unfurled chip links to `paths.projectDetail(uuid)`, so the href it was parsed from is discarded and a misparse could not send anyone anywhere. The prefix test was still the wrong instrument. Adding `&& !startsWith("//")` would have looked like a fix while leaving the backslash spelling through — the gap is the technique, not the case, so this resolves the href against the app origin with `URL` and compares `origin`, which is one comparison for every spelling and for the schemes (`javascript:`, `data:`) whose opaque origin can never match. Relative and absolute now take the same path, so the slugless legacy form parses identically whether or not it carries the origin — previously the absolute spelling was rejected by a reserved-slug test meant for workspace slugs, and the two disagreed. `openLink` still tests the prefix, and its result IS navigated. That is a live issue, older than this feature and wider than it; it needs its own change rather than a quiet ride here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(skills): state what mobile actually does with a project mention The projects skill told agents a `mention://project/<uuid>` link "renders as a navigable project chip on web, desktop, and mobile", and that a pasted project URL is unfurled into that same chip by "the reader's client". Neither holds on mobile: `apps/mobile/lib/markdown/markdown.tsx` renders the default enriched link and only routes the tap, and a bare URL still goes to `Linking.openURL`, which leaves the app. These files enter agent context and read as product contract, so an agent choosing between a mention link and a pasted URL was choosing on false information — and the URL is the option that strands a mobile reader in a browser. Both skills and both source maps now say chip on web/desktop, ordinary link that opens the project on tap on mobile, and unfurling as web/desktop only. The projects skill also now states the preference outright rather than presenting the two forms as equivalent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(views): give a project mention the component an issue mention has `IssueMentionCard` owns "chip inside a link" for issues; the project equivalent lived inline in the readonly renderer, so nothing named the pairing and nothing held the rules that come with being a link. That cost was not hypothetical. Both gaps fixed a commit ago landed on project mentions alone: `.project-mention` never got the CSS rule cancelling generic link chrome, and the hover card never learned to skip it. Each was written for `.issue-mention` at the component that owns it, and project had no such place for the second half to be written. `ProjectMentionCard` is that place. No behaviour change: same anchor, same href, same hover affordance, same accessibility contract that project-mention-a11y.test.tsx pins. The "open in new tab" preference stays out — it is scoped to issue links, and inheriting it by symmetry would be inventing product. Also drops `not-prose` from both cards. It has no definition anywhere in the repo — Tailwind's typography plugin is not installed, and the class does not appear in built CSS — so it read as protection that was not there. The editor's `MentionView` keeps its hand-rolled anchors: it needs a modifier-click intent hook `AppLink` does not expose, and it does the same for issues, so the two stay symmetric there too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai> Co-authored-by: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
cd9b956269 |
fix(agent): spawn Copilot's native binary on Windows so the prompt survives (MUL-5586) (#6236)
On Windows the daemon passes the full multi-line prompt as `-p <prompt>` but spawns npm's `copilot.cmd`, which we already rewrite to `powershell -File copilot.ps1`. Neither launcher can carry that argument: - `copilot.cmd` forwards with `%*`, which cmd.exe expands by re-tokenising the raw command line. - `copilot.ps1` ends in `& node.exe npm-loader.js $args`, and PowerShell re-serialises `$args` onto node's command line. Under Windows PowerShell 5.1 (and pwsh <= 7.2, which default to Legacy native argument passing) embedded double quotes are not re-escaped, so the prompt is re-tokenised. Copilot then sees several argv tokens where one was intended and refuses the run with "It looks like your prompt was not quoted, so the extra words were treated as separate arguments" — the same defect class already fixed for cursor-agent in #5649, except Copilot has no stdin prompt channel to escape through, so the prompt must stay on the command line and the launchers have to go. Copilot CLI ships a native per-platform binary and `npm-loader.js` does nothing but `spawnSync` it with argv untouched, so resolve `copilot-win32-{x64,arm64}\copilot.exe` out of the npm layout and spawn it directly. That leaves exactly one hop, Go -> native binary, and Go's syscall.EscapeArg is the exact inverse of the CRT parsing that binary uses. This mirrors resolveOpenCodeNativeFromShim / resolveDevecoNativeFromShim. Both the nested (current npm) and hoisted (older npm) platform-package locations are probed; when neither resolves, we keep falling back to the PowerShell launcher, which is still better than cmd.exe. Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
f48bd655bc |
MUL-5562: optimize application-owned workspace deletion (#6230)
* fix(workspace): optimize application-owned deletion Co-authored-by: multica-agent <github@multica.ai> * fix(workspace): address deletion review findings Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
e4b6f7a31b |
MUL-5581: add Qoder CN CLI runtime (#6232)
* feat(agent): add Qoder CN CLI runtime Co-authored-by: multica-agent <github@multica.ai> * fix(agent): address Qoder CN review nits Co-authored-by: multica-agent <github@multica.ai> * fix(agent): defer Qoder CN version gate Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
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 <github@multica.ai>
* 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 <github@multica.ai>
* 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 <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
|
||
|
|
98766cc06a |
feat(daemon): remove the Linux Codex per-task HOME; default Linux to danger-full-access (MUL-5578) (#6233)
* feat(daemon): default Linux Codex to danger-full-access on the real HOME (MUL-5578) Linux Codex tasks ran under the `workspace-write` Landlock sandbox with a generated per-task HOME: the daemon rewrote HOME/XDG_*/npm_config_cache into `<envRoot>/home`, symlinked a hand-maintained allowlist of seven credential paths back into it, and granted that directory as a `writable_roots` entry. That mechanism could not converge. Any host CLI outside the allowlist (aws, kubectl, gcloud, glab, rclone, cargo, …) started every task as if unconfigured even though it works in the daemon user's shell, and three open issues asked to extend it in three incompatible directions (#5636, #5573, #3867). The containment it bought was also narrower than it looked: workspace-write restricts writes only — reads and network were already unrestricted, and `.ssh` was seeded into the task home — so credentials under the real HOME were readable and exfiltratable regardless. Linux now runs `danger-full-access` on the daemon user's real HOME and inherited XDG environment, matching macOS and Windows. The task filesystem boundary is the boundary the daemon itself runs inside (VM, container, or dedicated Unix user), which is what the other providers already assumed — Claude Code runs with `--permission-mode bypassPermissions` today. This also removes the split-brain the mechanism could enter: the task-HOME decision read the platform default only, so a `-c sandbox_mode=danger-full-access` override (which passes arg filtering and wins over config.toml) left a task running unsandboxed while the daemon still redirected HOME and emitted writable_roots for a sandbox that was not in effect. With no HOME rewrite there is only one environment contract left to disagree about. Removed: prepareTaskHome, prepareCodexSandboxHome, TaskHomeEnv, Env.TaskHome, both seed allowlists, and the now-unfed WritableRoots plumbing through codexSandboxPolicy / CodexHomeOptions. Env roots created by older daemons keep working; their leftover `home/` directory is simply ignored and reclaimed with the env root. Task-scoped CODEX_HOME is untouched — it is managed Codex state, not the Unix HOME. macOS, Windows, and non-Codex providers are unchanged. Co-authored-by: multica-agent <github@multica.ai> * docs: document the agent execution security model (MUL-5578) The docs had no page describing what a task can reach on the machine that runs it — the sandbox posture was only discoverable from source. Adds one, stating plainly that tasks run with the full permissions of the daemon user and that isolation must come from a dedicated Unix user, container, or VM. Also separates what Multica genuinely isolates (per-task workdir, task-scoped CODEX_HOME, agent+task-bound API tokens) from what is not a boundary (the coding tool's own sandbox and approval settings), and links it from step 5 of the self-host quickstart, where the daemon is first installed. Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): address review on the Linux full-access default (MUL-5578) Docs, both must-fix: - The Chinese page rendered the product concept as `Agent`; the repo glossary in developers/conventions.zh.mdx mandates 智能体. Fixed all nine occurrences. ja/ko already used エージェント / 에이전트 and needed no change. - The security page asserted without qualification that every task runs with the daemon user's full permissions and that the Codex filesystem sandbox is always off. codexSandboxPolicyForWindows still keeps workspace-write when a user explicitly opts into a native windows.sandbox, so an authoritative security page contradicted the code. It now states that Multica makes no filesystem-sandbox guarantee, names the Windows opt-in as the one current exception, and says which combinations sandbox anything is a compatibility detail that moves with tool versions. All four locales updated, plus the historical callout which now says Linux matches the macOS/Windows *default*. Both stale comments from the non-blocking notes: - prepareCodexHome no longer claims to assume workspace-write + network_access; it pins GOOS=linux, which now resolves to danger-full-access. - ensureCodexSandboxConfig's warn-level logging is no longer described as a macOS-only fallback; it fires for every danger-full-access resolution. Adds TestCodexTaskShellEnvInheritsRealHome at the daemon env-assembly layer: HOME and the XDG base dirs must reach a Codex task's shell tools from the inherited daemon environment. Verified it fails when that pass-through breaks. It guards the pass-through, not runTask's decision not to inject a HOME of its own — that decision is inline in runTask and has no unit seam. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
cee985592e | fix(agent): drain trailing ACP notifications in kimi, kiro, qoder, and traecli (#5951) | ||
|
|
cac453fe01 |
MUL-5505: fix(selfhost): one source of truth for the backend host port (#6168)
* fix(selfhost): read the published host port back from Compose (MUL-5505)
`make selfhost` polled the wrong port when the backend host port was passed
through the environment. Make and Compose disagree on variable precedence: an
assignment in an included makefile (the Makefile `include`s .env) overrides a
real environment variable, while for Compose the environment overrides .env.
Because .env.example ships BACKEND_PORT uncommented, .env always pinned the
recipe's view of the port, so `BACKEND_PORT=9000 make selfhost` published the
stack on 9000 while the health check hammered 8080 for 60s and then reported
"Services are still starting" on a perfectly healthy install. The success
message printed the same wrong backend URL. FRONTEND_PORT had the same
inversion, affecting the printed frontend URL.
Stop re-deriving the host port in the recipe and ask Compose, which is the only
authority on what it published. The wait-and-report block (three port
references per target, duplicated across selfhost and selfhost-build) moves into
scripts/selfhost-wait.sh, which resolves both host ports via `docker compose
port` and falls back to the compose default chain when the stack is not up.
Also fix the input contract: `PORT` was the only port variable documented in
SELF_HOSTING_ADVANCED.md, yet compose read only BACKEND_PORT, so setting the
documented variable was silently ignored. The backend host port mapping now
falls back to `${PORT:-8080}`, matching Makefile and scripts/local-env.sh, and
the docs describe both variables as host ports.
Container-internal ports (8080/3000), the global PORT derivation used by local
dev, and the 127.0.0.1 bindings are unchanged; the default self-host experience
is byte-identical.
Fixes #6145
Co-authored-by: multica-agent <github@multica.ai>
* fix(selfhost): make PORT the real backend port and test the whole chain
Addresses review feedback on #6168.
The first round's root-cause claim was wrong. It said `BACKEND_PORT=9100 make
selfhost` published on 9100 while the health check probed 8080. It does not:
when make drives Compose, Compose inherits make's exported values, so both
sides agree on 8080 and the override is silently dropped instead. Re-measured
through the real recipe, the genuine disagreements were:
- `.env` setting PORT with no BACKEND_PORT: probe followed PORT, Compose
published ${BACKEND_PORT:-8080} — 9100 vs 8080.
- `make selfhost PORT=8080` over a .env with BACKEND_PORT=9100: probe 8080,
Compose published 9100.
Both are the "wrong port variable" defect the GitHub issue names, and reading
the port back from Compose fixes both. The comments and PR text that blamed
make/Compose precedence are corrected.
The PORT fallback added to docker-compose.selfhost.yml was also unreachable:
.env.example shipped BACKEND_PORT=8080 uncommented, so the fallback never
engaged and `SELF_HOSTING_AI.md`'s "edit PORT and FRONTEND_PORT" instruction did
nothing. Pick one contract and apply it everywhere: PORT is the backend port to
edit, BACKEND_PORT/API_PORT/SERVER_PORT are optional aliases that override it.
That is already what Makefile, scripts/local-env.sh, scripts/install.sh and
install.ps1 do; compose and the web dev fallback were the outliers.
- .env.example ships BACKEND_PORT commented out, like its sibling aliases, so
editing PORT works out of the box.
- apps/web/config/runtime-urls.ts walks the same alias chain instead of
reading BACKEND_PORT alone, so `pnpm dev` follows an edited PORT.
- SELF_HOSTING_AI.md and SELF_HOSTING_ADVANCED.md state the contract.
Configuration that cannot take effect is now reported instead of ignored.
scripts/selfhost-preflight.sh warns when an alias in the env file overrides an
edited PORT, and when a port from the shell environment is overridden by the env
file. The Makefile captures the pristine origins before its include, because
afterwards there is no way to tell that `PORT=9000 make selfhost` was asked for.
Tests now cross environment -> make -> compose -> report for real. The docker
stub is no longer told what to publish: on `up` it asks the real
`docker compose config` to interpolate the compose file with the environment the
recipe actually handed it, records that, and answers `port` from the recording.
Verified failing on the old code — with the old recipe and compose file the
suite reports published 8080 against probe 9100, the exact defect.
Co-authored-by: multica-agent <github@multica.ai>
* ci: gate the self-host test on scripts/selfhost-preflight.sh too
The new preflight script was missing from the frontend path filter, so a
change to it alone would skip the test that covers it.
Co-authored-by: multica-agent <github@multica.ai>
* fix(selfhost): honour the full backend port alias chain in Compose
Addresses the second review round on #6168.
The contract this PR documents is
BACKEND_PORT -> API_PORT -> SERVER_PORT -> PORT -> 8080
and Makefile, scripts/local-env.sh, scripts/install.sh, scripts/install.ps1 and
apps/web/config/runtime-urls.ts all implement it. docker-compose.selfhost.yml
implemented only BACKEND_PORT -> PORT -> 8080, so `API_PORT=9100` or
`SERVER_PORT=9200` in .env still published 8080.
That is not only a direct-Compose concern. scripts/install.sh:407 derives its
health-check port from the full chain and then runs this compose file, so an
alias it honours but Compose ignores puts the probe on 9100 while the stack
listens on 8080 — the original #6145 defect, reached through the installer.
- docker-compose.selfhost.yml publishes the full nested chain, verified to
keep the same precedence and the same 8080 default.
- scripts/selfhost-wait.sh's fallback matches, so the degraded path agrees
with what Compose would have published.
- The Makefile captures the pristine origins of API_PORT and SERVER_PORT too,
and the preflight reports them, so no alias can be silently overridden by
the env file while the others are reported.
Tests pin the chain on the boundaries a recipe-level test cannot see, because
Makefile normalises all four aliases into PORT before any recipe runs: a matrix
over the direct Compose path asserts each alias moves the published port with
the right precedence, and every case additionally asserts that
scripts/install.sh's resolver returns the same value Compose published. That
invariant is the one that actually protects the installer.
Verified failing without the fix: with the two-level chain restored the suite
reports `expected 9200 / compose published 8080 / installer resolved 9200`, and
with the alias warnings narrowed it reports the missing API_PORT notice.
Co-authored-by: multica-agent <github@multica.ai>
* fix(selfhost): resolve one winner before reporting ignored port config
Addresses the third review round on #6168.
The preflight walked each alias independently, so it made a separate "wins"
claim per alias. With
PORT=9000
BACKEND_PORT=8000
API_PORT=7000
SERVER_PORT=6000
in .env it announced that BACKEND_PORT, API_PORT and SERVER_PORT each won, when
only BACKEND_PORT=8000 can. Two of the three notices were simply false.
It also compared only same-named shell and file variables, so shadowing across
aliases stayed silent: .env with PORT=8000 and BACKEND_PORT=8000 plus a shell
API_PORT=7000 produced no output at all, even though API_PORT was discarded.
Resolve the winner along the documented chain first — within a variable the env
file beats the environment, then BACKEND_PORT beats API_PORT beats SERVER_PORT
beats PORT — and report only the inputs that winner actually shadows. Output now
names one winning input and lists the rest, whichever variable or source they
came from. An input carrying the winning value is not reported: it is redundant
but the user still gets the port they asked for, so there is nothing to fix.
Tests cover both cases the old logic got wrong: every alias set at once must
yield exactly one winner claim and list the others as unused, and an env-file
alias beating a lower-priority shell alias must be reported. Also asserted that
a redundant same-value alias and a default configuration both stay silent.
Measured against the previous implementation, phrasing aside: with all four
values set it made 3 winner claims where there is now 1, and on the cross-alias
case it printed nothing where API_PORT is now reported.
Co-authored-by: multica-agent <github@multica.ai>
* fix(selfhost): track env-file existence so empty port assignments resolve right
Addresses the fourth review round on #6168.
The Makefile passed only values to the preflight, so the script inferred "the
env file does not set this" from an empty string. An explicit empty assignment is
a different input from an absent one: make lets `BACKEND_PORT=` in the env file
override `BACKEND_PORT=9000` from the environment, and the variable then drops out
of the alias chain instead of setting a port.
With .env holding PORT=8080 and BACKEND_PORT=, invoked as
`BACKEND_PORT=9000 make selfhost`, the stack published 8080 and the health check
probed 8080 — correct — while the preflight announced
the backend host port resolves to 9000 from BACKEND_PORT (environment)
Set but unused: PORT=8080 (.env)
naming the ignored value as the winner and the winning value as unused. A report
that contradicts the startup is worse than no report. FRONTEND_PORT= had the
matching hole: it shadowed the environment, Compose fell back to 3000, and the
preflight said nothing.
The Makefile now captures ENV_FILE_<VAR>_IS_SET from $(origin) alongside the
value, for all five port variables via one $(foreach) instead of hand-written
lines. The preflight treats a variable the file defines as taking the file's
value even when empty, so it shadows the environment, and an empty value never
wins — resolution continues down the chain and falls back to 8080. Inputs the
winner shadows are still listed, including ones shadowed by an empty assignment.
The frontend check mirrors this and now names the port that actually resolved.
Recipe-level tests cover an empty alias over a shell value, every link of the
chain emptied at once, and the frontend equivalent — each asserting the reported
winner, the Compose published port and the probed port all agree. Against the
previous implementation the first case fails with `reported: 9000 / actual: 8080`.
Co-authored-by: multica-agent <github@multica.ai>
* fix(selfhost): installers read the published port; drop the second parser
Addresses all four blockers from the consolidated review on #6168.
1. Both installers probed and printed the wrong port. scripts/install.sh:402 and
scripts/install.ps1 start Compose inheriting the current environment, and
Compose lets that environment outrank .env — but the installers then derived
the probe port and the summary URL from .env alone. Measured on this branch
before the fix, every port variable diverged:
ambient PORT=9100 -> compose 9100, installer 8080
ambient BACKEND_PORT=9200 -> compose 9200, installer 8080
ambient API_PORT=9300 -> compose 9300, installer 8080
ambient SERVER_PORT=9400 -> compose 9400, installer 8080
ambient FRONTEND_PORT=3100 -> compose 3100, installer 3000
Both now read the ports back once with `docker compose port` after `up -d`
and reuse that single result for the health check and the summary. If the
query fails they fail loudly instead of claiming success. The .env-derived
helpers are deleted rather than left beside the new path.
2. The Make preflight is removed entirely, as the review recommended. It
modelled `origin=environment` and `origin=file` but not `command line`, so
`make selfhost BACKEND_PORT=9000` over a .env with API_PORT=7000 announced
7000 while Compose published 9000. That was its fourth wrong report in four
rounds, because it was a second parser of precedence rules — the very thing
this PR removes elsewhere. `docker compose port` is the runtime truth, so the
script, the Makefile captures and the macro are gone.
3. Tests now cover the installer paths that were unguarded. scripts/install.test.sh
gains a `--with-server` matrix (defaults, .env PORT, all four backend aliases,
FRONTEND_PORT, ambient overriding .env for all five, and explicit-empty
fallback) plus a case proving an unresolvable port fails loudly. A new
scripts/install.ps1.test.ps1 drives the same matrix through Start-LocalInstall.
Every case asserts Compose's published port == the probed URL == the printed
URL. Ambient variables are explicitly cleared per case so a runner's own PORT
cannot leak. The Makefile matrix gains the command-line origin cases. CI runs
the PowerShell suite on windows-latest in the always-on installer job, and the
frontend filter now includes both installers.
4. Docs state the real contract: the alias order is shared, but which *source*
wins is per entry point — Compose prefers the environment, make prefers the
included env file, and a make command-line assignment outranks both. The
removed preflight's promise is gone from SELF_HOSTING_AI.md, and the compose
header no longer claims the installer derives its own health-check port.
Verified failing without the fixes: the Bash matrix reports
`[ambient PORT beats .env] compose published 9500 / probed 9100 / printed 9100`
against the old installer, and the PowerShell matrix reports
`printed backend=8080` against an injected summary divergence.
Co-authored-by: multica-agent <github@multica.ai>
* fix(selfhost): keep web dev proxy off frontend port
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
|
||
|
|
51f44873cc |
fix(labels): always enable resource labels (MUL-5563) (#6225)
* fix(labels): always enable resource labels (MUL-5563) Co-authored-by: multica-agent <github@multica.ai> * docs(labels): clarify resource label rollback safety (MUL-5563) Co-authored-by: multica-agent <github@multica.ai> * docs(labels): correct compat client range (MUL-5563) Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
c3f5df8bf4 |
MUL-5492: fix timeline cap dropping newest entries + stop double-broadcasting descriptions (#6175)
* fix(timeline): cap the issue timeline at the newest end and report the clamp The per-issue timeline cap was applied with ORDER BY created_at ASC LIMIT 2000, so once an issue accumulated more than 2000 comments or activities the cap discarded the NEWEST rows. The timeline appeared to stop at some point in the past and every later event was invisible, with nothing in the response indicating anything was missing. Activity is machine-paced — description autosave, every agent run, status and assignee changes all write rows — so this was reachable in normal use, not only on pathological issues. Take the window with the keyset ordering (created_at DESC, id DESC) in a subquery and re-sort ascending in the outer query. This keeps the chronological contract for every existing caller, including the comment list endpoint that shares ListCommentsForIssue, and is served as an index-only scan by the idx_*_keyset indexes already added in migration 068 — no new migration, no call-site changes. Two things beyond the ordering flip: - Clamp both lists to a shared window floor. The two caps are applied independently, so each list has its own floor. Merging windows with different floors produces a timeline that looks continuous but, below the higher floor, contains only one of the two kinds — e.g. comments with no interleaved activity. That is worse than a timeline that visibly stops, because nothing about it looks wrong. Both lists are now clamped to the newest floor, so the result is a contiguous, correctly interleaved slice. - Stop truncating silently. The unpaginated response is a bare JSON array with nowhere to put a flag, so the clamp is reported via X-Timeline-Truncated and X-Timeline-Window-From, added to ExposedHeaders because a custom response header is otherwise unreadable from browser JS. The legacy wrapped shape's has_more_before is now truthful instead of hardcoded false. Queries read one row past the cap so "hit the cap" is distinguishable from "holds exactly 2000 rows", which would otherwise report a complete timeline as truncated and drag the other list's window down with it. Regression tests cover all four properties and were confirmed to fail against both the original query and a floor-less DESC flip. Co-authored-by: multica-agent <github@multica.ai> * perf(realtime): stop broadcasting two full descriptions on issue:updated issue:updated carried prev_description alongside the new description in the issue object, and the WS forwarder reuses the producer's payload map verbatim. Every debounced description autosave therefore pushed two full copies of the description to every connection in the workspace, including users who did not have the issue open. The DB write is O(1); the fanout was O(connections x description size), and it repeats on every pause in an editing session. prev_description and prev_title exist only for in-process listeners — subscriber_listeners adds newly @mentioned users, notification_listeners builds mention notifications, activity_listeners records the title change. No client reads them: IssueUpdatedPayload in packages/core/types/events.ts does not declare either field. Project the payload on the way out. The bus dispatches bus.Subscribe handlers before the SubscribeAll forwarder, so the in-process consumers are unaffected, and projecting at the forwarder covers both the single- node Hub and the Redis relays since that is where the frame is serialized. The producer's map is copied rather than mutated. The removed keys are listed in a table rather than an if on one event type. The bug was structural, not a typo: the next large field added to a published payload inherits the same cost silently, and a declarative list puts the internal/external payload boundary in one reviewable place. issue.description itself is deliberately kept — clients apply it to their cache, so stripping it would trade fanout bytes for N refetches. Cutting the remaining fanout needs the per-issue scope routing already scaffolded server-side for MUL-1138, which is blocked on the client sending subscribe frames. Tests assert both halves: the keys are absent from the serialized frame, and the in-process listener still receives them. Co-authored-by: multica-agent <github@multica.ai> * fix(timeline): keep comment threads whole under the newest-N cap Review found that the newest-N window can orphan a reply, and an orphaned reply is invisible rather than merely mis-nested: the timeline builds its top level from "activities + comments with no parent_id" and renders replies by looking them up under their parent, so an orphan sits in the map with no card to render it. MUL-1847 / #2263 was exactly this shape — 1 root + 29 replies, root dropped, all 29 vanished from the UI while the API returned them. Root cause of the regression: capping with the OLDEST n could never orphan anything, because a reply is always newer than its parent, so a prefix of the timeline is closed under "parent of". A newest-n window is a suffix and has no such property. Flipping which end the cap bites silently invalidated a structural property the comment tree relies on. Two changes. Drop the cross-kind clamp. The previous revision trimmed both lists to a shared floor so the window was provably contiguous. That was the wrong trade and it was also the dominant source of orphans. Comments are human-paced (p99 ~30, max ever observed ~1.1k) and essentially never reach the cap, while activity is machine-paced and reaches it routinely — so the shared floor was almost always the activity floor deleting comments that had been fetched successfully and would have rendered fine. On an issue with thirty comments it was pure loss. Each list now reports its own truncation and X-Timeline-Truncated names which kinds were affected. Not clamping costs only activity density in the older part of the range, which is metadata rather than content, and it is reported rather than hidden. Complete parent chains for the case that remains — comments themselves exceeding the cap. ListMissingAncestorComments walks parent_id upward via a recursive CTE and returns the ancestors not already held; the handler merges them and restores the ascending order. This only ever ADDS rows, so unlike clamping it cannot hide anything the caller would have seen, and it is bounded by the number of distinct missing ancestors. Whole- thread windowing was considered and rejected: a single thread can exceed any row budget, so its degradation is not definable. Applied to the shared query's default list path too, not just the timeline. foldResolvedThreads documents a COMPLETE-thread set as its precondition and comment.go asserts the default list mode satisfies it; a half thread made that assertion false and a resolved thread whose root was cut stopped folding correctly. Also drops X-Timeline-Window-From. It was second-precision RFC3339 while the real ordering key is (created_at, id) at full precision, so it could not resume a read without skipping or repeating rows inside a shared second. A resumable cursor should be opaque and carry both halves; worth designing when there is a consumer rather than shipping as a lossy approximation. Tests: the reviewer's exact scenario, plus a no-orphaned-replies invariant on both endpoints, the fold-still-works case, and a guard that activity truncation does not delete comments. Each was confirmed to fail with the fix disabled. TestListTimeline_JointWindowHasNoOneSidedRegion was rewritten rather than deleted — it pinned the clamp behaviour being abandoned here, so leaving it would lock in the wrong contract and deleting it would drop the coverage. Co-authored-by: multica-agent <github@multica.ai> * fix(comments): bound parent-chain completion and stop folding partial threads Second review round on MUL-5492. Four must-fixes, all stemming from one conflation: parent-chain closure, a newest-N window, and a complete thread are three different things. Closure makes a reply renderable; it does not license thread-level derivations. Do not fold a truncated read. fetchCommentsForList closes parent chains, but older siblings and descendants of a retained reply stay outside the window, so the set holds partial threads. Folding them produced wrong answers rather than incomplete ones: a resolution reply outside the window made a resolved thread look unresolved, and folded_count reported a total derived only from retained replies. The previous revision claimed closure restored foldResolvedThreads' COMPLETE-thread precondition; it did not, and that claim is removed. --recent and untailed --thread still return whole threads and still fold. Bound the walk. The recursive CTE climbed to the root with no depth limit, so a deep chain could drag its entire ancestry back and defeat the row cap it was meant to preserve. Depth is genuinely unbounded in stored data: the general write path stores the exact comment being replied to (only the agent path collapses to the thread root), so chains can run far deeper than the two levels the UI renders. Replaced with a layered walk under explicit budgets — 2000 extra rows, 64 levels — making a response provably bounded by 4000 comments, or 6000 timeline entries with activities. Scope every level to the tenant. The CTE's recursive branch matched on parent_id alone. parent_id carries a foreign key to comment(id) but not to a matching issue, so a stray cross-issue parent reference is representable, and the walk would have followed it into another issue's comments. The replacement filters issue_id and workspace_id on every level. A negative test confirms the leak: with the filter removed it reports "a comment from another issue leaked into this issue's response". Degrade by pruning, not by orphaning. When a budget is exhausted, a parent row is missing, or a parent is out of scope, keepRootConnected drops the affected comments instead of returning replies the UI cannot render. Dropping a node also drops its descendants, since their chains run through it. Returning fewer new replies is conservative and already signalled as a truncated read; leaking another tenant's data, returning an unbounded response, or emitting invisible orphans are all worse. Also: probe read on the comment list so exactly-2000 is not misreported as truncated, which would needlessly suppress the fold; CommentsTruncated is carried on fetchCommentsResult rather than inferred from the result length, which is meaningless once completion adds rows. The new query returns db.Comment directly instead of a hand-copied row, which is how quick_action_id came to be dropped after the rebase — a backfilled quick-action root would have rendered as a raw prompt. Corrected three inaccurate comments: the "index-only scan" claim (the index avoids the sort but does not cover SELECT *), the "write path collapses replies to root" claim, and a test header still describing the abandoned contiguous-window behaviour. Tests cover exactly-at-cap still folding, truncated reads not folding (both reply-resolved and root-resolved), depth beyond budget pruned not orphaned, shared ancestors fetched once, cross-issue parents never crossing the boundary, and quick_action_id surviving backfill. Each was confirmed to fail with its specific fix disabled; the reply-resolved fold test was reshaped after the first version passed for the wrong reason. Co-authored-by: multica-agent <github@multica.ai> * fix(timeline): preserve complete threads under comment cap Co-authored-by: multica-agent <github@multica.ai> * fix(comments): preserve newest bounded views Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
f4bf8e2c36 |
fix(realtime): bound inbound WebSocket message size (MUL-5569) (#6222)
The client-facing realtime hub upgraded a connection and read from it without ever calling SetReadLimit, so gorilla buffered a whole inbound message in memory before any application-level check ran. A fragmented message with interleaved pong frames keeps refreshing the read deadline, so a single connection could grow that buffer without bound and OOM the process, taking every workspace on the instance down with it. Set a 64 KiB limit — matching the daemon hub, three orders of magnitude above the largest legitimate frame — immediately after the upgrade rather than in readPump: the token auth path reads its first frame before the caller is authenticated, so a limit installed any later leaves that read unbounded. Over-limit closes get their own counter on both paths so the breach stays visible instead of blending into ordinary churn. Closes #6210 Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
b530bbad5b |
fix(issues): use a solid tone for the filtered empty-state icon (MUL-5580) (#6223)
The FilteredEmptyState added in #6191 draws its FilterX glyph with `text-muted-foreground/40`, the transparency-as-hierarchy pattern that #6152 removed from the codebase and then guarded with a test. That PR was cut before the guard landed, so the merge reintroduced the one shape the test rejects and main's frontend-test job has failed on every commit since. Switch to `text-faint-foreground`, the solid token the rule names for icons and the one every other empty-state glyph already uses. Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
abdfd3e28c |
refactor(skills): make the brief's skill list a names-only index (MUL-5529) (#6207)
* refactor(skills): make the brief's skill list a names-only index (MUL-5529)
Step 3 of MUL-5529. Every runtime CLI discovers the SKILL.md files the daemon
writes and builds its own listing from their frontmatter — verified against 11
locally installed CLIs plus official docs for 5 more. The brief's copy of those
descriptions was therefore the same routing signal paid for twice: measured on
a real task, `## Skills` was 13,295 chars, 40% of the entire brief, against a
16,304-char CLI listing of the same 28 skills.
Now 850 chars for that same set — roughly 3,100 tokens back per brief.
The index itself stays. It is the one skill listing Multica controls; each
CLI's own listing is theirs, and its format — or its existence — can change
with any release.
Three changes:
- Descriptions dropped from the `## Skills` entries.
- The per-provider branch is gone. Its fallback told providers outside a
hardcoded list to read `.agent_context/skills/`, but the only providers
that ever reached it were grok and traecli, whose files are written to
`.grok/skills` and `.traecli/skills` and which discover natively. The
pointer was wrong for everyone it addressed, so removing the branch
deletes the bug rather than relocating it. This closes MUL-5537.
- issue_context.md and its quick-create / autopilot variants no longer render
`## Agent Skills`. That copy duplicated the brief once both were
names-only, and nothing ever read it: no prompt references the path, and
grepping the server finds only the writer. `.agent_context/skills/` had the
same fate for hermes (issue #5242). Quick-create, previously skipped in the
brief and served only by that unread copy, now gets the brief section like
every other kind — one index, one place.
Not included: skills carrying `disable-model-invocation` are still written to
disk for every provider. The plan assumed that key needed provider-specific
handling for everything except claude; probing the installed CLIs shows 9 of 11
honor it, and only opencode and hermes do not. The remaining question is
narrow and a genuine product tradeoff — withholding the file honors the
author's intent but also removes explicit invocation — so it is left to a
separate decision rather than folded in here.
Co-authored-by: multica-agent <github@multica.ai>
* docs(skills): align stale comments with the names-only brief contract (MUL-5529)
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Steve Jobs (Multica Agent) <agent-steve-jobs@multica.ai>
|
||
|
|
00e2060978 |
docs(license): name it the Multica License and restore the verbatim Apache 2.0 text (MUL-5558) (#6206)
* docs(license): name the license Multica License and restore the verbatim Apache 2.0 text (MUL-5558) Co-authored-by: multica-agent <github@multica.ai> * docs(license): define the combined license and fix the NOTICE 4(d) claim (MUL-5558) Addresses the two must-fixes from review on #6206. Must-fix 1 — the file held Multica conditions 1-2 followed by Apache sections 1-9, and Apache section 1 defines "License" as sections 1-9 of the document. So "a copy of this License" in section 4(a) and the inbound terms in section 5 could both be read as reaching only the Apache half, leaving downstream recipients and future contributors unsure which set of terms actually binds them. Split the file into `Part I — Additional Conditions` and `Part II — Incorporated Apache License 2.0 Text` so the two "1 / 2" numberings no longer collide, and add condition 3 as the operative bridge: both parts together are the Multica License, "this License" in Part II means the whole file (naming 4(a) and 5 explicitly), Part I controls on conflict, and redistribution must deliver the complete file. Condition 2 becomes "By submitting a contribution ... you agree" instead of the non-binding "you should agree", gains 2c pinning contributions to the whole license, and drops the stale "open-source agreement" wording. The same inbound terms now also appear in CONTRIBUTING.md. Must-fix 2 — NOTICE claimed section 4(d) requires reproducing its entire contents. It does not: 4(d) covers the relevant attribution notices, and a NOTICE cannot create obligations of its own. Replaced with a statement that the file is informational and does not modify the license. Conditions 1a-1d are untouched. The Apache 2.0 text is untouched and still hashes to cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
13b06f038e |
fix(issues): count agents working in the surface, not the workspace (MUL-5525) (#6191)
* fix(issues): count agents working in the surface, not the workspace (MUL-5525) The "N agents working" chip ran its own workspace-wide `/api/working-agents` read while the list it filters came from the surface's own compiled query. Two definitions of the same question, so on a project page the chip could advertise agents working nowhere near that project and then open an empty list. Every other narrowing the list knows about — status, priority, assignee, creator, label, custom property, date, sub-issue display, the /issues Members/Agents tabs — was invisible to the count for the same reason. Only /my-issues (relation) and the issue-detail sub-issue chip (parent) were narrowed, because those were the two cases the endpoint had grown parameters for. Rather than add a `project_id` parameter and leave the next dimension to be discovered the same way, the count now comes from a `working_agents` facet on the existing issue-table facets endpoint: same scope, same filters, same compiled WHERE clause the rows come from, joined to running issue tasks and grouped by agent. Correct-by-construction instead of correct-by-keeping-two-lists-in-sync. - Facet is disjunctive like every other one: it drops `working_issue_ids` / `working_only`, so the answer is identical whether the filter is on or off and the number does not move when you click the chip. - Facet keys are agent ids, so they pass the same visibility gate as the other workspace-wide agent aggregations — a private or non-allow-listed agent is not disclosed by id, count, or presence. - Gantt keeps a client-side count: its canvas projection (scheduled + dated + showCompleted) cannot be expressed in the Table query spec, so it counts the agents holding canvas rows instead. - The chip is now presentational; `undefined` renders the existing indeterminate label rather than a zero it cannot stand behind. - Removes the MUL-4884 `workingScopeIssues` plumbing, dead since the count moved to the endpoint in MUL-5200, keeping only the Gantt branch that still has a real consumer. Also fixes the empty state that bug dropped you into: a filtered-empty surface claimed "No issues linked — create one" while 41 issues sat behind the filter. Shared filtered-empty state now precedes each surface's own copy and offers to clear exactly the filters it blames. Verified: pnpm typecheck, pnpm test (469 files), pnpm lint (0 errors), go test ./internal/handler (new facet tests cover project scope, status and sub-issue narrowing, filter-independence, and the access gate). Co-authored-by: multica-agent <github@multica.ai> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(issues): keep the working-agents unknown state unknown (MUL-5525) The chip correctly refused to print a number for an unresolved projection, then handed the hover card `agents ?? []` — so hovering an indeterminate chip read "No agents working right now". That is the same unearned claim this issue is about, made by the one surface with room to spell it out: the label said "—" while the body next to it asserted zero. - `WorkingAgentsHoverContent` takes `readonly WorkingAgentSummary[] | undefined` and distinguishes all three states: `undefined` renders new `agent_activity.unknown_hover` copy, `[]` keeps the empty sentence, a non-empty list keeps the roster. The chip passes its projection through untouched. - The colour tier had the same collapse: unknown wore the neutral tier WITH muted text, which is exactly the "nothing is happening here" tier a known zero wears. `chipAppearance` now takes a `ChipActivity` ("unknown" | "none" | "some") instead of a boolean, so the three cases cannot be written as two, and unknown stays neutral but undimmed. - The sub-issues chip is unaffected: it passes a resolved array and renders nothing at zero, so it never claimed anything either way. Regression tests cover the hover path specifically — reverting either downgrade fails "does not let the hover body downgrade an unresolved projection to zero", "does not dim the chip while the projection is unresolved", and the chipAppearance unknown case (verified by reverting). `WorkingAgentsHoverContent` also gets direct unknown / empty / roster tests, and `chipActivity` one for the three-way split. Verified: pnpm typecheck, pnpm test (469 files), pnpm lint (0 errors). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
e6610c0831 |
fix(usage): close the per-agent rollup windows so the leaderboard cannot exceed the totals (MUL-5551) (#6194)
The Usage page showed a single agent with 1021.0M tokens under a workspace Tokens KPI of 805.9M for the same 1D window. Both halves read the same rows and disagreed only on the window. parseSinceParamInTZ deliberately returns N+1 calendar days of headroom, and the date-bucketed series (usage/daily, runtime/daily) get trimmed back to -(days-1) client-side before the KPIs and the chart are computed. The two per-agent rollups behind the leaderboard carry no date column, so nothing trimmed them and they kept the full N+1 span: at days=1 that is today PLUS yesterday. One busy agent's two-day total then trivially exceeded the workspace's one-day total. Same defect and same fix already applied to failures/by-agent: switch usage/by-agent and agent-runtime to parseExactSinceParamInTZ. This also realigns the Run time / Tasks KPI tiles, which are sourced from agent-runtime and were therefore a day wider than the Cost / Tokens tiles beside them. Co-authored-by: Eve <eve@multica-ai.local> |
||
|
|
0a54485ab6 | chore(llm): use gpt-5.6-luna by default | ||
|
|
c3cc777acb |
fix(onboarding): add logout escape (#6179)
Closes #3960 |
||
|
|
2e0c599edd |
fix(agent): avoid H1 headings in issue bodies (#6199)
Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
32ab1e77dc |
fix(agents): drop the runtime badge from agent avatars (MUL-5567) (#6208)
The provider mark overlaid on the avatar's top-right read as clutter: half of it overhangs the disc, so on the agent page, the profile card, and the chat header it looked like something stuck to the avatar rather than part of it — and every one of those surfaces already names the runtime in text a line away. Removes the overlay and everything that existed only to feed it: the `AgentRuntimeBadge` module, the `showRuntimeBadge` prop on `ActorAvatar`, and `useAgentRuntimeProvider`. The presence dot keeps the wrapper, now back to a single overlay. The provider mark on the agents list Runtime column stays — it sits beside its label instead of on an avatar, which is where scanning "which of these run on Codex" is a shape match rather than a read. Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
8d4cf83a47 |
chore(release): drop the inaccurate Apache-2.0 SPDX declaration from the Homebrew formula (MUL-5558) (#6205)
Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |