Commit Graph
1948 Commits
Author SHA1 Message Date
d276991c60 MUL-6743 refactor(issues): remove the assignment handoff note (#7626)
* refactor(issues): remove the assignment handoff note (MUL-6743)

Usage data puts the handoff note at 0.2% of assignments, so the box
occupied the confirm dialog's centre for the other 99.8% and bought
them nothing. Removing it takes the whole feature with it: the note
input and its runtime version gate, the `handoff_note` write field on
PATCH/batch issue updates, the task column write, and the daemon's
opening-prompt + issue_context.md handoff sections.

The confirm dialog itself stays — it still names who is about to run
and still offers "Don't start yet" (suppress_run).

The `*WithHandoff` enqueue methods only ever varied by that note, so
they become `EnqueueTaskForIssueByActor` / `EnqueueTaskForSquadLeaderByActor`
— the actor-carrying variants autopilot already called with an empty
note. `preview-trigger` drops `handoff_supported`, which nothing read
after the gate moved client-side.

`agent_task_queue.handoff_note` is deliberately left in place: it holds
historical rows and this is the expand half of an expand-contract pair.
Nothing reads or writes it now, so a follow-up migration can drop it
once no deployed binary selects it.

Co-authored-by: multica-agent <[email protected]>

* docs(issues): reshoot the assignment confirm dialog (MUL-6743)

The asset still showed the handoff note box the surrounding prose no
longer describes. Recaptured from the app at the same 600x450 frame;
the confirm button now carries the send-chord keycaps it has shipped
with since MUL-5694.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-03 16:13:33 +08:00
571e61128d MUL-7002: remove database reads from WebSocket heartbeats (#7976)
* fix(runtime): remove websocket heartbeat lookups

Co-authored-by: multica-agent <[email protected]>

* fix(runtime): preserve explicit deregistration

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-03 16:12:34 +08:00
035abddfe7 perf(db): retire delegated failure pending index (#7977)
Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-03 15:49:21 +08:00
d1c7ec25b1 feat(agents): add live model catalog refresh (#7919)
Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-03 15:05:11 +08:00
731af7ccd6 MUL-7002: push runtime deletion invalidation to active daemons (#7969)
* fix(runtime): invalidate deleted active runtimes

Co-authored-by: multica-agent <[email protected]>

* fix(runtime): separate invalidation delivery metrics

Co-authored-by: multica-agent <[email protected]>

* fix(runtime): complete invalidation metric separation

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-03 14:50:14 +08:00
Xichang(Seacen) Zhao 03b597dd8a feat(wecom): report a failed run to the chat that asked (#7952)
wecom/outbound.go subscribed to EventChatDone and EventInboxNew only, while
DingTalk, Lark, Slack and Telegram all handle EventTaskFailed. A run that
failed in WeCom therefore said nothing, and logged nothing either, because
no handler ran.

task:failed now goes out on the same route a reply takes — task delivery
row, origin gate, installation status, socket or relay — carrying the
platform's own redacted failure text behind a warning mark, the way DingTalk
delivers it. Nothing is sent while an auto-retry is pending: the retry
attempt reports its own outcome. task:cancelled is subscribed for the log
line only; a cancellation ends the run without an answer and WeCom has
nothing to take back, which is Lark's choice too.

Tests: the failure reaches the bound chat; a retry-pending attempt, a
failure with no text, and a web-UI run's failure all stay silent; the wiring
through a real bus, with a cancellation publishing nothing; and the same
failed run through the real query layer against Postgres. With the two
subscriptions removed the bus-wired tests fail; with the content helper
reverted to chatDoneContent the processEvent test fails.
2026-09-03 14:25:44 +08:00
Soda 0431218061 MUL-6833 fix(web): localize auth callback and error pages (#7752)
Localize the web auth callback, 404 page, and root error boundary, plus the shared section-level ErrorBoundary.

Also fixes several pre-existing auth-path bugs surfaced during review:
- access_denied was unreachable (the !code branch preceded the error-param check)
- UserLocaleSync's reload could replay a consumed OAuth code and show 'Login Failed' after a successful sign-in
- Google token-exchange and userinfo provider failures were misreported as user-actionable causes
- checkSignupAllowed reported 'registration is disabled' for an allowlist miss
2026-09-03 14:13:47 +08:00
3df7c38ed1 fix(agent): normalise the provider prefix in the Hermes set_model skip-gate (MUL-6958) (#7908)
* fix(agent): normalise the provider prefix in the Hermes set_model skip-gate

The gate added in #5690 (MUL-5029) compares the configured model against the
model the session reports as current with a raw `==`:

    if opts.Model != "" && effectiveModel == sessionCurrentModel {

Those two values come from different id namespaces, so for an agent whose
`model` is stored bare the comparison can never be true. The gate is dead code
for those agents and `session/set_model` is replayed on every turn — which is
the exact situation #5690 was opened to prevent.

Why the two sides cannot match:

  - Hermes always reports a provider-encoded id. `_encode_model_choice` in its
    ACP adapter returns `f"{provider}:{model}"` whenever a provider resolves,
    and the provider falls back to `detect_provider()` or `"openrouter"`, so it
    is effectively never absent. Confirmed against a live `hermes acp` session:
    session/new populates `models.currentModelId` from that encoder, so a bare
    configured `claude-sonnet-4-5` on a `custom` provider is reported back as
    `custom:claude-sonnet-4-5`.

  - `agent.model` is persisted verbatim. `handler/agent.go` writes `req.Model`
    straight into the column on create and update; nothing normalises or
    validates a `provider:` prefix on that path.

  - The bare spelling is a documented, first-class input. `cmd_agent.go`'s own
    flag help is `--model  Model identifier (e.g. claude-sonnet-4-6,
    openai/gpt-4o)`. Only the ACP-catalog dropdown produces the prefixed form,
    because `acpModelEntry` uses the advertised id as `Model.ID`.

So dropdown-configured agents match and skip; every agent configured through
the CLI, the API, a seed, or a row predating the catalog does not, and never
will.

What the replay costs, per turn, in the Hermes runtime: `set_session_model`
replaces the session's agent with `_make_agent`, which re-runs `load_config` and
`resolve_runtime_provider`, joins background MCP discovery for up to
`mcp_discovery_timeout` (~1.5s), and constructs a fresh agent whose system
prompt is then re-derived from disk. It also re-runs `_resolve_model_selection`,
i.e. the provider auto-detection this gate exists to avoid — so the
`custom:deepseek-v4-pro` mis-route from #5690 is still live for precisely the
agents that hit it. Running Hermes' own resolver over the ids this daemon sends
reproduces it: `custom` + `deepseek-v4-pro` resolves to `openrouter`, and
`anthropic` + a bare Claude id resolves away from `anthropic`, both of which
make `_make_agent` drop base_url and api_mode.

The fix replaces the raw comparison with `acpModelIDsEquivalent`, which
normalises the optional prefix before comparing:

  - both sides carry an explicit provider → compare the whole id, so a genuine
    provider switch (openrouter:X → custom:X) still sends set_model;
  - exactly one side carries a provider → compare model names only; a bare
    configured id expresses no provider preference, so the session's own
    provider is authoritative;
  - either side empty → not equivalent, preserving the existing fall-through
    for runtimes that report no current model.

Only the first colon separates provider from model, so slash-bearing
OpenRouter-style names (`nous:moonshotai/kimi-k2.6`) split correctly, and the
comparison is case-insensitive because Hermes lowercases the provider when it
encodes the id.

No migration and no operator action. Rows already written in the prefixed form
keep working — both spellings now resolve to the same selection.

Deliberately not argued on cost. A cache-hit-rate difference first pointed here,
but that measurement was grouped by "does the agent have a configured model",
which on the measured deployment was confounded with the transport wire, so this
change does not claim it. It stands on the comparison being unsatisfiable and on
the guard it restores.

Tests: 12 `acpModelIDsEquivalent` cases, including the provider-switch and
different-model negatives, the case-folding and whitespace cases, and the
slash-bearing model id; plus 6 `splitACPModelID` cases covering the
leading-colon and multi-colon shapes.

* fix(agent): only skip set_model when an explicit provider is confirmed

Review caught a regression introduced by the previous commit: the third
equivalence rule was symmetric, but the risk is not.

    acpModelIDsEquivalent("openrouter:hermes-4", "hermes-4") // => true

Comparing model names whenever *either* side lacked a provider meant a caller
who named a provider explicitly got the switch swallowed, silently, and the
session kept whatever provider it had defaulted to. `origin/main` sends
set_model for that input, so the previous commit changed behaviour for it.

The premise that justified the symmetry — "Hermes always reports a
provider-encoded id" — is false, and this repository already contained the
counterexamples: `acp_effort_test.go` captures `currentModelId: "hermes-4"`
from `hermes acp` v0.20.0 and `currentModelId: "gpt-5.6-sol"` from jcode, both
unprefixed, and jcode routes through this same backend (hermes.go states it
covers both binaries). `_encode_model_choice` returning a prefix is what
happens when a provider resolves; it is not a property of every input.

The rule is now asymmetric:

  - bare configured id  -> equivalent on the model name alone. It expresses no
    provider preference, so the session's provider is authoritative. This is
    the case the gate exists for, and it still skips.
  - explicit configured provider -> equivalent only when the session reports
    that same provider. A bare current id cannot confirm the match, so we fall
    through and send set_model, as before this change.

Tests:

  - three `acpModelIDsEquivalent` table entries, including the missing
    `provider:model` vs bare-id row that let the regression through;
  - `TestHermesSkipsRedundantSetModelForBareConfiguredModel`, an end-to-end
    frame-recording test that asserts the gate actually fires for the bare
    spelling — the unit tests never checked that set_model stopped going out;
  - `TestHermesSendsSetModelWhenExplicitProviderAndBareCurrent`, which fails
    without the asymmetric rule (verified: the recorded frame is absent).

Also addressed from the same review:

  - dropped the stale cache-hit-rate paragraph from the `acpModelIDsEquivalent`
    comment. The PR description claimed it had been removed; it had not.
  - dropped the paragraph arguing the benefit via MCP refresh and tool-cache
    prefix invalidation. It described Hermes' Python internals this repository
    cannot verify or track, and it read as arguing against the sentence right
    after it.
  - softened "the comparison can never be true": it holds when the runtime
    reports a prefixed id, which is not always.
  - noted that `splitACPModelID` cannot distinguish a provider prefix from a
    colon inside a bare model name (`llama3:8b`), and that the degradation is
    safe in the only direction that matters — it falls back to sending
    set_model, never to a false skip.

* docs(agent): correct the bare-current-id evidence in the set_model gate

The justification for the asymmetric equivalence rule cited acp_effort_test.go
as capturing unprefixed model ids from both `hermes acp` and jcode. Only the
jcode half holds.

Verified against a live `hermes acp` v0.20.0 — the same version the fixture
names — driving `initialize` + `session/new`, the two calls this backend makes:
`models.currentModelId` came back provider-encoded in all three cases (no model
param, a bare model param, and a provider-encoded one), and all nine
availableModels ids were prefixed too. Sending a bare `gpt-5.6-terra` returned
`openai-codex:gpt-5.6-terra`, which is the defect this gate fixes and is now
first-hand rather than inferred.

Hermes Agent's encoder does have a bare branch — acp_adapter/server.py
`_encode_model_choice` returns the model alone when the provider is empty,
confirmed by calling it directly — but `_build_model_state` defaults the
provider (`state.agent.provider or detect_provider() or "openrouter"`) and
`normalize_provider("")` falls back to `openrouter`, so a normal install never
reaches it. The `hermes-4` in that fixture is better read as a simplification
made while writing a test about configOptions, not a verbatim capture.

The rule itself is unchanged and still correct: this backend serves Hermes
Agent and jcode, jcode really does report unprefixed ids, and refusing to
confirm an explicit provider against a bare current id costs nothing.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Garfield <[email protected]>
Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-03 13:53:56 +08:00
879d0de916 refactor(agent tasks): drop the Active sibling runs prompt block (MUL-6984) (#7937)
Every issue turn carried a claim-time snapshot of the same agent's other
in-flight tasks, scoped only by (agent, workspace) and capped at five. The
scope had no relatedness filter, so the list answered "what else is this
agent running right now", not "what else touches the code you are about to
change".

Measured over one day of runs on this workspace: the block was injected 78
times across 30 runs, listing 96 distinct (current issue, sibling) pairs, and
not one pair shared a PR, a branch, or an issue family. `run-messages` was
invoked zero times off it. Meanwhile the day's one genuine overlap — a PR
review pushing to server/internal/metrics/pricing.go and a concurrent change
to the model catalog next to it — was never paired, because overlapping in
time is not the same as overlapping in code.

The duplicate-run defect this shipped with (#6947) is fixed by
shouldSuppressActiveSelfAssignment, which stops a second run on the same
(issue, agent) at the server. Once that holds, the block can only ever list a
DIFFERENT issue, so it was structurally unable to help with what it was added
for. #7922 then gave the coordination question a correct answer —
`issue runs --active` / `--siblings`, cross-agent, family-scoped, and read at
the moment it is asked — which is the same information as a pull instead of a
per-turn push.

Removed end to end rather than hidden behind a flag: the prompt block, the
claim payload field on both sides of the wire, and ListActiveSiblingIssueTasks.
The field was `omitempty` and the daemon already rendered nothing for an empty
list, so old and new server/daemon pairs stay compatible in both directions;
nothing outside server/ ever read active_sibling_runs.

Kept: the self-assignment suppression and its tests, `--no-start`,
`issue run-messages`, and `issue runs --active/--siblings`.

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-03 13:28:05 +08:00
603519d158 MUL-6977: test: remove implementation-only coverage (#7932)
* test: remove implementation-only coverage

Co-authored-by: multica-agent <[email protected]>

* test: remove remaining implementation-only coverage

Co-authored-by: multica-agent <[email protected]>

* test: restore compatibility and fragment navigation coverage

Co-authored-by: multica-agent <[email protected]>

* test: preserve behavioral cleanup contracts

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-03 13:23:25 +08:00
4dfa08917b fix(daemon): isolate managed OpenClaw MCP servers without reading user config (MUL-6684, MUL-6857) (#7279)
* fix(daemon): build the managed-MCP config from `config get mcp --json`

Preparing a task for an OpenClaw agent with a managed `mcp_config` reads the
user's resolved configuration so the wrapper can replace `mcp.servers` while
keeping the user's other `mcp.*` settings. That read asks for the resolved root,
`openclaw config get --json` with no key path, and current releases do not
answer it: measured against 2026.5.27 the CLI writes zero bytes to stdout and
never exits, and the same shape is reported against 2026.7.1-2. Every
managed-MCP task on such a host therefore fails preparation.

`config get mcp --json` is answered, and it is also the only part of the
configuration this code needs. Switching to it means the snapshot can no longer
be a flat copy of the user's resolved config, so it becomes an include chain:

  1. `$include` the user's live config,
  2. merge `{"mcp": null}`, which OpenClaw's include merge treats as a
     wholesale replacement of the user's MCP object,
  3. restore the resolved non-server MCP settings.

The wrapper then contributes the managed server set, which is the only server
definition the chain can yield. `sessionIdleTtlMs` and its siblings still
resolve; user-only servers do not.

Security posture moves in both directions, so both are worth stating:

- Better: the task directory no longer receives a copy of the user's resolved
  configuration. That copy was a flat snapshot of everything the loader
  produced, which the code it replaces acknowledged could carry API keys and
  model-provider tokens, mitigated only by 0o600. The chain writes include paths
  and MCP tuning, and nothing else.
- Worse: because the chain reaches out to the user's real config, the daemon has
  to grant its directory through OPENCLAW_INCLUDE_ROOTS. The flat copy was
  self-contained and needed no grant. The trade is a read grant on one directory
  instead of duplicating that directory's secrets into per-task state, which
  also means the include never goes stale relative to the user's config.

The MCP read uses the same deadline as every other call in the preparation
rather than a shorter budget of its own. It is one of the
openclawMaxCLICallsPerPreparation calls the budget arithmetic already accounts
for, it is the same class of invocation as the discovery calls — measured at
4.3s on the slow host in #7142 — and it has to honour
MULTICA_OPENCLAW_CLI_TIMEOUT for the same reason those do. A shorter private
budget would fail closed on exactly the machines that raise the override because
they need it.

`openclawResolvedFullConfig` and `stripUserMcpServers` lose their only caller.
The discovery cache's note about deliberately not caching the resolved config is
updated: the reason is no longer that the payload holds secrets, but that the
MCP subtree is per-agent state that has to reflect the user's configuration as
it is now.

Tests: the three strict-replace tests move to the MCP subtree and assert the
chain rather than a copy, including that the snapshot contains none of the
user's server bytes. TestPrepareOpenclawConfigIncludeRootGrants covers the grant
for all three include targets, because getting it wrong fails at OpenClaw's
include-confinement check with nothing pointing back here. The real-CLI test
behind the agentintegration tag asks the installed binary to resolve the
generated chain, since the correctness of the reset bridge lives in OpenClaw's
merge semantics rather than in the JSON this package writes.

* fix(ci): compile the agentintegration tests, and correct the snapshot doc

Two review follow-ups on the managed-MCP include chain.

The `openclawUserSnapshotFile` comment still described the flat sanitized
copy this PR replaced — "the user's config minus the `mcp` block". The file
now holds no user config bytes at all, only an include chain, which is also
why its 0o600 is about the paths it names rather than secrets it carries.
The name stays because the wrapper's include target is not worth churning
for a rename; the comment says why.

The design's load-bearing claim — that `{"mcp": null}` makes OpenClaw's
include merge replace the user's MCP object rather than merge into it —
is verified by openclaw_mcp_real_integration_test.go, and nothing compiled
that file. `agentintegration` guards nine files across the module and no CI
job referenced the tag, so a helper renamed in an ordinary test file, or two
branches each defining one in the same package, breaks the tagged build with
every untagged job still green. That is not hypothetical here: #6275 adds
openclaw_real_integration_test.go to this package with its own
`realOpenclawBin`, and dropping it in beside this one leaves `go build ./...`
passing while the new step reports

  vet: openclaw_real_integration_test.go:18:6: realOpenclawBin redeclared

vet type-checks test files, so the gate needs no agent CLI and no runner
that can execute one. It sits next to Build, before the suite that would
otherwise be the first thing to notice.

* docs(execenv): scope the root-read claim to what was actually measured

Two comments asserted that "current releases" do not answer
`config get --json`, citing a report against 2026.7.1-2 as corroboration. The
measurement behind them is one host on 2026.5.27, and the citation does not
support the claim: the 2026.7.1-2 reports (@kaverjody in #6275, #7308) are both
about `openclaw config file` — cold-start timings and a descendant holding
stdout — not about the root read. #7308 in fact measures `config file` exiting
cleanly in 4.2s on that version.

Which command is which is the whole premise of this change: `config get mcp
--json` is answered while the root is not, so treating an observation of one as
evidence about the other undercuts the argument it was meant to support.

Scoped both comments to the measurement, and stated that the root read's
behaviour on 2026.7.1-2 is unmeasured. The decision does not need the broader
claim: the subtree is the only part of the configuration this code needs, and
the fail-open branch being removed is a silent-degradation bug on its own terms,
reachable from any cause that makes the read come back empty.

* fix(daemon): harden OpenClaw MCP isolation

* fix(execenv): correct the root-read diagnosis against measured channels

Measured 2026-08-26 on Windows 10.0.19045.6466 against the three npm channels.
Two claims in this branch were wrong, and the corrected version is a stronger
argument for the change rather than a weaker one.

**The root read does not hang; it is not a valid invocation.** `config get`
requires a path, and the pathless form is rejected outright:

    2026.6.34   (extended-stable)  exit 1 in 2.9s  Missing required argument "path".
    2026.7.1-2  (latest)           exit 1 in 3.0s  same message
    2026.8.1-beta.3 (beta)         exit 1 in 4.4s  same message, JSON envelope

So `openclawResolvedFullConfig` did not degrade on some hosts — it failed
preparation for every managed-MCP task on all three channels shipping today.
2026.5.27 writing nothing and never exiting within 60s is one version differing
from every current one, and is now recorded as that rather than as the general
case. Switching to the subtree stops being a workaround for a hang and becomes
the request this code should always have made.

**The "exits 0 with empty stdout" shape is unobserved.** It needs exit 0; all
three channels exit 1. The fail-open branch removal still stands on its own —
it is a silent-degradation path and the new chain has no equivalent — but it is
no longer offered as the mechanism behind the reported `Unknown model`, which
remains a real symptom with an unestablished cause.

Also corrected from the same measurements:

- The missing-key transports are not a migration. 2026.6.34 and 2026.7.1-2 are
  stderr-only with empty stdout; only 2026.7.2-beta.7 onward puts the envelope
  on stdout. Both paths are load-bearing for channels shipping today, which the
  detector doc now states with the version table.
- `config get agents.list --json` on 2026.6.34 and 2026.7.1-2 emits
  "Config path not found: agents.list" and names the path, so requiring the
  diagnostic to name the requested key does not regress the registry fallback on
  current stable. `agents list --json` answers on all three.
- 2026.8.1-beta.3's `mcp` schema is exactly `servers` + `apps` with
  additionalProperties:false; `sessionIdleTtlMs` is gone. Stage 3 of the chain
  can only carry keys the same CLI just returned, which is what keeps it safe
  against that constraint. Comments now use `mcp.apps` as the current example.

New regressions, all from verbatim measured output:

- the pathless usage error must not read as a missing key, in both the stderr
  and the JSON-envelope form. This is the shape that actually occurs, and
  misreading it would turn a broken invocation into a silent "user has no mcp
  block";
- beta.3's invalid-config envelope (schema violation in a sibling `issues`
  array) must not read as a missing key either.

The smoke workflow now fails when the test skips. A skipped Go test reports `ok`
at the package level, so the job would have gone green whenever the CLI was
absent or the -run pattern stopped matching — green exactly when nothing was
verified. #6897's Windows step documents the same hazard.

The real-CLI test's doc records the platform, the three resolved versions and
their PASS durations (36.9s / 40.6s / 34.2s), and the hand-run control: dropping
the reset stage leaks `user-only` back into the resolved server map on all three
channels, which is what makes those passes mean something.

* test(execenv): pin the reset-stage/wrapper-mcp pairing the loader depends on

The reset stage is only safe because the wrapper always contributes its
own mcp object under the same condition: OpenClaw merges sibling keys
over the include result, and a null that instead survives to the
resolved root fails schema validation (zod .strict().optional() rejects
null), taking the whole config load down. The pairing held by
construction but nothing named it; this assertion fails in the PR that
breaks it, instead of a week later in the scheduled smoke.

Co-Authored-By: Claude Fable 5 <[email protected]>

* docs(execenv): match the deadline-budget arithmetic to the constant

openclawMaxCLIDeadlinesPerPreparation dropped from 4 to 3 when the
managed-MCP read step was removed, but the worst-case explanation on
openclawCLIMaxTimeout still said 4 x 60s = 4m. Say 3 x 60s = 3m so the
documented safety arithmetic matches the constant and its test.

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: weiweiwei <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
2026-09-03 12:33:39 +08:00
Firer d4a712abf3 MUL-6771 feat(cli): filter and sort issue list by custom property (#7664)
Expose the `properties=` filter and `sort=property:<id>` that GET /api/issues
already implemented but the CLI never surfaced, so scripts no longer have to
page through everything and filter locally:

  multica issue list --property "Impact=High" --property "Impact=Medium"
  multica issue list --property "QA Status=__none__" --status in_review
  multica issue list --sort property:Impact --direction desc

--property takes one Name=Value per flag; repeating a property ORs its values,
different properties AND. Values resolve by name the way the rest of the
property CLI works. __none__ is the reserved unset filter. A value the server
could never have stored — a non-http(s) url, over-long text, a non-numeric
number, a date in another format — is rejected up front rather than returning
the empty page that reads like a real answer. The >=, <= and != spellings are
reserved so comparison filters can be added later without breaking scripts.

Also fixes propertySortExpr, which sorted a select property by the raw stored
value — the option-id UUID, an arbitrary order. Values now rank by their
position in the definition's option list, so an ordinal scale sorts by meaning
(Low < Medium < High), matching what board grouping already treats as
canonical.

Closes #7663
2026-09-02 18:42:25 +08:00
765321f053 MUL-6951 fix(autopilot): carry the trigger creator's authorization on scheduled runs (#7918)
* fix(autopilot): carry the trigger owner's authorization on scheduled runs

A schedule/webhook autopilot run left originator_user_id NULL, so
canInvokeAgent saw no human and fail-closed against private agents. Arming
a trigger is the same authorizing act as clicking "run now" — deferred, not
absent — so withholding the originator did not remove that authority. It
only forced every capability the automation legitimately needed to arrive
as a separately-bounded borrow path: MUL-4857 for child creation, MUL-6691
for the run's own issues, #7902 for the accountable member's issues, with
sub-issue dispatch queued up as the fourth. Each carries a hand-written
scope that no operator can see in the UI.

attribution.TriggerOwner / RuleOwner now populate UserID (the originator)
instead of only the accountable side. finalizeAttribution mirrors it, so
the originator == accountable invariant that migrations 190/197 enforce
holds without a schema change or backfill. Both autopilot execution modes
resolve through triggerOwnerAttribution, so run_only and create_issue are
covered by the same two lines.

This grants exactly the trigger owner's own rights — canInvokeAgent is
untouched, only the principal handed to it changes — and originator_source
still distinguishes a scheduled fire from a manual one in the audit trail.

The borrowed-authority machinery in handler/agent_access.go is now dead for
newly enqueued runs but is deliberately left in place: tasks enqueued by
the previous backend are still running during a rolling deploy and carry
originator NULL, and they must keep the path they were admitted under.
Deleting it is a follow-up once this is deployed.

Composio connected-app credentials also key on originator_user_id, so
scheduled runs now receive the trigger owner's connected apps. Decoupling
that from authorization was considered and explicitly deferred.

Verification (real PostgreSQL 17):
- internal/attribution, internal/service, internal/handler: PASS
- the five attribution_stamp tests that pinned "originator stays NULL" were
  updated to assert the new principal, and fail against the old constructors
- pre-existing, unrelated failures in internal/daemon/execenv (4 Hermes
  store-path tests) reproduce identically on a clean tree

Co-authored-by: multica-agent <[email protected]>

* refactor(autopilot): delete the borrowed-authority machinery

With a scheduled run carrying its trigger owner as originator, every
autopilot-shaped authority path in agent_access.go is unreachable: the
invoke gate resolves a real human from the speaking task and judges it
exactly as it judges that human acting directly.

Removed:
  - autopilotTaskAssignAuthority + autopilotAssignAuthorityFromRequest (MUL-6691)
  - issueBoundToAutopilotTask, taskRunsAutopilotInWorkspace
  - autopilotDelegationAuthority and its FromRequest / FromComment resolvers (MUL-4857)
  - effectiveInvocationAuthorityFromRequest
  - assignAuthorityScope, assignScopeKind and the four scope constructors
  - commentTriggerComputeOptions.AutopilotDelegationAuthorityUserID + effectiveInvoker()

validateAssigneePair and triggerTasksForComment lose their now-meaningless
scope / delegation-authority parameters; every caller passes the run's
originator instead. This also retires the fourth borrow scope #7902 proposed.

CreateIssue still loads the parent issue when assigning, even though the gate
no longer needs the row: it is what turns a cross-workspace or missing parent
into 400 "parent issue not found" instead of a 403. Covered by a test.

Two behaviour notes, both accepted:

  - Authority now propagates down a delegation chain, because the parent run
    finally has an originator to copy. Under the old design each hop had to
    re-borrow. This matches what a manual "run now" already did.
  - The old borrow path required task.Status == "running"; the originator path
    does not, and never did for any non-autopilot run. Task tokens are revoked
    at the terminal transition (TaskService, MUL-2600), so the primary guard
    remains; this only drops a second layer that existed solely for autopilot
    runs.

Tests: the two suites that pinned the borrow bounds (autopilot-created-issue
binding, authored-by-this-task binding) described a mechanism that no longer
exists, so they collapse into one file asserting the rule that replaced them —
rights / no rights / no human at all, across create-top-level, create-child,
update and batch. The mention-authority suite keeps its shape; its fixture now
gives the run a human, as a live run has.

Verification (real PostgreSQL 17): internal/attribution, internal/service and
internal/handler all PASS. Pre-existing unrelated failures in cmd/multica,
internal/cli, internal/daemon and internal/daemon/execenv reproduce identically
on a clean tree.

Co-authored-by: multica-agent <[email protected]>

* fix(autopilot): resolve one principal per dispatch and expire it with the run

Addresses Elon's review of #7918. Three findings, all confirmed against the
code before fixing.

P1-1 — admission and the run's identity could be two different people.
Automatic dispatch admitted the assignee agent as the AUTOPILOT CREATOR
(canCreatorInvokeAgent) while the task took its originator from the trigger.
With A owning both the autopilot and a private agent, and B owning the
trigger, a dispatch was admitted as A and then executed as B — rights neither
holds alone. The old suite could not see it because it called dispatchRunOnly
directly, skipping admission.

  - autopilot_trigger gains an IMMUTABLE created_by (migration 449, best-effort
    backfill from published_by). published_by keeps its audit meaning and still
    transfers on a substantive edit; it is no longer an authorization input,
    because letting an editor move it silently re-authorizes the automation as
    that editor. Per Bohan's ruling the run always acts as the trigger CREATOR.
  - ResolveAutopilotTriggerPrincipal is now the single answer to "who does this
    run act as". Admission, the task stamp and every delegated run read it, so
    the fork is unrepresentable. It also binds the trigger to its autopilot
    (GetAutopilotTriggerForAutopilot — the old lookup was WHERE id = $1, which
    would let a foreign trigger select the principal) and re-checks workspace
    membership on every dispatch, so removing a member revokes their triggers.
  - canCreatorInvokeAgent is now unreachable and deleted.
  - RuleOwner reverts to accountable-only. It is a guess at who owns a rule;
    promoting it would hand a legacy trigger somebody's rights without that
    person arming anything. Such runs carry no originator and fail closed.

P1-2 — a terminal task could still spend its human's rights.
invokeOriginatorFromRequest now refuses completed / failed / cancelled tasks.
Token revocation at the terminal transition stays the primary guard, but it is
best-effort, explicitly non-fatal on failure, with a 24h expiry behind it. The
check sits at the authorization boundary rather than the auth boundary, so a
finishing run can still post its final comment but cannot start new work.

P1-3 — the PR and code comments described Composio backwards.
BuildTaskOverlay ignores the originator entirely and builds from the AGENT
OWNER's connections and allow-list (MUL-3963). Naming the originator hands out
no third-party credentials. Comments corrected; no decoupling was ever needed.

Operational note: a trigger with neither created_by nor published_by (predating
migration 186) resolves no principal, so its autopilot stops dispatching until
the trigger is re-saved. That is the intended fail-closed behaviour.

Tests: full dispatch through admission for both fork directions; principal
fails closed on legacy / cross-autopilot / removed-member; terminal task
refused on all three statuses; the edit-transfer tests now assert the principal
STAYS with the creator while published_by moves. Verified the fork test fails
when admission is reverted to canCreatorInvokeAgent.

Verification (real PostgreSQL 17, pristine database): internal/attribution,
internal/service, internal/handler and cmd/server all PASS. Migration 449
up/down round-trips. Pre-existing unrelated failures in cmd/multica,
internal/cli, internal/daemon and internal/daemon/execenv are unchanged.

Co-authored-by: multica-agent <[email protected]>

* fix(autopilot): bind the trigger principal to its workspace, correct stale docs

Second round of Elon's review on #7918.

1. The trigger lookup only enforced half of what its comment claimed.
GetAutopilotTriggerForAutopilot matched on (trigger id, autopilot id); the
workspace was used only for the creator's membership check afterwards. That
check proves the resolved human belongs to the workspace PASSED IN, not that
the autopilot does — a member of two workspaces satisfies it while the trigger
came from the other tenant. The query now joins autopilot and filters on
workspace_id, and the resolver passes it through. Regression test seeds two
workspaces sharing one member and asserts the foreign workspace resolves
nobody; verified it fails against the pre-fix query.

2. Several permission-boundary comments still described the pre-MUL-6951
model — that a run's authority and its accountable human transfer to whoever
last edited the trigger. That is now the opposite of the code, and these are
the comments a future reader would use to reason about the authorization
model, so they are corrected at all four sites: the trigger create site, the
SetAutopilotTriggerPublisher query, triggerOwnerAttribution, and the
dispatchRunOnly attribution block. published_by is described as what it now
is: a config-audit column with no bearing on a run.

3. The migration comment now states the backfill's imprecision plainly: for an
already-edited trigger it freezes the last recoverable EDITOR as the immutable
creator, not the historical creator, which the schema never recorded. It also
records that a NULL/NULL trigger has no recovery path by decision (Bohan:
leave them empty) — re-saving re-stamps published_by, not created_by — so its
runs stay refused with a recorded failure_reason.

Not done, per Bohan's ruling: no re-save takeover path for legacy triggers and
no migration-period alerting.

Verification (real PostgreSQL 17, pristine database): internal/attribution,
internal/service, internal/handler and cmd/server PASS; full suite shows only
the four pre-existing unrelated failures (cmd/multica, internal/cli,
internal/daemon, internal/daemon/execenv). go vet ./internal/... and
git diff --check clean.

Co-authored-by: multica-agent <[email protected]>

* docs(autopilot): carry the creator-principal semantics into every description

Third round of Elon's review on #7918. Two must-fixes, no behaviour change.

1. The final semantics had not reached every surface that describes them.

Migration 189 wrote COMMENT ON COLUMN text for autopilot_trigger.published_by_*
saying it decides the accountable human of the runs a trigger fires. That is
now false, and because sqlc generates column comments into models.go, the
stalest description of the authorization model sat in generated code every
reader passes through. 189 is released and untouched; migration 449 overrides
both comments and documents created_by_* for the first time, and sqlc is
regenerated. 449's down restores 189's original text verbatim so a rollback
does not leave MUL-6951 wording describing a model the database no longer runs.

The same stale semantics remained in prose at six code sites — the plain and
manual dispatch entry points, the scheduled-plan path, the create_issue enqueue
comment, the autopilot-level republish path, and the webhook trigger create
site. All said a run's authority or accountable human follows the last editor,
or that automation resolves rule_owner. These sit on the authorization path, so
they are corrected rather than left to drift. published_by is now described as
what it is: config audit, with no bearing on a run.

Also corrects the backfill comment's migration reference: trigger publishers
arrived in 189, not 186.

2. The test file this PR added open-coded INSERT ... RETURNING id with matching
t.Cleanup(DELETE ...), which CLAUDE.md prohibits. Rewritten onto
testutil.Fixture: a small principalFixture embeds it and every row — members,
runtimes, private agents, autopilots, triggers, the second workspace — is built
and cleaned up by dbfx. The only remaining DELETE is the mid-test member
removal that IS the behaviour under test. Re-verified after the rewrite that
both regressions still fail against the old code: reverting admission to the
autopilot-creator check reddens the fork test, and dropping the workspace join
reddens the cross-workspace test.

Verification (real PostgreSQL 17, pristine database): full suite shows only the
four pre-existing unrelated failures (cmd/multica, internal/cli,
internal/daemon, internal/daemon/execenv). Migration 449 up/down round-trips
and the down restores 189's comments. go vet ./internal/... and
git diff --check clean.

Co-authored-by: multica-agent <[email protected]>

* docs(autopilot): state the backfill widening plainly, drop raw-SQL test seeding

Fourth round of Elon's review on #7918. Both items are leftovers from the
previous round; no behaviour change.

1. Migration 449's backfill comment carried two factual errors.

It said published_by_* arrived in migration 186; it was 189. The previous round
intended to fix this but the string replacement did not match the wrapped line
and silently did nothing, so the wrong number shipped.

It also claimed the backfill "is no wider than the authority those runs carry
today", which is false: before MUL-6951 an automatic run had no originator at
all, only narrowly-scoped borrow paths, so promoting a backfilled editor to the
run's full originator IS a widening. The product decision to accept it stands;
the comment now says so directly as a compatibility tradeoff instead of denying
the widening. A migration comment that understates what it grants is worse than
no comment.

2. The new test file still reached raw SQL transitively.

It called seedAttributionFixture, which hand-writes INSERT and cleanup for its
user / workspace / member / runtime / agent — so the file's own claim that
"every row goes through testutil.Fixture" was untrue and the repository's
fixture rule was still unmet. It now bootstraps the base identity with
Fixture.User / Workspace / Member on an unbound fixture and adopts it, which
also drops the agent and issue that helper builds and these tests never use.
The comment explains why the helper is deliberately not reused.

Re-verified after the fixture change that both regressions still fail against
the old code: reverting admission to the autopilot-creator check reddens the
fork test, and dropping the workspace join reddens the cross-workspace test.

Verification (real PostgreSQL 17, pristine database): full suite shows only the
four pre-existing unrelated failures (cmd/multica, internal/cli,
internal/daemon, internal/daemon/execenv). go vet ./internal/..., gofmt and
git diff --check clean.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-02 18:29:11 +08:00
d7247fa9fc MUL-6975: preserve departed member identity in timelines (#7931)
* fix(timeline): preserve departed member identity

Co-authored-by: multica-agent <[email protected]>

* fix(timeline): preserve identity across live updates

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-02 17:58:14 +08:00
a6911a9520 MUL-6972 perf(server): sweep delegated recovery every five minutes (#7929)
* perf(server): reduce delegated recovery sweep cadence

Co-authored-by: multica-agent <[email protected]>

* refactor(server): isolate delegated recovery sweep

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-02 16:58:36 +08:00
835c5437a9 MUL-6971 fix(issues): stop rejecting description edits against a stale baseline (#7926)
* fix(issues): stop rejecting description edits against a stale baseline

The description editor autosaves on a 1.5s debounce and sent the base it
had adopted; the server rejected the write whenever that base was not
byte-identical to the stored Markdown. Two ways that fired with no second
writer present:

- The editor normalizes its base (`normalizeMarkdown` = trim) while the
  server compares raw bytes, so any description stored with surrounding
  whitespace — every CLI/integration write that keeps a trailing newline —
  rejected EVERY edit, permanently.
- A save whose own echo arrived while the editor was dirty could not
  advance the base (ContentEditor refuses external syncs on a dirty
  document), so the next autosave carried a base the server had already
  moved past. Entering the conflict state then fed the local draft back in
  as the base, wedging the editor for the rest of the session.

The guard also never covered the writers most likely to race a human here:
mobile omits text baselines deliberately, and the CLI/agent path sends no
base at all. It mostly rejected the user's own autosave.

Descriptions are now last-write-wins. `description_base` stays in the
request because it is also the channel-media merge metadata — dropping it
would make media the editor had adopted impossible to delete.

Title and comment baselines are untouched: both save explicitly, send an
un-normalized base, and are not reachable by either failure above.

Co-authored-by: multica-agent <[email protected]>

* test(issues): build the new description-baseline test on the shared fixtures

The test landed on the legacy pattern this package is trying to stop
growing: `insertWorkflowTestIssue` plus a hand-written cleanup, raw SQL to
seed the column under test, and a recorder/status quartet. Rebuild it on
`dbfx.Issue` and `testutil.Call(...).Want(...)`, which own the cleanup and
print the request line and response body on a mismatch.

Behaviour is unchanged — still red against the pre-fix handler with the
reported 409, green after it. The assertions added to the surrounding
legacy test stay in their host's style; the rule covers new tests.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-02 16:39:57 +08:00
9fce92f427 MUL-6846: feat(cli): expose active and cross-issue agent runs on issue runs (#7922)
* feat(cli): expose active and cross-issue agent runs on `issue runs`

An agent could already read one issue's task runs, but only as the full
execution history — every past run, hydrated with attribution and token
usage — with no way to ask the one question coordination actually needs:
who is working right now. And nothing at all could answer it across a
parent's sub-issues, which is the case #7768 describes: several agents
claim sibling children in parallel and each starts overlapping work
without knowing the others exist.

The claim-time `## Active sibling runs` block does not cover this. It is
scoped by agent_id — your OWN other in-flight tasks — so a different
agent running on a sibling issue is invisible, and it is a snapshot taken
before the turn started.

Two query params on the existing task-runs endpoint, and the flags that
drive them:

  multica issue runs <id> --active      # in-flight runs on this issue
  multica issue runs <id> --siblings    # ...and across the sub-issue family

`--siblings` roots at the issue's parent, or at the issue itself when it
has none. One rule covers both directions: a child sees its siblings, a
parent sees its children, a standalone issue degenerates to its own
active runs — so a caller does not have to know an issue's shape before
choosing a flag. Rows carry the issue they belong to, ordered
running-first and bounded, because a parent that fanned out hundreds of
children must not turn an advisory read into an unbounded response.

Both reads are advisory. Nothing here reserves an issue or serialises
anything; they tell a run whom to coordinate with, and coordination
still happens in comments.

With neither param the response is byte-identical to before, which the
issue-detail execution log and the CLI's short-task-ID resolver both
depend on.

Co-authored-by: multica-agent <[email protected]>

* fix(cli): address review on active/sibling run reads

Four fixes from review, all on the same theme: a coordination read has to
be cheap and has to be honest about what it did not tell you.

- `--active` no longer hydrates usage. ListIssueTaskUsage returns a row
  per (task, provider, model) for every task the issue ever ran, so the
  active path was still paying the full-history cost the filter exists to
  remove — for a column that is near-empty on runs that have not
  finished. The execution log keeps it.

- An invalid `active` value is a 400 instead of a silent fallback.
  `active=tru` used to answer "who is here now?" with the whole history
  under a 200, and a pile of finished runs reads as the live ones. Same
  reason the unknown-scope check already existed, same shape as the
  comment-list boolean params.

- The 50-row family cap now announces itself. The query asks for one row
  past the cap and sets X-Active-Runs-Truncated when it comes back; the
  CLI warns on stderr. Without it, "the answer was cut off" and "no run
  on that sibling" are the same response, and an agent reads the second.
  The cap is also documented in the flag help, the skill and
  CLI_AND_DAEMON.md, along with the ordering it depends on.

- The skill's new agent-facing contract is pinned in
  builtin_skills_test.go: that the claim-time block is same-agent only,
  the `--siblings` command itself, the cap disclosure, and the
  advisory/no-lock boundary — the last a negative anchor, because an
  agent that reads these as a lock skips the coordination they exist to
  prompt.

Also reuses service.IssueIdentifier instead of re-deriving the format.

Co-authored-by: multica-agent <[email protected]>

* fix(server): expose the truncation header to CORS, correct the cap rationale

Both from re-review, and both about the cap telling the truth.

- HeaderActiveRunsTruncated was missing from corsExposedHeaders. No
  browser client requests scope=family today, so nothing is broken right
  now — but a custom response header that is not exposed is unreadable
  from JS, which is the exact silent-truncation failure the header was
  added to remove. The registry and its guard test exist because this
  drifted once already (MUL-5492); completing both is two lines.

- The comment justifying 50 was wrong. It claimed real family
  concurrency is bounded far below the cap so 50 sits above any
  legitimate answer. It does not: agentconfig.MaxMaxConcurrentTasks lets
  ONE agent run 50 concurrent tasks, this feature exists for the case
  where several agents work a family at once, and the returned set
  includes queued / dispatched / waiting rows that no execution slot
  bounds at all — a parent that fans out 200 children can have 200
  enqueued. So 50 is a response-size budget and nothing more, truncation
  is an ordinary outcome rather than a pathological one, and that is
  precisely why it has to be reported. Comment and PR body now say that.

Co-authored-by: multica-agent <[email protected]>

* refactor(cli): give the family read its own compact row, cap at 20

The family read was reusing AgentTaskResponse — the execution log's row.
Measured against real data that is ~1645 bytes per run, of which the
caller reads almost nothing: attribution (625 bytes, and its own extra
query), result, work_dir, relative_work_dir, trigger_summary,
coalesced comment ids. An agent asking "is anyone working next to me?"
was spending ~20k tokens of its own context on the answer.

ActiveRunSummary carries what the question needs and nothing else: task,
issue, agent, status, and timestamps. 367 bytes per row. It is a separate
type on purpose — not AgentTaskResponse, and not ActiveSiblingRunData
(the claim payload's row, which has no agent field because those runs all
belong to the claiming agent by construction). Reusing either would mean
an optional field only one caller ever sets, which is the shape this
commit removes from AgentTaskResponse.

The query now names its columns instead of embedding the task row, so
Postgres stops detoasting the JSONB blobs too, and the handler drops
attribution hydration along with the field.

Cap goes 50 -> 20 (maintainer call): a handful of runs is the normal
case, so the budget is set to the normal case. Truncation was already
reported and still is, which is what makes a small cap safe.

Worst-case response: 80 KB / ~20k tokens -> 7 KB / ~1.8k tokens.

The CLI's family table drops COMPLETED and ERROR, which are empty on
every row of a read that only returns work still in flight.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-02 16:21:05 +08:00
a075e58b80 MUL-6961 feat(agent): discover Claude Code's model catalog from the CLI (#7917)
* feat(agent): discover Claude Code's model catalog from the CLI

Claude was the last first-party runtime answering "what models do you
have?" from a hand-maintained list. A static catalog cannot know what the
CLI in front of it can run, so every new Anthropic model opened a window
where Multica offered a model that 400s:

    API Error: 400 Claude Code 2.1.246 does not support this model;
    version 2.1.251 or newer is required.

Claude Code does have a discovery hook, just not on the command line: the
stream-json control protocol answers a `list_models` control request with
the rows its own /model picker renders. Ask the installed binary and the
window closes at the source — a model it cannot run is either absent or
flagged disabled, computed against the real CLI version and the logged-in
account.

Verified against 2.1.223/228/246/258. The three older builds return Fable
5.1 as a disabled row carrying "Update to 2.1.255+ to use Fable 5.1";
2.1.258 returns it as a normal pick. Those rows are the reason no
per-model minimum-version table is needed here: upstream already computes
one, including the remedy copy, which we forward verbatim and grey out.

No version gate guards the request. An unsupported subtype is answered,
not ignored — every build tested replies "Unsupported control request
subtype" in ~2s and exits 0 — so an old CLI costs one cheap round trip and
falls back. Gating would mean hand-maintaining exactly the kind of number
this change removes.

Notes:

- The static catalog stays, now as the fallback and as the server-side
  accept list. It is flagged Catalog.Fallback so a discovery failure is
  not pinned in the server's day-long cache (MUL-5549).
- Discovery replaces rather than merges, as with every other dynamic
  provider. The picker narrows to the CLI's own list; agents already
  pinned to an older model keep working, and manual entry still reaches
  them.
- Effort levels now come from each row instead of scraping `claude
  --help` and narrowing through the hand-kept claudeModelEffortAllow.
- Catalog ids keep the context-window tag, because `claude-opus-5[1m]` is
  what the CLI would really run for that row. That exposed a latent bug
  in two capability lookups, which normalized the query side but not the
  catalog side; both now normalize both, or a model would fail to find
  its own effort picker.

MUL-6961

Co-authored-by: multica-agent <[email protected]>

* fix(agent): keep unrunnable Claude models out of the selectable catalog

Review found the first cut carried "this CLI cannot run it" as a flag on
the model itself. That was wrong in a way a flag cannot fix.

Only the create-agent dropdown learned the flag. The inspector picker and
the agent builder read the same list and kept offering the row, so
editing an existing agent still reached the 400 this change exists to
prevent. And the part no amount of teaching fixes: an already installed
desktop client has never heard of the field, so it renders the row as an
ordinary model and persists a model id the CLI rejects — exactly what the
repo's mixed-version API rule forbids.

Unavailable models now travel in their own list, end to end: daemon
report, store, catalog cache, and the client result. `models` contains
only selectable entries. The inspector and the builder needed no changes
to become correct — they read `models`, and the rows are no longer in it
— and an old client is correct for the same reason. Both pickers render
the advisory rows through one shared component that takes no click
handler, so nothing can turn them back into controls.

Two more review findings:

- The default row was dropped when no sibling row named the same model,
  which an org-restricted install can produce. That is not one missing
  picker entry: ValidateThinkingLevelWith resolves an empty model — the
  common "follow the CLI default" configuration — through the entry
  flagged Default, and fails closed without one, silently discarding the
  user's effort on every such task. It is now materialised, keeping the
  row's own capabilities. The test that claimed to cover this asserted
  the opposite of its own comment; rewritten, plus a case proving an
  unrunnable default is never materialised.

- "One cheap round trip" for a CLI without list_models was one round trip
  PER TASK: every Claude task carrying a thinking_level loads the catalog
  before it starts, and cachedDiscovery refuses to memoise a fallback.
  The structured unsupported-subtype answer is now remembered per
  (command, CLI version) — a property of the binary, not a bad moment —
  so an old CLI is probed once. Transient failures stay uncached and
  retryable, and the version in the key makes an upgrade take effect at
  once rather than after a TTL. Detecting it costs ~0.01s against the
  ~1.7s it saves, and the fallback path already paid for it.

The discovery timeout keeps its 20s ceiling, now with the measurements
that justify it: 1.57-2.19s over five runs against 2.1.258, 1.64-1.79s
against 2.1.246. The ceiling is for a CLI that never answers, not a slow
one.

Verified against real binaries: 2.1.246 offers four selectable models and
one unavailable row carrying upstream's own upgrade hint, 2.1.258 offers
Fable 5.1 normally, a missing binary falls back to the flagged static
catalog.

MUL-6961

Co-authored-by: multica-agent <[email protected]>

* docs(agent): record the downgrade case as an accepted boundary

Dynamic discovery governs selection, and only selection. An agent already
configured with a model whose CLI is later downgraded still starts and
still gets the upstream 400 — discovery cannot reach a value already
persisted.

That is deliberate, not an oversight, so it is written down next to the
code that invites the wrong fix. Clearing the configured model turns a
legible error into a silent config change the user did not make;
substituting a runnable one is worse, because it succeeds using a model
nobody chose. The downgrade is rare and the upstream error is accurate,
so failing loudly at task start is a fine way to report it.

MUL-6961

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-02 15:48:38 +08:00
eveningb123andguowanfeng 07430d1152 MUL-6939 fix(util): preserve JSON keys during NUL sanitization (#7889)
* fix(util): preserve JSON keys during NUL sanitization

* fix(util): make JSON key collision allocation linear

---------

Co-authored-by: guowanfeng <[email protected]>
2026-09-02 15:35:31 +08:00
a2d819ea40 MUL-6962 fix(daemon): give environment preparation failures their own failure reason (#7915)
* fix(daemon): give environment preparation failures their own failure reason

A failed execenv.Prepare / Reuse is a filesystem error on the daemon's own
host, but handleTask routed it through taskfailure.Classify — a classifier
written to read agent and provider output — so a full disk, a denied
permission or a directory held open by another process all landed in
agent_error.*: the catchall today, provider_server_error on the report that
opened the issue and sent hours of diagnosis at an LLM vendor.

The daemon now tags those errors structurally (environmentSetupError) and
reports the new platform-side reason environment_prepare_failed. The four
existing sentinels still win: task identity, the prepare budget, skill
downloads and the OpenClaw CLI deadline each name a specific cause inside the
same phase.

Installed daemons update on their own cadence, and they report a non-empty
reason that FailTask deliberately leaves alone, so NormalizeDaemonReason
upgrades any agent_error.* value whose error text opens with the prepare /
reuse wrapper. That witness proves the run died before an agent existed, so
every label in that namespace is wrong by construction.

Not retryable and resume-safe: a full disk reproduces identically on the next
attempt, and no session was touched.

Closes #7913

Co-authored-by: multica-agent <[email protected]>

* fix(mobile): parse failure_reason as an open string, and match copy to real scope

Review follow-up on #7915.

AgentTaskSchema pinned failure_reason to the six pre-MUL-1949 coarse values
with `.catch("")`, so every refined reason was erased to undefined before any
surface read it. run-row's badge map was widened in MUL-5370 and has been
unreachable ever since: agent_error.*, skill_bundle_unavailable and the new
environment_prepare_failed all rendered a bare "Failed". The field is an open
string on the wire (packages/core/types/agent.ts) and in the chat message
schema beside it; the task schema now matches, and the badge map moved to
lib/run-failure-badge.ts so mobile's node-only vitest lane can cover the
lookup a component test cannot reach.

execenv.Prepare is not only filesystem work — it also writes and validates the
per-provider local config (Codex home, Hermes overlay, Cursor MCP, and an
OpenClaw config it fails closed on), so a malformed local config lands on this
reason too. The Go docs, the chat copy in four locales and the docs table said
"disk, permission, directory in use", which would send the reader past half
the real causes — the same misdirection this reason exists to end. They now
point at the raw error first and name the config case, with regression tests
for a non-filesystem preparation failure on both the daemon and the legacy
wire path.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-02 14:45:41 +08:00
Rusty Raven 2e424e06a1 MUL-5154: fix(agent): surface Kimi provider API errors (#5785)
Surface Kimi provider failures that ACP reports as successful empty turns, preserve terminal error details across retries and EOF, and connect poisoned-history failures to the existing guarded fresh-session recovery path.
2026-09-02 14:45:39 +08:00
yarrumretep 9d09902dfe MUL-6943 feat(agent): add Claude Fable 5.1 model and pricing (#7892)
Adds `claude-fable-5-1` to the static Claude Code model catalog and to the
backend and frontend pricing tables, so the model is selectable with its
effort picker enabled and its usage prices at the published rates
($10 input / $50 output / $12.50 5m cache write / $0.25 cache read — 5.1
prices cache hits at 0.025x input rather than the standard 0.1x).

Maintainer follow-ups on top of the contribution tighten backend alias
matching: both Fable rules now end at a shared `claudeVersionEnd`
terminator anchored to the end of the id, and the tag-stripping retry in
`PriceForModelAlias` peels exactly one context tag. Together these stop
Fable 5 and 5.1 from inheriting each other's tier, keep an unknown variant
(`claude-fable-5-2`, `-10`, `-1-preview`, `[1m][2m]`) unmapped instead of
silently borrowing a rate, and keep the backend and the dashboard resolving
the same suffix forms. Regression coverage added for the dated, `-latest`,
context-tagged and doubly-tagged spellings.

Sonnet 4.6 remains the sole default. Known follow-up: Claude Code rejects
this model below 2.1.251 while the global Claude runtime floor is 2.0.0, so
members on an older daemon see it in the picker and fail at run time; the
fix is a per-model minimum-runtime-version signal, tracked separately.
2026-09-02 13:38:15 +08:00
10f4f8e938 MUL-6957 fix(daemon): log full task ids so concurrent runs stay distinguishable (#7910)
* fix(daemon): log full task ids so concurrent runs stay distinguishable

The workdir isolation fix for #7326 gave same-prefix tasks separate env
roots, but the daemon log still rendered every task id as its first eight
hex chars. Task ids are UUIDv7: those chars are the high 32 bits of a
millisecond timestamp and only advance every ~65s, so any two tasks
started in the same window print an identical `task=` value. Two healthy
concurrent runs were indistinguishable in the log exactly when scheduling,
provider or workdir behaviour needed to be traced per run.

Structured log fields (`task`, `chat_session`, `holder`, `target_task`)
now carry the whole id — the same key the task JSON, the env-root
ownership manifest and the server logs print, so a log line can be joined
to them. shortID stays for the one human-facing label that is read rather
than joined (the "waiting for local_directory (held by task …)" reason the
UI renders), with a doc comment fencing it off from log fields.

Two follow-ons: the idle watchdog no longer passes a second `task` field
that duplicated the one its task-scoped logger already carries, and the
desktop log panel's collapsed field budget goes from 32 to 36 chars so a
canonical UUID is not clipped at the entropy end of the value.

Verified: new regression tests reproduce the reported `task=01a05ec1` for
both field ids and pass on the fix; the rest of the daemon suite is
unchanged (same 7 pre-existing environment failures before and after).

Co-authored-by: multica-agent <[email protected]>

* refactor(daemon): drop the duplicate task field on the reuse-mismatch log

Review nit on #7910: the "reused workdir is not the directory that was
claimed" line passed "task" into taskLog, which handleTask has already
bound the full id to, so the id printed twice on that line — 72 chars of
duplicate now that the value is a whole UUID. Same shape as the idle
watchdog case already cleaned up in the parent commit; this was the last
one in the package.

Guarded from both sides. The behavioural test now asserts no emitted line
carries more than one `task=` field, which covers everything handleTask
reaches. Most taskLog calls live deeper inside runTask behind a real agent
process, so a structural test parses the package sources and fails on any
taskLog.<Level>(...) passing a literal "task" key, following the AST-scan
precedent in agent_command_names_test.go — including its lesson: the scan
asserts it still matches at least 20 calls (it sees 81) so a rename or a
file move fails loudly instead of passing vacuously.

Verified: both guards fail with precise file:line output when the
duplicate is reintroduced, and pass on the fix. Daemon suite unchanged
(same pre-existing environment failures).

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-02 13:37:41 +08:00
3c23c491fa MUL-6924: feat(billing): notify workspaces about autopilot quota (#7681)
* feat(billing): notify workspaces about autopilot quota

Co-authored-by: multica-agent <[email protected]>

* fix(billing): isolate and throttle quota notices

Co-authored-by: multica-agent <[email protected]>

* fix(billing): tighten quota notice delivery

Co-authored-by: multica-agent <[email protected]>

* fix(billing): reuse quota admission snapshot

Co-authored-by: multica-agent <[email protected]>

* fix(autopilot): make quota notices actionable

Co-authored-by: multica-agent <[email protected]>

* fix(billing): align quota recovery notifications

Co-authored-by: multica-agent <[email protected]>

* fix(billing): align quota notice recipients

Co-authored-by: multica-agent <[email protected]>

* fix(billing): finish quota notice review

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Sol-Boy <[email protected]>
2026-09-02 12:59:26 +08:00
f01eb5a06c MUL-6928 perf(issues): index scalar property contains behind a bigram prefilter (#7878)
* MUL-6928 perf(issues): index scalar property contains behind a bigram prefilter

Scalar `contains` property filtering runs `(properties ->> def) ILIKE
'%needle%'`, which no index can serve, so it degrades to a workspace-wide
sequential scan. At 1M issues that costs ~8x the throughput of an equality
filter, and the pgxpool connections it holds queue every other tenant's cheap
query behind it.

Put a redundant, indexable prefilter in front of the per-key check:
`LOWER(properties::text) LIKE LOWER('%needle%')`, served by a new pg_bigm GIN
index on the same expression. The per-key ILIKE still decides the result; the
prefilter only narrows the candidate set. On a 500k-row pg_trgm proxy a
selective needle goes from a 183ms Parallel Seq Scan to a 1.4ms Bitmap Index
Scan.

The prefilter is skipped, falling back to today's behaviour, for needles it
cannot screen soundly or usefully: jsonb escapes `"`, `\` and control
characters on serialization, so those never appear literally in
properties::text, and a single character carries no bigram to narrow with.

Migration 446 cannot use the DO ... EXCEPTION guard migrations 032 / 036 use
for pg_bigm — a concurrent build cannot run inside one — so the runner gates it
on the operator class instead: databases without pg_bigm record the version
with its SQL skipped and keep the unaccelerated (still correct) filter.
cmd/migrate/README.md documents building it out of band afterwards.

Co-authored-by: multica-agent <[email protected]>

* MUL-6928 fix(issues): prefilter short contains needles too

The single-character skip rule was derived from the pg_trgm proxy used to
develop this change, where a 1-character needle can only drive a sequential or
full-index scan. pg_bigm is different — indexing 1- and 2-character keywords is
the capability it exists for over pg_trgm — so the rule denied the target
extension exactly the searches it was chosen to serve, and a single CJK
character is already a word.

Measured on PostgreSQL 16.15 with pg_bigm 1.2 over 1M issues: a rare
1-character CJK needle goes from a 134ms Parallel Seq Scan to a 1.2ms Bitmap
Index Scan (8.3 -> 10527 tps at 4 clients). Only the JSON-escaping guard
remains, which is about soundness rather than benefit.

Co-authored-by: multica-agent <[email protected]>

* MUL-6928 fix(issues): analyze the properties expression index after building it

Building an expression index does not collect statistics for its expression.
Until they exist the planner falls back to a pattern-length heuristic that
estimates any single-character LIKE at a few percent of the table, so it cannot
tell a needle matching 50 rows from one matching 50,000 — and migration 446's
index ends up built, valid, ready, and never chosen. Autovacuum does not rescue
a large, quiet issue table promptly: its default threshold is 10% of rows
changed.

Measured on PostgreSQL 16.15 with pg_bigm 1.2 over 1M issues, with
max_parallel_workers_per_gather=4: a rare single-character CJK needle plans as a
Parallel Seq Scan at ~101ms with 446 alone — indistinguishable from the
pre-change baseline — and as a 0.64ms Bitmap Index Scan once the statistics
exist. Whether the wrong estimate actually flips the plan depends on how cheap a
parallel scan looks, so without this the shipped behaviour would vary by
instance sizing.

447 is deliberately not gated on pg_bigm the way 446 is: ANALYZE is valid, cheap
(557ms on 1M rows) and side-effect-free everywhere, and refreshing ordinary
column statistics is useful even where the expression index was skipped.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-02 12:36:52 +08:00
mrlonely 2e29745100 MUL-6870 fix(daemon): require proven task ownership before GC mutation (#7798)
Daemon GC treated every second-level directory under WorkspacesRoot as a task
root and deleted it once it had no .gc_meta.json and went past the orphan TTL,
without ever proving Multica had created it. Pointing workspaces_root at a
directory holding real user content therefore made GC delete that content.

Require valid Prepare-time .task_owner provenance, matching the exact
workspace/task path, before every GC mutation including artifact-only cleanup.
Revalidate after taking the env-root lock and again immediately before
os.RemoveAll. Directories that cannot prove ownership are retained.
2026-09-02 00:44:58 +08:00
11bd18a507 MUL-6921 fix(daemon): honor the Co-authored-by toggle in existing checkouts (#7868)
* MUL-6921 fix(daemon): honor the Co-authored-by toggle in existing checkouts

Turning off the workspace's Co-authored-by switch only affected repos
checked out after the flip. The prepare-commit-msg hook the daemon
installs appends the trailer unconditionally and lives in the git common
directory, so it outlives the checkout that created it: every later
commit in an existing checkout kept getting the trailer — including
`git commit --no-verify`, which bypasses pre-commit and commit-msg but
not prepare-commit-msg. On Gitea the trailer renders as a second commit
author with its own avatar, so the stale value is publicly visible.

The hook now re-reads the workspace's current setting at commit time
from a state file the daemon publishes, instead of freezing the decision
into the script at checkout time. The daemon writes that file whenever
it resolves the setting: on every settings refresh, on every workspace
sync (covers a daemon restart and repo-cache GC, costs no request), and
on checkout. A missing state file keeps the trailer, which is what the
hook's presence meant before.

Settings edits also had no way to reach a running daemon — the server
only woke daemons on rename, and the periodic sync deliberately skips
the repos/settings endpoint for a tracked workspace. UpdateWorkspace now
notifies members' daemons on a settings change too, and that hint
refreshes settings for the workspaces the daemon already tracks.

Co-authored-by: multica-agent <[email protected]>

* MUL-6921 fix(daemon): make the Co-authored-by verdict converge on its own

Review found three ways the first cut still left a stale trailer live.

A checkout snapshots the setting before its fetch and lock waits, then
wrote that snapshot to the workspace-wide state file — so a slow checkout
could resurrect a value the user had already turned off. The daemon is
now the only publisher: it holds the ordering between settings updates,
and the checkout reads what was published (falling back to its snapshot
only when nothing has been published yet). Publication itself takes a
per-workspace lock and reads the verdict inside it, so a publisher that
started before a settings update can no longer overwrite its result.

Hooks written by earlier releases read no state at all, so publishing a
new value never reached them: an upgraded host kept adding the trailer
until each repo was checked out again. Publication now also reconciles
the hooks in the workspace's bare caches — enabled rewrites them to the
gated script, disabled deletes them, hooks the daemon does not own are
left alone — which runs on every settings refresh and every workspace
sync. Hook files are published by rename, since these repos have live
checkouts committing against them.

A settings edit made while the daemon's websocket was down produced a
hint nobody received, and the reconnect reconcile only synced workspaces
and profiles. It now re-reads settings for tracked workspaces too.

Regression tests for all three, each verified failing without its fix;
the migration test seeds the previous release's hook verbatim and makes
real commits without any CreateWorktree call.

Co-authored-by: multica-agent <[email protected]>

* MUL-6921 fix(daemon): migrate isolated checkouts' Co-authored-by hooks too

The hook sweep only reached bare caches, so a checkout with isolated git
metadata — Codex on Linux and the Windows sandbox — kept whatever hook
its task workdir was prepared with. On a host that upgraded, that is the
previous release's unconditional script, which reads no state: the user
turns the setting off, commits in that workdir, and the trailer is still
there. That is the reported bug, still live for exactly the checkouts the
first sweep could not enumerate.

Publication now also walks the daemon's env roots for the workspace and
reconciles each checkout that owns its git metadata. Env roots are
attributed by their owner record, so one workspace never rewrites another
workspace's hooks, and a linked worktree is skipped because its hook
belongs to the bare cache. The walk is bounded to the depth repos are
checked out at and never descends into a matched checkout.

Also makes the publish-ordering regression deterministic: the verdict
read is now injected, so the test parks a publisher between taking the
lock and reading, and a second publisher reaching the cache during that
window is itself the failure. It fails immediately when the read is moved
back outside the lock, instead of depending on a 20ms sleep landing right.

Co-authored-by: multica-agent <[email protected]>

* MUL-6921 fix(daemon): attribute pre-v0.4.35 env roots to their workspace

The isolated-checkout sweep required the owner record to name the
workspace, which only became true in v0.4.35 (cd169947b). Env roots
prepared by v0.4.32–v0.4.34 recorded a bare task ID, and older ones no
marker at all — and cd169947b deliberately does not migrate workdirs
already on disk. So the sweep skipped exactly the long-lived directories
an upgrade carries forward, and their pre-MUL-6921 hook kept adding the
trailer after the setting was turned off.

Those releases put env roots under <workspaces root>/<workspace ID>/<task
key>, so when an owner record names no workspace the directory name is
the evidence and has to BE the workspace ID. The readable layout that
replaced it always appends a suffix, so a bare workspace UUID can only be
one of the older roots and this cannot alias a modern one. An unreadable
marker still attributes nothing: skipping costs a stale hook in one
workdir, guessing would rewrite another workspace's.

Regression covers both legacy shapes (no marker, task-ID-only marker),
and asserts the same two shapes under another workspace's directory —
plus a readable-layout root whose marker names no workspace — are left
alone. Verified failing without the fallback.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-01 17:28:48 +08:00
4fe6586b32 MUL-6790 feat(issues): support operator matching in scalar property filters (#7765)
* feat(issues): support operator matching in scalar property filters

Closes #7692.

Scalar custom-property filters (text / number / date / url) previously
matched by exact equality only, so a text fragment or a numeric range
returned an empty list. A property filter member is now either a plain
string (exact equality — the unchanged default that keeps every existing
saved view, URL, and API call meaningful) or an operator object:

- contains — case-insensitive substring (text / url)
- gt / gte / lt / lte — numeric comparison (number)
- before / after — lexicographic comparison of the stored
  "YYYY-MM-DD" strings (date)

Backend: parsePropertiesFilterParam accepts {op, value} members and
compiles them to operator patterns; propertiesFilterPredicate renders
ILIKE / numeric / date-string comparisons and the static ListOpenIssues
unroll in issue.sql mirrors them. contains values are LIKE-escaped at
parse time and ops are fully validated before compilation, so the SQL
branches only ever see legal shapes. The table-filters request carries
members as raw JSON so both shapes survive the round-trip, and the
query fingerprint canonicalizes them byte-exactly.

Frontend: the filter menu grows an operator row above the scalar value
input ("is" is the default and still commits a bare string), the client
matcher in issueMatchesPropertyFilters mirrors the server predicates,
and chips summarize operator members with per-locale phrases or math
symbols. Operator members ride in saved-view baselines and chip deltas
through a canonical propertyFilterValueKey.

Verified: full Go suite (race) and the internal/handler property,
facet, and issue-table tests; new unit + DB-backed operator tests on
both tiers; pnpm typecheck and the core/views Vitest suites including
new operator matrix, menu, and saved-view baseline tests.

* test(issues): cover operator members through the table-rows endpoint

The properties filter serves through three paths — the paginated list,
the open_only static unroll, and the table rows/facets compile — but
only the first two had operator-member coverage. The table path has
PR-specific code (raw-JSON members in issueTableFiltersRequest and the
byte-exact sortedUniqueRawJSON fingerprint) that no test exercised.
Drive ListIssueTableRows with operator members ANDed across two
definitions so the decode → fingerprint → parsePropertiesFilterParam
chain is pinned like the other paths.

* fix(issues): canonicalize numeric operator bounds and align empty-needle contains

Adversarial review findings:

- strconv.ParseFloat accepts forms Postgres ::numeric rejects ("0x1p4"
  hex-float, "1_000" underscores on older PG). The dynamic predicate
  binds the parsed float and succeeds while the static open_only unroll
  casts the raw string inside SQL and 500s — same filter, two list
  endpoints, different outcomes. Canonicalize the compiled pattern
  value with strconv.FormatFloat(num, 'f', -1, 64) at validation time;
  the dynamic re-parse and the static cast now see plain decimals.
  Regression tests cover the hex-float bound on both serving paths.
- issueValueMatchesOperator treated an empty contains needle as
  match-all ("".includes("") is true) while the server rejects empty
  operator values — a hand-edited saved-view blob could match every
  issue client-side. The matcher now refuses empty needles, and the
  test asserts it against a SET value (the unset case short-circuits
  before the operator runs and could never catch this).
- Server e2e: pin unset-key exclusion for contains / before / after on
  both serving paths via the seeded empty issue.

* fix(issues): align scalar property operator semantics

* test(issues): cover boolean contains mismatch

* fix(i18n): use exclusive Japanese wording for date before/after filters

The `before` / `after` operators compare strictly (`<` / `>` on both the
server predicate and the client matcher), so a date equal to the bound is
never a match. Japanese 以前 / 以後 mean "on or before" / "on or after" —
they include the reference date, so a user filtering 「2026-03-01 以前」
reasonably expects March 1 in the results and does not get it.

より前 / より後 carry the exclusive meaning the implementation actually
has. English "before"/"after" and Chinese 早于/晚于 are already exclusive
and are left unchanged.

Korean 이전 / 이후 share the same Sino root and are inclusive by
dictionary definition, but they are also the conventional rendering for
before/after filters in Korean software — left as-is pending a native
speaker's call rather than guessed at.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-01 17:21:11 +08:00
310cfdc1e5 MUL-6896 fix(i18n): finish the MUL-5703 glossary rollout across ja/ko and docs (#7867)
* fix(i18n): finish the MUL-5703 glossary rollout across ja/ko and docs (MUL-6896)

MUL-5703 settled the rule — the issue entity is translated (任务 / タスク /
태스크) and the agent run keeps `task` / 作業 / 작업 — but only ever landed in
the zh-Hans UI. Everything else drifted:

- ja/ko shipped 39 UI strings that still spelled the entity in English
  (`issue`), or as イシュー / 課題 / 이슈.
- zh and ja spelled the *run* with the *issue* word in the project worktree
  copy and in the MCP delete dialog — the exact collision MUL-5703 exists to
  prevent. ko already had this right (작업).
- ko wrote runtime-health "issues" as 이슈, so a machine card read as the
  entity. zh/ja use 异常 / 問題, and ko's own health_clear already said 문제.
- `conventions.ja.mdx` / `conventions.ko.mdx` still documented the *pre*-
  MUL-5703 rule ("issue stays English"), contradicting the en/zh glossary.
- The docs prose sweep the glossary calls "being swept separately" never
  happened: ~471 English `issue` in zh, ~414 イシュー in ja, ~418 이슈 in ko.

Sweeping the entity into 任务 / タスク / 태스크 forces the run to move too —
zh docs wrote it 执行任务, ja 実行タスク, ko 실행 태스크, all of which collide
with the entity word once the entity lands there. So the run is rewritten to
`task` / 作業 / 작업 in the same pass, matching what each UI already shows.
Standalone labels keep the English source's capitalisation (`Task` page
title and links); inline mentions are lowercase, like `skill`.

Also corrects the en source for two squad strings: squads receive *issues*
and the leader delegates *sub-issues*, not "tasks" / "sub-tasks". The three
translations already said the entity, so only en moves.

Not touched: the landing changelog archive, the generic English noun "task"
in chat/onboarding marketing copy, `issue` in the "problem" sense, and the
unrelated structural drift in the ja/ko conventions pages (they are missing
the "Runtime display names" section from an older sync).

Verified: 4907 views tests pass including locale parity, typecheck and lint
clean, go build plus the integrations/cli suites pass, and no doc anchor
broke (checked before/after).

Co-authored-by: multica-agent <[email protected]>

* fix(docs): sweep the last old run-word leftovers in zh copy (MUL-6896)

Review caught the docs home page still linking "[执行任务](/tasks)" while ja/ko
had already moved to 作業 / 작업. Four more of the same class turned up next to
it: the same link in README.zh, "任务生命周期" in VISION.zh (en: "the task
lifecycle"), and two redundant "task(task)" glosses the sweep produced where
the source had written "执行任务(task)".

With these, `执行任务` / `実行タスク` / `실행 태스크` are gone repo-wide, so the
"prose now follows the table" line added to conventions.mdx is true.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-01 16:04:34 +08:00
61ea48fd2e MUL-6881 fix(daemon): keep one worktree branch per conversation in local worktree mode (#7815)
* fix(daemon): keep one worktree branch per conversation in local worktree mode

Every comment on a local_directory issue started a new task, and worktree
mode gave each one a branch of its own forked from the user's HEAD. Turn two
therefore stood in a tree that did not contain turn one's work, with that work
stranded on another branch and nothing saying so.

Tasks that share a conversation — the turns of one issue, one chat session —
now share a branch, agent/<agent>/<issue>, and a follow-up checks it out at its
tip instead of forking from HEAD. Env roots stay per task, so sibling tasks
still run concurrently; when one already holds the conversation's branch, the
other forks from its tip onto agent/<agent>/<issue>-<task> rather than falling
back to HEAD.

Replaying the user's uncommitted tree onto a continued branch cannot use the
turn-one path: that merge takes the user's HEAD as its base, so it conflicts
exactly when the agent edited the lines the user had left uncommitted. Each
turn now records the snapshot it saw at refs/multica/local-state/<branch> and
the next one replays only the difference — a cherry-pick whose parent is that
snapshot. A genuine conflict leaves the branch as it stands and logs which
files did not come across, rather than stranding the conversation.

Teardown follows the same rule everywhere: a branch this prepare did not
create belongs to the turns before it, so Finalize's read-only cleanup, Discard
and the prepare rollbacks only ever drop a branch they created themselves.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): prove branch ownership and hand replay conflicts to the agent

Review of the per-conversation worktree branch found four ways it could put
the wrong tree in front of the agent. All four came from the same two gaps:
the user's snapshot did not describe their whole directory, and a branch was
recognised by its name.

The snapshot is now one commit covering tracked edits AND untracked files,
built in a private index inside the task's env root. It answers the question a
continued branch actually has to ask — what has the user changed since the
state I already carry — for every file, so a once-untracked file the agent
edited is no longer reverted to the user's older copy each turn, and their
later deletion of one carries. Redirecting GIT_INDEX_FILE also removes the
.git/index.lock race the previous capture had to retry around (#7434).

A conflict between the user's newer edits and the branch's own work is now the
agent's to resolve, per the product decision on the issue: the worktree is
handed over mid-merge, the turn's prompt names the files and says which side is
which, and Finalize refuses to deliver — preserving the worktree — while
anything is still unmerged. The snapshot only advances once the branch really
carries it, so an unresolved edit is offered again next turn instead of being
recorded as synced. Before this, the conflict was reset away and the user's
change was lost from every later turn.

Continuing a branch is decided by an ownership record — workspace, agent and
conversation ids, recorded in the snapshot commit at
refs/multica/local-state/<branch> — never by the branch name. A same-named
branch the user created is left untouched and the task takes
agent/<agent>/<issue>-<fingerprint>, which its own follow-ups then continue.
The same check keeps two workspaces that mint one issue identifier apart.

Those refs are now GC'd: every prepare drops the record of a branch that no
longer exists, and the docs say where they live and how to clear them.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): tie a task branch's record to the branch, and quote untrusted paths

Ownership proved that Multica once wrote a branch of this name, which stayed
true after the user deleted it and created their own under the same name. No
prepare runs in that gap, so the orphan sweep never sees the branch missing and
the next turn continued straight into the user's unrelated work.

The record now also pins the branch tip it was written at, as the second parent
of the record commit, and a branch is only continued while that commit is still
an ancestor of its tip. Deleted-and-recreated, force-moved and rebased branches
all fail that test; committing on top of a delivered branch — the normal case —
passes it and picks the user's commit up. Finalize re-records afterwards,
because a checkpoint written at prepare time sits on the branch's base, which is
exactly where a recreated branch would sit.

Conflicted paths reaching the turn's prompt are quoted rather than wrapped in a
code span. They come from the user's repository and git allows newlines,
backticks and quotes in a filename, so a raw one could close its list item and
continue as an instruction line of its own; the same quoting is applied to the
Finalize error. The list is bounded at 50 names with the remainder counted.

Also drops a duplicated pair of bullets in the Chinese docs and corrects two
file comments that still described the stash-and-copy replay.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): make the branch record part of delivery, and bound the conflict list by bytes

The record could still authorise a branch that was not ours. Finalize wrote it
after removing the worktree and treated a failure as non-fatal, so a failed
update-ref left the ref pointing at the record Prepare had written — whose
checkpoint is the branch's base, which is exactly where a branch the user
deletes and recreates from HEAD sits. The ancestor check then passed on their
work.

Two changes close it. A checkpoint is only written when it is a commit the
branch could not sit at without this conversation's work: Finalize records the
tip it actually delivered, and Prepare records early only once the branch has
moved off the user's HEAD — which is the baseline commit, or a continued
branch's own tip. And the record is now part of delivering: Finalize writes it
BEFORE removing the worktree and fails the task if it cannot, so the worktree is
still there to preserve, exactly as for a commit that could not be made. The
checkpoint is the commit the caller delivered rather than a re-read of the
branch ref, so a branch moved in that window fails the next ancestor check
instead of being recorded as current.

A sibling racing the very first turn of a conversation on a clean tree now finds
no record yet and starts its own line of work rather than forking from a branch
it cannot prove is ours. That is the price of the rule and it is the safe side.

The conflict list in the prompt is bounded by rendered bytes rather than by
entries — a git path is as long as the filesystem allows, so 50 entries bounded
nothing — with the remainder counted and a single oversized path unable to
overrun the budget either.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): give every task branch a commit of its own, and verify what a run delivers

The checkpoint rule — record only a commit the branch could not sit at without
this conversation's work — held in Prepare and not in Finalize. Finalize took
whatever the worktree's HEAD was, so a run that reset its worktree back to the
user's own HEAD had that plain user commit recorded as the branch's checkpoint,
and a branch the user later recreated there passed the ownership check.

Two changes make the rule total. Every branch this mode creates now gets a
baseline commit of its own, empty when the user's tree was clean: without it the
branch sits exactly where the user's HEAD does and nothing tells it apart from a
branch they create at the same place. It also makes `git diff <baseline>..
<branch>` the agent's work on every branch rather than only on the ones that
started dirty, which is what the surrounding comments already claimed.

And Finalize verifies the delivery point before recording it: the tip has to BE
the task's branch — not a detached checkout, not a branch someone moved
underneath the run — and it has to still contain the anchor, which is that
baseline or the checkpoint the previous turn recorded and this one verified. A
delivery that fails either test is not recorded at all: the task fails, the
worktree is preserved, and the error names what is missing.

A sibling racing the first turn of a conversation forks from it again, since
every branch now carries a recordable checkpoint from the moment it is prepared.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): keep the anchor on the commit that carries this turn's user edits

A follow-up turn commits the user's newest edits as its own baseline, but the
anchor stayed on the checkpoint the previous turn had recorded. A run that then
reset its worktree back to the previous tip still passed verification — the old
checkpoint is an ancestor of it — so the turn was recorded as having delivered
edits the branch no longer had. The turn after that computed its replay from a
state the branch never took, and the user's edits were simply absent from the
worktree with nothing to indicate it.

The anchor now follows every baseline this task commits, so it always names the
newest commit carrying the state about to be recorded. Resetting past it fails
the turn and preserves the worktree, as for the first-turn case.

The conflict path had the same hole from the other end: there the branch does
not carry the snapshot until the agent resolves and something is committed. When
a turn that started mid-merge ends with the branch exactly where it began, the
record keeps the state the branch is known to carry instead of advancing, and
the next turn offers those edits again. A resolution in favour of the branch's
own side leaves the same empty trace as discarding the merge, so this trades a
merge that may have to be concluded twice against recording edits as delivered
that may have been dropped.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): measure a delivery against the commit its own turn started from

A turn that starts mid-merge only carries the user's edits once the agent
resolves and something is committed, and the guard for that compared the
delivered tip against the checkpoint an earlier turn had recorded. Those are not
the same commit: the user committing on a delivered branch is supported and
tested, so the branch tip a later turn starts from can already be past that
checkpoint. A resolution that committed nothing then left the tip equal to the
turn's own starting point but not to the older checkpoint, the guard did not
fire, and the edits were recorded as delivered — missing from the turn after
with nothing to indicate it.

Both questions now measure against LocalWorktree.BaseCommit, the commit this
turn actually started from. It is at least as new as the earlier checkpoint in
every case, so the separate anchor field is gone: a delivered tip that contains
this turn's starting point contains everything an earlier turn recorded, and a
tip that does not is a run that reset away work already delivered.

Co-authored-by: multica-agent <[email protected]>

* test(daemon): cover the claim-to-branch wiring with a two-turn Prepare test

Every multi-turn test so far drove PrepareLocalWorktree with hand-filled
params, which skips the step that turns a claim into them: which field becomes
the branch name, and which become the identity the branch is recorded under.
This one runs two turns of one issue through Prepare itself, and a third for
another workspace carrying the same issue identifier. Both halves of that
mapping are load-bearing — blanking the conversation key or fixing the
workspace id makes it fail.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): carry the worktree's own state across the preparation helper

Prepare runs in a short-lived helper process and the Environment it built comes
back to the daemon as JSON. LocalWorktree keeps everything Finalize reasons
about in unexported fields, and ordinary struct marshalling drops those without
a word — so the daemon finalized a worktree whose owner, snapshot, tracksState
and createdBranch were all zero.

Nothing failed; the guarantees were simply absent in production. No delivery
verification ran, Finalize recorded nothing (leaving the checkpoint at the
baseline Prepare had written inside the helper), a conflicted turn could not
hold its snapshot back, and a read-only turn kept its branch. Every in-process
test passed throughout, because they call PrepareLocalWorktree directly.

Custom Marshal/Unmarshal now carry that state. aborted stays out of the wire
deliberately: the daemon sets it after Prepare returns, so it belongs to the
parent. The regression tests run two turns through PrepareIsolated — the real
boundary — and assert the state arrives, that Finalize's record names the tip it
delivered rather than the baseline, and that a read-only turn still drops its
branch.

Found by running the whole stack: a real daemon, a real local_directory in
worktree mode, and two real turns.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-01 13:50:52 +08:00
438a30ef00 MUL-6902 fix(agent): fix Pi v0.4.37 Windows session regression (#7843)
* fix(agent): avoid locking Pi transcript on Windows

Co-authored-by: multica-agent <[email protected]>

* test(pi): cover sidecar lock contention and release

* test(ci): run the Windows Pi session-lock regression tests

The windows-execenv job runs ./pkg/agent through an explicit -run
allowlist, and the ubuntu backend job cannot compile a windows-tagged
file. Without this step the two tests added for #7840 would never
execute in CI, leaving the same coverage gap that let the v0.4.37
regression ship.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: worker-opencode <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Co-authored-by: J <[email protected]>
2026-09-01 13:12:38 +08:00
mrlonelyandL 108407088c MUL-6836: fix(codex): allow explicit standard speed (#7756)
* fix(codex): allow explicit standard speed

* fix(codex): negotiate explicit standard support

---------

Co-authored-by: L <[email protected]>
2026-09-01 13:04:32 +08:00
f1ce9d4542 MUL-6883 perf(server): stop rescanning settled delegated-failure recoveries (#7820)
* feat(server): retire settled delegated-failure recoveries from the outbox scan

ListPendingDelegatedFailureRecoveries had no indexable notion of a finished
recovery. Its partial index (343) selected every recovery comment ever
written, so each sweeper tick re-proved through four joins and two NOT EXISTS
subqueries that history was already handled. LIMIT bounds the rows RETURNED,
never the rows CHECKED, so a tick that found nothing still grew more expensive
as history accumulated.

Add comment.recovery_settled_at and a partial index that holds only unsettled
recovery comments, and write the marker inside the transaction that makes a
task terminal. Every other exclusion the scan applies is reversible — an issue
can leave 'done', a cancelled retry can be superseded — so none of them can be
frozen into an index predicate. A delivery receipt on a terminal task cannot be
taken back, which is what makes it safe to record as durable state.

The marker is deliberately written only once the task is terminal.
SetTaskDeliveredCommentIDs replaces rather than appends a receipt while a task
is still dispatched, so a reclaim by a differently-capable daemon can drop an
id it will not deliver; settling at receipt-write time would freeze that
legitimate transient window into a permanently lost recovery. Exhaustion is the
one terminal outcome whose receipt lands on a row that may still be running, so
it retires the comment directly in the same transaction.

An unmarked row is the safe direction: it is scanned exactly as it was before
this column existed. That is what makes the rolling-deploy window harmless —
an older replica that writes a receipt without the marker only leaves work for
the backfill.

Co-authored-by: multica-agent <[email protected]>

* feat(server): backfill delegated-failure recoveries settled before the marker

The marker only covers tasks that reach a terminal status after the writing
code ships. History written before that keeps matching the unsettled index
predicate and keeps being re-checked on every sweeper tick, which is the cost
this change set exists to remove.

The walk retires a candidate only when a terminal task already recorded it in
delivered_comment_ids — the same monotonic condition the runtime path uses.
Its keyset watermark advances over every candidate EXAMINED rather than every
candidate marked: a recovery that is genuinely still pending must be left
alone, and keying on updated rows would hand back the same watermark forever
and never terminate.

Separate from migrations and startup, in the shape the other backfill commands
already use, so a large comment table cannot block a deploy. Running it is a
performance recovery step, not a correctness step — until it finishes the scan
behaves exactly as it does today.

Co-authored-by: multica-agent <[email protected]>

* fix(server): close the review gaps in the settled-recovery backfill and paths

Three fixes from review.

1. The backfill reproduced the bug it was written to fix. Its keyset ordered by
   id while idx_comment_delegated_failure_unsettled is (created_at, id), so the
   candidate scan read every unsettled row and sorted it to return LIMIT of
   them; and the receipt test ran a per-candidate EXISTS over agent_task_queue,
   the largest table in the database. The keyset now matches the index, and the
   receipt lookup is inverted into one agent_task_queue read per BATCH that
   hash-joins delivered ids back against the candidates.

   Measured on 500k comments / 200k tasks / 50k unsettled recoveries:
   17,393ms and 816,963 temp blocks before, 235ms and no temp spill after. The
   candidate scan goes from reading 50,000 rows to reading the 1,000 it needs.

   Total walk time is flat across batch size (50x228ms, 10x1016ms, 5x2199ms —
   all ~11s), so batch size only trades how long one statement holds row locks.
   1000 stays, now for a measured reason rather than a guessed one.

2. Bulk cancellation paths could strand a delivered receipt permanently
   unsettled, which lets the new index regrow the history it exists to remove.
   Manual rerun's pending-slot clear and agent archive were the two named in
   review; auditing the rest found comment-edit cancellation, both chat-session
   cancellations, workspace revoke and runtime teardown in the same position.
   All now settle — inside the caller's transaction where one exists, and
   through CaptureCancelledTasks for bulk paths that finalize outside one. The
   invariant is stated on the exported helper so the next such path has
   somewhere to read it.

3. --max-batches could not actually resume. The watermark lived only in the
   process, so a bounded rerun restarted at the front of the index — which is
   precisely where the rows this walk never settles live, so repeated bounded
   runs would rescan the same prefix and never reach the settleable history
   behind it. Adds --checkpoint-file (atomic replace, read on start) and
   explicit --after-created-at/--after-id, and logs the resume cursor on every
   exit path including SIGTERM.

Co-authored-by: multica-agent <[email protected]>

* fix(server): make delegated-failure settlement terminal-checked and atomic

Two fixes from review.

1. The settlement SQL trusted its caller. "A delivery receipt is only immutable
   once the task is terminal" is the safety premise of the whole design, but the
   exported helper enforced nothing: passing a dispatched or running task would
   settle a receipt a reclaiming daemon can still replace, and the marker is
   monotonic, so nothing could undo it. The predicate now lives in the SQL, so a
   non-terminal task updates no rows instead of destroying the obligation.

2. Bulk settlement was post-commit best-effort, and the comment claiming the
   sweeper would replay a failed one was simply wrong: the covering task is
   terminal and already holds the receipt, so ListPendingDelegatedFailureRecoveries
   excludes the comment. It would neither replay nor settle — it would sit in
   the partial index forever, restoring the unbounded scan this change set
   removes.

   Every terminal write now commits with its settlement. CancelTasksForIssue /
   ForAgent / ByTriggerComment and the rerun pending-slot clear run in a
   transaction; CancelQueuedChatTasks settles inside the one it already had; the
   sweeper and daemon-recovery bulk fails move behind TaskService wrappers that
   own the transaction; agent archive gets CancelTasksForArchivedAgent, which
   cancels and settles atomically while still skipping the per-task broadcast
   the archive event makes redundant.

   BroadcastCancelledTasks, CaptureCancelledTasks and HandleFailedTasks run
   after their caller committed, so they no longer settle at all — a failure
   there could be neither rolled back nor repaired. The best-effort helper is
   deleted rather than left available; every remaining call site passes a qtx.

Tests: a dispatched/running task with a receipt is not settled (fails without
the SQL predicate), a bulk cancel whose settlement fails rolls the cancel back,
and the archive path settles through its atomic entry point.

Co-authored-by: multica-agent <[email protected]>

* docs(server): correct settlement invariant comment and drop leaked test function

The SettleDeliveredDelegatedFailureRecoveries doc still described the
pre-fix contract — settlement reachable through CaptureCancelledTasks or a
post-commit best-effort call — which is exactly the shape the atomicity fix
removed. Restate the rule as it now stands (same qtx as the terminal write,
never from a post-commit helper) so the stale text cannot guide a future
change back into stranding rows in the partial index.

TestBulkCancelRollsBackWhenSettlementFails dropped its trigger but left
reject_recovery_settlement() behind in the shared test database; drop the
function too.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Eve <[email protected]>
2026-09-01 12:11:50 +08:00
4cf5a2ca9c MUL-6880 fix(daemon): wait for the previous run to let go of the workdir (#7818)
* MUL-6880 fix(daemon): wait for the previous run to let go of the workdir

Editing a comment cancels the run it triggered and enqueues the replacement in
the same request. The server considers the predecessor finished the moment it
writes 'cancelled', and ClaimAgentTask's per-(issue, agent) fence lets the
replacement be claimed; the PROCESS is not finished — it learns of the
cancellation from its own poll tick and holds .task_lock for another few
seconds while it exits. The replacement declined the workdir on sight, prepared
a fresh one, and lost the provider session that lived in the old one: the agent
came back with no memory of the conversation being edited.

A busy lock at this point has exactly one cause in a healthy system — the
predecessor the server already considers finished is still exiting — so the
replacement now waits for it, bounded at 15s, giving up into precisely the old
behaviour if the lock never frees.

Alternative to the server-side stop lease in #7813, for comparison.

Co-authored-by: multica-agent <[email protected]>

* MUL-6880 chore(daemon): log what the env-root wait actually cost

15s is a reasoned guess — one cancel-poll interval plus the agent's exit — and
it stays a guess unless production can contradict it. Every outcome now
reports the real elapsed wait rather than the budget: acquired after waiting,
gave up at the budget, or abandoned because the run was cancelled.

Co-authored-by: multica-agent <[email protected]>

* MUL-6880 fix(daemon): report a cancelled env-root wait as its own outcome

Review catch. When the run was cancelled mid-wait, the loop returned the
ErrEnvRootBusy it happened to be carrying, so lockReusablePriorEnvRoot logged
"still in use after waiting for it; starting a fresh environment" and answered
ok=false — and runTask went on to prepare a whole fresh environment for work
nobody was waiting for. Production's isolated Prepare sees the dead context and
returns quickly, so nothing hung; but it is not "cancel and stop", and it files
one cancellation under both "cancelled" and "budget exhausted" in exactly the
logs the 15s budget is supposed to be judged by.

Cancellation is now a third outcome, not a flavour of "no reuse":
lockReusablePriorEnvRoot returns the context's cause alongside its decision,
and runTask ends the task on it.

Covered at both levels: the inner function must report context.Canceled rather
than a bare false, and runTask must return it without writing .managed_env.json
for a fresh environment or logging a budget-exhausted line. Both fail if the
cancellation is flattened back into "no reuse".

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-31 20:02:30 +08:00
79559ebb92 fix(ci): unblock the release by fixing two flaky main failures (MUL-6890) (#7828)
* test(wecom): move the relay claim tests off a shared Redis DB index (MUL-6890)

The relay outcome tests keep their claims in Redis DB 12, which
internal/service also takes for its own suite — and both flush that DB on
entry and exit. `go test ./...` runs packages concurrently, so a flush from
one package can delete a live delivery claim held by the other, and the
publisher's outcome watch then reads an absent claim as "nobody delivered"
and counts a drop for a reply the holder had already sent.

That is the intermittent `outbound_dropped on the publisher = 1, want 0 —
it was delivered` on main. Reproduced by running internal/service's
Redis-backed tests alongside this one; fixed by giving the package an index
nothing else uses, with the current allocation written down so the next one
does not collide either.

Co-authored-by: multica-agent <[email protected]>

* build(turbo): generate fumadocs .source once instead of racing (MUL-6890)

`build` and `typecheck` both ran `fumadocs-mdx` before their real work, and
turbo is free to run those two tasks for the same package at the same time.
fumadocs-mdx regenerates `.source/` by removing the directory and writing it
back, so the two processes fight over one tree: whichever one mkdir's first
can have it deleted underneath, and its write dies on `ENOENT: no such file
or directory, open '.source/index.ts'` — the frontend-build failure on main,
which is pure timing and passes on a rerun.

Move the generation into its own `mdx` task that both depend on. It runs
once, its output is cached like any other task's, and the two consumers can
still run concurrently. Packages with no `mdx` script are unaffected: turbo
skips a dependency nobody implements, the same way `cache-inputs` already
works here.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-31 18:29:37 +08:00
eddb0dc2ad MUL-6884 feat(metrics): attribute agent_runtime lookups by product source (#7822)
* feat(metrics): attribute agent_runtime lookups by product source

MUL-6884 asked which product API is behind the hot GetAgentRuntime query.
It has no single answer: a dozen unrelated paths issue the same primary-key
read, so pg_stat_statements sums them into one row and can report that the
statement is busy without saying which behaviour is driving it. Deciding
between "raise the heartbeat interval" and "back off the 500ms browser polls"
needs that split, and today it is a guess.

Add multica_agent_runtime_lookup_total{source,result} and route every
production read through service.RuntimeLookup, which carries the source with
the connection and classifies pgx.ErrNoRows as not_found rather than as an
error — a deleted runtime is the daemon's self-heal signal, not an outage.

The labels are a closed enum (15 sources x 3 results, all prewarmed at zero)
because this fires on the hottest read in the system; an unclassified call
site degrades to "other" instead of minting a series. No query, no timing
histogram, and no extra SQL on /metrics — this PR only makes the existing
calls countable.

An AST lint test fails CI on any direct Queries.GetAgentRuntime call outside
the wrapper, since a bypassed call site would quietly undercount whichever
source it belonged to and send the follow-up work after the wrong caller.

Co-authored-by: multica-agent <[email protected]>

* refactor(metrics): drop the standalone handler lookup file

The two helpers it held are not an endpoint — no route, no HTTP handler, no
new file under cmd/ — but a new agent_runtime_lookup.go under internal/handler
reads like one, and a file name that has to be opened before it can be ruled
out is a bad file name.

Move both into runtime.go next to requireRuntimeReadAccess, where the rest of
the runtime read helpers already live, and name the metrics test after what it
tests. No behaviour change.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-31 17:46:56 +08:00
7c1d826a98 chore(metrics): remove database-sampled metrics (#7819)
Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-31 17:06:17 +08:00
mrlonely 2ebe0ae327 MUL-6862: fix(codex): widen thread setup handshake budget (#7790)
* fix(codex): widen thread setup handshake budget

* fix(codex): preserve handshake timeout overrides
2026-08-31 17:02:24 +08:00
3e55ce4db0 MUL-6882: remove unused active database samplers (#7817)
* perf(metrics): remove unused active samplers

Co-authored-by: multica-agent <[email protected]>

* chore(metrics): document retired active samplers

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-31 16:25:58 +08:00
db86c75e12 MUL-6878: avoid per-row issue status resolution (#7814)
* perf(server): avoid per-row issue status resolution

Co-authored-by: multica-agent <[email protected]>

* fix(server): address issue status query review nits

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-31 16:00:45 +08:00
Xichang(Seacen) Zhao 1dd6b9ecdb MUL-6570 feat(wecom): route a reply to the replica that holds the bot's socket (#7429)
WeCom is the only channel with no outbound REST path: every write goes over the aibot WebSocket, and the WS lease means exactly one replica holds it. `chat:done` is published on the in-process bus by whichever replica served the daemon's completion — a load-balancer decision, unrelated to the lease. Off-lease, the reply was dropped (#7215, #6890), and the documented workaround was to run the WeCom-enabled backend single-replica.

This routes the reply over the Redis Stream relay the repository already runs for the same shape of problem, so the replica holding the socket sends it.

- At-most-once across replicas and restarts via a Redis claim keyed on the turn, with a TTL derived from the relay's `ReplayGrace` rather than a constant.
- Delivery runs on a bounded dispatcher, never on the shared shard read loop, with per-installation lines that keep two answers to the same person in order even across a re-offer.
- Attachments and `inbox:new` pushes are routed too, not just reply text.
- Every routed reply gets a final outcome, settled exactly once by the replica that routed it: `outbound_dropped_total{reason="no_live_connection"}` when no replica took the delivery. The residual window — every replica mid-reconnect — remains, but is now measurable instead of silent.

Multi-replica WeCom is supported in sharded/dual relay mode; legacy mode and deployments without Redis keep the previous behaviour and the single-replica requirement. `SELF_HOSTING.md` and the four `channels*.mdx` docs state the split.
2026-08-31 15:35:46 +08:00
Janez ŠtuparandJanez Stupar bfe32f5e39 fix(runtime): wake daemon for local skill requests (#7713)
Co-authored-by: Janez Stupar <[email protected]>
2026-08-31 15:31:29 +08:00
f93855470f MUL-6874 fix(desktop): recover unexpectedly stopped daemon (#7808)
* MUL-6874 fix(desktop): recover unexpectedly stopped daemon

Co-authored-by: multica-agent <[email protected]>

* MUL-6874 fix(desktop): bound daemon recovery retries

Co-authored-by: multica-agent <[email protected]>

* MUL-6874 fix(desktop): queue one-shot daemon operations

Co-authored-by: multica-agent <[email protected]>

* MUL-6874 fix(desktop): reject nested daemon operations

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-31 15:27:30 +08:00
8d7e6f6a0a perf(server): resolve only the requested skill bundles (#7810)
The daemon resolves one skill bundle per request -- deliberately, so each
download gets its own size-scaled deadline and is cached independently
(GH #4505). ResolveTaskSkillBundles answered each of those by calling
LoadAgentSkillBundles, which reads and hashes EVERY skill the agent has,
then picks the single requested bundle out of the result and discards the
rest.

So the per-request cost scaled with the agent's skill count, and the
number of requests did too. A cold dispatch for an agent with N skills
read the whole skill set N+1 times and computed N*(N+1) bundle hashes to
deliver N bundles. #7689 cut each of those loads from N+1 queries to 2,
which fixed the round-trip count but not the multiplier: the bytes read
from Postgres and the SHA-256 work stayed quadratic, and because the
daemon issues these requests serially, that work lands directly in
prepare latency.

Load only what was asked for:

- ListAgentSkillsByIDs is ListAgentSkills narrowed to a set of ids. The
  junction predicate is unchanged, so it is still the authorization: an
  id the agent does not have enabled returns no row, which the caller
  reports as not-found. It is deliberately NOT workspace-scoped, because
  ListAgentSkills is not either -- a stricter resolve could 404 a ref
  that claim had just advertised, breaking prepare instead of protecting
  it.
- Files come from ListSkillFilesBySkillIDs, the batch query #7689 added,
  for those skills only.
- Builtins resolve by name from the embedded FS and touch no query.
- Only the requested skills are hashed.

For 15 skills / 600KB that takes a cold dispatch from ~9.6MB read and 240
hashes to ~1.2MB and 30, with no schema change, no new index, and no
change to the wire contract: stale-hash still returns the current bundle,
plugin hash pinning still conflicts, response order still follows the
request, and a read failure still fails closed with a 500 rather than a
404 the daemon would treat as permanent.

LoadAgentSkills and the new loader share one assembly helper, so claim
and resolve cannot drift on skill order, per-skill file order, or the nil
file list of a skill with no files.

Tests: an agent with three skills resolving one must issue one scoped
query for one id and never the full ListAgentSkills; a builtin ref must
touch no query at all; request order and ref dedup; and the denials the
old full-set lookup used to provide -- unassigned skill, disabled
assignment, another workspace's skill, unparseable id, a source with no
server-side producer -- plus a scoped-read failure returning 500. Both
the scoped load and the enabled predicate are mutation-verified.

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-31 15:14:18 +08:00
Xichang(Seacen) Zhao 7c0e750452 MUL-6569 feat(wecom): give every undelivered reply a reason an operator can count (#7428)
Adds outbound delivery accounting to the WeCom adapter: every reply and every file now ends in exactly one counted outcome — delivered, dropped, skipped or unconfirmed — with a closed, low-cardinality reason label.

Closes the ambiguity behind GH #7215 and #6890, where a reply sat in the Multica transcript while the WeCom chat stayed quiet and the server-side evidence could not say why.
2026-08-31 14:59:46 +08:00
be93feb063 MUL-6792: perf(server): batch skill-file loads in LoadAgentSkills (#7689)
* perf(server): batch skill-file loads in LoadAgentSkills

LoadAgentSkills issued one ListSkillFiles query per skill inside a loop,
so an agent with N skills cost N+1 round trips on the task-claim hot
path (three callers in daemon.go). Each query also pulls full file
bodies, so the fixed per-query overhead (pool acquire, RTT, parse) was
paid N times.

Add ListSkillFilesBySkillIDs (skill_id = ANY(...), ordered by skill_id,
path — backed by idx_skill_file_skill) and load all files in one query,
grouping by skill_id in a single linear pass. N+1 becomes 2. Output
order is preserved by assembling in ListAgentSkills order. Total bytes
transferred are unchanged; this removes round trips and pool acquires,
not payload. The per-skill ListSkillFiles is retained for the single-
skill handler callers that legitimately need it.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(server): fail closed when the agent skill read fails

Batching the per-skill ListSkillFiles loop into one query changed what a
swallowed read error means. Before, one failed query dropped one skill's
supporting files. Now a single failure drops the files of EVERY skill --
and the `files, _ :=` discard let claim and resolve carry on as if that
were the agent's real skill set.

Nothing downstream can catch it. BuildAgentSkillBundles hashes whatever
loaded, so the refs sent at claim and the bundle served at resolve agree
with each other; the daemon's validateSkillBundle compares a bundle
against a ref derived from that same bundle, accepts it, and writes it to
the on-disk cache. The agent then starts on skills whose rules are
missing, and the next dispatch reuses the cached truncated copy. The same
was true of the ListAgentSkills error above it, which could silently
strip every workspace skill from an agent.

LoadAgentSkills and LoadAgentSkillBundles now return the error, and the
three call sites settle it:

- claim (both the slim skill-refs path and the legacy inline-skills
  path) refuses with 500 via rejectClaimSkillLoad and leaves the task
  dispatched, so the stale-dispatched reclaim redelivers it -- the same
  treatment rejectClaimSourceLoad gives a transient source read.
- ResolveTaskSkillBundles returns 500, which the daemon's existing
  resolve retry schedule already treats as transient, instead of serving
  a bundle built from a failed read.

Tests: service-level coverage for both read failures, for
LoadAgentSkillBundles failing closed rather than returning built-ins
only, and for the batch grouping itself -- the fixture puts the two
result sets in deliberately different orders (skills by name, files by
skill_id) so a positional zip would hand each skill another skill's
files, and asserts the per-skill query is no longer issued. Handler-level
coverage asserts the 500s and that a refused claim leaves the task
dispatched and unstarted.

Co-Authored-By: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-31 14:52:00 +08:00
a5c331bce3 MUL-6868 fix(migrations): repair partial VCS link schema (#7796)
* fix(migrations): repair partial VCS link schema

* test(migrations): preserve healthy VCS link schemas

* fix(migrations): renumber VCS repair migration (MUL-6868)

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-31 13:49:53 +08:00
mrlonelyandL 6231962e98 MUL-6840 fix(pi): preserve sessions across workdir changes (#7760)
* fix(pi): resume session across workdir changes

* fix(pi): serialize session transcript writers

* fix(pi): keep busy sessions resumable

---------

Co-authored-by: L <[email protected]>
2026-08-31 13:49:38 +08:00
88e9170dc1 refactor(redact): drop home-directory masking from Text (#7772)
redact.Text rewrote the local home directory to /Users/****/ on its way
to the database and the WebSocket broadcast. Removing it.

It was never an access-control boundary. Anyone who can read a task
transcript already sees repository paths, file contents, commands, diffs
and error output; masking one path segment out of all that protected
nothing, while it did make paths non-copy-pasteable and harder to line up
when debugging a run.

It was also incoherent about whose home directory it hid. homeDir and
username were resolved from the process running Text, and the main
transcript fields (Content / Output) are only redacted in the server's
ingest handler -- so a hosted deployment matched them against the
*server's* home directory, never the machine actually running the agent.
Only tool inputs and stderr diagnostics went through the daemon-side call
where the resolved home was the right one.

Transcript visibility is an authorization concern and belongs at that
layer. Secret redaction (API keys, tokens, PEM blocks, connection
strings) is a real boundary and is untouched.

Two tests asserted the removed behaviour and now assert its absence, so
nobody reintroduces host-path masking as a side effect of another fix:
redact.TestTextLeavesHomePathIntact (replacing TestRedactHomeDirectory
and TestInputMapRedactsNestedHomePath) and the home-path arm of
agent.TestCursorExecuteReportsSanitizedStderrOnProcessFailure, which
keeps its bearer-token assertion.

Dropping the branch also resolves the Windows failure in
TestRedactHomeDirectory reported in #7744: Windows can return a
DOMAIN\user account name that is not a substring of the home path, so the
strings.Replace never fired. CI only runs Go tests on ubuntu, which is
why main stayed green.

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-31 13:39:39 +08:00
mikyan fe59262188 feat(agent): add Huawei Cloud CodeArts support (#6985)
* feat(agent): add Huawei Cloud CodeArts support

* fix(migrations): resolve CodeArts prefix collision

* fix(agent): address CodeArts review feedback

* fix(agent): tighten CodeArts integration scope

* fix(agent): resolve CodeArts follow-up review blockers

* fix(agent): redact CodeArts launch arguments

* docs: sync supported runtime counts

* fix(agent): own CodeArts model probe process tree
2026-08-31 12:30:59 +08:00