mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-04 17:18:35 +02:00
main
607 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
6b5d26b235 |
feat: add QwenPaw ACP backend support (MUL-5355) (#5986)
* feat: add QwenPaw ACP backend support Add qwenpaw as a supported agent backend. QwenPaw runs via `qwenpaw acp` over stdio using the ACP (Agent Client Protocol) JSON-RPC 2.0, reusing the hermesClient transport layer. Changes: - New backend: pkg/agent/qwenpaw.go (ACP stdio via hermesClient) - Register in agent.SupportedTypes, New(), launchHeaders - Daemon config: probe qwenpaw binary, QWENPAW_ARGS env var - Display name 'QwenPaw', inline system prompt support - Runtime config: AGENTS.md injection, .qwenpaw/skills/ discovery - User-level skills via QWENPAW_HOME env var - DB migration 224: add qwenpaw to protocol_family whitelist * fix(ci): add qwenpaw to CLI guard names and rename migration 231 - Add 'qwenpaw' to scripts/agent-cli-command-names.txt (TestAgentCLIGuardCoversDefaultCommands) - Rename migration to 231 to avoid collision with existing migrations (TestMigrationNumericPrefixesStayUniqueAfterLegacySet) * fix: address Bohan-J review comments on qwenpaw backend (PR #5986) Blocking fixes: 1. config.go: Add qwenpaw CLI probe so daemon discovers the binary 2. models.go: Add qwenpaw case returning empty list (same as qwen) 3. qwenpaw.go: Fix resume path — use session/load (QwenPaw implements load_session, not session/resume), use resolveResumedSessionID, only set resumeRejected on session-not-found errors, fail the run on set_model failure instead of silently continuing 4. daemon.go: Add qwenpaw→"QwenPaw" to runtimeDisplayNameOverrides 5. agent.go: Update error message to list qwenpaw Non-blocking fixes: 6. qwenpaw_test.go: Add 10 tests covering session/new, session/load, session/load not-found, set_model failure, ListModels, blocked args, protocol verification (session/load not session/resume), timeout, usage tracking, and backend construction 7. sidecar_manifest_test.go: Add qwenpaw to allFileBasedProviders * fix: address Bohan-J second-round review on qwenpaw backend (PR #5986) Blocking fixes: 1. qwenpaw.go: Send qwenpaw.coding_project_dir inside _meta, not as top-level parameter — ACP Pydantic model ignores extra root-level keys (model_config has no extra='allow'), only merges field_meta into kwargs 2. qwenpaw.go: Handle set_model returning result:null (not RPC error) — real QwenPaw server swallows exceptions and returns None, which serialises as JSON null; json.RawMessage('null') is non-nil so bytes.Equal check is needed 3. context.go: Workspace skills are at <workDir>/skills, not <workDir>/.qwenpaw/skills (confirmed against QwenPaw v2.0.1) 4. local_skills.go: QwenPaw resolves global root from QWENPAW_WORKING_DIR -> COPAW_WORKING_DIR -> ~/.copaw -> ~/.qwenpaw, not from QWENPAW_HOME Test additions: 5. TestQwenpawSetModelReturnsNull — regression test for result:null 6. TestQwenpawSessionLoadTransientError — transient error does not set ResumeRejected=true 7. TestQwenpawSessionNewSendsCodingProjectDir — verifies _meta format 8. TestQwenpawSessionLoadSendsCodingProjectDir — same for session/load 9. TestQwenpawTimeout — deterministic sync via signal file All 15 Qwenpaw tests pass. Verified against real qwenpaw v2.0.1 binary (end-to-end: prompt, session ID, resume, system prompt). * fix: rebase against upstream/main and add per-task qwenpaw workspace/agent isolation - Rebase add-qwenpaw-backend branch on upstream/main (88 commits ahead) - Resolve conflict in config.go: keep refactored probe() with shell resolution fallback, which already includes qwenpaw probe - Add --workspace and --agent CLI args to qwenpaw acp for per-task skill isolation and agent identity isolation - Mark --workspace and --agent as blocked in qwenpawBlockedArgs so user custom_args cannot override them - Add deriveQwenpawAgentID() to produce deterministic agent IDs from task's issue ID and agent ID - Wire QwenpawWorkspace and QwenpawAgentID through ExecOptions - Add prepareQwenpawWorkspace() in execenv to materialize bound skills into a per-task workspace directory * fix: address Bohan-J third-round review on qwenpaw backend (PR #5986) Three blocking issues resolved: 1. Agent ID registration — remove session/set_model and --agent entirely. The simpler route: QwenPaw model override is declared unsupported, eliminating the need for agent profile registration in QwenPaw config. 2. Skill revocation — prepareQwenpawWorkspace now does os.RemoveAll on the skill_pool dir and manifest before rebuilding, making it idempotent. A->empty (revoke all), A->B (replace), and A->A (repeated reuse) all work correctly. Added 3 new unit tests. 3. Skill root path — changed 'skills' to 'skill_pool' in both prepareQwenpawWorkspace and skillsDirPath to match QwenPaw's store.py get_workspace_skills_dir. Also cleaned up: removed deriveQwenpawAgentID function and its test, removed QwenpawAgentID from ExecOptions, removed --agent from qwenpawBlockedArgs, removed session/set_model test cases. * fix: add missing qwenpaw probe() call in agents_probe.go The TestDefaultAgentCommandNamesCoversAllProbes test found only 17 probe() calls in agents_probe.go but defaultAgentCommandNames has 18 entries. The probe() call for qwenpaw was missing, causing the backend CI test failure. Adding the probe ensures GUI-launched daemons can resolve qwenpaw via the login shell fallback, matching all other providers. * fix: sync models.go with upstream/main (remove qwenpaw from ListModels) * fix: CI failures — migrate prefix 235→236 + update test - migration prefix 235 was reused by upstream 235_chat_message_quick_actions; renamed our qwenpaw migration from 235 to 236 - TestQwenpawListModels called len() on Catalog struct (compile error); fixed to expect error for unknown provider type * fix: bump qwenpaw migration prefix 236->241 (upstream took 236) * fix: qwenpaw local skill root — 'skills' → 'skill_pool' (MUL-5355) Bohan-J third-round review blocker #3: local_skills.go still scanned <QWENPAW_HOME>/skills but the QwenPaw shared skill pool is <QWENPAW_HOME>/skill_pool (store.py get_workspace_skills_dir). Verified no other qwenpaw paths in the codebase assume the wrong layout. * fix: bump qwenpaw migration prefix 241->242 (upstream took 241) lint test fails with: migration prefix 241 is reused by [241_comment_parent_lookup_index 241_runtime_profile_add_qwenpaw]. Upstream added 241_comment_parent_lookup_index; bump our migration. * feat: add QwenPaw integration test and version declaration (MUL-5355) - New: server/pkg/agent/qwenpaw_integration_test.go with three agentintegration build-tagged tests: TestQwenpawRealACPSmoke — full end-to-end ACP smoke test with session/new → session/prompt and session/load resume validation. TestQwenpawRealWorkspaceSmoke — validates skill_pool workspace flag handling and per-task skill isolation. validateQwenpawVersion — attempt version detection via qwenpaw --version, pip show qwenpaw, and python import. - Document QwenPaw v2.0.1 as the supported baseline version in qwenpaw.go package comment, noting the contract details: _meta qwenpaw.coding_project_dir for Coding Mode, session/set_model NOT supported, skill_pool workspace layout. - This test suite is gated by MULTICA_RUN_REAL_AGENT_SMOKE=1 and requires qwenpaw on PATH, matching the pattern used by grok, cursor, and traeecli integration tests. * fix: bump qwenpaw migration prefix 242->243 (upstream took 242 for qoderclicn) Upstream added 242_runtime_profile_add_qoderclicn in the same rebase window, colliding with our 242_runtime_profile_add_qwenpaw. The migration lint test TestMigrationNumericPrefixesStayUniqueAfterLegacySet catches duplicate prefixes after the legacy range. Also add 'qoderclicn' to our migration's CHECK constraint so it doesn't regress the whitelist added by upstream's 242. * temp: stub out integration test to isolate CI failure * fix: restore upstream probeAgentCLIs() call in config.go (rebase regression) Rebase conflict resolution accidentally reverted upstream's MUL-5439 refactor (extracting probe logic to agents_probe.go) back to the old inline probe block. This also dropped qoderclicn detection and duplicated the probe logic already in agents_probe.go. Restore the single 'agents := probeAgentCLIs()' call — qwenpaw is already probed in agents_probe.go. * fix: bump qwenpaw migration prefix 243->251 (upstream took 243-250) Upstream added migrations 243-250 since our last rebase. Bump to 251, the next available prefix. * feat: add QwenPaw integration test (agentintegration build tag) TestQwenpawRealACPSmoke drives the real qwenpaw acp binary end-to-end: - session/new + session/prompt produces 'pong' - session/load resume with ResumeSessionID works - --workspace flag is forwarded correctly Gated by MULTICA_RUN_REAL_AGENT_SMOKE=1, matching grok/cursor pattern. Validated against QwenPaw v2.0.1. * fix: workspace skills dir 'skills' not 'skill_pool' + add skill loading integration test Two fixes: 1. qwenpaw_workspace.go: write skills to <workspace>/skills/ instead of <workspace>/skill_pool/. QwenPaw's get_workspace_skills_dir() looks for workspace skills at <workspace>/skills/ (store.py:65-67), not skill_pool (which is the shared pool at WORKING_DIR/skill_pool). Verified against real qwenpaw acp — skills in skill_pool/ were never discovered. 2. Add TestQwenpawRealWorkspaceSkill integration test that proves a bound skill is actually loaded and effective: writes a skill that overrides the agent's response, sends an unrelated prompt, and asserts the skill's marker text appears in the output. This addresses R3 review feedback: 'please also add a test that exercises an actually-bound skill'. All three integration tests pass against QwenPaw v2.0.1: - TestQwenpawRealACPSmoke (session/new + prompt + session/load resume) - TestQwenpawRealWorkspaceSkill (skill discovery + effectiveness) * feat: add ACP model discovery for qwenpaw via session/new models field QwenPaw v2.0.1+ now includes a 'models' field (SessionModelState) in the session/new response, added by agentscope-ai/QwenPaw#6531. This lets ACP clients discover available models without session/set_model. - ListModels for qwenpaw now uses discoverACPModels (same pattern as traecli/grok/kiro) to spin up 'qwenpaw acp', call session/new, and parse the models catalog from the response. - discoverQwenpawModels mirrors discoverTraecliModels — ACP-native, no auth selection needed. - Model override via session/set_model remains unsupported: it persists to agent.json at the agent scope (not session-scoped), so calling it would mutate the user's shared agent config. The model picker shows available models for display/selection, but the daemon does not send set_model. - Updated TestQwenpawListModels to verify qwenpaw is a recognized type (not 'unknown agent type' error). * fix: address Bohan-J Review 5 — ModelSelectionSupported=false, version bump to v2.1.0-beta.1, remove debug files - ModelSelectionSupported('qwenpaw') now returns false with rationale (session/set_model persists to agent scope, not session scope) - Add TestQwenpawModelSelectionUnsupported regression test - Update version references from v2.0.1 to v2.1.0-beta.1 (includes agentscope-ai/QwenPaw#6531 — models field in session/new response) - Remove check_ci.py, jobs.json, runs.json debug artifacts * fix: bump qwenpaw migration prefix 251->253 (upstream took 251) Upstream added 251_agent_runtime_unbind. Bump to 253, the next available prefix after 252_agent_builder_draft. * fix: address Bohan-J Review 6 — drop unused discovery, always attribute to unknown - ListModels for qwenpaw returns empty catalog without spawning ACP subprocess (model selection is unsupported, so no consumer exists) - Usage attribution always uses 'unknown' instead of opts.Model (the backend never sends opts.Model to QwenPaw) - Add TestQwenpawUsageModelIgnored regression test - Fix stale v2.0.1 comment in TestQwenpawListModels * chore(agent): clean up qwenpaw model-discovery leftovers Follow-up nits from review 7 on PR #5986: - Drop discoverQwenpawModels: it lost its only caller when ListModels started returning an empty catalog for qwenpaw. - Correct the version contract in qwenpaw.go. The execution path needs only the ACP surface present in v2.0.1 (current stable); the models field on session/new landed in v2.1.0-beta.1 but has no consumer now that model selection is unsupported. - Make TestQwenpawListModels actually guard the no-subprocess promise. It pointed at a nonexistent path, which the old discovery helper also answered with an empty catalog, so it passed either way. It now uses an executable fake that records invocation; verified it fails if a discovery path is reintroduced. - gofmt agent.go (ExecOptions alignment broke when QwenpawWorkspace was added) and restore the trailing newline in qwenpaw_test.go. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: niudakok <niudakok@users.noreply.github.com> Co-authored-by: Bohan-J <bohan.optimism@gmail.com> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
79c5832e1d |
MUL-5708 fix(taskfailure): classify response-side context-window overflow (#6366)
* fix(taskfailure): classify response-side context-window overflow (MUL-5708)
Claude Code 2.1.x reports an exhausted context window on the response,
not as a 400 on the request: the turn ends with stop_reason
"model_context_window_exceeded" and the CLI prints
API Error: The model has reached its context window limit.
That string carries none of the phrases rule 1 matched and no "token",
so it classified as agent_error.unknown. Unknown is absent from the
resume blacklists (resumeUnsafeFailureReason, GetLastTaskSession,
GetLastChatTaskSession), so the over-full session stayed pinned as the
resume pointer for the (agent, issue) pair and every later comment on
the issue resumed the same transcript and overflowed again.
Match the CLI copy and the raw stop reason so the failure lands in
agent_error.context_overflow, which those blacklists already exclude —
the next comment then starts from a fresh session instead of replaying
the overflow.
Co-authored-by: multica-agent <github@multica.ai>
* fix(taskfailure): upgrade an old daemon's catchall on context overflow (MUL-5708)
Installed daemons update on their own cadence, and FailTask only
re-classifies when the caller supplied no reason. A daemon whose rule 1
predates the response-side wordings reports agent_error.unknown, which
is on no resume blacklist — so until every host updates, the over-full
session stays pinned as the (agent, issue) resume pointer and every
later comment replays the same overflow. One un-upgraded host means a
permanently stuck issue, not just a missing label.
Recognise the two witnesses server-side in NormalizeDaemonReason, next
to the MUL-5370 skill-bundle rule it mirrors, so the retirement lands
the moment the server deploys. The accepted legacy set is narrower than
that rule's: only the catchall and the pre-MUL-1949 coarse agent_error.
A refined reason means the old daemon matched an earlier rule on the
same text, which says more about what ended the run than a witness
appearing somewhere in the blob does.
The witnesses move into one shared var so Classify and the normalizer
cannot drift apart.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
|
||
|
|
398b7fd06c |
fix(agent): rank Copilot usage sources, fix model attribution, surface the token gap (MUL-5712) (#6373)
* fix(agent): correct Copilot token accounting and stop attributing tokens to a fake model (MUL-5712) The Copilot backend counted tokens from exactly one field, assistant.message.outputTokens, so input and cache tokens were always zero and cost estimates only ever priced the output side. Worse, that field is optional in the CLI's own event schema and current Copilot CLI builds no longer populate it, which leaves Result.Usage empty — the daemon then skips ReportTaskUsage entirely and a Copilot run records no usage at all. Parse every token-bearing event the CLI can emit and resolve one source per run instead of summing overlapping ones: - assistant.usage: per model call, full breakdown. Authoritative, and delta-based so it stays correct across resumed sessions. - assistant.message.outputTokens: legacy, older CLIs. - session.shutdown.modelMetrics: session-wide totals, used only as a last resort and never on a resumed run — the CLI restores its accumulators from a checkpoint, so reporting them on a follow-up turn would bill every earlier turn again. Copilot reports cached tokens inside inputTokens, so the cached tiers are subtracted back out to get the uncached input that prices at the input rate. Also parse the model on assistant.message. Without it the first turn's tokens landed under the seed model — the literal string "copilot" when no model is configured — which no price table maps, so even a run that did report tokens estimated $0.00. Finally, warn when a completed Copilot run reports no usage at all. That is the current state on Copilot CLI 1.0.77, which filters assistant.usage and session.shutdown out of --output-format json; it went unnoticed until a user reported it, and the daemon log should show it next time. Co-authored-by: multica-agent <github@multica.ai> * fix(agent): rank Copilot usage sources by completeness and ignore tokenless ones (MUL-5712) Two defects in the source selection this PR introduced, both found in review. Ordering put assistant.message ahead of session.shutdown, so a fresh run that received both an old-CLI outputTokens field and the complete session totals returned early on the output-only source and dropped input and cache entirely — the opposite of preferring the most complete source. Rank by completeness instead: session.shutdown (fresh runs only, it is the CLI's final accounting for a session that here IS this run), then assistant.usage, then assistant.message. Selection also tested only for map presence, while every token field on assistant.usage is optional upstream. A usage event naming just a model created a zero-valued entry that marked the source populated and shadowed whatever did carry numbers. addUsage now ignores records with no tokens at all, and selection tests for real numbers rather than presence. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
9fb46adc43 |
fix(agent-builder): serialise draft autosave against session delete (MUL-5642) (#6376)
SaveAgentBuilderDraft read the chat session, then upserted agent_builder_draft as a separate statement, with no lock between them. DeleteChatSession takes LockChatSessionForDelete for its whole transaction, so a save could pass its checks, block on nothing, and land its INSERT after the delete committed. agent_builder_draft carries no chat_session FK (repo rule), so nothing rejected that write. The surviving row held the configuration the user had just confirmed discarding. It is invisible to the UI — the drafts list joins through chat_session — and no prune can reach it: DeleteAgentBuilderDraft and the runtime teardown both key off a session that no longer exists, leaving only the workspace teardown. The client autosaves on an 800ms debounce and the conversation is addressable by URL, so a second tab can autosave at any moment while this one discards. Add LockChatSessionForDraftWrite, the same row and lock mode the delete and runtime-bind paths take, and run the upsert in a transaction that acquires it first and re-reads the session under it. Existence and status are the only two things a concurrent writer can change, and both are now decided inside the lock; workspace, creator and carrier are immutable for a session and stay on the cheap unlocked read. Either ordering is now correct: the save commits first and the delete prunes it, or the delete commits first and the save returns 404. The same lock closes the archive variant, where the last autosave after "create agent" could write a draft onto an already read-only session. Both regression tests drive the interleaving deterministically — hold the session row, prove the save blocks, then commit — and fail on the pre-fix handler with a 204 that writes the orphan. Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
c1ec1f4646 |
docs: cut docs/ to design + product-overview, English by default (MUL-5698) (#6364)
* docs: delete completed plan docs and fix stale repo documentation (MUL-5698) Ten repo docs were verifiably out of date against the code. Completed plans are deleted outright rather than archived — they are dead weight in every agent's context and their decisions already live in the code. Deleted (all describe work that has shipped): - docs/agent-quick-create-plan.md — marked "未动工", but agenttmpl templates (Phase 1) and the AI-create-agent page (Phase 3) are live - docs/docs-outline.md — tracker planning "Chinese only, 25 pages"; the doc site is 39 pages x 4 languages - docs/docs-rewrite-plan.md — plans 55 mdx, lists webhook autopilot triggers as unrouted; both superseded by what shipped - docs/docs-onboarding-optimization-plan.md — work log for #5714, which landed the four languages and screenshots it lists as pending - docs/onboarding-refactor-plan.md — v3 shipped (welcome-store, welcome-after-onboarding, onboarding_shim) - apps/mobile/docs/project-v1-{plan,gap-audit}.md — marked "pre-implementation"; the picker files they scope no longer exist - docs/plans/* + docs/ideation/* — implementation plans for agent access-scope (agent.visibility) and issue-table server query (/api/issues/grouped), both shipped Fixed: - docs/codex-sandbox-troubleshooting.md — the decision matrix claimed non-darwin gets workspace-write. Linux is danger-full-access (MUL-5578) and Windows is danger-full-access (MUL-4957); Windows had no row at all - apps/mobile/docs/rnr-migration.md — Phase 1 is complete, not "not started"; every checklist item is in the tree - AGENTS.md — architecture list was missing apps/mobile, apps/docs, packages/eslint-config - docs/ui-consistency-audit.md — §3.2 status column is a mid-PR snapshot; #5263 and #5258 have merged - server/internal/agenttmpl/loader.go — drop reference to a deleted doc Co-authored-by: multica-agent <github@multica.ai> * docs: reduce docs/ to design + product-overview, English by default (MUL-5698) docs/ now holds two documents, each with an English default and a .zh.md translation. Everything else was engineering scratch that agents load as context on every run without ever being read by a human. Deleted: - docs/analytics.md, docs/feature-flags.md, docs/timezone-architecture-rfc.md, docs/codex-sandbox-troubleshooting.md, docs/codex-usage-cache-backfill.md, docs/custom-runtimes.md, docs/ui-consistency-audit.md Language convention — English is the default filename, translations carry a language suffix: - docs/design.md (new English) + docs/design.zh.md (was design.md) - docs/product-overview.md (new English) + docs/product-overview.zh.md (was product-overview.md) While translating product-overview, three facts were corrected against the code rather than carried over from the 2026-04-21 survey: the provider list now matches README, onboarding is the shipped three-step about_you/workspace/runtime sequence with Helper creation moved after exit (packages/core/onboarding/step-order.ts), and the stale "28 tables" total was dropped. Reference cleanup so no comment points at a deleted file — .env.example, server/internal/analytics/{client,events}.go, packages/core/analytics/index.ts, server/cmd/server/main.go, server/pkg/featureflag/doc.go, server/cmd/backfill_task_usage_hourly/main.go, and the doc pointers in migrations 100/101/103/104. Migration edits are comment-only; the runner tracks applied versions by filename, not by checksum. docs/assets/ is kept — README.md and README.zh-CN.md embed those images. Co-authored-by: multica-agent <github@multica.ai> * docs: drop product-overview, fix rnr body and CLAUDE/AGENTS drift (MUL-5698) Addresses all four blockers from review. 1+2. Delete docs/product-overview.md and docs/product-overview.zh.md. The review found the doc carried facts that would make an agent do the wrong thing — skill injection claimed a .agent_context/skills fallback for providers that now have native paths in execenv/context.go, and it documented `multica skill create --title` when the CLI only registers --name. Rather than chase those, the document goes: it is derived from code and can be regenerated from code when it is actually wanted. That also removes the zh-as-historical-snapshot problem, since neither language survives. docs/ is now design.md + design.zh.md + assets/. design.zh.md is a faithful translation of the English, not a snapshot, so blocker 1 does not apply to it. 3. apps/mobile/docs/rnr-migration.md: the body contradicted its own status line. §1 asserted in present tense that there is no theming infrastructure, hardcoded tailwind hex, and a three-line global.css; §5.2 said the same. Both are now marked as the pre-Phase-1 baseline with the shipped state noted, §6's Phase 0/1 checklists are backfilled as complete, and Phase 2 is labelled not started. 4. CLAUDE.md gains apps/docs/ and packages/eslint-config/, so the authoritative Project Shape list matches the pointer list in AGENTS.md. The two lists are now identical. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
eea687d461 |
MUL-5686: fix(chat): resume the cancelled turn's session instead of starting cold (#6352)
* fix(chat): resume the cancelled turn's session instead of starting cold
Stopping a chat turn the agent had already begun answering, then sending
the next message, produced a reply with no memory of the conversation.
The cancelled turn's provider session is real and recorded — the daemon
pins it onto the task row mid-flight and cancellation keeps it there —
but nothing could hand it back:
- GetLastChatTaskSession / GetLastTaskSession only considered
'completed' and 'failed' rows, so a cancelled row's session was
invisible to resume resolution;
- chat_session.session_id is written only by CompleteTask / FailTask,
and a cancelled task reaches neither: the daemon discards its result
and sends a cancel-ack. On a chat whose first turn was cancelled the
pointer therefore stayed NULL and the next turn started cold, since
buildChatPrompt injects only the current user message.
Let cancelled rows into both resume lookups, and advance the chat-level
pointer at cancel time so a provider that mints a new session id per
resume does not rewind past the cancelled exchange. The mid-flight pin
may now also fill an EMPTY session slot on a just-cancelled row, which
is what a Codex pin waiting on its rollout needs when the cancel wins
the race; occupied slots and completed/failed rows stay untouchable.
Retired sessions, poisoned-failure filters and the rollout-present guard
are unchanged. A transcript killed mid-tool-call that the provider later
refuses is still caught by taskfailure.UnresumableHistory, which retires
the session and starts the next turn fresh.
Fixes #6340
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): close the two cancel/pin races on the chat resume pointer
Review found the pointer advance had the same shape of hole it was meant
to fix, on both sides of the cancel.
1. The status flip and the pointer advance were separate statements. In
the gap the row is already `cancelled` while the pointer still names
the PREVIOUS turn, so a follow-up the user had queued could be claimed
there and resume the older session — and a failed pointer write only
logged, still reporting the cancel as successful. Both now commit in
one transaction and the error propagates.
2. The mid-flight pin may land AFTER the cancel (for Codex it waits for
the rollout), so the cancel transaction sees no session to publish and
the pin only fills the task row. On a chat that already had history
the stale pointer kept shadowing it, which is precisely the case the
pin change was added for.
Both paths now run one guarded statement,
AdvanceCancelledChatSessionPointer. It reads the task row itself rather
than trusting an in-memory copy, ignores anything that is not a cancelled
chat task, and refuses to move the pointer when a NEWER task on the chat
already recorded a session — so a straggler pin cannot drag the
conversation back onto the interrupted turn.
Regression tests, both failing before this commit: a cancel whose pointer
write is blocked must not expose `cancelled` to another connection, and a
late pin on an already-cancelled row must reach the next real claim. The
newer-turn guard is covered too.
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): make the pin atomic and restore the chat->task lock order
Second review round found the remaining half of the same class of bug.
1. PinTaskSession still committed the session onto the task row and
advanced the chat pointer as two statements, and the pointer failure
only logged while the endpoint still answered 204. A follow-up claimed
in that window resumes the previous turn — the exact case the pin
change was added for. Both writes now share one transaction and every
failure is reported.
2. Adding the pointer write gave the cancel transaction two rows to hold,
and it took them in the wrong order: agent_task_queue first, then
chat_session. DeleteChatSession takes them the other way round, so the
two could deadlock (40P01, and runInTx has no deadlock retry). The
repo's documented order is chat_session -> agent_task_queue; both the
cancel and the pin now open with LockChatSessionForTask, the same
helper FinalizeDeferredCancelledChat uses. ErrNoRows there means a
non-chat task or an already-deleted session — nothing to lock and
nothing to advance.
Regression tests, all three failing before this commit (the concurrency
one with a real `deadlock detected`): the pin must not expose a session
on the task row while its pointer write is blocked; a cancel waiting on
the chat session must hold no lock on the task row (FOR UPDATE NOWAIT
probe); and cancel racing a chat delete must never come back with 40P01.
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): put every chat-task terminal write behind the same lock order
The cancel and pin paths took chat_session before agent_task_queue; the
terminal reports still took them the other way round, so the two could
deadlock (40P01) — reproduced deterministically as cancel-vs-complete.
Choosing per-path was never going to work: either every writer that holds
both rows agrees on an order or none of them are safe.
CompleteTask, FailTask and the cancelled-chat finalize now open with the
same LockChatSessionForTask the cancel, pin, DeleteChatSession and
FinalizeDeferredCancelledChat paths use, via a shared helper that
documents the invariant in one place.
Also de-flakes the concurrency tests this PR added. They held a row lock
and then read other rows on a FRESH pooled connection; the suite shares
one database, so a sibling package's DDL could queue an ACCESS EXCLUSIVE
request in between, park our later ACCESS SHARE request behind it, and
wedge the package until the 10-minute timeout (observed in a parallel
`go test ./internal/...` run). Those transactions now take their table
locks up front and read on the connection that already holds them, and
every racing call is bounded so a stall fails loudly instead of hanging.
Regression tests, all failing before this commit: complete and fail must
hold no lock on the task row while waiting for the chat session (FOR
UPDATE NOWAIT probe), and cancel-vs-complete plus pin-vs-fail must never
come back with 40P01.
Co-authored-by: multica-agent <github@multica.ai>
* test(chat): fail the race tests on any unexpected error, bound every call
Review nits on the concurrency tests.
Matching only *pgconn.PgError(40P01) let the pin side through: a pin that
loses a deadlock is reported by the HTTP handler as a plain 500 with no
PgError to unwrap, so the check skipped exactly the failure the test
exists for. Both racers now fail on anything except the one benign
outcome — whoever finalized the task first leaves the loser matching no
row (pgx.ErrNoRows).
The remaining racing calls still used an unbounded context.Background(),
which this PR had already claimed were bounded. They now share the same
raceTimeout as the rest.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
|
||
|
|
55dc302d0e |
MUL-5610: add Codex first-item wait telemetry (#6312)
* fix(codex): add first-item wait telemetry Co-authored-by: multica-agent <github@multica.ai> * test(codex): stabilize first-item telemetry race coverage Co-authored-by: multica-agent <github@multica.ai> * test(codex): harden first-item wait lifecycle fixture Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
671a1a0c21 |
fix(server): read the chat's channel from its binding instead of guessing (#6330)
A claim reported chat_channel_type by trying the binding lookup once per
channel it knew the name of. The list was Slack alone, then {Slack, Feishu}
after MUL-4899. Any channel added later — WeCom is the first — fell off the
end and claimed as a web chat, so the runtime brief told the agent to deliver
files with `multica attachment upload` into a conversation that cannot carry
an attachment. That is the same failure MUL-4899 fixed, reintroduced by the
shape of the fix.
Every channel writes the same channel_chat_session_binding row and differs
only in channel_type, and UNIQUE (chat_session_id) allows at most one, so the
row already holds the answer. Read it by session id and take channel_type off
what comes back. chat_in_thread stays Slack-only, now keyed on the row's own
channel_type: the two commands it picks between are hardwired to the Slack
history reader, and no other channel has one.
The channel_type-scoped query stays for the outbound senders, which are
per-platform by construction and must not deliver into a foreign channel.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
e6a0d6f1a3 |
test(agent): make the ErrWaitDelay regression deterministic (MUL-5631) (#6320)
* test(agent): make the ErrWaitDelay regression deterministic (MUL-5631) Follow-up to the review on #6276. TestOpenclawExecuteToleratesLingeringStderrHolder asserted only the outcome — status stays `completed` — but that outcome is identical whether the ErrWaitDelay branch handled the run or was never reached at all. Reaching it depended entirely on the stub's descendant outliving the 500ms WaitDelay, so on a loaded runner the descendant could exit first and the test would pass without exercising the branch it exists for. A silent loss of coverage, which would let a later change delete the branch with CI still green. The branch logs a warning that nothing else in the tree emits, so the test now asserts on that: the log is the only observable proof of which path ran. newOpenclawTestBackendWithLog tees the logger into a mutex-guarded buffer while still writing to stderr, so a failure stays readable. The stub's hold also goes from 1s to 5s, taking the margin over WaitDelay from 2x to 10x — but that only lowers the odds of a vacuous pass; the assertion is what stops it being silent. Verified by mutation: redirecting the descendant's stderr away, so it no longer holds the pipe, leaves all three original assertions passing and fails only the new one, with `logged warnings were: ""`. Test-only. openclaw.go and openclaw_stdout.go are byte-identical to main. * docs(agent): address review nits on the ErrWaitDelay regression test Two comment-only follow-ups from review of #6320: - The test's doc comment still said the descendant holds stderr for ~1s; the stub was changed to 5s in this PR. - The warning the test asserts on is split across a string concatenation, so the asserted fragment does not turn up in a source grep. Note the coupling next to the warning so a future reword sees it before CI does. No behaviour change. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: weiweiwei <weiweiwei@xiaomi.com> Co-authored-by: Bohan-J <bohan.optimism@gmail.com> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
547fcca069 |
MUL-5631: fix(openclaw): finish at the result boundary when the CLI does not exit (#6276)
* fix(openclaw): finish at the result boundary when the CLI does not exit
A chat reply was generated and then never delivered. Timeline from a host
running openclaw 2026.5.27, relative to the start of the run:
T+0s openclaw started
T+24s the complete result blob was written to stdout
T+8min process still alive, task slot still held, user saw nothing
processOutput read stdout with io.ReadAll, which returns only at EOF, and EOF
requires every write end of the pipe to be closed. `openclaw agent --local
--json` printed its complete result blob and then did not exit, so the pipe
stayed open, the read never returned, the goroutine never reached cmd.Wait,
and the finished answer sat in the daemon's buffer while the task held its
execution slot until the idle watchdog eventually reclaimed it.
## The boundary already has a precedent here
cursor-agent has the same misbehaviour, and cursor.go already handles it: its
`result` case notes that current versions "can emit the terminal result event
but keep a worker process alive", so it treats result as the protocol
boundary, calls cancel(), and guards its final status switch with
`if resultSeen` so the deliberate kill is not reported as an abort.
openclaw parses one whole-buffer blob rather than line events, so the
equivalent condition is "the buffer parses as a complete result" instead of
"a result line arrived". This change gives openclaw the same three pieces:
- readOpenclawStdout replaces io.ReadAll. It returns at EOF as before, or
early once the buffer parses as a complete result AND stdout has been
idle for 2s, reporting cutShort.
- On cutShort, Execute cancels the run context so CommandContext kills the
lingering process and cmd.Wait can return.
- The status switch gets a leading `case scanResult.cutShort:` so the
resulting cancellation is not turned into "aborted" — that would discard
a reply we already hold.
Both read conditions are required. Idle alone is not enough: an agent may
pause for minutes while thinking, and cutting off a partial buffer would
throw away work it has already done, which is worse than the hang. Parseable
alone is not enough either, since more output may still follow. Nothing is
cut short before any output arrives, so a silent agent stays governed purely
by the caller's context and its behaviour is unchanged.
## WaitDelay 10s -> 500ms
Matching cursor-agent, for the same reason. WaitDelay only applies once the
child is gone but its stdio is still held — which is exactly the cut-short
path, since that is where we kill a process still holding the pipe. Leaving
it at 10s would add 10s to every reply that takes this path. A CLI that exits
cleanly never reaches the delay at all. End to end this took the reproduction
from ~12.5s to ~2.9s.
## Verification
- pkg/agent green with -race under CI's flags (-p 2 -parallel 2); 4
consecutive runs, no flakes. All existing Openclaw* tests unchanged and
passing, including TestOpenclawProcessOutputReadError and the
empty-buffer cases, which exercise the new reader's EOF and error paths.
- GOOS=windows build, vet and test compilation all pass.
- 3 new tests, each mutation-verified against the mechanism it guards:
* io.ReadAll restored -> the test hangs and the dump is the production
stack: io.ReadAll <- openclawBackend.processOutput.
* cutShort status guard removed -> status = "aborted" instead of
"completed", i.e. the reply is thrown away.
* boundary cancel() removed -> the test hangs in os/exec.(*Cmd).Wait.
* fix(openclaw): keep a delivered result when a descendant outlives WaitDelay
Addresses the review on #6276. The finding is correct and the comment it quoted
was wrong: lowering WaitDelay to 500ms introduced a regression on the
*clean-exit* path, which is worse than the hang this PR set out to fix.
Verified against the documented contract rather than assumed:
The WaitDelay timer starts when either the associated Context is done or a
call to Wait observes that the child process has exited, whichever occurs
first. ... If pipes are closed due to WaitDelay, no Cancel call has occurred,
and the command has otherwise exited with a successful status, Wait and
similar methods will return ErrWaitDelay instead of nil.
So a clean exit does reach the delay whenever a descendant still holds one of
the pipes os/exec manages — and this backend has one, since cmd.Stderr is a
plain io.Writer for which os/exec creates an internal pipe plus a copy
goroutine. The path was:
1. openclaw writes its complete result, closes stdout, exits 0.
2. readOpenclawStdout returns via EOF, so cutShort is false.
3. A short-lived descendant keeps stderr open for >500ms.
4. cmd.Wait returns exec.ErrWaitDelay.
5. cutShort is false and runCtx.Err() is nil, so the switch fell through to
"openclaw exited with error" and a fully parsed, deliverable reply was
reported as failed.
The review is also right that the cursor-agent comparison did not carry over.
cursor ignores *every* exit error once a terminal result is parsed
(`if resultSeen`), whereas this diff had narrowed that protection to
`case scanResult.cutShort:` — which is self-consistent only if the clean-exit
route can never produce an exit error, and it can.
Fix, taking the reviewer's preferred option since it is the smallest and leaves
the WaitDelay timing and the watch goroutine alone: keep the success status when
the error is specifically ErrWaitDelay and a complete result was parsed. By
definition ErrWaitDelay means the process exited successfully, so this cannot
mask a real failure; the only thing lost is a tail of stderr log lines, and that
is logged as a warning. It is deliberately a separate case from cutShort, since
that path cancels on purpose and a Cancel call makes Wait report the kill rather
than ErrWaitDelay.
The comment that stated the wrong premise is rewritten to say what WaitDelay
actually bounds, and why lowering it is still right: the delay is only reached
when something is holding a pipe open, and on the cut-short path we deliberately
kill a process doing exactly that.
Also takes the non-blocking nit: readOpenclawStdout's ticker branch copied the
whole accumulated buffer before checking whether stdout had actually gone idle,
so a large result was reallocated on every 100ms tick. The cheap conditions are
now checked under the lock first and the buffer is copied only once the silence
threshold is met.
Regression test as requested: the parent writes a complete result, closes stdout
and exits 0 while a descendant holds stderr for ~1s (its own stdout goes to
/dev/null so the stdout pipe still reaches EOF), and the final status must be
completed. Mutation-verified — dropping the new case reproduces the reported
failure verbatim:
status = "failed" (error: "openclaw exited with error: exec: WaitDelay
expired before I/O complete"), want completed
pkg/agent passes with -race under CI's flags, all pre-existing Openclaw* tests
included, and GOOS=windows build and vet are clean.
---------
Co-authored-by: weiweiwei <weiweiwei@xiaomi.com>
|
||
|
|
a5c1d44701 |
MUL-5642: fix(agents): stop the creation studio polling, and stop it losing work (#6307)
* feat(agents): make AI agent creation resumable (#6246) Leaving the Agent Creation Studio destroyed the conversation. The unmount cleanup called deleteChatSession, so a sidebar click, a tab close or a route change deleted the builder session and every message in it — the bug external users reported. Archiving instead (PR #6247) would have stopped the deletion without giving anyone a way back in: builder sessions hang off a hidden `kind = 'system'` carrier agent, which the `kind = 'user'` filter keeps out of every chat list, so an archived one is unreachable rather than recoverable. A creation conversation is now a durable object with its own address. Server: - GET /api/agent-builder/sessions lists the caller's unfinished creations. Creator-scoped like every other chat read. It reports the CARRIER's runtime, not chat_session.runtime_id — the latter is the daemon's resume pointer and is deliberately left stale after a switch, so resuming from it would put the picker on a runtime that executes nothing (MUL-5163). - PUT /api/agent-builder/sessions/{id}/draft stores the configuration, including the edits the user typed but never sent. Migration 251 adds agent_builder_draft (no FK per repo rule; pruned explicitly by DeleteChatSession, the runtime teardown and the workspace teardown, and registered in the workspace-deletion manifest). - The payload is opaque to the server: its shape is the studio's AgentDraft, validated client-side. Teaching Postgres and the handler about it would create a second definition to keep in sync for no gain. Client: - The session id lives in `?session=`, so a refresh, a back/forward and a reopened tab land back in the same conversation. - Leaving no longer deletes anything. The only destructive path is an explicit "discard", confirmed in a dialog, next to the create button. - Creating the agent archives the conversation instead of deleting it: it is the record of how that agent was designed, and an idle carrier costs nothing since usage is booked per task. - The configuration autosaves (debounced) and restores on arrival, with the applied-assistant-message marker stored alongside it so a restore cannot re-apply the last reply over edits made after it. - The 1.5s polling of messages and pending-task is gone. The global realtime sync already invalidates both per session id, exactly as it does for the main chat window, which has never polled. - The `<agent_draft>` block collapses to one "configuration updated" line. The regex now also swallows an unterminated block, which is what streaming produces — the raw payload used to scroll past on every turn. The 2185-line agent-creation-studio.tsx is split into three routes (`/agents/new`, `/agents/new/manual`, `/agents/new/ai`), its pure logic moves to packages/core/agents/ with its tests, and the unreachable template flow — `setMode("templates")` had no caller — is removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(agents): let the builder panes resize The conversation / configuration split was not draggable. Two structural reasons, both fixed by giving the group the same shape the chat page uses: - The panels reached the group through BuilderWorkspace's fragment, so they were not children the group could measure. - The group's children alternated between one panel (runtime setup) and two (conversation), under one persisted layout id. The group now lives inside BuilderWorkspace with its two panels as its only children, and the setup screen renders no group at all — it has nothing to split. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ui): give the resize handle a cursor on hover The separator had no cursor of its own, so the only signal that a split was draggable arrived after the drag started — the library writes a global `cursor: ... !important` while dragging, and nothing before it. Fixed on the shared handle rather than at one call site: every split surface (chat, inbox, issue detail, project detail, the agent builder) was missing the same affordance. The library's drag-time rule still outranks this one, so the cursor keeps narrowing to `e-resize` / `w-resize` once a panel hits its bound. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(agents): render the builder's draft block as an inspectable row Every builder reply ends in an <agent_draft> block that rewrites the form on the right. Flattening it to a line of prose said that something changed but not what, and the payload — the only record of what the builder actually claimed — was unreachable. A settled reply now carries a full-width row saying the configuration was updated, which opens the exact payload. A streaming one keeps a text line instead: the block is still being written, so there is nothing complete to open, and without the line the half-finished JSON scrolls past. ChatMessageList gains an optional `renderAssistantAddon`. It is opt-in per surface and undefined everywhere but this one, because no other chat speaks this protocol — the alternative was to keep pushing an embedded protocol through `transformContent`, which can only ever produce prose. `extractBuilderDraftBlock` returns an unparseable payload verbatim rather than withholding it: a malformed block is exactly when someone wants to read it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(agents): address review blockers on the resumable builder 1. Migration prefix collision. `251_agent_runtime_unbind` landed on main after this branch cut, so the backend's prefix-uniqueness guard failed. Renumbered to 252. 2. #6287 — the manual form still lost everything. The route split moved where you land, not what survives: the draft was `useState`, so a tab switch (the desktop shell mounts only the active tab) remounted it empty, and the beforeunload guard covered a hard reload and nothing else. It now persists through the repo's draft-store factory, scoped by what is being created — a blank agent and a copy of agent X are different work, and a copy of X is not a copy of Y — cleared once the agent is committed, and registered for logout / workspace-delete cleanup. 3. A saved draft with no messages was unreachable. The configuration form is editable from the moment a builder session exists and autosaves, so someone could open it, type a name and leave before the first turn; the list keyed "is this a draft" on messages alone, so that row existed and nothing could reach it. A session now qualifies on a message OR a stored draft, and sorts by whichever it has. 4. The debounce dropped the last edits. Its timer died with the component, so navigating away inside the 800ms window lost exactly the keystrokes the user had just made. The pending payload is now flushed on unmount. `useUnsavedDraftWarning` is gone with its last caller: both routes persist, so the browser prompt would have been warning about work that is already saved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ui): stop the resize cursor flipping mid-drag The library narrows its cursor the moment a panel hits a bound — col-resize while both directions are open, a one-way arrow once only one is. Truthful, but it reads as a glitch: the icon changes under your hand halfway through a drag you never stopped making. `disableCursor` turns that global rule off; the handle's own `cursor-col-resize` is now the only source. A drag captures the pointer and walks it across the panels, away from the 8px handle, so the group carries the same cursor for as long as a separator is active — otherwise it would fall back to a text caret the instant the pointer left the handle. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(agents): key manual drafts by owner, drop the draft-block rendering Two changes. One slot destroyed the other flow's work. The manual draft was stored under a single key: opening a blank form, or a copy of a different agent, refused to adopt the stored draft and then immediately wrote its own empty form over it — so a half-finished copy of agent A died the moment the user opened anything else, before typing a character. Drafts are now keyed by what is being created, the same shape the chat composer uses for its per-session drafts, and a slot is dropped when its content is gone rather than parked blank (which also stops the map growing a dead key per agent ever opened for duplication). Committing an agent clears that flow's slot only. The `<agent_draft>` block goes back to being hidden outright. Labelling it and opening its payload dressed up machinery as content: the block drives the configuration form, and the form is where its effect is already visible. `renderAssistantAddon` goes with it — ChatMessageList is back to what it was, since no surface needs the slot. The two-pattern strip stays: an unterminated block is what streaming produces, and without matching it the raw JSON scrolled past the reader on every turn. Also removed six barrel exports nothing imported through, and unexported five types only their own file used. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(agents): keep a manual draft whose only edit is a picker The "is this worth storing" predicate listed six fields by name, and the draft serializes eleven. A form whose only change was the model, the thinking level, the service tier, the access scope or a team grant read as untouched, so the next save deleted its slot — picking a model before typing a name and switching tabs lost the model. Enumerating was the mistake, not the specific omissions: the predicate stops covering every field added after it is written, and the failure is invisible because each field saves correctly as long as some *other* field is also set. It now compares the whole draft against a fresh one. The runtime stays outside that comparison, on the entry rather than in the draft, because the form seeds it on every visit and counting it would store a draft for a form nobody touched. Covered field by field, one edit at a time, so a future field cannot quietly fall out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6890812ae4 |
MUL-5657 fix(agent): add missing streamingCurrentTurn gate to kimi ACP backend (#6308)
kimi.go was the only ACP backend missing the streamingCurrentTurn gate. Without it, history replay emitted by the Kimi CLI during session/resume contaminates Result.Output and the message stream with previous-turn content — the user sees the old answer duplicated alongside the new one. The root cause is chronological: the gate was introduced for Hermes in PR #2024 (2026-05-03) but kimi already existed at that point and was not updated. Later backends (grok, traecli) were written after the fix and included the gate from day one. Add the same atomic.Bool gate + acceptNotification callback pattern used by hermes, grok, traecli, kiro, and qoder. Pin with TestKimiBackendDropsHistoryReplayOnResume. |
||
|
|
4fe94a6d40 |
revert: "MUL-5637 fix(mcp): agent mcp_config is an authoritative allowlist (#6292)" (#6314)
This reverts commit
|
||
|
|
aa349fed02 |
MUL-5637 fix(mcp): agent mcp_config is an authoritative allowlist (#6292)
* fix(mcp): treat agent mcp_config as an authoritative allowlist
An agent's saved mcp_config was silently widened with the runtime host's
own user-level MCP servers, so an explicitly empty `{"mcpServers":{}}`
resolved to the COMPLETE host set instead of no servers at all — the
opposite of what the operator configured (GitHub #6283).
`--strict-mcp-config` was being passed correctly; the merge happened
before it, in the daemon, so strict mode constrained an already-widened
set. Introduced by #5277 and present in v0.4.16 through main.
Restore the three-state contract in resolveEffectiveMcpConfig:
null / unset -> inherit the provider's native MCP configuration
{"mcpServers":{}} -> strict empty, no host servers
non-empty object -> strict allowlist, exactly those servers
Two explicit inherit paths keep the additive behaviour reachable without
weakening the default:
- runtime_config.mcp.inherit_runtime = true opts an agent back in.
- The claim response now carries mcp_config_overlay_only so the daemon
can tell an agent-authored config from a per-task Composio overlay.
Without it, enabling an integration on an agent that never configured
MCP would have stripped the host servers it was already inheriting.
Both decode paths fail closed: malformed runtime_config never enables
inheritance, and a failed runtime merge falls back to the agent's own
config.
The web MCP tab and the `agent create/update --mcp-config` help text
described the old additive behaviour, which is how a tightened config
could look correct while exposing every host server; both now state
which mode is in effect.
Note for rollout: the fix lives in the daemon, so self-hosted users must
upgrade the daemon — a server/UI upgrade alone does not apply it.
Co-authored-by: multica-agent <github@multica.ai>
* fix(mcp): close review gaps in the authoritative mcp_config change
Addresses the four must-fix findings from review of #6292.
1. Deleting the last managed server no longer widens access.
removeManagedMcpServer cleared the config to null, which now means
"inherit the host's MCP servers" — so a delete took the agent from one
allowed server to every server on the host. It now leaves an explicit
`{"mcpServers":{}}`. Restoring inheritance moved to a separate
clearManagedMcpConfig action behind its own confirmation that states the
widening. The delete dialog no longer claims "Runtime servers are not
affected", which was the opposite of the truth.
2. The UI no longer promises a boundary an old daemon does not enforce.
The strict semantics live in the daemon, so a config saved against an
older daemon is not yet in effect. Adds the authoritative-mcp-v1 daemon
capability:
- The daemon advertises it and reports authoritative_mcp on the
runtime-capabilities response.
- The claim path fails closed: a managed, non-inheriting mcp_config
claimed by a daemon without the capability cancels the task and
returns 412 with an actionable message, instead of letting that daemon
merge the host's servers in. runtime_config.mcp.inherit_runtime is the
documented escape hatch, and it is honest — it declares that the
operator accepts the host's servers.
- The MCP tab shows "needs upgrade" rather than "Not exposed" while the
bound runtime lacks the capability.
3. Saving OpenClaw settings no longer drops the inherit opt-in.
parseOpenclawRuntimeConfig discarded unknown keys and the tab persisted
the result as the whole runtime_config, so one unrelated routing save
silently deleted mcp.inherit_runtime. Unknown keys now round-trip
through OpenclawRuntimeConfig.passthrough, excluded from the dirty check
so they cannot make the form look edited.
4. Documents the new semantics in the built-in creating-agents skill and
its source map: the three states, the persisted
runtime_config.mcp.inherit_runtime field, and the claim-time capability
gate.
Also corrects the PR's rollout claim: there is no database migration, but
this does add a persisted JSON field and change the meaning of an existing
one.
Co-authored-by: multica-agent <github@multica.ai>
* fix(mcp): stop the authoritative-daemon gate from blocking valid claims
CI's backend job failed three handler claim tests with the new 412. Two
distinct problems, both real:
1. The gate fired for a non-object mcp_config. 66 handler fixtures seed
`[]`, which is not a valid MCP config and cannot carry `mcpServers`, so
it expresses no boundary to protect. An old daemon does not widen it
either: mergeRuntimeAndAgentMcpConfig fails to unmarshal a non-object
and falls back to the agent config alone (verified directly). Gating
these blocked tasks with no security benefit, so the gate now requires a
JSON object.
2. The shared daemon test-request helper advertised no capabilities, so
every claim test was accidentally simulating a pre-#6283 daemon. It now
defaults authoritative-mcp-v1 on, matching what every current daemon
sends. Only that capability — skill-bundles / coalesced-comments / rpc
are feature negotiations whose absence tests real legacy behaviour, so
they stay opt-in per test.
Adds claim-level coverage for the gate itself, which is what the unit tests
alone could not catch: an outdated daemon gets 412 with an actionable
message and the task is cancelled; a capability-advertising daemon gets
200; the inherit_runtime opt-in lets an outdated daemon through; and an
unmanaged or non-object config is never gated.
Verified against a real migrated schema this time (throwaway Postgres),
which is how the three failures were reproduced locally and confirmed
fixed: `go test ./internal/handler ./internal/daemon` both ok.
Co-authored-by: multica-agent <github@multica.ai>
* fix(mcp): surface the daemon-upgrade refusal and stop gating safe providers
Addresses the second review round on #6292.
1. The refusal is now visible wherever the operator looks. The default
claim path is the machine-level BATCH endpoint, which skips build
failures and still answers 200 {"tasks":[]}, so the previous bare
CancelTask showed a task that vanished with no stated reason — turning an
explicit upgrade requirement into an unexplained failure. The claim path
now fails the task with a new classified reason,
mcp_config_daemon_outdated, plus the actionable message. That reaches the
user on all three claim paths and on any daemon version, which a new
response field could not: the audience is by definition a daemon too old
to read one. The per-runtime path keeps its 412.
The reason is deliberately not auto-retryable — the same outdated daemon
would claim the retry and fail it again.
2. The gate no longer cancels safe tasks. It applied to every provider, but
only claude / codebuddy / codex / cursor / opencode / openclaw were ever
merged with host MCP by an old daemon (loadRuntimeMcpServerConfigs).
Qwen was never merged and already had strict semantics, so its tasks were
being failed for a risk that does not exist. Scoped via
providersOldDaemonsMergedRuntimeMcp; an unknown provider does not gate,
because the gate should only fire where the old behaviour is concrete.
3. The new authoritative_mcp flag now goes through the API schema layer.
Both local-skills responses were returning raw network JSON, so the flag
that decides whether the UI may assert an MCP boundary rested on an
unchecked type assertion. Adds RuntimeLocalSkillListRequestSchema with
authoritative_mcp and mcp_supported defaulting to FALSE — the fail-closed
direction — and a MALFORMED_ fallback that cannot express a guarantee.
Claim-level tests now cover all three paths, which is what the previous
helper-only tests missed: per-runtime 412, batch recording the refusal on
the task while still delivering the healthy tasks in the same batch, WS RPC
refusing and accepting, the qwen negative case, the inherit_runtime escape
hatch, and unmanaged / non-object configs.
Verified against a real migrated schema (throwaway Postgres):
go test ./internal/handler ./internal/daemon ./pkg/agent ./pkg/taskfailure
./internal/service all ok.
Co-authored-by: multica-agent <github@multica.ai>
* fix(mcp): register the new failure reason and wire its copy into the UI
Addresses the third review round on #6292.
1. mcp_config_daemon_outdated was declared but never registered in
taskfailure.allReasons, so metrics.NormalizeFailureReason missed the
known-value map and fell through to free-text Classify() — relabelling a
platform-side refusal as `agent_error.unknown` (verified directly) and
leaving the Prometheus series un-pre-warmed. Registered it, canonical
count 22 → 23 (platform 8 → 9), with the wire value and IsAgentError split
pinned. New test pins the WHOLE canonical set through
NormalizeFailureReason so forgetting the next reason fails a test instead
of quietly mislabelling a metric; NormalizeFailureReason had no coverage
at all before.
2. The upgrade copy was dead. The locale strings landed last round but
neither consumer mapped the reason: chatFailureCopy fell back to generic
failure text with the actionable detail buried in the collapsed raw
error, and task-failure.ts rendered the bare wire value
`mcp_config_daemon_outdated` in the agent activity list and issue
execution log. Both are mapped now, with regression tests, plus the
runtime class pinned in failure-class.test.ts. This directly contradicted
the claim in the claim-path comment that every path reaches the user, so
that is now actually true.
3. providersOldDaemonsMergedRuntimeMcp is documented as what it is: a FROZEN
record of what pre-capability daemons merged, not a mirror of the daemon's
current provider switch. The old "keep the two lists in lockstep" note was
actively harmful advice — runtime MCP discovery for a new provider can only
ship in a daemon that already advertises the capability (never gated), so
adding it here would fail tasks on old daemons that never merged for it,
re-creating the qwen false-positive. Pinned with a test.
Also corrects a stale count in task-failure.ts (7 → 9 platform reasons).
Verified against a real migrated schema (throwaway Postgres): full backend
suite green apart from the pre-existing environmental cmd/multica guard; all
9 TestMcpGate_* integration tests pass.
Co-authored-by: multica-agent <github@multica.ai>
* docs(taskfailure): correct taxonomy counts and finish the reason registration
Non-blocking nits from the fourth review round on #6292.
- Taxonomy counts now say 23 reasons / 9 platform-side. Registering
mcp_config_daemon_outdated last round updated the assertions but not the
prose. Swept the whole repo rather than only the flagged lines, which
turned up four more that were already stale at 21 and drifted further:
handler/dashboard.go, daemon/poisoned.go, core/types/agent.ts, and the
db/queries/task_usage.sql comment sqlc copies into the generated file.
The generated file's comment was updated by hand to match its source.
Running `sqlc generate` churned 58 lines across 47 unrelated files — the
local sqlc version differs from the one that produced the checked-in
output — so that churn was reverted and only the one intended line kept.
- failure_test.go's `required` list now includes
ReasonMcpConfigDaemonOutdated. Length and label assertions already covered
the reason, but the list is documented as the complete canonical set, so
the omission contradicted its own comment.
- Restored the line break in chat-message-list.test.tsx that a previous edit
of mine collapsed.
Comment, test-fixture and formatting only; no behaviour change.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
|
||
|
|
b06af2ae17 |
feat(runtime): unbind agents on runtime delete instead of destroying them (#6220)
* feat(runtime): unbind agents on runtime delete instead of destroying them Deleting a runtime archived its agents and then hard-deleted the rows, so the agents and every conversation with them disappeared — while the confirmation dialog said "archive", which a user reasonably reads as recoverable. Retiring a laptop is an ordinary action; losing the agents configured on it is not an ordinary consequence. An agent is now a persistent business object and a runtime is replaceable execution capacity: deleting a runtime unbinds its agents. `runtime_id IS NULL` means unbound — orthogonal to archived — and the agent keeps its instructions, skills, chats, labels, channel installations, autopilots and task history. service.AgentReadiness already refused an agent with no runtime, so the scheduling safety gate needed no change. Two columns become nullable, not one. Without `agent_task_queue.runtime_id`, deleting the runtime still cascades the task history away (and task_message / task_usage / task_token with it), so the agents would survive with no record of anything they did — the same class of loss. A NOT VALID CHECK keeps NULL confined to history: an active task must always have a runtime, so claim / dispatch / delivery-CAS paths can never observe one without. It is written against completed_at rather than a status list so a future non-terminal status fails closed instead of slipping through. Two prerequisites this depends on: - 'deferred' (migration 128) was missing from CancelAgentTasksByRuntimeOrAgent. It went unnoticed because the delete used to cascade those rows away; with the new CHECK it would abort the delete and make the runtime undeletable. - The channel-installation / label / chat-pin / invocation-target / draft-restore cleanups were scoped to "archived agents on this runtime". Archived user agents now survive, so that scope is narrowed to kind='system' — otherwise the fix would produce a subtler loss: agent alive, configuration wiped. Also removes the squad guard that refused (409) when an active squad's leader was an archived agent on the runtime, plus the archived-squad delete that existed only to get past squad.leader_id's RESTRICT FK. The leader is no longer deleted, so nothing needs to be given up to retire a machine. Autopilots are no longer paused either: their assignee survives, and a rebind restores them without the owner having to remember to re-enable. Reason codes: an unbound agent reports agent_runtime_required, not runtime_offline. The copy for runtime_offline tells users to reconnect a machine; an unbound agent has no machine to reconnect, and the fix is to bind a runtime. Chat's bare 409 string gains the same code so the composer can offer that action. API: agents gain runtime_bound. runtime_id stays a string (empty when unbound) so installed clients keep parsing and no gated two-release rollout is needed. The confirmed-delete endpoint is /unbind-agents-and-delete; /archive-agents-and-delete still routes to it, and the compared expected_active_agent_ids set is unchanged — widening it would 409 every older client forever. Co-authored-by: multica-agent <github@multica.ai> * fix: make runtime unbinding recoverable Co-authored-by: multica-agent <github@multica.ai> * fix: address runtime unbind review nits Co-authored-by: multica-agent <github@multica.ai> * fix: resolve runtime unbind review blockers Co-authored-by: multica-agent <github@multica.ai> * fix(migrations): renumber runtime unbind after main merge Co-authored-by: multica-agent <github@multica.ai> * test(daemon): avoid late-request lease flake Co-authored-by: multica-agent <github@multica.ai> * test(autopilots): bind validation fixture runtime Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
33a2743f93 |
fix(llm): make quick actions GPT-5.6 compatible (MUL-5573) (#6243)
* fix(llm): use max completion token limits Co-authored-by: multica-agent <github@multica.ai> * fix(llm): harden GPT-5.6 JSON generation Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
28b6105edc |
fix(subscribers): notify the human an agent files sub-issues for (MUL-5483) (#6209)
When an agent created a sub-issue while working on a human's behalf, that human received no notifications for it at all. issue_subscriber modelled ACTOR identity, so an agent-created, agent-assigned issue had a full subscriber list and zero members to deliver to. The platform already knew who the work was for (agent_task_queue.originator_user_id, MUL-4302); notification never asked. - attribution.DelegatedSubscriber: one shared rule over the same origin waterfall ClassifyDirect uses. agent_create subscribes the originator as 'delegated'; quick_create keeps the direct 'creator' tier; autopilot and degraded attribution subscribe nobody. - Delegated is a reduced delivery tier: in_review/done/cancelled/blocked plus failures and mentions. Routine churn is suppressed, and the parent bubble cannot re-deliver what the tier dropped. - Unsubscribe becomes stateful: an unsubscribed_at tombstone survives later rule passes, and opt_out_scope distinguishes "this issue" from "this subtree" so a narrow opt-out no longer silently suppresses future children. - Subtree unsubscribe is its own endpoint. A body flag cannot fail loudly against an older backend (Go drops unknown fields); an unknown route 404s, which the UI now surfaces with a distinct message. - Eligibility and the write share one statement under a (workspace, user) advisory lock that subtree unsubscribe and member revoke also take, closing the check-then-insert races. Revoke additionally clears the departing member's subscriptions in the same tx. - UI explains a delegated subscription and offers both unsubscribe scopes. Migrations 249/250 add the delegated reason, the opt-out tombstone, and the opt-out scope, using NOT VALID + VALIDATE CONSTRAINT so the widened CHECK does not scan issue_subscriber under an exclusive lock. Reviewed across eight rounds; an earlier write-time subtree roll-up was built and then removed in full once it proved unfixable without serializing every topology mutation. The parent's own status transition already carries that signal. Closes MUL-5483. |
||
|
|
cd9b956269 |
fix(agent): spawn Copilot's native binary on Windows so the prompt survives (MUL-5586) (#6236)
On Windows the daemon passes the full multi-line prompt as `-p <prompt>` but spawns npm's `copilot.cmd`, which we already rewrite to `powershell -File copilot.ps1`. Neither launcher can carry that argument: - `copilot.cmd` forwards with `%*`, which cmd.exe expands by re-tokenising the raw command line. - `copilot.ps1` ends in `& node.exe npm-loader.js $args`, and PowerShell re-serialises `$args` onto node's command line. Under Windows PowerShell 5.1 (and pwsh <= 7.2, which default to Legacy native argument passing) embedded double quotes are not re-escaped, so the prompt is re-tokenised. Copilot then sees several argv tokens where one was intended and refuses the run with "It looks like your prompt was not quoted, so the extra words were treated as separate arguments" — the same defect class already fixed for cursor-agent in #5649, except Copilot has no stdin prompt channel to escape through, so the prompt must stay on the command line and the launchers have to go. Copilot CLI ships a native per-platform binary and `npm-loader.js` does nothing but `spawnSync` it with argv untouched, so resolve `copilot-win32-{x64,arm64}\copilot.exe` out of the npm layout and spawn it directly. That leaves exactly one hop, Go -> native binary, and Go's syscall.EscapeArg is the exact inverse of the CRT parsing that binary uses. This mirrors resolveOpenCodeNativeFromShim / resolveDevecoNativeFromShim. Both the nested (current npm) and hoisted (older npm) platform-package locations are probed; when neither resolves, we keep falling back to the PowerShell launcher, which is still better than cmd.exe. Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
f48bd655bc |
MUL-5562: optimize application-owned workspace deletion (#6230)
* fix(workspace): optimize application-owned deletion Co-authored-by: multica-agent <github@multica.ai> * fix(workspace): address deletion review findings Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
e4b6f7a31b |
MUL-5581: add Qoder CN CLI runtime (#6232)
* feat(agent): add Qoder CN CLI runtime Co-authored-by: multica-agent <github@multica.ai> * fix(agent): address Qoder CN review nits Co-authored-by: multica-agent <github@multica.ai> * fix(agent): defer Qoder CN version gate Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
f13969b996 |
refactor(chat): generate quick actions server-side via the LLM layer (MUL-5573) (#6214)
* refactor(chat): generate quick actions server-side via the LLM layer (MUL-5573)
Follow-up suggestions were produced by a second, full provider CLI invocation
per chat turn: the daemon resumed the just-finished session and ran a
suggestion-only pass. That pass inherited the main turn's exec options, so its
20s budget had to cover process spawn, every MCP handshake, session replay, and
model reasoning at the agent's own thinking level — typically 8-15s of visible
skeleton, and every turn paid two provider cold starts.
Generate them here instead, through the same pkg/llm layer that backs chat
auto-titling. Suggestions need no tools, workdir, or agent identity — only the
tail of the conversation — so a bounded 8s call on the deployment's small model
replaces the whole resumed turn.
Quality changes that came with the move:
- The prompt now states the frame explicitly ("you write FOR THE USER"). The
old pass ran inside the agent's session and inherited the runtime brief's
identity, which drifted suggestions toward agent-operations actions.
- Previously-offered labels are replayed as ALREADY SUGGESTED. The old
architecture had the opposite effect: on providers that append on resume,
each pass saw its predecessor's JSON and anchored on it.
- A failed generation broadcasts failed=true. Before, a timeout delivered an
empty array — indistinguishable from "nothing worth suggesting", so every
slow pass read as a quality problem.
- The in-band footer is still stripped from replies but its actions are now
discarded, so a pre-upgrade session is not pinned to the retired
suggestions with the replacing pass suppressed.
The refresh path no longer enqueues an agent task: it validates the target and
calls the same generator, which also drops the not-resumable refusal — a session
whose runtime was rebound can now be refreshed. Client contract is unchanged
(chat:done pending flag, chat:quick_actions supplement); the only frontend
change is the pending window, resized from 30s to 12s to match the new budget.
Also removes the daemon's TMPDIR-after-cleanup hazard by construction: the old
pass started after runTask's defers had already deleted the temp dir it was
still pointed at.
Co-authored-by: multica-agent <github@multica.ai>
* refactor(chat): drop the quick-actions opt-out setting (MUL-5573)
Suggestions are always on. The Settings → Chat toggle is removed along with
the whole per-turn opt-out path it fed: the persisted client preference, the
quick_actions_enabled send field, the quick_actions_disabled task stamp, and
the eligibility gate that read it.
The toggle predates server-side generation, when it could only hide chips a
provider pass had already paid for. Now that generation is a bounded call the
server decides on, an off switch buys nothing a user would miss, and it was
the last piece of UI implying the feature might be unavailable.
agent_task_queue.quick_actions_disabled is no longer written (dropped from
CreateChatTask's INSERT; the column keeps its false default). Left in place
alongside regenerate_quick_actions_for for a later drop migration — removing
columns an already-running binary still inserts would break mid-deploy.
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): address quick-actions review findings (MUL-5573)
Four defects from review of the server-side generation change.
1. Automatic failures were reported as refresh failures. The generator
broadcast failed=true on any LLM error, but the client turns every
failed=true into a "couldn't refresh" toast — so an automatic timeout
popped a toast for an action the user never took. This also contradicted
ChatQuickActionsPayload.Failed, which documents false for the automatic
pass. The caller now passes its origin; only an explicit refresh reports.
2. Generation context was not bound to the target turn. The pass re-read the
session's newest messages while always writing to the task it was handed,
so a turn landing between the completion callback and the detached read
supplied the context for a reply it did not belong to. Worse, a user
typing a follow-up in the second after a reply left the window ending on
a user row, which the old code treated as "nothing to build on" — that
turn silently never got pills. The window is now anchored on the target
assistant message and queried strictly before it.
3. No concurrency or idempotency bound on generation. Refresh stopped
creating a task, so the busy check could not see a pass already running:
two refreshes both returned 202, spent two upstream calls, and raced to
write one row. Nothing bounded generation process-wide either. Adds a
per-session in-flight guard (refresh now 409s on a duplicate) and a
process-wide ceiling; a shed pass still resolves the client placeholder
so no skeleton hangs on work that never started.
4. A new daemon could not safely talk to an older server. The refresh task
discriminator was deleted, so a regenerate task from such a server fell
through to the ordinary chat path: no user message, but the agent would
answer anyway and the server would persist it as a real reply. The field
is restored as a refusal marker only — the task completes empty, which is
the shape the retired pass produced and which that server writes no row
for. Not a restored execution path.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
|
||
|
|
cee985592e | fix(agent): drain trailing ACP notifications in kimi, kiro, qoder, and traecli (#5951) | ||
|
|
c3f5df8bf4 |
MUL-5492: fix timeline cap dropping newest entries + stop double-broadcasting descriptions (#6175)
* fix(timeline): cap the issue timeline at the newest end and report the clamp The per-issue timeline cap was applied with ORDER BY created_at ASC LIMIT 2000, so once an issue accumulated more than 2000 comments or activities the cap discarded the NEWEST rows. The timeline appeared to stop at some point in the past and every later event was invisible, with nothing in the response indicating anything was missing. Activity is machine-paced — description autosave, every agent run, status and assignee changes all write rows — so this was reachable in normal use, not only on pathological issues. Take the window with the keyset ordering (created_at DESC, id DESC) in a subquery and re-sort ascending in the outer query. This keeps the chronological contract for every existing caller, including the comment list endpoint that shares ListCommentsForIssue, and is served as an index-only scan by the idx_*_keyset indexes already added in migration 068 — no new migration, no call-site changes. Two things beyond the ordering flip: - Clamp both lists to a shared window floor. The two caps are applied independently, so each list has its own floor. Merging windows with different floors produces a timeline that looks continuous but, below the higher floor, contains only one of the two kinds — e.g. comments with no interleaved activity. That is worse than a timeline that visibly stops, because nothing about it looks wrong. Both lists are now clamped to the newest floor, so the result is a contiguous, correctly interleaved slice. - Stop truncating silently. The unpaginated response is a bare JSON array with nowhere to put a flag, so the clamp is reported via X-Timeline-Truncated and X-Timeline-Window-From, added to ExposedHeaders because a custom response header is otherwise unreadable from browser JS. The legacy wrapped shape's has_more_before is now truthful instead of hardcoded false. Queries read one row past the cap so "hit the cap" is distinguishable from "holds exactly 2000 rows", which would otherwise report a complete timeline as truncated and drag the other list's window down with it. Regression tests cover all four properties and were confirmed to fail against both the original query and a floor-less DESC flip. Co-authored-by: multica-agent <github@multica.ai> * perf(realtime): stop broadcasting two full descriptions on issue:updated issue:updated carried prev_description alongside the new description in the issue object, and the WS forwarder reuses the producer's payload map verbatim. Every debounced description autosave therefore pushed two full copies of the description to every connection in the workspace, including users who did not have the issue open. The DB write is O(1); the fanout was O(connections x description size), and it repeats on every pause in an editing session. prev_description and prev_title exist only for in-process listeners — subscriber_listeners adds newly @mentioned users, notification_listeners builds mention notifications, activity_listeners records the title change. No client reads them: IssueUpdatedPayload in packages/core/types/events.ts does not declare either field. Project the payload on the way out. The bus dispatches bus.Subscribe handlers before the SubscribeAll forwarder, so the in-process consumers are unaffected, and projecting at the forwarder covers both the single- node Hub and the Redis relays since that is where the frame is serialized. The producer's map is copied rather than mutated. The removed keys are listed in a table rather than an if on one event type. The bug was structural, not a typo: the next large field added to a published payload inherits the same cost silently, and a declarative list puts the internal/external payload boundary in one reviewable place. issue.description itself is deliberately kept — clients apply it to their cache, so stripping it would trade fanout bytes for N refetches. Cutting the remaining fanout needs the per-issue scope routing already scaffolded server-side for MUL-1138, which is blocked on the client sending subscribe frames. Tests assert both halves: the keys are absent from the serialized frame, and the in-process listener still receives them. Co-authored-by: multica-agent <github@multica.ai> * fix(timeline): keep comment threads whole under the newest-N cap Review found that the newest-N window can orphan a reply, and an orphaned reply is invisible rather than merely mis-nested: the timeline builds its top level from "activities + comments with no parent_id" and renders replies by looking them up under their parent, so an orphan sits in the map with no card to render it. MUL-1847 / #2263 was exactly this shape — 1 root + 29 replies, root dropped, all 29 vanished from the UI while the API returned them. Root cause of the regression: capping with the OLDEST n could never orphan anything, because a reply is always newer than its parent, so a prefix of the timeline is closed under "parent of". A newest-n window is a suffix and has no such property. Flipping which end the cap bites silently invalidated a structural property the comment tree relies on. Two changes. Drop the cross-kind clamp. The previous revision trimmed both lists to a shared floor so the window was provably contiguous. That was the wrong trade and it was also the dominant source of orphans. Comments are human-paced (p99 ~30, max ever observed ~1.1k) and essentially never reach the cap, while activity is machine-paced and reaches it routinely — so the shared floor was almost always the activity floor deleting comments that had been fetched successfully and would have rendered fine. On an issue with thirty comments it was pure loss. Each list now reports its own truncation and X-Timeline-Truncated names which kinds were affected. Not clamping costs only activity density in the older part of the range, which is metadata rather than content, and it is reported rather than hidden. Complete parent chains for the case that remains — comments themselves exceeding the cap. ListMissingAncestorComments walks parent_id upward via a recursive CTE and returns the ancestors not already held; the handler merges them and restores the ascending order. This only ever ADDS rows, so unlike clamping it cannot hide anything the caller would have seen, and it is bounded by the number of distinct missing ancestors. Whole- thread windowing was considered and rejected: a single thread can exceed any row budget, so its degradation is not definable. Applied to the shared query's default list path too, not just the timeline. foldResolvedThreads documents a COMPLETE-thread set as its precondition and comment.go asserts the default list mode satisfies it; a half thread made that assertion false and a resolved thread whose root was cut stopped folding correctly. Also drops X-Timeline-Window-From. It was second-precision RFC3339 while the real ordering key is (created_at, id) at full precision, so it could not resume a read without skipping or repeating rows inside a shared second. A resumable cursor should be opaque and carry both halves; worth designing when there is a consumer rather than shipping as a lossy approximation. Tests: the reviewer's exact scenario, plus a no-orphaned-replies invariant on both endpoints, the fold-still-works case, and a guard that activity truncation does not delete comments. Each was confirmed to fail with the fix disabled. TestListTimeline_JointWindowHasNoOneSidedRegion was rewritten rather than deleted — it pinned the clamp behaviour being abandoned here, so leaving it would lock in the wrong contract and deleting it would drop the coverage. Co-authored-by: multica-agent <github@multica.ai> * fix(comments): bound parent-chain completion and stop folding partial threads Second review round on MUL-5492. Four must-fixes, all stemming from one conflation: parent-chain closure, a newest-N window, and a complete thread are three different things. Closure makes a reply renderable; it does not license thread-level derivations. Do not fold a truncated read. fetchCommentsForList closes parent chains, but older siblings and descendants of a retained reply stay outside the window, so the set holds partial threads. Folding them produced wrong answers rather than incomplete ones: a resolution reply outside the window made a resolved thread look unresolved, and folded_count reported a total derived only from retained replies. The previous revision claimed closure restored foldResolvedThreads' COMPLETE-thread precondition; it did not, and that claim is removed. --recent and untailed --thread still return whole threads and still fold. Bound the walk. The recursive CTE climbed to the root with no depth limit, so a deep chain could drag its entire ancestry back and defeat the row cap it was meant to preserve. Depth is genuinely unbounded in stored data: the general write path stores the exact comment being replied to (only the agent path collapses to the thread root), so chains can run far deeper than the two levels the UI renders. Replaced with a layered walk under explicit budgets — 2000 extra rows, 64 levels — making a response provably bounded by 4000 comments, or 6000 timeline entries with activities. Scope every level to the tenant. The CTE's recursive branch matched on parent_id alone. parent_id carries a foreign key to comment(id) but not to a matching issue, so a stray cross-issue parent reference is representable, and the walk would have followed it into another issue's comments. The replacement filters issue_id and workspace_id on every level. A negative test confirms the leak: with the filter removed it reports "a comment from another issue leaked into this issue's response". Degrade by pruning, not by orphaning. When a budget is exhausted, a parent row is missing, or a parent is out of scope, keepRootConnected drops the affected comments instead of returning replies the UI cannot render. Dropping a node also drops its descendants, since their chains run through it. Returning fewer new replies is conservative and already signalled as a truncated read; leaking another tenant's data, returning an unbounded response, or emitting invisible orphans are all worse. Also: probe read on the comment list so exactly-2000 is not misreported as truncated, which would needlessly suppress the fold; CommentsTruncated is carried on fetchCommentsResult rather than inferred from the result length, which is meaningless once completion adds rows. The new query returns db.Comment directly instead of a hand-copied row, which is how quick_action_id came to be dropped after the rebase — a backfilled quick-action root would have rendered as a raw prompt. Corrected three inaccurate comments: the "index-only scan" claim (the index avoids the sort but does not cover SELECT *), the "write path collapses replies to root" claim, and a test header still describing the abandoned contiguous-window behaviour. Tests cover exactly-at-cap still folding, truncated reads not folding (both reply-resolved and root-resolved), depth beyond budget pruned not orphaned, shared ancestors fetched once, cross-issue parents never crossing the boundary, and quick_action_id surviving backfill. Each was confirmed to fail with its specific fix disabled; the reply-resolved fold test was reshaped after the first version passed for the wrong reason. Co-authored-by: multica-agent <github@multica.ai> * fix(timeline): preserve complete threads under comment cap Co-authored-by: multica-agent <github@multica.ai> * fix(comments): preserve newest bounded views Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
e6610c0831 |
fix(usage): close the per-agent rollup windows so the leaderboard cannot exceed the totals (MUL-5551) (#6194)
The Usage page showed a single agent with 1021.0M tokens under a workspace Tokens KPI of 805.9M for the same 1D window. Both halves read the same rows and disagreed only on the window. parseSinceParamInTZ deliberately returns N+1 calendar days of headroom, and the date-bucketed series (usage/daily, runtime/daily) get trimmed back to -(days-1) client-side before the KPIs and the chart are computed. The two per-agent rollups behind the leaderboard carry no date column, so nothing trimmed them and they kept the full N+1 span: at days=1 that is today PLUS yesterday. One busy agent's two-day total then trivially exceeded the workspace's one-day total. Same defect and same fix already applied to failures/by-agent: switch usage/by-agent and agent-runtime to parseExactSinceParamInTZ. This also realigns the Run time / Tasks KPI tiles, which are sourced from agent-runtime and were therefore a day wider than the Cost / Tokens tiles beside them. Co-authored-by: Eve <eve@multica-ai.local> |
||
|
|
0a54485ab6 | chore(llm): use gpt-5.6-luna by default | ||
|
|
75c11db048 |
MUL-5549: feat(agent): discover codebuddy models over ACP instead of scraping --help (#6203)
* feat(agent): discover codebuddy models over ACP instead of scraping --help (MUL-5549) CodeBuddy speaks ACP, and `session/new` answers with a structured catalog under models.availableModels plus a currentModelId — exactly the shape the shared parseACPSessionNewModels already reads for Copilot / Kimi / Kiro / Qoder / Grok / TRAE. Scraping the `--model` line out of `codebuddy --help` was never necessary. The help text carried IDs and nothing else, which cost us three things: - Labels were guessed from the ID and were simply wrong. `kimi-k3-1` rendered as "Kimi K3 1" where the CLI says Kimi-K3; `deepseek-v3-2-volc` as "Deepseek V3 2 Volc" where the CLI says DeepSeek-V3.2. - The default model was a "first entry wins" guess rather than the advertised currentModelId. - The effort catalog needed a second regex over the same output. All three come from the handshake now. The effort catalog rides along in the same session/new response as the `thought_level` config option, so it costs no extra process — which also retires the "at most one --help per request" constraint added in #6196, because --help is no longer run at all. One trap worth naming: thought_level advertises `enabled` ("On (default)") alongside the six real levels, but `--effort enabled` is not a valid command line — the daemon passes the selected level straight to the flag. Advertised levels are filtered against the flag's accepted set, and a currentValue outside that set (the default `enabled`) becomes an empty DefaultLevel, which the UI renders as a generic "Default" instead of a value we cannot pass through. Two adjacent inaccuracies surfaced while confirming the real level set against CodeBuddy 2.130.0, both fixed here: the static effort fallback omitted `minimal` and `max`, and so did the server-side IsKnownThinkingValue gate — so the server rejected two levels the CLI genuinely accepts. Discovery keeps its fallback, still marked Fallback so it can never be cached as authoritative (#6196). That covers the not-logged-in case, which is deliberately NOT special-cased with an auth step: the catalog came back without calling authenticate on a logged-in CLI, and inventing an auth branch we cannot exercise would be speculation. Removes codebuddyModelRe, parseCodebuddyModels, codebuddyModelLabel, codebuddyModelProvider, codebuddyEffortRe, parseCodebuddyEffortHelp, codebuddyEffortSuperset, codebuddyHelpOutput and its 60s help cache. Co-authored-by: multica-agent <github@multica.ai> * fix(agent): keep codebuddy's vendor grouping after the ACP migration (MUL-5549) Review nit, and a real regression in the previous commit. Dropping codebuddyModelProvider looked like removing dead code, but it was the only thing populating Model.Provider for CodeBuddy — and the picker groups on that field. acpModelEntry can only recover a vendor from a `vendor:model` id. CodeBuddy's are bare (`glm-5.2`, `kimi-k3-1`), so every model came back with an empty Provider, and model-dropdown renders the empty group with no header at all: all 16 models would have collapsed into one unlabelled list where main shows Zhipu / Kimi / MiniMax / DeepSeek / Hunyuan sections. Restores the prefix inference as a post-pass over the ACP catalog, exactly the shape discoverCopilotModels already uses for the same reason. Verified against the real CLI: all 16 models land in five vendor groups with none ungrouped. Tests assert the vendor for every id CodeBuddy 2.130.0 advertises plus the static fallback ids, and that the fallback entries' hardcoded providers agree with the inference. Removing the post-pass fails them. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
0fdc38704e |
MUL-5149: add agent-generated Chat quick actions (#5766)
* feat(chat): add agent-generated quick actions
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): preserve mid-response quick-action fences
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): drop quick actions on empty reply to keep no_response fallback
An actions-only completion — a quick-actions footer with no visible text —
wrote an empty-content assistant message (message_kind=message). Older
Desktop/mobile clients ignore the quick_actions field and render that as an
empty bubble, breaking the MUL-4351 contract that an empty turn always gives
old clients a visible no_response fallback.
Drop the quick actions when the visible body is empty so an actions-only turn
falls through to the visible no_response outcome, and revert the completion
switch to gate the message row on visible text only. Update the completion
test to pin the corrected behavior.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): generate quick actions via daemon suggestion pass
Replace the in-band runtime-brief instruction with a dedicated post-completion
provider turn: after a direct chat reply finishes, the daemon resumes the same
session with a JSON-only suggest prompt and forwards the raw output on the
complete callback. The server parses it leniently and reuses the existing
sanitize/redact/store/broadcast pipeline; the stripped in-band footer stays as
a fallback for older daemons and pre-upgrade sessions. The footer strip now
covers every chat completion, fixing the intro-turn protocol leak. Adds a
Settings → Chat toggle (client-persisted, default on) that hides the chips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(chat): deliver quick actions async with skeleton placeholders
Decouple suggestion generation from the turn: the daemon reports completion
immediately (chat:done carries quick_actions_pending as a per-turn capability
signal) and runs the suggestion pass in the background, delivering results
through a new supplement endpoint + chat:quick_actions broadcast. A new turn
on the same session cancels the stale pass. Clients render pill skeletons
under the finished reply until the supplement resolves them (entrance
animation on arrival, 30s safety timeout); older daemons never raise the flag
so no skeleton dangles. Suggest usage re-reports merged totals because
task_usage upserts replace per (task, provider, model). Prompt now asks for
exactly 3 actions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(chat): make the quick-actions toggle stop generation, not hide pills
The Settings → Chat toggle previously only hid rendered pills while the
daemon kept burning a suggestion call every turn. It now travels with each
send (quick_actions_enabled, absent = enabled for older clients), is stamped
on the chat task (migration 213), forwarded on the claim, and gates the
daemon's suggestion pass at the source — no call, no pending flag, no
skeleton. Existing suggestions stay visible; settings copy now says
'generate' instead of 'show'.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(migrations): renumber quick-action migrations onto current main
Merging current origin/main brought the vcs migrations to their canonical
216-221 prefixes, which collided with the quick-action migrations that were
sitting at 219/220 (backend CI red in
TestMigrationNumericPrefixesStayUniqueAfterLegacySet). Renumber them to the
next unused prefixes:
- 219_chat_message_quick_actions -> 222_chat_message_quick_actions
- 220_agent_task_quick_actions_disabled -> 223_agent_task_quick_actions_disabled
Contents are unchanged; sqlc regeneration produces no drift since the added
columns are independent of the vcs tables.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(mobile): render async chat quick actions via chat:quick_actions
The daemon generates quick actions in a background pass after the turn
finishes, delivering them on a separate chat:quick_actions event. Mobile
only handled chat:done (which invalidates + refetches an actions-less
message list) and keeps the messages query at staleTime: Infinity, so an
active mobile session never rendered async-generated quick actions until a
manual pull-to-refresh or refocus.
Add applyChatQuickActionsToCache — mirroring web's patcher — which patches
the supplement onto the targeted assistant message in the flat messages
cache, and subscribe to chat:quick_actions in use-chat-session-realtime.
Patch-only (no invalidate), matching web and mobile's cellular
patch-over-invalidate rule; an empty supplement is a terminal no-op. Covered
by chat-ws-updaters.test.ts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): cancel in-flight messages refetch before quick-actions patch
The chat:done invalidate can leave a messages refetch in flight that read the
assistant row before the daemon persisted the quick actions. If that refetch
resolves after the chat:quick_actions setQueryData patch, it overwrites the
freshly-patched actions with an actions-less row. Both message caches are
staleTime: Infinity, so the overwrite never self-heals and the actions vanish
permanently (MUL-5149, Howard review).
applyChatQuickActionsToCache now awaits cancelQueries for the affected caches
(web: flat messages + messagesPage, mobile: flat messages) before patching, so
a stale in-flight refetch is cancelled and cannot land after the patch. Cancel
must precede setQueryData because cancelQueries reverts to the pre-fetch state.
WS handlers call it via `void` (fire-and-forget).
Adds an active-query race regression test on both web and mobile that holds a
refetch open across the supplement and asserts the patched actions survive;
verified to fail without the cancel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): quick-actions refresh/regenerate + review hardening (MUL-5149)
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): address quick-actions re-review (MUL-5149)
- Ack alignment: refresh request carries the target message_id; server
atomically confirms it is still the session's latest turn (409 stale
otherwise), so the client marker always matches the resolving
chat:quick_actions — no response reconciliation. Adds a regression test.
- Converge the pending marker on every terminal path: HandleFailedTasks
(sweeper/orphan) now resolves it, and the daemon reports a failed supplement
so FailTask resolves it instead of leaving a completed-but-unresolved task.
- Timeout fallback now clears the real query state (useQuickActionsPendingTimeout)
instead of a component-local flag that only masked the UI; drop the skeleton's
and pill row's local timers.
- frontend-test type-scale: text-xs -> text-caption. Strip EOF blank line.
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): close quick-actions refresh races and failure feedback (MUL-5149)
Third-round review of the refresh button surfaced three issues; all three
are addressed here.
§1/§2 Session-busy race + concurrent-refresh double-spend: a newer reply
that is queued/running but whose assistant row hasn't landed leaves the old
turn as latest-persisted, so the stale check passes and the regen resumes
the newer provider state — attaching suggestions to the wrong turn. And two
concurrent refreshes each enqueue a quota-spending pass. Add
HasActiveChatTaskForSession and refuse a refresh (ErrChatQuickActionsBusy →
409) whenever the session has any task in flight, checked under the same
session lock as the enqueue so no sibling insert slips past.
§3a Timeout re-arm on surface switch: the pending marker now carries an
absolute expires_at deadline instead of a per-mount timer, so switching
between the floating window and the chat tab resumes the same deadline
rather than restarting a fresh 30s window each remount.
§3b Generation failure masked as success: runChatSuggestPass now returns ok
so an explicit refresh distinguishes a failed pass (didn't start / didn't
complete / timed out) from a completed-but-empty one. On failure the regen
task reports failure, resolveFailedRegenerateQuickActions broadcasts a
FAILED chat:quick_actions, and the client resolves the spinner AND toasts
"couldn't refresh" instead of silently stopping on unchanged pills.
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): count deferred tasks in refresh busy check; solid refresh icon tone (MUL-5149)
Two re-review blockers on
|
||
|
|
5e3b7a8c37 |
feat(issues): Issue Quick Actions — preset agent + prompt, one-click from the sidebar (MUL-5465) (#6132)
* feat(issues): Issue Quick Actions — preset agent + prompt, one-click from the sidebar (MUL-5465)
Preset "who to call and what to say" once in Settings, then trigger it from
any issue's sidebar with a single click.
Running one is NOT a new dispatch path. The server renders the prompt, posts
a `quick_action` comment carrying the target's mention markup, and hands off
to the existing comment -> mention -> task trigger. Permission
(canInvokeAgent), attribution, squad-leader routing, the execution log, and
pending-task coalescing are inherited rather than reimplemented — the
MUL-3375 lesson about four drifting copies of one trigger decision.
Three things the UI has to be honest about, because the backend already
decided them:
- One pending task per (issue, agent) is a DB invariant
(idx_one_pending_task_per_issue_agent). A second click against a busy agent
starts no new run; the comment merges into the pending task. The toast says
"Added to Lambda's current run", not "Lambda started working".
- An offline target defers rather than fails; the run reuses the existing
dispatch.ReasonCode vocabulary instead of inventing one.
- Private agents are deny-by-default with no admin bypass. The sidebar filters
by the caller's own invoke verdict, so a dead button is never rendered, and
a direct API call still 403s with `invocation_not_allowed`.
Visibility is DERIVED from the bound agent's permission_mode on every request,
never stored — so it cannot drift after someone flips an agent between private
and public_to. Binding a workspace action to a private agent is allowed (the
alternative pressures people into making agents public just to satisfy a
config constraint) but the settings form says so at bind time, and the
catalog badges it. The target's name is withheld from callers who cannot see
it, so the response never discloses a private agent's existence.
Prompt templating is flat substitution over a closed whitelist. No
conditionals, loops, or filters — the agent already reads the whole issue, so
natural language is the control flow. One optional runtime input ({{input}})
keeps a single action from splitting into five near-identical variants; both
directions of the input/{{input}} agreement are rejected at write time so a
typo can never land silently.
Surfaces: sidebar (top 5, rest behind More), the `/` menu in the comment
composer (inserts the server-rendered body to edit before sending), and
Alt-click for the same hand-off from the sidebar.
Migrations 234-236: quick_action table, its listing index (CONCURRENTLY, own
file), and comment.type + comment.quick_action_id.
Co-authored-by: multica-agent <github@multica.ai>
* refactor(issues): simplify quick action permissions to a stored public/private intent (MUL-5465)
Replaces the derived four-value visibility model with a two-value choice made
at creation, and collapses permission handling to a single check.
The old model computed visibility per request from the bound agent's
permission_mode and used it to filter the sidebar. That filtering was the
problem: two people on one issue saw different sidebars with nothing to
explain the difference, which is harder to debug than a button that tells you
why it refused. It also required the list endpoint to run an invocation-target
query per action per request.
Now:
- `visibility` is stored INTENT — 'public' or 'private' — chosen up front.
- A public action must bind a target every workspace member can invoke
(public_to carrying a workspace target), enforced at write time. So a
public action is runnable by construction and dead buttons are eliminated
at the source rather than filtered out later.
- A private action allows any target and is returned only to its creator.
That scoping is what the field MEANS, not a permission check.
- Permission is checked in exactly one place: RunQuickAction. A refusal is a
structured 403 the client renders as one dialog. The dialog does not
distinguish "no permission" from "the binding drifted" — the person
reading it takes the same next step either way, and the person who can fix
it looks at settings.
Removed: can_run, position + manual ordering (settings sorted by usage while
the sidebar sorted by position — one list, two orders), the derived
visibility_broken flag, the runnable_only projection and its second cache
entry, target_name redaction, the alt-click composer hand-off (the `/` menu
covers insert-then-edit and is discoverable), and the sidebar_limit response
field (now a shared constant).
Ordering is use_count DESC everywhere. Settings shows the target's current
reachability as plain metadata ("Nova · private"), so a public action pointing
at a now-private agent reads as visibly wrong without a bespoke error state.
The tradeoff — no active signal when that drift happens — was accepted
deliberately: drift is rare and the failure is loud at click time.
Migration 234 is edited in place rather than layered, since the PR is
unmerged and the table has never been deployed.
Co-authored-by: multica-agent <github@multica.ai>
* refactor(issues): drop quick action variables and runtime input (MUL-5465)
V1 ships a preset prompt sent verbatim, triggered from the sidebar or the `/`
slash command. Two features are removed and one guard is kept.
Runtime input goes because `/` already covers it. Typing `/code review` drops
the rendered body into the composer, where any part of it can be edited before
sending — strictly more flexible than one fixed field, and the field was
specified before `/` was in V1. Two UIs for one need.
Variables go because none of them passed their own test. The rule was that a
variable earns its place only if it changes what the agent ATTENDS TO, not what
it KNOWS. Checked one by one — {{issue.title}}, {{issue.identifier}},
{{issue.url}}, {{user.name}}, {{date}} — the agent already has every one from
the issue context and from the fact that the comment is authored by the person
who triggered it. They were inherited from autopilot's title template rather
than justified.
The REJECTION survives the feature: any `{{...}}` is refused at write time,
naming the offending token. Someone carrying the habit over would otherwise
have `{{issue.title}}` rendered literally into an agent's instructions and
never notice — the exact silent-typo failure the whitelist existed to prevent.
The check is a fraction of the interpolation engine it replaces and keeps the
door open to enabling variables later without touching stored data.
Removed: 4 columns (input_enabled/label/placeholder/required),
renderQuickActionPrompt + the variable whitelist + quickActionIssueURL, the
two-way {{input}} agreement logic, the run/render `input` parameter, the
variable insert chips, the entire "Ask for input on click" block, and the
sidebar's Popover branch — every row is now a plain button. The settings
dialog drops from six field groups to four.
Migration 234 is edited in place rather than layered, since the PR is unmerged
and the table has never been deployed.
Co-authored-by: multica-agent <github@multica.ai>
* refactor(settings): align Quick Actions with the Labels/Properties list, then fix what the UI review found (MUL-5465)
The tab used a bespoke card list while its two siblings — Labels and
Properties — share one table layout. These three are the workspace's catalog
of small named things and should read as one surface, so Quick Actions now
uses the same structure: search + primary action row, bordered card, responsive
column grid that collapses to stacked rows under `md`, and an overflow menu
instead of a row of icon buttons. Columns are Name / Runs as / Who / Used /
Updated. The tab joins the max-w-5xl group for the same reason.
A UI review pass over the result found five things, four of which are fixed
here:
- The visibility chooser communicated selection through border and background
only, so a screen reader announced both options identically. Added
aria-pressed.
- The editor dialog was max-w-xl while both siblings use sm:max-w-lg, and the
unprefixed cap applied at every breakpoint.
- The empty-state hint diverged from the Properties tab it was copied from
(text-sm and no max width vs mx-auto max-w-sm text-xs).
- Two hardcoded `text-amber-600 dark:text-amber-400` usages replaced with the
`text-warning` semantic token, per the repo's design-token rule.
Also fixed a signal-quality bug the review surfaced: the usage column
highlighted anything with use_count 0, so an action was flagged the instant it
was created. Staleness now means "has had time to be used and wasn't" — 90
days since last use, or 90 days since creation for one never used.
Not fixed here: the overflow trigger is size-7 (28px), under the 44px touch
floor. Labels and Properties use the identical size, so changing only this tab
would break the consistency this commit exists to create; it needs one pass
across all three.
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): drop the quick_action comment type, widen the mention guard, harden the slash race (MUL-5465)
Second review round on PR #6132. All four remaining findings.
**Comment type removed entirely (#2 blocker + #3).** Adding a `quick_action`
type meant dropping and re-adding comment_type_check, and re-adding a CHECK
holds ACCESS EXCLUSIVE on `comment` for a full table scan — a read/write stall
on one of the hottest tables in the product, every deploy. It was also
forgeable: `type` is client-supplied on POST /comments, so any member could
post type='quick_action' and have an ordinary comment render as an action
audit record with its body collapsed out of view.
Both go away by not having the type. A quick action now posts an ORDINARY
comment marked with `quick_action_id`, and the collapsed card keys off that id.
There is no request field for it, so the marker cannot be forged, and the
migration is a bare nullable ADD COLUMN — metadata-only and instant. Verified
against a fresh database: comment_type_check is untouched.
The generic comment endpoint now also validates `type` instead of letting the
DB CHECK reject it. An unknown type surfaced as a 500 on a constraint
violation, which reads as a server fault for plainly bad input; it is a 400
now. `status_change` and `system` are excluded from what a client may author —
claiming those would be forging system narration.
**Member mentions rejected too (#1).** The first pass allowed
`mention://member/...` in prompts on the reasoning that it "only renders a
link". That was wrong: notification_listeners.go adds member mentions to the
recipient set and creates an inbox item, so a saved prompt pinged that person
on every single click. Only `mention://issue/...` reaches nobody and stays
allowed.
**Slash race, properly this time (#4).** The previous fix checked only that the
range still started with "/". Rewriting `/review` into `/fix` while the request
was open passed that check, and the stale response overwrote the new command.
The exact original text is now captured and compared; if the command was
edited, moved, or removed, the pick is abandoned rather than inserted
somewhere wrong. Adds the three regression tests the review asked for:
delayed resolve, rejection, and edit-during-flight.
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): stop the quick action card repeating its own prompt, and insert the `/` body as markdown (MUL-5465)
Two fixes, one reported and one found while verifying it.
**The card printed the prompt twice.** The collapsed header previewed the
prompt's first line, and expanding showed the mention line plus that same
prompt again. The header now identifies WHICH action ran — "Code Review via
Lambda" — which is both non-redundant and something the body never told you:
the prompt text alone does not say which action produced it. This is what the
original design called for; previewing the prompt was the implementation
drifting from it.
When the action cannot be resolved — deleted, or another member's private one
and so absent from this viewer's catalog — the header falls back to the
prompt's opening line, which is the previous behaviour.
**The `/` menu inserted its body as literal text.** insertContentAt was called
with a plain string, so Tiptap treated the server-rendered markdown as text
rather than parsing it. The mention never became a node; it serialised back out
with escaped brackets (`\[@Lambda\](mention://agent/…)`) and rendered as raw
markup in the thread. Passing `contentType: "markdown"` — the same option the
description editor already uses — parses it properly. Found by reading the
comment rows while checking the first fix: one had escaped brackets and no
quick_action_id, which is what a slash-inserted comment looked like.
The existing async test now asserts the contentType, so the option cannot be
dropped again without failing.
Co-authored-by: multica-agent <github@multica.ai>
* docs(issues): correct the stale quick actions sidebar comment (MUL-5465)
The comment still claimed the section renders nothing when no action is
runnable by the member. Permission filtering was removed several rounds
ago -- the list is deliberately unfiltered and a refusal is explained at
run time -- so the comment described behavior that no longer exists.
Co-authored-by: multica-agent <github@multica.ai>
* refactor(settings): cut the quick action dialog's helper copy in half (MUL-5465)
The dialog had five blocks of explanatory prose around four fields, and
three of them wrapped to two lines, so the form read as a paragraph with
inputs in it.
Each helper now earns its line or loses it:
- The header explained the implementation ("keeps the same history,
permissions, and execution log as an @mention") -- an architecture note
the person creating an action does not need. Reduced to the one fact
they do: it posts a comment.
- "Who can use it" is a question, so the hints answer it as noun phrases
("Everyone in the workspace" / "Only you") instead of restating the
verb. Both now fit one line, which also makes the two cards the same
height -- the shorter one used to sit in dead space.
- The target and prompt hints front-load the constraint rather than
burying it mid-sentence.
70 words to 32 across the dialog, with no fact dropped. Field spacing
goes 4 -> 5 so the gap between groups beats the gap inside one.
Co-authored-by: multica-agent <github@multica.ai>
* refactor(issues): render a quick action comment as an ordinary comment (MUL-5465)
The card had a collapsed one-line header that expanded to reveal the
prompt, on the theory that repeated runs of the same action would bury
the discussion. That was solving a problem the feature does not have:
prompts are a sentence or two, the header restated what the body already
said, and the disclosure only put a click between the reader and the
text.
A quick action posts a real comment through the real mention path, so
the honest rendering is the one every other comment gets. Drops
QuickActionCommentBody, its query for the action catalog, and the
now-orphaned quick_action_ran_via string in all four locales.
quick_action_id stays on the comment: it is provenance, and it was never
the reason the card looked different -- keying the special rendering off
it is what is going away, not the record itself.
Co-authored-by: multica-agent <github@multica.ai>
* fix(settings): use the faint tone token for the empty-state icon (MUL-5465)
main added apps/web/app/text-contrast.test.ts, a guard that rejects
transparency standing in for a text tone. The empty-state Zap used
text-muted-foreground/60, which is exactly the pattern it forbids: an
alpha-dimmed tone lands at a different contrast on every surface it is
composited over, so it cannot be reasoned about the way a token can.
text-faint-foreground is the token the guard names for icons and glyphs.
The rule arrived on main after this branch's last merge, so local runs
never saw it -- CI tests the merge commit, which is why only CI caught
it. Merged main first so the branch is checked against the same rules.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
|
||
|
|
44ce16d9b8 |
MUL-5549: fix(agent): stop reporting a failed model discovery as a real catalog (#6196)
* fix(agent): stop reporting a failed model discovery as a real catalog (MUL-5549) Selecting the CodeBuddy runtime showed a model list that shares no IDs with what the CLI actually supports, so every pick was an ID codebuddy rejects (GH #6180). The list in the report is codebuddyStaticModels() verbatim: the daemon had fallen back, but nothing downstream could tell. discoverCodebuddyModels returned (staticModels, nil) on all three failure paths, and copilot/cursor/grok do the same. A failed discovery therefore arrived as a successful one, which defeated every guard built to catch it: the daemon reported status "completed", the picker's discovery_failed hint only renders on isError, and cacheableModelCatalog — whose own comment says an empty list means transient failure — waves through a non-empty stand-in and stores it as last-known-good for the full 24h serve window. One blip got pinned as the answer for a day. Discovery now returns a Catalog carrying a Fallback marker, which the daemon forwards as an additive `fallback` field (older servers ignore it; an older daemon omitting it keeps the previous behaviour). A fallback catalog is still rendered — the picker stays populated and manual entry still works — but it is kept out of both the daemon's 60s discovery cache and the server's catalog cache. On the server it maps to Keep rather than Drop: a stand-in is no grounds to evict a real catalog, matching how a `failed` report is treated. Also stop codebuddyHelpOutput swallowing the exec error. CombinedOutput folds in stderr, so a codebuddy whose `#!/usr/bin/env node` interpreter is missing from a GUI-launched daemon's PATH had `env: node: No such file or directory` parsed as help text — and cached as such for 60s. Verified against CodeBuddy CLI v2.130.0: the parser itself is fine (16 models from real --help), so this fixes the reporting of the failure, not the parse. Co-authored-by: multica-agent <github@multica.ai> * fix(agent): run codebuddy --help at most once per model-list request (MUL-5549) Review catch on the previous commit. Model discovery and effort discovery both read `codebuddy --help`, and the effort pass called it independently. That was free while a failed --help was (wrongly) memoised, but once failures correctly stopped being cached, the failure path ran the 35s command twice in a single request — past the server's 60s running timeout, so the request timed out and the late report was then discarded as stale. The user got nothing, not even the fallback list the previous commit exists to preserve. discoverCodebuddyModels now owns the thinking annotation, so the one help capture feeds both catalogs, and the failure path uses codebuddyFallbackCatalog to apply the static effort levels without exec'ing at all: whatever broke --help for the model catalog breaks it for the effort catalog too. Also strengthen the handler tests. They decoded into a struct declared in the test rather than calling ReportModelListResult, so a wrong JSON tag or a mis-wired cache branch would have passed. They now drive the real endpoint with daemon auth and chi params, covering: a fallback report leaving a previously discovered catalog intact, an older daemon omitting the field still warming the cache, and an authoritative empty catalog still dropping the snapshot. Both fixes are mutation-tested — reverting either makes the new tests fail. Co-authored-by: multica-agent <github@multica.ai> * docs(agent): correct codebuddy --help comments after the single-capture refactor (MUL-5549) Review nit. The comments still described the pre-refactor call graph, where both discoverCodebuddyModels and codebuddyEffortSuperset called codebuddyHelpOutput and the cache was what stopped the duplicate run. The effort parser now takes an already-captured string, and the single-invocation guarantee is structural rather than cache-dependent — which matters, because a failed --help is deliberately not cached, so a second caller would re-run the full 35s timeout. Also note on codebuddyHelpOutput that it has exactly one caller and why a new one would reintroduce the bug. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
f0110da555 |
feat(inbox): mark a notification unread from the row context menu (MUL-5496) (#6137)
The inbox auto-marks a notification read the moment it is selected, so
"opened" and "handled" were the same signal — a row you glanced at and
meant to come back to was gone from the unread count with no way back.
Right-click any inbox row for a shared context menu: Mark as read /
Mark as unread, plus Archive (Unarchive in the archived view).
- POST /api/inbox/{id}/unread + MarkInboxUnread query, publishing
inbox:unread. Item-scoped, mirroring mark-read: the list renders one
row per issue carrying that group's newest item, so flipping the whole
group would resurrect siblings the user already dealt with.
- useMarkInboxUnread patches both lists optimistically and re-pulls the
cross-workspace unread summary on settle.
- One shared menu per list rather than a Base UI root per row (the same
shape IssueContextMenuProvider uses): only one is ever open, and a
per-row root would unmount with its menu when the row scrolls out of
the virtualized viewport.
- The read toggle is main-view only — archived rows deliberately render
as read and the unread count excludes them, so a toggle there would
report success and change nothing on screen.
- Parking the row that is currently open holds the auto-read effect off
that one item while it stays selected; re-opening it later marks it
read again.
- Mobile subscribes to inbox:unread so the unread dots agree across
clients.
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
|
||
|
|
99d8f29bde |
fix(codex): raise the first-turn no-progress ceiling to 60s (MUL-5542) (#6192)
The first-turn watchdog killed healthy turns. Two independent field reports measured the first progress event landing just past 30s on gpt-5.5, and ~39s for a WSL app-server, all inside the window the watchdog treats as "stuck". Raise the ceiling to 60s. The window's only job is to fail fast instead of waiting out the 10 minute semantic inactivity backstop, so 60s keeps that value while clearing the observed evidence with margin. Add a regression test for the clamp, which had none: the configured semantic inactivity timeout can only shrink this window, never raise it. Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
1d2a3499c9 |
test(agent): stop the cursor fixtures racing their own prompt write (MUL-5536) (#6174)
TestCursorExecuteFailsOnCleanEOFWithoutResult failed on main with "cursor-agent prompt write failed: write |1: broken pipe" where it expects "stream ended without terminal result". The fake cursor-agent exits without reading stdin, so the prompt write races the child's exit: win and the pipe buffer swallows it, lose and the read end is gone and the write returns EPIPE. writeErr outranks both exitErr and the generic no-terminal-result error in cursor.go, so a lost race replaces the asserted failure with the EPIPE one. A real cursor-agent reads stdin to EOF, so the fixtures now do too. That removes the race rather than reordering production error precedence, which is deliberate. Only the two fixtures whose expected error ranks below writeErr need the drain. Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
999e9f93c7 |
fix(codex): record file edit payload for patch_apply events (#6158)
* fix(redact): scrub secrets nested inside tool input maps and slices
InputMap only passed top-level string values through Text and documented
non-string values as "preserved as-is". Any secret one level down reached
the database and the WebSocket broadcast untouched:
flat -> [REDACTED ...] (scrubbed)
nested -> [map[diff:token=ghp_... path:a.go]] (leaked verbatim)
This is a prerequisite for recording structured file-edit payloads. Codex
reports an edit as changes[]{path, diff, content}, and the legacy protocol
reports a deletion as the whole outgoing file — so without this, deleting a
.env would persist its full contents in cleartext.
redactValue now walks the composite shapes json.Unmarshal produces, plus
[]string and map[string]string for argv-style inputs. Composites are copied
rather than scrubbed in place, because the caller keeps using the map it
passed in.
Nesting depth comes from daemon-supplied JSON, so the walk is bounded at 32
levels; a pathologically nested payload would otherwise recurse until the
stack blows. Hitting the bound yields a placeholder rather than the raw
value, keeping the fail-safe direction.
Verified: the five new tests each fail against the previous top-level-only
implementation and pass now; full ./pkg/redact suite green.
Co-authored-by: multica-agent <github@multica.ai>
* fix(codex): record file edit payload for patch_apply events
Both Codex protocol paths recorded a file edit as a bare call ID and set no
payload, so a run that edited six files left six blank, unexpandable rows in
the transcript. The same task on Claude or Grok showed a readable diff, and
the branch Codex pushed was the only surviving record of what it changed
(GH #6157).
The omission was specific to this one tool, not to the adapter: the
exec_command handlers directly above already captured command and output.
Both paths are fixed, since the protocol is sniffed at runtime. Their wire
shapes differ more than they appear, and the normalizer reconciles that:
- legacy patch_apply_begin/end carry map[path]FileChange, internally tagged
on `type`, where add/delete hold whole-file `content` and only update
holds a `unified_diff` plus `move_path`. There is no diff for every case,
so the normalized form keeps diff and content as alternatives.
- v2 fileChange items carry an ordered array of {path, kind, diff} where
`kind` is an object, not a string — reading it as a string silently
yields "" and loses the add/delete/update distinction.
- status spellings differ too: legacy is snake_case, v2 is camelCase and
adds inProgress. Both normalize onto one vocabulary, and a legacy event
predating `status` falls back to its `success` bool.
Legacy map iteration is sorted by path so a replayed event does not reshuffle
the file list.
Completion events now also produce a non-empty output (status, file count,
and any apply_patch stdout/stderr), because an empty output renders as an
unexpandable blank row just like a missing input.
Anything unrecognised — absent, wrongly typed, or malformed changes — returns
no payload, preserving exactly the previous degradation rather than risking
the transcript.
Total diff/content bytes are bounded at 64 KiB with UTF-8-safe truncation,
recording `truncated` and `original_bytes`; paths and kinds always survive,
since they are what a reviewer needs when the body is gone. The bound is
deliberately scoped to this new payload: other providers stream tool inputs
through unbounded, and clamping them here would silently truncate
transcripts that render correctly today. Unifying the limit at the
persistence boundary is left as a follow-up.
Verified: the new tests reproduce the reported symptom (Input:map[],
Output:"") against the previous call sites and pass now; ./pkg/agent and
./pkg/redact green, go vet and gofmt clean.
Co-authored-by: multica-agent <github@multica.ai>
* feat(transcript): render Codex multi-file patch payloads as diffs
The presenter identified an edit by input shape — a top-level file_path plus
old_string/new_string or content — which is Claude's and Grok's shape. Codex
records one patch_apply covering several files as changes[], so even with the
payload now populated it fell through to pretty JSON instead of a diff.
A new `patch` detail kind carries one entry per file, since collapsing them
into a single body would lose which change belongs where. Each file reuses the
existing single-file surfaces, so all bodies behave alike inside the
virtualized list.
Codex hands over a ready-made unified diff, so parseUnifiedDiff maps it onto
diff rows rather than recomputing one — there is no before/after pair to
compare, and reconstructing both sides from the diff just to diff them again
would be circular. Hunk headers become `gap` rows, which is what they denote:
skipped unchanged content.
A deletion renders as all-removals rather than as a whole-file write, because
the legacy protocol reports it as the outgoing file's content and a green
"+N" gutter would state the opposite of what happened.
The collapsed row needed its own fix: with no single path field, the summary
fell through the preference chain and came back empty. It now reads as the
first path plus "+N more".
Anything that is not this shape still falls back to pretty JSON, so a payload
this presenter does not understand stays readable.
Verified: 17 new tests (43 in the presenter suite) pass; repo typecheck and
lint clean. The one failing views test, layout/sidebar-resize, fails
identically on an untouched checkout.
Co-authored-by: multica-agent <github@multica.ai>
* fix(codex): route v2 add/delete payloads as content, not diff
Addresses review on #6158.
Upstream's format_file_change_diff only produces a unified diff for `update`.
For `add` and `delete` it returns the whole file's contents under the same
`diff` field, and for a moved `update` it appends a trailing
"\n\nMoved to: <path>" line:
FileChange::Add { content } => content.clone(),
FileChange::Delete { content } => content.clone(),
FileChange::Update { unified_diff, move_path } => ...
(codex-rs/app-server-protocol/src/protocol/item_builders.rs, rust-v0.145.0)
Recording that as a diff mislabels every line of an added or deleted file as
context, and actively inverts any line whose content begins with '+' or '-' —
so an added file containing "-minus lead" rendered as a deletion. The payload
is now routed by `kind` rather than by field name, and the "Moved to:"
sentence is stripped since move_path already carries the destination.
The previous v2 tests hid this by using a fixture the real protocol never
emits (an `add` carrying "@@ ... +package main"). They now use upstream's
shape, plus cases for delete, an empty add, and an add whose contents look
like diff headers.
Empty bodies are also kept on both paths: presence of the field, not its
non-emptiness, decides whether a body was reported, so an empty added file
renders as an empty body instead of "no content reported".
Verified: the new assertions fail against the previous normalizer — where an
`add` came through as {"diff": "package main\n"} — and pass now.
Co-authored-by: multica-agent <github@multica.ai>
* fix(transcript): stop treating header-like content lines as file headers
Addresses review on #6158.
parseUnifiedDiff matched "---" / "+++" / "diff --git" / "index " at any
position, so a changed line whose *content* starts with a dash or plus was
silently discarded:
parseUnifiedDiff("@@ -1 +1 @@\n--- old markdown\n+++ new markdown\n")
// before: [{ kind: "gap", ... }] — both changed lines gone
A removal of "-- old markdown" is spelled "--- old markdown" on the wire, so
this hit Markdown rules, embedded patches, and comment banners.
File headers only exist ahead of the first hunk, so they are only recognised
there; once inside a hunk every line is parsed strictly by its first
character.
Also localizes the multi-file summary count, which was hardcoded English and
so leaked into the zh-Hans / ja / ko transcript rows. The presenter owns no
React and no i18n by design, so the phrasing is injected by the caller rather
than imported here, keeping the module unit-testable in isolation; the English
form remains the fallback. The three Chinese/Japanese/Korean truncation
strings now use "..." to match the English source they translate.
Verified: both new parser assertions fail against the previous
strip-anywhere behaviour and pass now; 47 presenter tests green, repo
typecheck and lint clean.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): redact nested tool input before it leaves the daemon
Addresses review on #6158.
Recursive redaction ran only in the server's ingest handler. The daemon built
the new nested edit payload and sent msg.Input verbatim, so a daemon that
self-updated ahead of the server — or one talking to a server mid-rollout —
would ship whole-file edit contents to a peer that does not scrub nested
values yet. The legacy protocol reports a deletion as the whole outgoing file,
so that window covered a deleted .env in cleartext.
Ordering three commits inside one PR is not a deployment barrier, and daemon
and server upgrade independently. Deployment order is not a control we have,
so the sending side is now safe on its own; the server keeps redacting on
ingest as the second line of defence.
Scoped to Input, which is the field this PR newly fills with file contents.
Content and Output are plain strings already redacted server-side, and
changing their daemon-side handling would be unrelated to this fix.
Verified: the new daemon test asserts the nested token is masked in the
reported batch while the change metadata survives. It fails without this
change, reporting the full GITHUB_TOKEN= line on the wire, and passes with
it; ./internal/daemon green.
Co-authored-by: multica-agent <github@multica.ai>
* fix(transcript): correct the Chinese multi-file patch count semantics
Addresses review on #6158.
The summary is handed the number of files *beyond* the named one, but the
Chinese phrasing stated a total: "a.go 等 2 个文件" reads as two files including
a.go, so a three-file patch under-reported by one. English hides the
distinction ("+2 more"), which is why it survived the first pass.
Rewords zh-Hans to "另有 N 个文件". Japanese (他) and Korean (외) already read
as "besides", so their wording is unchanged.
Also renames the interpolation variable from `count` to `extra`, for two
reasons. i18next treats `count` as the plural selector — this very namespace
relies on that for events_one/events_other — so a plain number had no business
borrowing it. And the name is what a translator reads: `extra` cannot be
mistaken for a total the way `count` was.
Guards the whole bug class rather than just this string: a locale test asserts
every locale interpolates {{path}} and {{extra}} and never the reserved
{{count}}, and a presenter test pins that the injected number is the count of
additional files, not the total.
Verified: both new locale assertions fail against the reverted string and pass
now; rendering the real locale strings for a three-file patch yields "+2 more",
"另有 2 个文件", "他 2 件", "외 2개". 53 target tests pass, repo typecheck clean,
views lint back to its pre-existing 16 warnings and 0 errors.
Co-authored-by: multica-agent <github@multica.ai>
* fix(transcript): put the patch surface on the type scale
Addresses review on #6158.
The patch surface wrote text-[10px] / text-[11px] / text-[10px], copied from
the sibling transcript surfaces as they looked when this branch started. Since
then MUL-5451 (#6136) introduced a role-named type scale and migrated those
same siblings to text-micro, so these three call sites were the only remaining
arbitrary sizes — and the type-scale guard reports them precisely.
All three become text-micro. That matches the analogues they were copied from
now that those have moved: the FileWriteSurface line-count row, the
DiffDetailSurface header row, and the ToolDetailSurface body. It is also the
only correct target, since micro (11px) is the smallest step the scale defines
— there is nothing at 10px to map to.
Merges origin/main so the guard runs here rather than only in CI.
Verified: apps/web app/type-scale.test.ts 13/13 (it listed exactly these three
lines before), no `text-[` left in the file, repo typecheck clean, views lint
unchanged at 16 pre-existing warnings and 0 errors.
Co-authored-by: multica-agent <github@multica.ai>
* fix(codex): redact patch bodies before applying the size budget
Addresses review on #6158.
The adapter sized and truncated the normalized changes, and redaction only ran
later — in the daemon before sending, then again in the server on ingest. That
order loses secrets that straddle the budget.
The PEM rule needs both markers to match:
-----BEGIN[A-Z\s]*PRIVATE KEY-----.*?-----END[A-Z\s]*PRIVATE KEY-----
So a 70 KB private key whose BEGIN sits inside the first 64 KiB and whose END
falls past the cut stops matching once truncated. Neither later pass can
recognise what truncation already broke, so the marker and 64 KiB of key
material reach the database and the WebSocket broadcast. Measured on the
previous code:
stored bytes 65536 | BEGIN marker present | key body present | placeholder absent
Redaction now runs first, and the budget measures the redacted bodies — which
is also the honest measurement, since those are what actually gets stored and
redaction usually shrinks them (that key collapses to 23 bytes, so no trimming
is needed at all). `original_bytes` still reports the pre-redaction size so the
reader sees how large the real patch was. The daemon and server passes stay as
defence in depth; redaction is idempotent, so running three times is safe and
that is now asserted.
Note for callers: codexPatchInput no longer trims its argument in place, because
redaction copies first. Two existing tests were asserting on the caller's
original slice and had silently become vacuous; they now read the returned
payload, and one pins the no-mutation contract. The delete fixture in the
diff-vs-content routing test was also a credential-shaped string, which now
redacts — it is plain text so that test keeps testing routing.
Verified: the new boundary test fails on the previous order, reporting the
surviving BEGIN marker and key material, and passes now. go test ./pkg/agent
./pkg/redact ./internal/daemon green; execenv ByteIdentical green; go vet and
gofmt clean.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
|
||
|
|
9072cef12c |
Revert "MUL-5493: feat(chat): add a visible follow-up queue (#6133)" (#6171)
This reverts commit
|
||
|
|
d6bd4cf7d5 |
fix(agent): recover from Hermes resumed sessions it refuses without running (MUL-5509) (#6164)
* fix(agent): recover from Hermes resumed sessions it refuses without running Hermes never reports an unknown ACP session as a JSON-RPC error. Its adapter answers session/prompt for a session it cannot load with an ordinary success frame carrying stopReason=refusal, and session/resume echoes nothing back -- ACP's ResumeSessionResponse has no sessionId field at all, unlike NewSessionResponse -- so resolveResumedSessionID keeps the id we asked for. Nothing in the exchange is an error, so the isACPSessionNotFound branches at set_model and prompt time never fire for this backend. The result was a Result carrying the dead session id with ResumeRejected=false, which shouldRetryWithFreshSession reads as "not a rejection". GetLastTaskSession then handed the same dead id to every later dispatch on that (agent, issue) pair, so a Hermes agent was usable exactly once per issue: the first task worked, and every following comment, approval or @mention failed the same way until a manual rerun bought one more turn. When no provider error reached stderr the turn was worse than a failure -- it reported completed with empty output, an agent that silently did nothing. Treat a refusal on a resumed session with zero agent activity as the runtime telling us the session is gone: clear the session id, set ResumeRejected so the existing fresh-session retry and session-retirement path take over, and fail the turn instead of reporting an empty success. Both conditions are required -- stopReason=refusal alone is a legitimate model refusal, and a refusal after real work is not a lost session. The reason is applied after promoteACPResultOnProviderError so a captured provider error stays the user-visible message; the generic fallback only fills in when nothing more specific was seen. Verified against the real hermes CLI (upstream main ba7d214b6) on an isolated HERMES_HOME with a local mock endpoint: a fresh task completes, and resuming it now yields status=failed, SessionID="", ResumeRejected=true with the provider error preserved -- previously the dead id came back with ResumeRejected=false. Refs GH #6150, MUL-5509 Co-authored-by: multica-agent <github@multica.ai> * fix(agent): decide Hermes resume loss after the pipe drain, not at quiescence The notification quiet window closing does not end a turn — stdin EOF and the pipe drain do, and Hermes legitimately delivers a turn's final chunk in that gap (TestHermesBackendDrainsLateFinalNotificationAfterPromptResponse exists because of it). Freezing the resume-loss decision at the quiescence boundary therefore read turnActivity == 0 while a real answer was still in flight: a runtime that merely answered slowly after stopReason=refusal had its healthy session id cleared and ResumeRejected set, discarding a live conversation pointer and, with no tool use recorded, triggering an unnecessary fresh-session retry that re-ran the turn. Move the evaluation to the point where the turn has settled — after waitForHermesPipeDrain and streamingCurrentTurn.Store(false), where every accepted update has been counted. This also drops the resumeLost variable and the ordering hazard that came with it. The new regression sends the late chunk 600ms after the refusal, past the 250ms quiet window and inside the 2s drain grace, and asserts the session id survives with ResumeRejected=false. Confirmed to fail against the previous ordering with exactly the reported symptoms (session id emptied, ResumeRejected=true). Refs GH #6150, MUL-5509 Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <agent@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
b13657be71 |
MUL-5493: feat(chat): add a visible follow-up queue (#6133)
* feat(chat): add a visible follow-up queue Add a visible, manageable FIFO follow-up queue for Web and Desktop chat while preserving the existing per-session scheduler and backward-compatible pending-task response. * fix(chat): preserve queue after deferred cancellation --------- Co-authored-by: TeAmo <liu.junhui3@iwhalecloud.com> |
||
|
|
c25a82eee0 |
perf(agents): fast model discovery on runtime switch (MUL-5444) (#6098)
* perf(agents): make runtime model discovery fast on runtime switch (MUL-5444) Switching runtime in the agent creation form left the model picker spinning for ~8-20s. Two costs stacked up: - the list-models request sat in the store until the daemon's next scheduled heartbeat (0-15s, avg 7.5s of pure dead wait), and - the daemon then enumerated the catalog locally (static for claude, but a CLI/ACP round trip up to ~15s for everyone else). Both are addressed with the two standard techniques for a slow, low-frequency, read-only operation: push instead of poll, and stale-while-revalidate. Push (removes the heartbeat wait): - new additive `daemon:pending_work` hint, runtime-scoped, delivered through the existing daemon WS hub and the Redis relay so the API node holding the socket does the delivery. - the daemon answers a hint with ONE immediate heartbeat and dispatches what it claimed. The hint deliberately carries no work, so nothing has to be un-claimed when delivery fails and a duplicate hint cannot duplicate work - PopPending stays the atomic claim. - per-runtime coalescing plus a 1s floor keeps a caller-triggered hint from becoming a heartbeat amplifier. Cache (removes the discovery wait on repeat opens): - server-side per-runtime catalog cache (in-memory single-node, Redis multi-node) written on every successful report. - a snapshot younger than 15min answers the POST immediately as an already-completed request; older than 60s it also enqueues a background refresh that only warms the cache. - only supported, non-empty catalogs are cached; a completed-but-empty report invalidates instead, while a failed report keeps serving the last known good list. Frontend: staleTime 60s -> 5min and gcTime 30min, so a runtime revisited in the same session renders from cache and revalidates in the background instead of showing the spinner again. Compatibility: every wire change is additive. Old daemons ignore the unknown hint type and keep using the scheduled heartbeat; new daemons against an old server simply never receive one. The cached response is shaped exactly like a completed live discovery apart from the optional `cached` / `cached_at` markers. Co-authored-by: multica-agent <github@multica.ai> * fix(agents): address review on model discovery SWR (MUL-5444) Sol-Boy's review on #6098 found the client cache could outlive the server's own staleness promise, and that the two changed endpoints were still cast rather than validated. Must-fix 1 — client freshness now derives from the served answer. `staleTime` was a flat 5min, so a 14-minute-old snapshot (which the server returns while queueing its own refresh) was held as fresh for another 5min: observable staleness became server window + client window, and the refreshed catalog never reached the tab that triggered the refresh. `staleTime` is now a function of the query data: a `cached` answer is stale on arrival (bound stays the server's window alone, and the next mount/focus picks up the refreshed snapshot), while a live discovery — which just measured the truth — is trusted for the full 5min so a cold runtime is never re-enumerated inside one form session. `gcTime` stays 30min, so a revisited runtime still renders from cache and revalidates in the background; the pickers gate their spinner on `isLoading`, which stays false throughout. Must-fix 2 — both model-discovery responses go through a zod schema. `POST /api/runtimes/{id}/models` and its poll companion were casting network JSON to `RuntimeModelListRequest`, which the root CLAUDE.md API-compatibility rules forbid. Added a lenient schema (`status` stays `z.string()`, `supported` defaults to true, `.loose()` keeps unknown fields) plus a fallback record whose `status` is `failed`: a malformed body now surfaces "discovery failed" with manual entry still usable instead of a fabricated empty catalog or an endless spinner. `resolveRuntimeModels` was tightened to match — only an explicit `completed` is a catalog, so an unrecognised status is an error rather than a silent empty list, and `supported` can no longer be `undefined`. Nit — the in-memory catalog cache now deep-copies each entry's `Thinking` (and its level slice) and `ServiceTiers`, so it delivers the independent value its comment promises and matches the Redis backend's JSON round-trip semantics. Tests: staleTime policy for cached/live/no-data; a QueryObserver test proving the refreshed catalog reaches the same client with no blank loading state; unknown-status and omitted-`supported` handling; schema tests for live, cached, old-backend and nine malformed shapes; client tests that both endpoints degrade to an explicit failure; nested-field mutation isolation for the cache. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
280fa28e0d |
fix(agent): deny CodeBuddy's interactive plan-mode tools in headless runs (MUL-5383) (#6104)
CodeBuddy exempts AskUserQuestion and ExitPlanMode from permission-mode finalization, so `--permission-mode bypassPermissions` never auto-approves them. Once the model entered plan mode the session mode is Plan, not BypassPermissions, so ExitPlanMode also missed the daemon's bypass fast-path and went to the SDK permission bridge — which waits with no timeout for a confirmation the headless runtime cannot render. The task sat in-flight until the 2h tool watchdog, and users killed it by hand. Deny EnterPlanMode/ExitPlanMode alongside the AskUserQuestion we already deny. Each tool is passed as its own argv value because CodeBuddy matches disallowedTools entries exactly and does not split on commas. Also send `allowed: true` on control_response: CodeBuddy's SdkPermissionClient reads `allowed`, not Claude Code's `behavior`, so the daemon's "auto-approve" was being read as a denial. Fixes #6012 Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
30318b79bc |
MUL-5426: fix(daemon): retire sessions whose history the provider refuses to replay (#6083)
* fix(daemon): retire sessions whose history the provider refuses to replay A run killed mid-reply (machine shutdown, force-quit, SIGKILL) can leave an empty assistant message in the agent CLI's transcript. Every later resume replays it, the provider rejects the request, and the (agent, issue) pair is bricked with no self-healing and no user-facing recovery. Multica already has the mechanism for this — poisoned-session classification — but its detector paired "400" with "invalid_request_error", which is the Anthropic wire shape. The same defect reported by any other provider carried neither token, so it classified as agent_error.unknown: resume-safe by omission. GetLastTaskSession kept handing back the dead session on every follow-up, manual Rerun resolved it through the same predicate, and the in-turn fresh-session retry never fired because ResumeRejected is false here (nothing rejected the resume — the transcript loaded and the provider refused to replay it). Add taskfailure.UnresumableHistory, which recognises the defect by what the provider says is wrong — some content is empty, and here is which message in the history — rather than by status code or provider name. Both signals are required, so a tool reporting "field must not be empty" does not match. Wire it into the four places that decide whether a session survives: - classifyPoisonedError, so the task is written as api_invalid_request - shouldRetryWithFreshSession, so the turn recovers on all 17 backends instead of the subset whose adapter learned to detect it; the tools == 0 gate is unchanged, so a run that already used a tool is never re-run - ResumeUnsafeFailure, covering the manual-Rerun path - both resume queries, as defense-in-depth for hosts whose daemon predates this (self-host daemons upgrade on their own cadence) Fixes #6066. Also covers the daemon half of #5760. Co-authored-by: multica-agent <github@multica.ai> * fix(session): close the Chat and fresh-retry paths that resurrect a poisoned session Review found the previous commit stopped short in two places, both of which put the dead transcript back in play. Chat never consulted the guarded query. The claim handler reads chat_session.session_id first and only falls back to GetLastChatTaskSession when it is empty, so a poisoned pointer there bypasses every filter that query applies. The fail path merely declined to OVERWRITE the pointer, leaving it in place. It now clears it in the same transaction, matched on session and runtime so a concurrent turn's newer pointer survives. The promote guard moves to ResumeUnsafeFailure as well — the reason-only check passed an un-upgraded daemon's agent_error.unknown row and re-pinned what the clear had just removed. GetLastChatTaskSession also kept the row-level filter the issue query dropped in GH #5975: it discarded the newest poisoned row and fell back to an older completed row carrying the same dead session. It now judges each session by its latest terminal state, matching GetLastTaskSession. A recovered turn could not retire anything. A terminal report carried one session_id, and an empty one meant both "nothing to report" and "forget the old session", so a fresh-session retry that SUCCEEDED left the id it retried away from selectable — through an older completed row on the issue, or through the chat pointer. agent_task_queue.retired_session_id records the abandonment itself, reported on every terminal path including completed, and both resume lookups exclude it. This is the contract gap the previous PR deferred; the fresh-retry path now runs on all backends, so deferring it is not safe. Also narrows what the cross-backend test claims: it pins the shared decision, not that all 17 adapters surface the error into Result.Error (#5760 is the counter-example), and says so. Co-authored-by: multica-agent <github@multica.ai> * test(session): require pgx.ErrNoRows in the resume-exclusion assertions The `if err == nil && prior.SessionID.Valid` form these tests shared is false-green: any real fault — undefined column, syntax error, dead connection — makes err non-nil, so the condition is false and the test passes. Run against a database missing this branch's new column, the exclusion tests reported PASS on a SQLSTATE 42703, meaning they could not have caught a broken query. requireSessionExcluded demands pgx.ErrNoRows specifically and fails loudly on anything else, so a green run now means the filter worked rather than the query never ran. Applied to all nine sites, not just the four this branch added: the other five guard the same GetLastTaskSession exclusion behaviour that this branch changes, so leaving them false-green would leave the change under-tested. All nine pass on a correctly migrated database. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
de3e0ae556 |
fix(agent): make cursor stream protocol drift loud instead of silent (MUL-5434) (#6089)
* fix(agent): make cursor stream protocol drift loud instead of silent (MUL-5434) #6071 reports Cursor tasks that demonstrably read files, ran commands and called the Multica CLI, yet showed a single blob of agent text with no reasoning and no tool rows. The run still reported success and tools=0. `switch evt.Type` in cursor.go had no `default` branch, so any top-level event type we do not handle was dropped with no counter and no warning. Renaming only the top-level types of a healthy stream (`thinking`->`reasoning`, `tool_call`->`tool_calls`), leaving every nested field untouched, reproduces the report exactly: status=completed, output = the result text alone, tool_use=0, thinking=0, zero diagnostics. The existing unknown-subtype warning cannot catch this — it only increments once the type has already matched — so "no unknown-subtype warning" does not rule out protocol drift. Two diagnostic gaps are closed: - Add the `default` branch with a bounded, content-free tally of unhandled top-level types, reported once per run as a warning and alongside tool_use_count in the protocol summary. Type names are normalized through observedCursorEventType and distinct names are capped at 16 plus an overflow bucket, so a hostile or noisy stream cannot grow the map or leak payload into logs. `user` (the CLI echoing our prompt, present in every recorded run) is explicitly benign so the warning does not fire always. - Count assistant text separately from the terminal result text. The result event writes into the same builder, so last_assistant_bytes equalled result_bytes even when the assistant streamed nothing — erasing the signal that says "only the final answer arrived". This also aligns cursor with claude, which already reports assistant bytes only. Unrecognized events are still never coerced into tool or reasoning messages; guessing at upstream additions is the failure mode MUL-5231 already fixed once. This is diagnosis only and does not itself restore the missing tool rows — identifying which upstream shape changed requires a captured 2026.07.23 stream, and this warning is what makes that identifiable from a single production log line. Co-authored-by: multica-agent <github@multica.ai> * fix(agent): report cursor unhandled events as evidence, not as a verdict Addresses the review's must-fix on MUL-5434. The diagnostic was described as deciding WHY a transcript is empty, which it cannot do: - "tools=0 with unhandled types" does not establish that a rename ate the tool rows. Cursor 2026.07.23 also emits transport/control frames, so an unhandled type only proves the stream carried events we do not parse. - "tools=0 with no unhandled types" does not establish the agent used no tools. The CLI may execute tools without handing the updates to its stream serializer at all — the main branch #6071 has NOT ruled out — a new shape may be nested inside an event type we already recognize, or events may be lost to invalid framing or a scanner boundary. Changes, no behaviour change to messages, status or output: - Reword the tally doc, the switch default, the warning and the shared observation struct to state what a non-zero and a zero count each do and do not establish, and point at the branches that stay open. - Rename unknown* to unhandled* (fields, log keys, warning text, the pre-existing subtype counter) so the diagnostic never implies the type is unrecognized upstream — only that this parser does not handle it. - Classify `connection` and `retry` explicitly. They join `user` in cursorNonTranscriptEventTypes, with per-entry provenance recorded: `user` is confirmed in the recorded 2026.07.20 stream, the control frames are reported on newer builds and listed defensively. A build that does not emit them makes the entry inert; one that does must not have a known control frame reported as an unhandled protocol event. Tests assert signal presence and that nothing is fabricated, not causality: the healthy-stream test now carries `connection` / `retry` and additionally asserts the real thinking/tool rows still arrive, so suppressing the warning cannot silently suppress the transcript. TestCursorNonTranscriptEventType also pins that no type the parser handles can enter the suppression list. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
b3847b4172 |
MUL-5433: fix(agent/hermes): pin session id mid-flight for daemon resume (#6070)
* fix(agent/hermes): pin session id mid-flight for daemon resume Hermes was the only ACP-backed agent that created a session without emitting a running status carrying the session id. When the daemon restarted or a task was cancelled mid-flight, PinTaskSession had nothing to key on, so the resume pointer was lost and the task could not resume. Emit MessageStatus+SessionID immediately after session create, matching claude, codebuddy, codex, grok and qwen. The daemon already listens for this (daemon.go MessageStatus -> PinTaskSession), so this small pin is all Hermes needs. See #4969 for the companion daemon/SQL cancel-salvage work. Co-authored-by: multica-agent <github@multica.ai> * test(agent): cover Hermes mid-flight session pin Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai> Co-authored-by: Eve <eve@multica-ai.local> |
||
|
|
d4dac0e77c |
perf(agents): index-backed latest-terminal lookup in task snapshot (MUL-5436) (#6085)
ListWorkspaceAgentTaskSnapshot took each agent's latest completed/failed
task with a workspace-wide DISTINCT ON, so every presence load read and
sorted the workspace's whole terminal history. Neither existing index
matches that shape: (agent_id, status) has no completed_at, and migration
231's (completed_at) partial index is completed_at-first for the Usage
rollups.
Replace the outcome half with a per-agent JOIN LATERAL Top-1 and add a
partial index on (agent_id, completed_at DESC NULLS LAST, created_at DESC,
id DESC) WHERE status IN ('completed','failed'). On a 40-agent workspace
with 200k terminal rows this goes from 6631 shared buffers / 48.3 ms to
162 buffers / 0.1 ms, with an identical row set.
The (created_at, id) tie-break also makes the pick deterministic when
completed_at ties or is NULL — completed_at DESC alone left the winner up
to the plan.
Report #6075 asked to delete the outcome half as dead code, but PR #2608
made the Squad hover card (AgentLivePeekCard) read those rows for its
"last activity" line, so removing them would be a product regression for
shipped desktop builds. The response contract is unchanged here; splitting
the outcome into a lazy endpoint stays follow-up work.
Also tighten pickLatestTerminal to completed/failed only, matching the
snapshot's filter — it accepted cancelled, which the endpoint never
returns and which would have masked an agent's last real outcome.
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
|
||
|
|
2e9a3d0119 |
fix(dashboard): stop leaking private agents from the per-agent rollups (MUL-5409) (#6051)
* fix(dashboard): stop leaking private agents from the per-agent rollups (MUL-5409) Three per-agent dashboard endpoints authorized on workspace membership alone and returned a bare agent_id for every agent in the workspace: GET /api/dashboard/usage/by-agent GET /api/dashboard/agent-runtime GET /api/dashboard/failures/by-agent That told a plain member which private agents exist, how much they spend, how long they run and what they fail on. The client already collapsed those rows, but client-side filtering is decoration — one curl bypasses it. Server: rows for agents the caller may not view are now folded onto a `__restricted_agents__` sentinel before serialization, via one shared helper. Folded, not dropped: each of these responses is the per-agent half of a pair whose other half (usage/daily, runtime/daily, failures/daily) is workspace- scoped and unfiltered, so dropping rows would make the per-agent breakdown stop adding up to the KPIs rendered beside it. The bucket keeps its provider/model and failure_reason dimensions — both are derivable by subtraction from the workspace-level series anyway, and the client needs them to price the bucket and compute its failure rate. Owner/admin and agent actors short-circuit before any extra query, so the governance view is unchanged. Hard-deleted agents are deliberately excluded from the fold — they have no visibility left to protect and keep their own bucket. Client: fixes the mislabelling that shipped with this. A live private agent was folded into a row labelled "Deleted agents" with a bin icon, and counted into the card's "· N deleted" caption — telling the user N agents were deleted when they are alive and still running. The restricted bucket is now its own row with neutral copy, keeps its real Time / Tasks values, and counts as neither an agent nor a deletion in the caption. Tests: handler regression coverage proving a plain member's response contains no private agent UUID while every aggregate still sums to the privileged view's total, plus view coverage for the label and caption. Co-authored-by: multica-agent <github@multica.ai> * fix(dashboard): fold hidden system agent carriers into the restricted bucket (MUL-5409) Review follow-up. The first pass built the restricted set from ListAllAgents, which filters `kind = 'user'` — so it missed the hidden `kind = 'system'` execution carriers behind agent-builder sessions. Those carriers run real tasks and book real usage, and all three rollups aggregate over agent_task_queue / task_usage with no kind filter of their own. No list endpoint returns them either (ListAgents / ListAllAgents both filter on kind), so no client can resolve one to a name. Net effect: the exact two bugs this PR exists to fix, still live — a bare UUID exposing one member's builder session (with its spend and failure profile) to every other member, and, once the agent list loads, a running agent folded into the client's "Deleted agents" row and counted as a deletion. restrictedAgentIDs now reads a new ListAllAgentsAnyKind and restricts every non-user-kind agent for EVERYONE, workspace owner included — nobody can name one, so a bare UUID row is wrong for every viewer, not just plain members. User agents keep the per-viewer visibility rule. The invocation-target lookup is skipped for actors that rule can never restrict (agent actors, owner/admin), so the added cost is one indexed list query. Because the bucket now also carries carriers that are nobody's "restricted" agents, its copy drops to the neutral "Other agents" — the same wording the Errors card already uses for its equivalent row, in all four locales. Adds a regression test seeding a kind=system private carrier with tasks and usage: no endpoint may return its UUID to either the plain member OR the workspace owner who owns it, a bucket must be present to carry its rows, and every metric delta (tokens, seconds, tasks, failures, runs) must equal its exact contribution. Verified to fail on all three endpoints for both viewers with the kind-filtered query restored. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
0066ab259e |
refactor(agent): drop unreachable inline system-prompt branches (MUL-5392) (#6050)
* refactor(agent): drop unreachable inline system-prompt branches (MUL-5392) The daemon only populates ExecOptions.SystemPrompt for openclaw, kimi and traecli (providerNeedsInlineSystemPrompt); every other backend receives the runtime brief as a per-task context file in the workdir. The inline branches in claude, codex, opencode and pi were therefore dead, and read as if they were the live delivery path. Probed each backend over its real launch path with a canary in the context file and no inline delivery — claude 2.1.220 (CLAUDE.md), codex 0.144.6 via the app-server, opencode 1.17.7, pi 0.67.2, hermes 0.18.2 via ACP — and all of them picked the brief up from disk, so the branches were removable with no behaviour change. An empty workdir returned no canary, confirming the probe could fail. opencode's branch was worse than dead: `opencode run` has no --prompt flag, so enabling inline delivery there would have made every opencode task exit 1 with a usage dump. The DevEco backend, forked from opencode, already documents this constraint; opencode itself never got the fix. Regression tests pin all three arg builders against re-adding the flag, and providerNeedsInlineSystemPrompt now documents what was verified and what is still unprobed (grok, qoder, codebuddy). Hermes and kiro are untouched: their exclusion is deliberate and already tested. Co-authored-by: multica-agent <github@multica.ai> * test(agent): pin codex developerInstructions contract, drop stale pi flag doc Review follow-up on MUL-5392. buildPiArgs' doc comment still advertised --append-system-prompt after the branch that emitted it was removed — exactly the stale-signal this PR set out to delete. The two codex sites fixed to a literal nil had no regression test, so restoring nilIfEmpty(opts.SystemPrompt) would still have gone green. Both thread/start and thread/resume now run with a canary SystemPrompt and assert developerInstructions comes through as an explicit null. Mutation-checked: reverting either site fails its test. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <agent@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
962d376fda |
refactor(agent): share acpDeliverableTracker across ACP backends (MUL-5405) (#6044)
#6022 stopped qoder from delivering interim narration as Result.Output, but hermes / kimi / kiro / traecli / grok still accumulate every MessageText into one builder and hand the whole thing to Result.Output — the same leak (#6006), since Result.Output becomes the channel reply and the auto-generated issue comment. Extract the boundary rule into acpDeliverableTracker (observe / result) and share it across all six backends instead of copying qoder's block five times: Result.Output keeps only the text after the latest tool call, a turn that ends on a tool call falls back to the latest non-empty text block so the reply is never empty, and provider-error detection keeps reading the full text stream. Covered by tracker unit tests and a cross-backend regression test that pins both scenarios on all six backends, over both tool-use emission paths (emitted at the tool call and deferred to tool completion). Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
54469766fd |
fix(qoder): deliver final answer only (MUL-5394) (#6022)
* fix(qoder): deliver final answer only * fix(qoder): preserve tool-terminated replies |
||
|
|
2274f521dc |
feat(issues): agents-working chip on the sub-issues header (#5825) (#5834)
* feat(issues): aggregate agents-working chip on the sub-issues header (#5825) Add a live "N agents working" chip next to the sub-issues progress ring in issue detail. The per-row IssueAgentActivityIndicator shows which sub-issue is being worked; this chip shows how many agents are on the parent's children at a glance — and keeps that signal visible while the list is collapsed. Derives from the shared workspace agent-task snapshot narrowed by a new selectIssuesTasks select (structural sharing keeps unrelated snapshot churn from re-rendering the header). Counts unique agents to match the workspace chip, whose chip_agents_working / hover_header_queued strings it reuses — already translated in every locale. Hover opens the shared AgentActivityHoverContent task list. Fixes #5825 Co-authored-by: multica-agent <github@multica.ai> * refactor(issues): read the sub-issues chip from the working-agents projection (#5825) The chip landed deriving its own count from the workspace agent-task snapshot, which put a second definition of "an agent is working" in the client. It showed up immediately: the number came from the running tasks only while the hover body listed running plus queued, so a parent with 2 running and 3 queued agents read "2 agents working" over a five-row card. A header count is a claim about a scope, so let the server own both the scope and the arithmetic, exactly as the Issues list header already does. ListWorkspaceWorkingAgents grows an optional parent_issue_id narrowing and the chip reads /api/working-agents?type=issue&parent=<id>. The number, the avatars and the hover body are now one list rather than three derivations, so they cannot disagree. Row indicators keep reading the snapshot. One shared query sliced per row is the right shape for a per-row cue and a stale row decoration costs nothing; a header number is the opposite, it has to be authoritative. The new parameter is additive: omitted, the query and the response are byte-for-byte what they were, so an installed client that never sends it keeps the workspace-wide behaviour. A regression test pins that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai> Co-authored-by: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ba108978ac |
fix(channel): deliver the final answer only to Slack and Lark (MUL-5378) (#6016)
* fix(channel): deliver the final answer only to Slack and Lark (MUL-5378) Channel replies could carry the agent's interim narration alongside its answer (GH #6006). Two independent causes, both fixed at the layer that owns the deliverable. Prompt regression. #4776 told every channel-backed chat to reply with the answer only and not narrate. The MUL-4899 split (#5557) moved that rule into the Slack branch along with the `chat history` / `chat thread` commands its wording happened to mention, so Feishu/Lark silently lost it on 2026-07-17. The rule is a third axis — it keys off "is there a channel at all", like the attachment-upload axis — so it now sits outside the Slack gate, generalized from "these history reads" to any progress note. The old two-layer matrix could not catch the regression because it only asserted the rule on the Slack case; the new test pins all three states. Runtime contract. Result.Output is documented as "final user-facing output selected by the backend" (agent.go), but Codex concatenated every agent_message and Copilot joined every assistant turn with "\n\n", so a tool-using run handed the daemon narration + answer as one string. Codex now takes the message its app-server labels phase="final_answer", falling back to the most recent agent message on the legacy protocol; Copilot keeps the latest complete turn, with the streaming deltas retained as the process-died-mid-turn fallback. This narrows delivery only — every message still streams as MessageText, so the Multica transcript is unchanged. Claude Code, CodeBuddy and qwen already selected a terminal result and are untouched; opencode/deveco/openclaw share the accumulating shape and want the same audit (pi was already fixed this way in #4894). Verified: go build ./..., go vet, full ./pkg/agent and ./internal/daemon/... suites pass locally. Co-authored-by: multica-agent <github@multica.ai> * fix(channel): scope the no-narration rule to process, not results Review follow-ups on the channel delivery rule and the Copilot turn boundary. The prompt said a reply "must not say what you are about to do or just did", which literally forbids the deliverable itself: asked to create an issue, the correct reply IS "created issue X". Rewritten to ban planned and in-progress narration while explicitly protecting the completion confirmation. The test now pins both halves — a future edit that drops the carve-out, or restores the blanket past-tense ban, fails. The example also referenced "check the code", which is not a thing an agent does inside a Slack or Lark conversation. Replaced with a generic "let me look into that first". Copilot cleared pendingDelta only when the authoritative assistant.message carried content. A tool-only turn reports content:"" with the requests as the whole turn, so its streamed deltas stayed buffered and were stitched onto the next turn's partial text if the process then died mid-stream — verified: the new test yields "Checking the logs now.The retry loop" before the fix. The reset now happens on every assistant.message, since that event is the turn boundary regardless of whether it carries text. Verified: go build ./..., go vet, full ./pkg/agent and ./internal/daemon/... suites pass locally. Co-authored-by: multica-agent <github@multica.ai> * fix(channel): tighten the no-narration rule to one sentence Same contract, fewer tokens: the four-line rationale comment collapses to one, and the delivery rule drops the restatement, the second example and the completion examples. What survives is exactly the semantic boundary the tests pin — no planned/in-progress narration, completed actions still count as the outcome — plus the one narration example actually observed in the report. Verified: go build ./..., go vet, ./internal/daemon/... suite pass locally. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
c271f80999 |
MUL-5370 fix: label stalled skill-bundle downloads, align failure-reason copy with the backend taxonomy (#6001)
* fix(daemon): label stalled skill-bundle downloads and make them retryable A skill bundle that could not be downloaded during task preparation surfaced as the bare string "resolve skill bundles: context deadline exceeded". taskfailure.Classify has no rule for a Go context deadline, so it landed in agent_error.unknown — a bucket that is NOT on the server's retry allowlist. A transient stall therefore became a terminal chat failure carrying a label nobody could act on, and the failure was invisible on the Usage page's Errors breakdown. (MUL-5370) - Add the platform-side reason skill_bundle_unavailable and put it on retryableReasons. Retrying is cheap and safe: the agent process never started, and bundles that did arrive are already cached on disk, so successive attempts converge. - Carry a sentinel error from the resolve loop so the reason is derived structurally rather than by matching the wrapped transport error's text, and name the skill, its declared size and the elapsed wait in the wrap — enough to tell "this bundle is too big for the link" from "the link is dead" without reading daemon logs. - Normalise the wire shape an OLD daemon produces (a non-empty catchall plus the previous "resolve skill bundles:" wrapper) on the server side. Installed daemons upgrade on their own cadence, and FailTask only classifies when the caller supplied nothing, so without this the fix would reach only hosts that happened to update — while the un-upgraded hosts most likely to be hitting the bug kept failing terminally. - Teach Classify about "deadline exceeded" and net/http's "Client.Timeout exceeded while awaiting" so any other Go-side deadline that reaches it as text stops falling into the unknown bucket too. - Backfill historical rows in both agent_task_queue and chat_message. Scoped to agent_error.unknown alone — the old wrapper string postdates the in-flight classifier by three weeks, so no row carrying it can hold the legacy coarse value — which keeps the down migration an exact inverse. Co-authored-by: multica-agent <github@multica.ai> * fix(chat): give chat its own failure copy for the refined reasons #5991 rebuilt the operator-facing failure labels around an open wire string with a raw-value fallback, but the chat bubble kept its own exact-key lookup against the six coarse values from migration 055. So all 14 agent_error.* values still missed and rendered the generic "Something went wrong and the agent couldn't finish replying" — the classification the backend had already computed was discarded at the last step, and that is the message the MUL-5370 reporter saw. - Add resolveFailureReasonKey in packages/core: exact match, else degrade an `agent_error.*` value to its family, else undefined. A reason newer than the shipped client now lands on the family line instead of the fallback. - Rekey the chat copy map by wire value and route it through the helper. Chat deliberately degrades to friendly copy rather than adopting the operator surfaces' raw-value fallback: it is read by the person who just sent a message, and the raw error is one click away under the collapsible. - Add refined chat copy (en / zh-Hans / ja / ko) only where it can say something the family line can't — a different next step: network, auth, quota, rate limit, context overflow, missing/outdated CLI, skill download. - Give skill_bundle_unavailable a label on the web and mobile surfaces and a class on the Usage page's Errors breakdown (runtime — the operator response is "check the daemon's link to Multica", the provider is not involved). - Mobile's two label maps were still coarse-only for the same reason; rekey them by wire value and fill in the refined taxonomy. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
077ac8acc9 |
refactor(channel): claim media ledger rows one at a time (MUL-5367) (#5993)
The reconciler claimed a batch of ledger rows under one lease and settled them serially, so a tail row could expire and be reclaimed before its own DELETE was ever tried — inflating attempt/backoff for work that never happened, and delaying the row's first real attempt. Rows are now claimed one at a time, immediately before the work each claim authorizes, so attempt counts attempts. The per-row lease heartbeat is gone: the lease only has to cover one row's settle (30s delete timeout << 2m lease) and every settle write is already lease-token guarded. Migration 232 adds an index on next_attempt_at: migration 230's index leads with state and cannot serve the claim's cross-state ordering, so under backlog every claim in a sweep paid a full seq scan plus an external merge sort. Shutdown that lands mid-settle now stays quiet — the row keeps its lease and is reclaimed after expiry, like any interrupted worker. |