mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 18:06:45 +08:00
d276991c603e6041d20fb5c1e65df8756d35d2ae
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]> |
||
|
|
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]> |
||
|
|
035abddfe7 |
perf(db): retire delegated failure pending index (#7977)
Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
d1c7ec25b1 |
feat(agents): add live model catalog refresh (#7919)
Co-authored-by: Lambda <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
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]> |
||
|
|
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. |
||
|
|
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 |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]>
|
||
|
|
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 |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]>
|
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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. |
||
|
|
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. |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]>
|
||
|
|
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. |
||
|
|
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 ( |
||
|
|
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]> |
||
|
|
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]>
|
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]>
|
||
|
|
7c1d826a98 |
chore(metrics): remove database-sampled metrics (#7819)
Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
2ebe0ae327 |
MUL-6862: fix(codex): widen thread setup handshake budget (#7790)
* fix(codex): widen thread setup handshake budget * fix(codex): preserve handshake timeout overrides |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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. |
||
|
|
bfe32f5e39 |
fix(runtime): wake daemon for local skill requests (#7713)
Co-authored-by: Janez Stupar <[email protected]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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. |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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 |