104 Commits
Author SHA1 Message Date
Bohan Jiangandmultica-agent 46d4e26712 MUL-8347: fix Hermes compatibility with gateway-backed and older ACP (#9666)
* fix(hermes): support gateway-backed and in-process ACP

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

* fix(hermes): interrupt active task gateways during cleanup

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

---------

Co-authored-by: multica-agent <[email protected]>
2026-10-29 14:59:57 +08:00
90a88519df fix(daemon): defer issue status rules to project workflow (MUL-8145) (#9415)
* fix(daemon): defer issue status rules to the project workflow (MUL-8145)

When the claim carries a project workflow, the runtime brief no longer
tells the agent to set in_progress first and in_review on delivery. Those
statuses may be outside the workflow (the server rejects them), and
entering a handoff step reassigns the issue. The brief now points at the
per-turn [Project workflow] block, and the workflow brief carries the
entry/exit checks in workflow terms. Briefs without a workflow are
byte-identical to before.

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

* fix(squad): word the owning leader's status moves by the project workflow (MUL-8145)

The squad protocol (Agent Identity, which outranks the runtime brief) still
told an owning leader to move the parent to in_progress and then in_review,
which a workflow may not list and which can hand the parent to another
step's handler. When the claim carries a project workflow, responsibility 6
now keeps the ownership grant but moves the parent only through the
[Project workflow] commands. The claim resolves the workflow before the
briefing to choose the wording. Guest leaders and issues without a
workflow are unchanged.

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

* refactor(daemon): list no status keys in the brief for a workflow issue (MUL-8145)

With a project workflow, [Project workflow] already lists the issue's
statuses and the commands that move it, and the built-in keys may not be
accepted there. The status command line now keeps only the command and
points at that block; other issues learn valid statuses from the error.

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

* refactor(workflow): stop forbidding status moves nothing asks for (MUL-8145)

Under a project workflow the current runtime brief no longer states the
built-in in_progress / in_review moves, so the lines that forbade them
(in [Project workflow], the runtime brief and the squad protocol) only
named statuses the agent had no reason to use. Drop them, and the exit
check the runtime brief already states.

Older daemons still state the built-in rules, so the daemon now
advertises workflow-status-rules-v1; without it the claim keeps the
override lines in [Project workflow] and the squad protocol. The
multica-platform skill notes that [Project workflow] supersedes its
built-in status arc.

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

* refactor(workflow): shorten the step's moves in the workflow brief (MUL-8145)

Replace the handoff preamble with a plain "Your moves:" heading, name
who each move hands off to on its own line, and drop the "this
workflow has no blocked status" explanations from Can't proceed.

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

* refactor(workflow): state each step outcome as the status to set (MUL-8145)

The moves block read like raw CLI ('Step done → multica issue status MUL-7 x').
Each outcome now says which status to set and that the server assigns the
next handler; the CLI form is given once.

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

* Revert "refactor(workflow): state each step outcome as the status to set (MUL-8145)"

This reverts commit 2d82d2d342a99afe88cc40c48516b7ea8e860d1e.

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

---------

Co-authored-by: multica-eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-10-26 14:27:09 +08:00
700d25eef5 MUL-7420: render the workflow brief once on the server, for the assignee or an observer (#9293)
- issueworkflow.Brief is the only renderer: the claim carries it into every
  run's per-turn prompt; the editor preview and timeline show the same text.
- Workflow handoffs stop writing the brief into handoff_note, so a person's
  note no longer drops the brief and the handoff framing fits again.
- Agents that are not the issue's assignee get an observer brief without
  the step's commands.
- Clearer rule override and narrower wakeup guidance; user text is
  stripped of control characters.
- multica-platform skill documents project workflows.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-10-16 17:28:45 +08:00
da1671ca20 MUL-7420: Project workflows — columns and handoffs on the workflow (#8814)
* feat(workflows): workspace workflows with step handoffs (MUL-7420)

A workflow is a named, workspace-level selection of statuses from the
shared status catalog, in board order, with an optional handoff on each
step. Projects opt in through project.workflow_id; a project without one
keeps the whole catalog (the implicit Default workflow), so existing
workspaces see no change.

- issue_workflow table + project.workflow_id, CRUD API under
  /api/issue-workflows, POST /api/projects/{id}/workflow with a dry-run
  status mapping plan. Only issues on statuses the target workflow does
  not list are remapped, and remapping never hands issues off.
- Issue writes keep the status inside the project's workflow. Entering a
  step with a handler through a status change assigns the issue to that
  handler and starts its run with the step's brief as the handoff note;
  an explicit assignee in the same write wins. Creating an issue starts
  at the workflow's starting step.
- Task claims carry the issue's project workflow, and the brief renders
  a Project Workflow section so agents move along this project's
  statuses instead of the built-in ones.
- Statuses a workflow lists cannot be archived.
- Adoption is gated by the project_workflows_v1 flag; existing
  workflows keep working and projects can always switch back to Default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(workflows): workflow settings, project picker and workflow-aware boards (MUL-7420)

- Settings → Workflows (behind project_workflows_v1): the Default
  workflow plus named workflows, each shown as its step chain with
  handlers and the projects using it; an editor to pick statuses from the
  library, order them, mark the starting step, and set each step's
  handoff, instructions and next/back statuses.
- Project Workflow property: switching dry-runs first and asks where
  issues on unlisted statuses go; nothing else moves and nothing is
  handed off.
- A project board's columns are its workflow's steps, and each column
  head names who entering it hands the issue to.
- Status pickers offer only the issue's workflow steps and say who each
  hands off to; boards refuse drops onto statuses the dragged card's
  workflow does not list.
- New issues in a workflow project start on its starting step, with a
  hint naming who they will be handed to.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(workflows): name built-in steps once in the brief; roomier picker hints (MUL-7420)

Built-in statuses have no stored name, so the handoff brief printed
`todo` todo. Print the key alone when the name adds nothing.

The status picker widens for workflow projects and shows the handoff as
an arrow and name, so step names no longer truncate to one letter.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(workflows): handoff previews and a single timeline entry per handoff (MUL-7420)

- GET /api/issues/{id}/workflow-handoff?status= says what moving an issue
  would do: who it goes to, which runs of its current agent are still
  active, and the brief the handler receives. Backs the confirmation.
- POST /api/issue-workflows/preview-brief renders a step's brief from an
  unsaved workflow, for the editor's preview.
- Mapping plans list the statuses that stay, so the switch dialog can say
  which issues are untouched.
- A handoff is recorded on the status_changed activity (workflow, handler,
  whether a run started, the brief) instead of a separate assignee row.
- The brief now reads like the one shown in the design: the workflow, the
  current step, its instructions, the statuses, and the commands to hand
  off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(workflows): rebuild the workflow UI to the reviewed design (MUL-7420)

The first cut used generic controls where the design had purpose-built
ones. This brings every surface up to the mockup:

- Settings → Workflows is one page: the workflows (step chain with
  handler avatars, handoff count, projects using it; Default first) and,
  below, the shared status library with each status's workflow usage.
- The workflow editor is a full page instead of a dialog: draggable step
  cards summarising each handoff, and a panel for the selected step —
  swap its status, pick the handler with a segmented control and a
  searchable target, handoff instructions, next/back statuses, a preview
  of the brief the handler receives, duplicate and delete.
- The project Workflow picker lists each workflow's statuses, handoffs and
  projects, links to settings, and always confirms the switch with what
  moves and what stays.
- Moving an issue into a handoff step (status picker, table, board drop)
  asks first: who it goes from and to, a default-on option to stop the
  previous agent's run, and the brief.
- Status pickers name the workflow and project, show each step's handler
  with an avatar or that the assignee stays, and explain themselves.
- Boards: the handler chip in project column heads, hatched columns a
  dragged card cannot enter with the reason, and a drop hint naming who
  the card will go to.
- The timeline records a handoff under its status change, with the brief
  the run received; the issue sidebar shows the workflow it follows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(workflows): word a queued previous run as queued in the handoff confirm

The confirm dialog said the previous agent was "still running (for N
minutes)" even when its task had only been queued. Queued runs now read
as queued, and the checkbox cancels them instead of stopping them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(workflows): refuse a run's status change once the issue left its step

A run decides where the issue goes next from the step it was queued for.
When someone else moved the issue while the run worked, applying that
decision undid their move: in the live Grok run, a PM parked NW-1 as
blocked and the still-running agent moved it straight back to code
review.

Tasks now record the issue's status when they are queued
(agent_task_queue.workflow_step, written by the task INSERTs; automatic
retries keep their parent's step), and a run's own status changes move
it along. In a workflow project, an agent status change from a run whose
step no longer matches the issue is refused with 409
workflow_step_moved, naming where the issue is now and who moved it, so
the agent stops instead of retrying.

A handoff away from a squad now also stops the runs made on the squad's
behalf, and stopped runs record who stopped them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(workflows): keep every status entry point inside the issue's workflow

The issue context menu listed every catalog status and applied handoff
steps without the confirmation the status picker and board show, so a
right-click could pick a status the workflow refuses or start an agent
unasked. It now offers the workflow's steps and opens the handoff
confirmation through a shared modal.

One-click status actions no longer assume built-in statuses a workflow
may not list: "mark done" lands on the workflow's done step (hidden when
it has none), un-marking a duplicate returns to the workflow's start,
and "mark duplicate" is offered only when the workflow lists cancelled.
The batch toolbar offers a single project's workflow statuses when the
selection sits in one project.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(workflows): keep issue wakeups consistent with workflow steps

Three places where project workflows and issue wakeups met:

- A wakeup run is queued when it fires, so it took the issue's status at
  that moment as its workflow step. A wakeup an agent registered then
  bypassed the stale-step refusal: parked by a PM, the issue could be
  moved on by the agent's "check CI, then hand to review" wakeup. It now
  keeps the registering run's step; a member's wakeup still starts from
  the issue's status.
- Retries carried handoff_note only for wakeup runs, so a retried
  workflow handoff lost its brief. Rows with a workflow step keep it.
- Switching or editing a workflow remaps statuses in bulk without the
  close rule, so an issue remapped into a done status kept its wakeups
  and pending wakeup runs. The remap now ends them like any status write.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(wakeups): don't also wake the agent a write hands the issue to

A workflow handoff or an assignment already starts the receiving agent's
run. If that agent also subscribed an event wakeup to the issue's status
or assignee, the same write queued a second run for one fact, and
workflows made such writes frequent.

The write now names the agent it hands the issue to in the
transaction-local multica.handed_to_agent (the new agent assignee, a
squad's leader, or a step's handler even when it already owned the
issue), and wakeup capture skips that agent's subscriptions for the
write's issue.* events. Other agents' wakeups, and task, comment and
reaction events, are captured as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(workflows): tell agents not to watch workflow issues with wakeups

The handoff brief and the runtime's project workflow section now say
that each step's handler gets a run when the issue enters the step, so
wakeups on a workflow issue are for waiting on time or on something
outside it, not on its status, assignee or runs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(workflows): refresh project usage after project changes

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

* feat(workflows): MUL-7420 split columns from per-project handoffs

Workflows now only define a board's columns between a fixed head
(backlog, todo) and tail (blocked, done, cancelled). Handoffs move to
project.workflow_handoffs, so projects can share columns and still hand
off to different people. Default becomes a real, editable row that every
project without a named workflow follows. Adds project handoff, add-column
(project fork or shared) and project brief preview endpoints; same-agent
adjacent columns re-run the agent.

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

* feat(core): MUL-7420 project handoffs and Default workflow client model

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

* feat(views): MUL-7420 column-header handoffs and project workflow menu

Board column headers set the project's handoff for that column; the
project toolbar gets a workflow menu (full flow, switch, templates) and an
add-column action. Views outside a project show Default's columns, with
other statuses reachable through a status filter. Adds the issue flow card,
create-project workflow choice, a columns-only workflow editor and a
lighter status picker.

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

* fix(views): MUL-7420 wrap create-project workflow label in a menu group

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

* feat(workflows): MUL-7420 keep workflow structure in settings; no squad handoffs

Projects no longer add columns or create workflows: the add-column
endpoint is removed and creating a workflow is admin-only. Handoffs go
to one agent, a member or the project lead; squads route work themselves,
so a handoff to one is rejected, as is the issue-creator handler.

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

* refactor(core): MUL-7420 drop the project add-column client

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

* feat(views): MUL-7420 simpler handoff picker and workflow editor form

The handoff form is one searchable "Hand to" picker (no handoff, the
project lead, an agent or a member); the next status defaults to the next
column and next/back overrides sit under "More options". The workflow
editor gets labelled name and description fields and shows the fixed
statuses as one start and one end row. Templates move from the project
menu to Settings → Workflows → New, and the project board loses its
add-column action.

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

* refactor(server): MUL-7420 keep handoffs on the workflow, make backlog and blocked optional

Handoffs move back onto workflow steps: a workflow is the whole pipeline,
and a project only picks one. The project lead stays a relative handler, so
projects can still share a workflow. Handoff edits are admin-only, through
the workflow write or PUT /api/issue-workflows/{id}/handoffs/{status}; the
brief preview moves to the workflow too. Drops the unreleased
project.workflow_handoffs migration.

Todo, done and cancelled are required; backlog and blocked are optional and
keep their fixed places. Without blocked, the brief points agents at the
step's back status.

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

* feat(views): MUL-7420 edit handoffs on the workflow, optional backlog and blocked

The workflow editor sets each column's handoff in its side panel, and
Backlog and Blocked switch on and off. The board's column popover and the
flow panel edit the workflow's handoff, say how many projects it reaches,
and are read-only for non-admins. Drops the per-project handoff client
code.

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

* fix(wakeups): MUL-7420 keep platform rules on writes that hand off nobody

Migration 579 skips an issue.* event for the agent a write hands the issue
to. Its filter compared agent_id with the transaction's handed_to_agent
setting using IS DISTINCT FROM, which is false when both are NULL. Main's
platform rules (child_done) have no agent_id, so every issue.* event that
handed the issue to nobody was dropped for them: a parent leaving backlog
never woke its agent for sub-issues that closed meanwhile
(TestChildDoneHeldWhileParentInBacklog).

Rules without an agent are never the handoff target, so they always
capture. Extends TestHandoffDoesNotAlsoWakeTheHandler with a platform rule
on a handoff and on a plain status change; the latter fails on the old
filter.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(views): MUL-7420 three-way handoff choice; quieter move confirm

The handoff form picks one of three directions, as in the mockup: no
handoff, hand to an agent (starts a run), or hand to a member, project lead
included (assignee only). What the step does, where it goes next and the
back status only reach an agent's brief, so they show and save for agent
handoffs alone.

The board's move confirmation keeps the agent's brief collapsed. Create
project shows the workflow picker even when Default is the only workflow,
with a note on where to make more. The hidden-columns panel says a board's
columns come from its workflow, with a link to manage workflows for admins.

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

* feat(workflows): confirm handoff on project move, per-project handoff copy, two-pane editor (MUL-7420)

- A single issue moved into a workflow project from issue detail confirms
  the handoff of the column it lands on; the write opts in with
  enter_workflow_step. Batch, API and CLI moves stay administrative.
- The handoff preview takes project_id to preview such a move.
- Saving a shared workflow's handoff from a board can keep the change to
  that project: the server copies the workflow for it and switches the
  project in one transaction (copy_for_project_id).
- The column popover says which workflow it edits and who else uses it.
- The workflow editor is a step list with a detail panel for the
  selected step.

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

* style: gofmt workflow tests

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

* feat(views): MUL-7420 show next/back always; edit workflow name and description in place

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

* feat(views): MUL-7420 trim copy on the workflows settings page

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

* fix: MUL-7420 keep pre-workflow behavior for workspaces that never adopt workflows

The run step guard now applies only to workflows that hand off, and the
standalone Statuses settings page keeps its original header.

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

* fix(views): MUL-7420 use a solid placeholder tone on the workflow header

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

* fix(workflows): keep the runtime brief cache-stable; fix project board and batch status gaps (MUL-7420)

- Move the project workflow out of the runtime brief into the per-turn
  prompt so CLAUDE.md stays byte-identical across steps and issues.
- An explicit status filter overrides the project workflow's columns; the
  hidden-columns panel lists statuses outside the workflow.
- Batch status offers only statuses every selected workflow allows; a
  partial batch rolls back, refetches and warns.
- setProjectWorkflow returns void instead of an unparsed body.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Lambda <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-10-16 16:49:49 +08:00
e909e9c89d MUL-7587 fix(daemon): show on-behalf-of identity in agent context (#8688)
* fix(daemon): expose original requester in agent context

* fix(daemon): render run originator as on-behalf-of identity

* docs(daemon): update stale Task Initiator comments to On Behalf Of

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-24 15:35:54 +08:00
mrlonely 6194a8b3a7 MUL-7226 fix(daemon): decline Codex reuse when home preparation fails (#8236)
When the task-local Codex home cannot be prepared on the reuse path, Reuse
now returns nil so the daemon falls back to a fresh Prepare, instead of
returning an environment with an empty CodexHome that fails at launch with
"task CODEX_HOME is missing".

Declining reuse gives up the prior Codex session and the old workdir's
contents; a fresh Prepare that fails the same way still stops the task.

Co-authored-by: mameikagou <[email protected]>
2026-09-10 15:25:14 +08:00
49098deb6f MUL-6984 refactor(prompt): give each duplicated instruction one owner (#7971)
* refactor(prompt): give each duplicated instruction one owner (MUL-6984)

Four cases where the same data or rule was rendered two or three times into
one context window, following the sibling-block removal in #7937. Each is
resolved by picking the surface that owns it and pointing the others there —
no behavior change beyond what an agent stops reading twice.

The rule used to pick the owner is the one MUL-5377 already set for issue
tasks: the runtime brief holds what is true of this KIND of task (stable, and
it is the prompt-cache prefix), the per-turn user message holds what is true
of this RUN, and the workdir sidecar holds nothing. Autopilot, quick-create
and squad-leader paths had never been cleaned up against it.

Autopilot run data (run id, autopilot id, title, source, trigger payload,
instructions) was rendered in the brief, the per-turn message, and the
sidecar. Now per-turn only; the brief keeps the run-only framing and the
shared issue-command guard. This also settles a contract violation: those are
per-run values, and the brief promises not to carry any. MUL-5696 already
caught the two copies drifting on the guard sentence and fixed that one line
with a shared constant. The payload is now capped at 8 KB with an explicit
truncation notice — it is written by whatever system fired the trigger, so
nothing here bounds it, and a clipped JSON object read as a whole one is
worse than a short one.

The squad leader no_action rule was written four times: the Squad Operating
Protocol, the per-turn prompt, brief workflow step 4, and brief ## Output.
The four had already drifted — only some carried the MUL-6622 escape hatch
that keeps a FAILED `squad activity` call from ending the turn in silence.
The protocol is now the single statement (it is the only document sent
exclusively to leaders); the two brief sites and the per-turn reply imperative
carry the EXCEPTION to their own imperative and point back at it.

Quick-create's five run/output rules were in the brief's Workflow, the
brief's ## Output, and the per-turn "Output format" block; the
--description-file /tmp guidance was in the brief's Available Commands and
per-turn. The brief owns both now — its own comment says these guardrails
must hold "even if the user message is missing" — and per-turn renders only
the field values the modal picked.

.agent_context/issue_context.md is deleted outright. Nothing read it: the
rendered CLAUDE.md never mentioned .agent_context, no per-turn message
pointed at it, and no provider discovers it. Its writer's own comment said
"the runtime brief already carries every fact this file would". With it gone
the directory is created only when the default provider needs
.agent_context/skills, so a local_directory task no longer leaves an empty
managed directory in the user's checkout.

Compatibility: prompt text only. No wire field, CLI flag, or DB column
changes, so no server/daemon version coupling. Tests follow the text to its
new owner rather than being deleted: the protocol's no_action rule is now
pinned in internal/handler, the quick-create rules in the brief's own suite,
and the payload cap has its own coverage including the rune-boundary cut.

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

* fix(prompt): drop the autopilot payload cap, pin the moved invariant (MUL-6984)

Addresses review on #7971.

The 8 KB trigger-payload cap is removed. Its rationale was wrong twice over:
webhook ingress already bounds a body at 256 KiB
(handler.maxWebhookBodyBytes), so the payload was never unbounded from
Multica's side; and because de-duplication made the per-turn message the
payload's ONLY rendering, truncating there dropped task input with no way
back — GET /api/autopilots/{id}/runs/{runId} returns the full payload but no
CLI command exposes it, and `autopilot runs` lists runs without payloads. A
legitimate 9 KB webhook would have silently lost fields the autopilot
instructions might name, and the "JSON is incomplete" notice only prevents
misreading, it does not recover anything. The cap belongs with an agent-facing
read-back command and a threshold taken from real payload sizes, not as a side
effect of removing duplicates. Rendering it once instead of twice is still a
strict improvement over the pre-PR behavior.

TestBriefByteIdenticalAcrossRunsForEveryKind could not see the invariant this
PR actually moves: its autopilot row pins run-1 / ap-1 and every variant
mutates only the generic resume / initiator / connected-app fields, so putting
the title, source, payload or description back in the brief left it green.
TestAutopilotBriefByteIdenticalAcrossRunScopedFields adds a second and third
run that vary all of them — including a different autopilot id — asserts the
brief is byte-identical, and asserts the brief contains none of those values.
That second half matters: byte-identity alone also holds if the values reach
no surface at all. Verified against a deliberately reintroduced field, where
it fails as intended. TestBuildPromptAutopilotRunOnly gains the source and
payload assertions so the per-turn message is pinned as their sole owner.

Contract comments that still described the deleted sidecar are corrected:
agent.MinHandoffCLIVersion (the gate always measured the opening prompt — no
provider read the sidecar), the handoff_note field docs on both sides of the
claim wire, the claim handler's note, TaskContextForEnv.HandoffNote, and
errPathPreExists's namespace list. The Reuse path keeps its issue_context.md
mentions, now labelled as cleanup of manifests written by older builds rather
than of anything this code still writes.

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

* refactor(squad): keep the protocol's ownership note out of the prompt (MUL-6984)

Review nit on #7971. The Squad Operating Protocol ended with a paragraph
saying it is the single statement of the no_action rule and that the rule
changes here — true, and worth writing down, but addressed to whoever
maintains the text. That const is sent to the model on every leader turn, so
the leader paid ~40 tokens per turn for a sentence it cannot act on.

Moved to the const's doc comment, next to the other constraints a maintainer
reads before editing it. The skill side already records the same ownership in
multica-squads/references/squad-source-map.md.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-03 18:57:35 +08:00
d276991c60 MUL-6743 refactor(issues): remove the assignment handoff note (#7626)
* refactor(issues): remove the assignment handoff note (MUL-6743)

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-01 13:50:52 +08:00
mrlonelyandL 6231962e98 MUL-6840 fix(pi): preserve sessions across workdir changes (#7760)
* fix(pi): resume session across workdir changes

* fix(pi): serialize session transcript writers

* fix(pi): keep busy sessions resumable

---------

Co-authored-by: L <[email protected]>
2026-08-31 13:49:38 +08:00
feig a92e8f6f7e MUL-6689: feat(agents): support MCP config for the Oh-My-Pi runtime (#7565)
Adds MCP configuration support to the Oh-My-Pi (omp) runtime. The MCP tab is
now visible for omp agents, and the managed config is materialised as
`{workDir}/.omp/mcp.json` — omp's highest-precedence project-level path —
before the runtime starts.

Materialisation lives in `execenv` and is recorded in `sidecarManifest`, so it
inherits refuse-to-clobber on a pre-existing file plus daemon-side teardown and
reuse cleanup. `Reuse` declines (returns nil, forcing a fresh Prepare) when the
config cannot be written, matching the Cursor path, so a leftover file from a
crashed run cannot silently carry stale MCP servers into the next task.
`.pi` and `.omp` are added to the repo-cache git excludes so runtime sidecars —
which for MCP routinely embed third-party tokens — cannot be committed by an
agent; `.pi/skills/` was already being written unexcluded before this change.
`listRuntimeLocalMcpServers` reports omp's user-scope inventory from
`~/.omp/agent/mcp.json`, with a comment recording that this is deliberately
narrower than omp's full discovery chain.

Pi itself is excluded by design. Upstream states the position in its README
("**No MCP.** Build CLI tools with READMEs, or build an extension that adds MCP
support."), confirmed against pi 0.67.2: no MCP code in the published bundle,
no MCP flag or subcommand, and a probe server placed in a project config is
never launched. There is no `.pi/mcp.json` convention to write into, so the
negative assertion pinning `pi` out of the allowlist is kept and now carries
that reasoning.

Closes #7558 for omp only; Pi would require authoring a Pi extension for the
daemon to inject, which is separate work.
2026-08-28 15:53:09 +08:00
cd169947b5 MUL-6686 feat(daemon): use readable workdir paths (#7316)
Create new task environments under sanitized, bounded readable segments:
<workspace-slug>-<workspace-short>/<issue-key>-<task-short>/workdir.

The readable name is the visible part; the substance is moving task identity
off the directory name. A human-readable segment is mutable — workspace slugs
and issue prefixes change — so workspace/task identity now lives in
.task_owner, .gc_meta.json and a stable ID-keyed index under .task_roots that
freezes one physical root per task for its lifetime. GC and disk-usage read
that authoritative identity instead of inferring a workspace from a path
string, which also fixes active and preparation-crashed roots being attributed
to a nonexistent workspace.

Existing workdirs are not renamed or migrated, legacy roots are adopted into
the index, and both layouts render privacy-safe relative paths. Labels are
lowercased ASCII with separators and traversal collapsed, segments are capped
at 24 characters so the two together stay within the previous 49-character
Windows path budget, and stable ID suffixes drawn from the UUIDv7 random tail
keep roots distinct across renames and case-insensitive filesystems.

Closes #6063

Co-authored-by: iiwish <[email protected]>
Co-authored-by: J <[email protected]>
2026-08-26 16:34:10 +08:00
8f9c766e40 MUL-6460: feat(brief): inject the workspace status catalog into the agent brief (#7306)
Agents could not see custom issue statuses (MUL-6243): the brief's status
command line hardcoded the seven built-in keys, so a workspace's Human
Review / Rework statuses were invisible even when instructions asked for
them, and the CLI offers no catalog read an agent could fall back on.

Ship the catalog on the claim payload, the same path WorkspaceContext
already rides: buildClaimedTaskResponse reads the workspace's ACTIVE
CUSTOM statuses (built-ins stay off the wire — their keys, names and
semantics are locked) and the daemon copies them into TaskContextForEnv.
The brief's status bullet then renders the workspace's statuses grouped
by category, with each custom status's name and description — category
anchors the inherited platform behavior, name is what users call the
status in instructions, and description is the admin's disambiguator
when a category holds more than one status. A bridge bullet in the issue
workflow connects the existing category-level status rules (which stay
untouched) to the concrete key choice.

With no custom statuses — every old-server pairing included — both
renders are byte-identical to before, keeping the prompt-cache prefix
stable for existing deployments (MUL-5377). The catalog is durable
workspace configuration like WorkspaceContext, not per-turn state, so it
belongs in the brief rather than the per-turn prompt. The claim read is
not gated on the custom_issue_statuses flag: the flag only guards
creation, and a catalog written before a flag flip must stay visible as
long as issues can sit on it. Name/description are user-authored and
pass through sanitizeNameForBriefMarkdown; a key failing the code-token
guard drops its entry. A 30-entry cap with an explicit omitted count
keeps a scripted catalog from flooding the brief while disclosing the
truncation.

Also update the built-in skills that document status semantics
(multica-working-on-issues, multica-squads) and their source maps:
statuses beyond the built-ins inherit their category's behavior in full,
with the two literal-key exceptions (failed-task rollback writes todo,
merged close-intent PR writes done) called out.

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 17:40:27 +08:00
520359a2c9 MUL-6518 fix(daemon): reclaim orphaned per-task temp dirs in the GC (#7391)
* refactor(daemon): give the lock and dir-stat helpers neutral names

openEnvRootLockFile, releaseEnvRootLock and codexStoreStat are all
generic: they take a path, and nothing in them knows about env roots or
Codex stores. The next commit reuses the same execution lock for per-task
temp directories, where the old names would read as a bug at the call
site.

No behaviour change.

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

* fix(daemon): reclaim orphaned per-task temp dirs in the GC

Every task gets a private temp dir (multica-task-*) under the system temp
base, exported to the agent as TMPDIR/TMP/TEMP. Its only exit was the
RemoveAll deferred at the end of runTask, which does not run when the
daemon is killed and does not succeed while a file inside is still open —
the Windows case reported in #7364, where one machine had accumulated 174
leftover directories. The GC never covered them: it scans WorkspacesRoot,
and the temp base is a different root, so multica-task- had exactly one
create site and zero reclaim sites.

Sweep the temp base as a fourth out-of-root pruner. Liveness is decided by
the directory's .task_lock — the same OS advisory lock an env root uses —
not by age: the sweep removes a directory only once it has itself acquired
the lock the owner would still be holding, which the kernel grants only
after that owner is gone.

Using the lock rather than a marker or a TTL is what makes this correct
rather than probabilistic. It is held by an open file description, so it
answers for a live task in this very process as well as one owned by a
different daemon sharing /tmp, which no in-memory active set could see.
And it needs no upper bound on task runtime — there is none to assume,
since DefaultAgentTimeout is 0.

Directories left by daemons predating the lock carry no lock file, so age
is the only signal available for them; they are reclaimed after
MULTICA_GC_TASK_TEMP_LEGACY_TTL (72h). That branch exists to clear the
existing leftovers and can be dropped in a release or two.

The end-of-run RemoveAll stays exactly as it was, and nothing was added to
the task-completion path: the run releases the lock before removing, so a
removal that fails there costs nothing and the next GC cycle finishes the
job.

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

* fix(daemon): keep the temp dir marker when cleanup cannot finish

Two fixes from review.

1. A failed cleanup destroyed the marker the next sweep needs.

   os.RemoveAll keeps walking after a failure, so removing a directory
   whose payload is still open deleted the .task_lock sitting next to it
   and then returned the error. The directory came back as one with no
   marker — indistinguishable from a pre-lock leftover, so it was no
   longer reclaimable on liveness at all. That is exactly #7364's failure
   path, and it made the claim this change is built on ("a failed removal
   costs nothing, the next cycle retries") false.

   RemoveTaskTempDir removes contents first and stops at the first one it
   cannot remove, leaving the marker behind; the lock file goes last, once
   everything else is gone. Both the task-completion path and the GC use
   it. A cleanup that fails now leaves a directory that is still
   lock-bearing and still unlocked, so the next cycle takes the lock and
   retries — for as many cycles as the holder needs.

2. The legacy TTL could delete a live task's TMPDIR.

   Pre-lock directories were reclaimed on age after 72h by default, but
   age cannot establish liveness: DefaultAgentTimeout is 0, so a task may
   legitimately run far longer than any TTL; a daemon on another profile
   can still be running the old binary during an upgrade; and every daemon
   on the machine shares one temp base. The default is now 0 — leave them
   alone — and reclaiming them is an operator's explicit opt-in. Each
   cycle logs how many were left in place so the choice stays visible
   rather than silent.

   Classification also fails closed: only os.ErrNotExist means "no lock
   file". Any other stat error (another user's dir under a shared /tmp)
   now reads as in-use instead of legacy.

Regressions for both: a Windows test that makes a real sharing violation
with CreateFile minus FILE_SHARE_DELETE, asserting the marker survives the
failed cleanup and the directory goes once the handle closes (wired into
the Windows CI job, which runs targeted filters rather than whole
packages); and unix equivalents driving the same paths through an
unlinkable payload, plus fail-closed coverage for an unreadable directory.

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

* fix(daemon): publish the temp dir marker atomically, restore it on rmdir failure

Two more windows in the marker's lifecycle, from review.

1. A directory could be swept between marker creation and lock acquisition.

   LockTaskTempDir created .task_lock and locked it as two steps, so there
   was an instant where the marker existed unlocked. A sweep landing there
   reads exactly what a dead owner leaves behind: it takes the lock,
   concludes the owner is gone, and deletes the directory — after which
   the starting task's own lock call succeeds and it runs with a TMPDIR
   that no longer exists.

   The marker is now locked under .task_lock.claiming and renamed into
   place once held. Rename within one directory is atomic and the lock
   rides on the open file description, not the name, so the marker becomes
   visible and held in the same step.

   Before that rename the directory carries no marker, which classifies as
   legacy — and legacy only reclaims what is OLDER than legacyTTL. A
   directory created microseconds ago is younger than any positive TTL,
   and a TTL of 0 disables the branch outright, so no configuration can
   sweep the pre-publish state either.

2. A failed rmdir left a directory with no marker.

   The marker has to be deleted before the directory can be removed, so a
   failure at that last step — a Windows process holding a handle to the
   directory, or content an orphaned child recreated — left a directory
   nothing could classify again. With the legacy branch disabled by
   default that is a permanent leak, and it was the one path where cleanup
   got furthest. The marker is now put back when that removal fails, so
   every failure mode leaves the retry available.

Regressions: the publication order and the mid-claim on-disk state (spared
under a disabled TTL and under positive ones), a sweeper running flat out
against 200 publish/use/cleanup cycles, and the rmdir-failure branch —
which fails without the restore, verified by removing it.

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

* fix(daemon): decide the legacy sweep on content, not on age alone

The publication window was still open. The claim-then-rename ordering
stopped .task_lock from ever existing unlocked, but a directory mid-claim
carries no .task_lock at all, so the sweep still reaches it through the
legacy branch — and the argument that protected it there was wrong.

That argument was "a directory created microseconds ago is younger than
any positive TTL". MULTICA_GC_TASK_TEMP_LEGACY_TTL takes any duration,
down to a nanosecond, and an owner stalled between MkdirTemp and the
rename outlasts a large TTL just as easily. Reproduced at 1ns, 1ms, 1h
and 72h alike: the sweep deleted a directory whose claim was held.

Age cannot separate a pre-lock leftover from a directory being published,
so stop asking it to. Content can: a directory being published holds
nothing but its own lock files, because the task has not been handed the
path yet and cannot have written anything, while a leftover worth
reclaiming is by definition one with something in it. The legacy branch
now reclaims only directories that hold task content, which covers both
halves of the window — the empty directory before the claim exists, and
the claim-only directory after — at every TTL value, with no timing
assumption left in the argument.

The cost is that a pre-lock daemon which crashed before its task wrote
anything leaves an empty directory nothing reclaims. That is a few bytes
against deleting the temp dir of a task that is starting.

Tests: the mid-claim cases now run at 1ns/1ms/1h/72h with now pushed PAST
the TTL, so they fail if anything reintroduces a timing argument; the
pre-claim (empty directory) half is covered the same way; and the
invariant itself is pinned directly, so relaxing it breaks a test rather
than a race. The existing legacy TTL test now models a real leftover by
giving it content. Verified all of these fail with the content rule
removed.

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

* fix(daemon): re-check the lock immediately before removing a legacy dir

The legacy branch's decision is several separate observations — classify,
then walk for content, then walk for mtime — and a directory can finish
publishing in the middle of them. Classified as legacy while its marker
was still unpublished, then published and written to by its task, and the
removal went ahead without ever looking at the marker that had appeared:
the sweep deletes a live task's TMPDIR. What was accidentally holding this
together was that the task's own write refreshes the mtime — a timing
crutch, in the branch whose whole point is not to rely on one.

Re-read the classification immediately before removing, and skip unless it
is still legacy.

That closes the window rather than narrowing it, because of what content
means: a task only writes into the directory after it has been handed the
path, which happens only after LockTaskTempDir returns and therefore only
after the marker is published and held. So content observed at the top of
the branch, plus no marker seen at the bottom, proves no live task owns
this directory — and none can arrive later either, since MkdirTemp only
ever hands out a fresh empty directory under a new name.

The regression drives that exact interleaving through a barrier: the sweep
commits to removing the directory, the owner then publishes and its task
writes, and the sweep must notice. `now` is far past the TTL so age cannot
mask it. Verified it fails without the re-check.

Also from review: drop a stale test comment that still claimed a positive
TTL can only be configured far above a directory's age, and record in the
config and operator docs that a directory holding no task content is never
reclaimed on age — including a shell left by a CURRENT daemon that died
between creating the directory and publishing its lock, not just an old
daemon's empty leftover.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 17:38:44 +08:00
Bohan Jiangandmultica-agent d2532f766b MUL-6491 fix(daemon): fence env-root resets on a live execution, not a task id (#7356)
* fix(daemon): fence env-root resets on a live execution, not a task id

Follow-up to #7347. The identity marker answers WHO owns an env root, not
whether that owner is still running, so Prepare read "owner == my task id"
as "my own rerun, safe to reset".

That is reachable rather than theoretical. ReclaimStaleDispatchedTaskForRuntime
re-delivers a task row whose prepare lease expired using the SAME task id,
and a failing lease renewal only logs — it does not cancel the Prepare still
in flight. The re-delivered execution then wiped the running one's workdir,
config and worktree: the cross-execution deletion the env-root claim exists
to prevent, arriving through the one door it left open.

Add a second signal alongside the marker: an OS advisory lock on the env
root, held for the life of the Environment and released by Cleanup. The
kernel drops it when the holder exits for any reason, so it answers "is the
previous execution still alive?" across processes with no heartbeat, no PID
table and no stale-state cleanup path — a crashed execution's directory is
recoverable on the next dispatch, a running one's is not.

Taking the lock first also serialises the claim, which replaces the
Mkdir/O_EXCL arbitration with something simpler and stronger: no other
execution can be mid-claim, so the checks below it cannot race.

That in turn fixes the torn-marker case from the same review. The marker was
created empty and written a moment later, so a crash in between left a
zero-length file that read back as "no owner" while still counting as
directory content — the env root could then be neither adopted nor reset,
and stayed wedged until someone deleted it by hand. Under the lock an
unnamed marker is simply rewritten.

Tests, each verified to fail without its fix: an overlapping execution of one
task id is refused and the live work survives; the same id recovers once the
prior execution released the root; a torn marker is repaired instead of
wedging.

Refs #7326

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

* fix(daemon): release the env-root lock at the end of the task run

Windows CI caught that the lock outlived the work it guards. It was released
only by Environment.Cleanup, which production never calls — the GC reclaims
env roots on its own schedule — so the lock was effectively held until the
daemon exited. Two consequences: every later dispatch of that task would
fail closed as "held by a running execution", and on Windows the open handle
pinned the directory so nothing could remove it:

    TempDir RemoveAll cleanup: unlinkat ...\.task_lock:
    The process cannot access the file because it is being used by another process

Release it where the execution actually ends. The daemon now defers
ReleaseLock alongside unmarkActiveEnvRoot, which is the same lifetime: the
task run. Cleanup still releases too, and both are idempotent.

Also open the lock file with FILE_SHARE_DELETE on Windows. os.OpenFile does
not request it, and without it Windows refuses to delete a file anyone holds
open — the lock is meant to coordinate executions, not to pin a directory
against the GC. Unix already allows unlinking an open file; this makes
Windows behave the same.

Refs #7326

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

* fix(daemon): hold the env-root claim in the parent, not the prepare helper

Review found the lock never applied in production. Real daemons always
prepare through PrepareIsolated, which runs a short-lived helper process:
the lock was taken inside that helper, the kernel released it the moment the
helper exited, and Environment.lockFile is an *os.File that cannot cross the
helper's JSON response. The parent got back a nil lock, so ReleaseLock was a
no-op and the agent ran with no protection at all — the exact re-dispatch
window this was meant to close.

Every test added with the lock went through the in-process Prepare, where
the "helper" never exits, so none of them could see it.

Move the claim to the daemon parent, whose lifetime IS the task run:

- ClaimEnvRoot / EnvRootClaim.Release are now exported, and runTask takes
  the claim alongside markActiveEnvRoot and releases it on every exit.
- PrepareParams.EnvRootPreclaimed tells preparation the caller already holds
  the claim and performed its reset, so it does not try to take a lock the
  parent is holding. Direct in-process callers still claim for themselves.

Also cover the reuse path, which took no claim at all: a task continuing in
a PRIOR task's env root now excludes on that root through
LockEnvRootForReuse. Identity there belongs to the earlier task, so this is
the exclusion half of a claim only — without it two continuations of one
task could refresh and run the same workdir concurrently. Guarded to roots
under the workspaces root, since a local_directory task's PriorWorkDir is
the user's own directory.

Tests now exercise the production path: one drives PrepareIsolated through
the real helper harness and asserts the parent still holds the claim after
the helper exits, the other covers reuse exclusion. Against the previous
arrangement the first fails with "a second execution claimed the env root of
a task that just prepared".

Refs #7326

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

* fix(daemon): lock a prior env root only after proving it is a managed one

Review found the reuse guard wrote its lock file before it had established
where it was writing. runTask locked filepath.Dir(task.PriorWorkDir) up
front, gated only by a lexical filepath.Rel containment check — which does
not resolve symlinks. A link inside the workspaces root pointing anywhere
else passed that gate, and .task_lock was created in the target: a user's
own directory, or a path outside the root entirely. Eligibility was not
settled until much later, in shouldReusePriorWorkdir.

Reorder so the proof comes first. shouldReusePriorWorkdir already resolves
symlinks and verifies the {workspace}/{task}/workdir shape, managed
provenance and the task-context marker; it now returns the canonical path it
validated, and that is the only path ever opened. The lock is re-confirmed
against the same check while held, closing the gap between proving
eligibility and acting on it.

Declining is not fatal on this path: a busy root, a vanished directory or a
failed re-check falls back to a fresh Prepare, which costs session
continuity rather than correctness. ErrEnvRootBusy makes that distinguish-
able from a real I/O failure. The lexical isUnderWorkspacesRoot helper is
gone — it promised containment it could not deliver.

The canonical path deliberately stays internal. Reuse still receives
task.PriorWorkDir, so the workdir reported to the server and handed to the
next task keeps its existing shape.

Tests: negative cases for a symlink escaping the root, a plain outside path
and a managed-looking path without provenance, each asserting no .task_lock
appears outside — against the previous ordering the symlink case fails with
"prior-workdir guard wrote the lock outside workspaces root". A positive
case pins that a provenanced root IS locked, in the right directory, and
excludes a concurrent continuation. And a helper-process test pins that a
caller who holds the claim but forgets EnvRootPreclaimed fails immediately
and says so, instead of preparing while believing it is protected.

Refs #7326

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

* fix(daemon): claim a prior env root by identity, not by name

Review found the remaining TOCTOU: the canonical path validation returns is
a string, not a handle. Between proving eligibility and opening the lock,
the validated env root can be renamed away and a symlink to somewhere
outside dropped in its place — an ordinary open follows it and creates
.task_lock out there, and no later re-check can undo a write that already
happened. A swap to a different but equally provenanced directory at the
same path is worse still: every name-based check agrees, and the daemon ends
up holding a lock on one directory while running in another.

Resolve the lock through an os.Root pinned at the workspaces root. Root
refuses any component that resolves outside its tree, so the lock file
cannot land outside no matter when the swap arrives — containment stops
being a check that races and becomes a property of the open.

Root still follows symlinks that stay inside the tree, so containment alone
cannot answer "is this the same directory?". Chain identity instead: stat
the directory validation approved, require the lock to have landed on that
same inode, and require the directory handed to Reuse to be it as well.
os.SameFile compares identity; comparing canonical path strings cannot tell
a directory apart from its replacement at the same name.

Reuse now receives the canonical path that was locked, so the object locked
and the object used are the same one. Two consequences that fell out:

- The workspaces root has to be resolved before measuring the env root
  against it, or a symlinked root (macOS /tmp, a home on a linked volume)
  makes them look unrelated and every reuse is refused.
- gateResumeToReusedWorkdir compared workdirs textually, so a canonical
  env.WorkDir silently dropped the prior session. It now compares directory
  identity, which is what it meant all along.

Tests use a deterministic hook to swap the directory between validation and
the lock rather than racing it. Against the previous code they fail with
"validated canonical root was retargeted and the lock was written outside"
and "accepted reuse after the validated directory was replaced at the same
path".

Refs #7326

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

* fix(daemon): pin the workspaces root before validating, and lock from one handle

Three findings from review, all in how os.Root was being used rather than in
the choice of it.

Pin the workspaces root BEFORE validating anything. Opening it afterwards,
from a name validation had just approved, re-resolved that name: rename the
whole root aside, leave a symlink to a look-alike tree, and os.Root
faithfully pins the replacement. Root promises you cannot escape the tree it
opened — not that it opened the tree you meant. Holding the handle first
makes that ordering impossible.

Take the directory's identity and create its lock file from ONE sub-Root
pinned on the env root. Resolving the env root's relative path twice from
the workspaces Root — once to stat, once to create — allowed A on the first
resolution and B on the second, reporting A's identity while holding B's
lock.

Close the last window by detection rather than by claiming it is closed. A
file descriptor cannot cross into the preparation helper process, so Reuse
can only be handed a name, and the gap between the final identity check and
Reuse's own opens cannot be pinned away. The environment Reuse produces is
now checked back against the directory the lock is held on, so a
substitution ends the run in a fresh environment instead of executing in a
directory nothing holds. What remains uncovered is stated in the code: the
writes Reuse itself performs inside that window.

Also drop sameExistingDir's string fast path, which returned true without a
stat and contradicted the identity contract the function documents. Its
table test used synthetic paths that never existed; they are real
directories now, which is what the comparison was always about.

Tests, each verified to fail without its fix: a workspaces-root swap must
not let the lock be written into the replacement tree; reported identity
must be the directory the lock file is actually in; and a swap after the
claim, before Reuse, must end in a fresh environment rather than the
substitute.

Refs #7326

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

---------

Co-authored-by: multica-agent <[email protected]>
2026-08-21 14:49:27 +08:00
Bohan Jiangandmultica-agent 3622db21cf MUL-6491 fix(daemon): give each task an exclusively claimed env root (#7347)
* fix(daemon): derive task env roots and branches from the full task id

Task ids are UUIDv7, whose leading 8 hex chars are the high 32 bits of a
48-bit millisecond timestamp: they only advance once every 2^16 ms
(~65.5s). Deriving the env-root segment from that prefix meant every task
created inside one such window landed on the same directory.

Prepare removes an existing env root before recreating it, guarded only by
the comment "task IDs are unique". That premise held for the id but not for
the truncation, so the second task's Prepare ran os.RemoveAll over a live
sibling's env root — taking its workdir (including a worktree with
uncommitted work), output/, logs/ and the 0700 multica-config/ with it.
Overwritten CLAUDE.md / AGENTS.md identity files were the visible symptom;
the deletion was the actual damage.

Use the whole id for the env root and for the agent/<name>/<task> branch
names, which truncated the same way and collided for concurrent tasks of
one agent. shortID stays for logs and display only. Prepare now derives its
root through PredictRootDir so the two can no longer drift apart.

Existing short-prefix directories need no migration: the GC enumerates task
dirs with ReadDir and reads .gc_meta.json, so it still reclaims them.

Fixes #7326

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

* fix(daemon): take the task path segment from the id's random tail

The first attempt used the whole 32-char task id, which overflowed Windows
MAX_PATH: a branch name becomes a path under .git/refs/heads/ inside an
already-deep task checkout, and CI failed with

    cannot lock ref 'refs/heads/agent/windows-codex/<32 chars>':
    Filename too long

Length was never the fix. The bug was which end the segment came from: a
UUIDv7 leads with a 48-bit millisecond timestamp, so its first 8 hex chars
are the high 32 bits and only advance once every ~65.5s. Read from the tail
instead, where the id is random, and 12 chars are enough — 4 more than the
original segment rather than 24.

That trades a certainty for a probability, so make the destructive step
prove itself instead of assuming: Prepare stamps a .task_owner marker into
the env root and refuses to run os.RemoveAll over a root another task owns.
A rerun of the same task still resets its own directory. A task that fails
to start is recoverable; one that deletes a running sibling's uncommitted
work is not.

Two existing fixtures used synthetic ids differing only in the head and so
shared a tail. The guard caught them; they now differ where a real UUIDv7
does.

Fixes #7326

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

* fix(daemon): make the env-root claim atomic instead of check-then-delete

Review caught that the ownership guard did not cover the case it was added
for. It read .task_owner, then deleted, then wrote the marker — three steps,
so two tasks sharing a taskKey could both observe "no owner", and the slower
one would still os.RemoveAll the faster one's live directory. The guard only
held when a foreign marker was already durably in place, i.e. serially.

Replace it with a claim protocol built on two atomic filesystem primitives:
os.Mkdir fails if the directory exists, and O_EXCL fails if the marker
exists. Exactly one racing caller wins each, and a caller that wins neither
never reaches the destructive path.

The claim is then held for the rest of Prepare. A rerun clears the env
root's CONTENTS and leaves the marker in place rather than removing and
recreating the directory, so there is no window in which the root looks
unowned to a racing task.

A directory that holds files but names no owner now fails closed instead of
being deleted as abandoned. The marker is written before any content, so the
only unowned directory that is safe to adopt is an empty one — the residue
of a crash between Mkdir and the marker write, which would otherwise wedge
the task permanently.

Tests: a barrier-synchronised concurrent Prepare asserting exactly one task
starts and the winner's tree survives (fails against the previous sequence
with "directory not empty" / "invalid argument" — the deletion racing the
create), plus fail-closed and empty-adoption cases. repocache carried a
third copy of the task-segment rule with no test; it now has the same
contract test as the handler.

Fixes #7326

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

---------

Co-authored-by: multica-agent <[email protected]>
2026-08-21 00:36:59 +08:00
eb2a94e860 MUL-6368: Prevent cross-agent task and workdir contamination (#7205)
* fix(daemon): isolate task identity and workdir reuse

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

* test(llm): use validated retry override in contract

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

* fix(daemon): address task isolation review

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 15:30:19 +08:00
Bohan Jiang 4d5a679b9b fix(openclaw): unblock the OpenClaw runtime on slow hosts (MUL-6321) (#7142)
Fixes #7112.

Task preparation ran `openclaw config file` and `config get agents.list --json` with a hardcoded 5s deadline; on a 2020 Intel MacBook Pro those take 8.4-10.9s and 4.3s every time, so the OpenClaw runtime was unusable there with no way to raise the limit.

- Deadline 5s -> 30s, overridable via MULTICA_OPENCLAW_CLI_TIMEOUT / backends.openclaw.cli_timeout, clamped to [1s, 60s] so the worst case (4 serial calls) stays inside the 5-minute preparation budget.
- Config discovery cached per daemon profile (on disk, since preparation runs in a one-shot helper process), invalidated by binary identity, OpenClaw path env, active config stat, and a 60s TTL. Only complete successes are cached; any cache fault degrades to a live CLI run.
- The timeout is classified structurally into a new platform-side, non-retryable reason (runtime_cli_timeout) instead of being text-matched into agent_error.provider_network, which showed "check your network" copy and auto-retried a deterministic local failure. Copy added for web/desktop/mobile; a NormalizeDaemonReason shim upgrades reports from older daemons.
2026-08-18 18:33:23 +08:00
ceb6c6845c MUL-6132 fix: stop stranding daemon task markers in user directories (#6917)
* fix(daemon): roll back local_directory sidecars when prepare fails (MUL-6132)

Prepare writes the daemon task marker and the rest of the sidecar tree into
the workdir early, but only persists the manifest that records them at the
very end. Every error return in between left that tree on disk with no record
of what to remove, and since the caller gets no Environment on those paths,
none of the teardown defers that normally undo it were ever registered.

For a local_directory task the workdir is the user's own repository, so the
leftover marker disabled every human-local multica command for that whole
directory tree — silently, and until someone deleted the file by hand. No
crash was needed to reach this state: a failed provider setup was enough, and
writeSidecarManifest failing was only a warning, so a disk or permission
hiccup produced the same permanent residue.

Roll back in place from the in-memory manifest whenever Prepare fails after
writing context files, and treat a manifest write failure as fatal in place so
that rollback runs while the record still exists. Worktree and cloud envs are
untouched: the former discards the whole worktree, the latter is wiped by GC.

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

* fix(cli): retire stale daemon task markers instead of refusing forever (MUL-6132)

A daemon task marker left behind in a user's own directory made every
human-local command (daemon start/stop/restart/logs, login, logout, setup,
workspace switch, runtime profile set-path/unset-path) fail anywhere in that
directory tree, permanently and with a message that named neither the file nor
a way out. The reported instance sat undetected for five weeks.

Retire the marker automatically, but only on positive evidence that nothing is
running: every profile's daemon must answer and report zero active tasks.
Silence is not absence — an unreachable daemon is what a crashed one looks
like, and a task orphaned by that crash keeps running with this marker as its
only remaining guard, so any unreachable or unparseable profile keeps the
command refused. A minimum marker age closes the window where a daemon that
reports zero is claiming a task and about to write this very marker.

Two things stay out of scope by construction: markers without an agent and
issue id, which is the permanent node-wide guard at the workspaces root rather
than a task leftover, and any invocation carrying MULTICA_* task identity,
where the daemon really did launch this process and no file may override that.

When staleness cannot be proven the command still fails closed, but the error
now names the marker path, matching what newAPIClient has reported since
MUL-3922 and replacing a message that sent the user to the source.

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

* fix(daemon): arm the in-place rollback before the first sidecar write (MUL-6132)

The rollback was registered after writeContextFiles returned, but that function
writes the daemon task marker as its very first act and can then fail on any
later step — .agent_context, skill files, project resources. Those failures
returned before the defer existed, so the case the fix was written for still
stranded a marker in the user's repository with nothing beside it.

Arm the defer immediately after the manifest is allocated, before any write.
Rolling back an empty manifest is a no-op, which is what makes arming it that
early safe.

Adds a regression test whose induced failure lands inside writeContextFiles
(a plain file where .agent_context must be a directory), one step after the
marker is written. It fails on the previous commit and passes here.

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

* fix(cli): drop CLI-side marker deletion, keep the actionable message (MUL-6132)

The self-heal could delete the fail-closed guard of a running task. The daemon
increments active_task_count only after a claim returns, so a probe can read
zero for a task that is already claimed and about to write the marker at this
very path; the marker's age says nothing about a file written after the probe.
Serial probing across profiles widened the window further. Separately, the
"every profile answered" proof did not hold: profiles were matched by health
port, and that port is a hash that can collide, so one idle daemon could be
counted as two profiles while a crashed one kept an orphan task running.

Both are properties of doing this from outside the daemon. Removing a guard
needs synchronization with the claim path that only the daemon has, so
retirement moves there and the CLI keeps the half it can do soundly.

What remains is the message, now shared by both refusal paths. Previously only
newAPIClient named the marker file, so whether a user could act on the refusal
depended on which command they happened to run first; requireHumanLocalCommand
sent them to the source instead. Both now resolve the leftover through one
helper and report it identically.

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

* fix(cli): report leftover markers on every refusal path, never the root marker (MUL-6132)

Two gaps from the previous round's review.

requireTaskLocalConfigRoot is the third path a leftover marker can refuse
through — config show, config set and auth status all call it — and it still
returned a message that named no file. Whether the error was actionable
depended on which command the user happened to run first, which is exactly what
unifying the other two paths was meant to end. It now appends the same suffix
when a marker is the only daemon signal.

The leftover helper also could not tell the two markers apart, because
daemonTaskContextMarkerPath matches on managed_by alone. Running from the
daemon workspaces root therefore described the permanent node-wide guard as a
leftover to delete — advice that closes the hole it exists to cover, aimed at
whoever reads the error, including an agent subprocess that lost its
environment. The helper now requires task identity, defined as agent_id OR
issue_id: a chat task has no issue, so requiring both would misfile its marker
as the root marker, while the root marker has neither field.

chdirWithDaemonTaskMarker was writing the bare managed_by form, which under
this rule models the root marker rather than the workdir marker its tests are
about; it now writes a task-scoped one.

Also corrects two comments the earlier commits left stale: cmd_agent.go still
described an automatic retirement that no longer exists, and
writeSidecarManifest still claimed every caller treats a write failure as
non-fatal, which stopped being true when the in-place path was made fatal.

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

* docs(cli): correct the refusal-path count in leftover-marker comments (MUL-6132)

Both comments still said "both refusal paths" from when the suffix was shared by
newAPIClient and requireHumanLocalCommand only. requireTaskLocalConfigRoot
joined them in e02a393, so the count is three; list them by name so the next
addition has somewhere obvious to register.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-14 02:23:44 +08:00
c9727e6b2b feat(daemon): worktree mode for local_directory resources (MUL-5707) (#6759)
* feat(daemon): worktree mode for local_directory resources (MUL-5707)

Local directory resources serialise every task on a per-path mutex, so two
tasks on one folder queue behind each other. This adds an opt-in worktree
mode: each task gets its own git worktree of the user's repo inside the
daemon's env root, so tasks run concurrently and deliver their work as a
branch instead of editing the user's working copy.

Opt in per resource with execution_mode=worktree (default stays in_place,
so existing resources are unchanged). Requires a git repo with at least one
commit; a non-git directory fails with an actionable message rather than
silently falling back to serial execution.

Design points that make the mode safe to use:

- The agent starts from what the user sees, not HEAD. Tracked edits are
  captured with `git stash create` (which writes a commit object without
  touching the index, working tree, or stash list) and replayed into the
  worktree; untracked non-ignored files are copied, bounded at 2000 files /
  200MB with the skipped count logged at warn level.
- That replayed state is committed as a baseline, so "did this task change
  anything?" has an exact answer and `git diff <baseline>..<branch>` is
  precisely the agent's work.
- Nothing is silently discarded: leftovers are committed to the branch
  before the worktree is removed, including when the task fails partway.
- A task that changes nothing leaves no branch behind.
- Sidecars stay out of the branch by running the existing
  CleanupRuntimeConfig + CleanupSidecars pass over the worktree before
  finalizing. The .git/info/exclude approach repocache uses is not usable
  here: a linked worktree resolves info/exclude to the user's own common git
  dir, so it would change what `git status` hides in their checkout.
- Worktree-mode env roots do not get the local_directory GC exemption, so
  they age out normally instead of accumulating one env root per task.
- `git worktree prune` on create self-heals registrations orphaned by a
  crashed daemon.

Adds --execution-mode to `project resource add/update`, and updates the
docs and the projects-and-resources built-in skill.

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

* fix(daemon): close data-loss and isolation gaps in worktree mode (MUL-5707)

Review follow-up on #6759. Six issues, all on failure or skew paths.

1. Preserve work when the final commit fails. Finalize logged a warning and
   then ran `git worktree remove --force`, destroying the agent's changes.
   commit.gpgSign with no key available to the daemon triggers this, and
   --no-verify does not disable signing. Finalize now returns an error and
   deliberately keeps the worktree — it stays registered, so `git worktree
   list` points at the work — and the daemon fails the task instead of
   reporting success with a branch that is missing the changes.

2. Fail closed on an unsupported execution_mode. Treating an unknown mode as
   in_place was wrong: execution_mode is how a user asks for ISOLATION, so
   the "safe" fallback would edit the working copy they asked us to stay out
   of. An unrecognised mode now fails the task naming the version skew.

3. Keep Codex conversations alive across turns. The per-issue session store
   was keyed on LocalWorkDir != "", so worktree tasks — which also never
   reuse an env root — got a fresh empty CODEX_HOME per turn and silently
   lost the thread. Covered by a two-turn regression test that fails without
   the fix.

4. Fail closed on an incomplete snapshot. A failed stash create/apply, a
   failed untracked copy, or hitting the copy bounds all degraded to "agent
   sees HEAD" with only a daemon log line. A half-replayed tree looks like a
   working checkout, so nothing downstream questions it while the agent reads
   different code than the user has. All of these now abort the prepare and
   tear the partial worktree back down.

5. Do not snapshot a directory mid-mutation. The same real path can be
   attached to another project as an in_place resource; snapshotting under a
   running in_place task captured a half-written tree plus that task's
   in-flight .agent_context/.multica sidecars, which then reached the branch
   and could feed the agent another issue's brief. Worktree prepare now takes
   the per-path mutex for the snapshot alone (released before the run, so
   tasks still overlap), and the untracked replay skips Multica's own
   sidecars.

6. Stop the test helper from masking regressions. gitRun skipped on any git
   failure, including post-finalize assertions, so a lost branch could report
   as a skip. Git availability is now checked once at repo setup; every later
   git failure is fatal.

Also rebased onto main, keeping both MulticaConfigRoot and LocalWorktree in
the Environment initialiser.

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

* fix(daemon): close sidecar-commit and wait-visibility gaps in worktree mode; gate worktree saves on daemon version (MUL-5707)

- runTask: register the sidecar-cleanup defer immediately after the Finalize
  defer. Previously two early returns (temp-dir setup, StartTask failure) sat
  between them, and on those paths Finalize auto-committed the sidecars
  Prepare had just written — delivering a branch whose only content is
  Multica's own runtime files (in_place: leaving them in the user's tree).
- runTask: give the worktree snapshot wait the same plumbing as the in-place
  acquire — waiting_local_directory status, resourceWaitTasks accounting, and
  a cancellation poller. The holder can be an hours-long in_place task; the
  wait was invisible and a user cancel went unnoticed until the 5-minute
  prepare timeout.
- isMulticaSidecarPath: match sidecar dirs as whole path segments at any
  depth, not only at the repo root. An in_place resource pointed at a
  SUBDIRECTORY leaves its sidecars below that subdirectory; replaying them
  committed another issue's brief into the delivered branch.
- handler: refuse saving execution_mode=worktree while the owning daemon's
  registered cli_version is below MinLocalWorktreeCLIVersion (422,
  daemon_version_unsupported). An old daemon json-skips the field and runs
  tasks IN PLACE — the daemon-side unknown-mode refusal cannot protect a
  daemon that predates the field, so the server has to. Residual (documented):
  a daemon downgraded after the save needs a claim-time gate.
- copyUntrackedFiles: read the -z listing raw instead of TrimSpace-ing it,
  which corrupted filenames beginning/ending with whitespace into paths that
  silently vanished from the replay.
- docs + skill: describe the save-time version gate; drop the stale claim
  that old runtimes fail the task themselves.

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

* feat(views): UI switch for local_directory execution mode (MUL-5707)

Worktree mode shipped CLI/API-only, which for its target user — someone
running several tasks at once from Desktop — means it does not exist. This
adds the switch to the resources panel.

The choice is framed by what the user gets back, not by the mode
identifiers: "Edit this folder directly" (one task at a time, changes land
in your working copy) vs "Run in parallel, isolated" (tasks run at the same
time and each hands back an agent/… branch). `in_place` / `worktree` stay
visible as a secondary hint for anyone cross-referencing the CLI or docs.

Two entry points, matching where the decision is actually made:

- The add-a-folder flow now asks for the mode after the folder is picked and
  validated, instead of creating the resource and leaving the mode to be
  discovered later.
- Every local_directory row has a mode control. It is deliberately NOT gated
  on being the owning machine — switching the mode only rewrites a field, so
  it works from the web app too, unlike rename or the folder picker.

Both blocked states are handled where the user chooses, not after they
commit:

- Non-git folder: the desktop validator now reports whether the path is
  inside a git working tree (walking up for `.git`, so a subdirectory of a
  repo counts), and the option is disabled with that reason. Unknown — an
  older desktop build, or an existing row — stays permissive; the daemon
  re-checks authoritatively.
- Daemon too old: the version floor is mirrored from the server as
  MIN_LOCAL_WORKTREE_CLI_VERSION and checked against the runtime for the
  resource's OWN daemon, not the machine the browser happens to be on. A
  server rejection is rendered inline in the dialog rather than as a toast,
  so the dialog stays actionable.

A "Parallel" badge on the row is always visible (not hover-only): without it
there is no way to tell whether tasks on a folder edit it directly or hand
back a branch, which is the first thing anyone asks when a task queues.

Also adds `execution_mode` to LocalDirectoryResourceRef — the field existed
server-side but had no TypeScript counterpart — and brings the zh/ja/ko
project-resources docs in line with the English ones, which were the only
locale updated when the mode landed.

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

* fix(daemon,handler): close worktree isolation and delivery gaps (MUL-5707)

Second review round. Five must-fix items, all on paths where the mode's
promises could still break.

1. A failed sidecar cleanup no longer ships Multica's own files as the
   user's branch. The cleanup errors were logged and Finalize then ran
   `git add -A`, so a permission/disk/lock failure could deliver a branch
   whose entire content was the runtime brief and issue context. Worktree
   mode now escalates a cleanup failure to an abort: nothing is committed,
   the worktree is kept for inspection, and the task fails with the
   preserved path. In place it stays a warning — no commit, no branch, and
   the leftovers are visible in the user's own tree.

2. The untracked replay no longer drops files silently. `Lstat` failures
   and symlinks were `continue`d without counting, so the caller read a
   partial snapshot as complete. Both now count as skipped, which the
   existing fail-closed check turns into a refused prepare. Non-content
   entries (sockets, FIFOs, devices) stay uncounted deliberately.

3. Prepare rolls back a worktree it created. Any later failure — context
   files, provider home, MCP config — returned without an Environment, so
   nothing downstream knew a worktree existed, leaving a registration and
   a branch no task ever ran in. A deferred Discard now runs unless Prepare
   reached its successful return.

4. The daemon-version gate moved to where it can actually hold. Save-time
   validation cannot see a machine downgraded afterwards, and an old daemon
   json-skips execution_mode entirely — it would run the task in place,
   editing the working copy the user asked to isolate. Claims are now
   checked against the runtime that is actually claiming, and a blocked
   task is cancelled with a reason rather than left to be redelivered to
   the same too-old runtime forever.

5. branch_name is delivered end to end. The daemon sent it, but
   TaskCompleteRequest had no field so it vanished at decode, and the
   failure path never sent it at all. It now has a column, travels on both
   terminal callbacks, and renders as a copyable row in Run details. The
   failure path matters most: worktree mode commits the agent's leftovers
   before tearing the worktree down, so a run that died partway still has
   work worth finding.

Docs (all four locales) and the projects-and-resources skill are updated
for the claim-time gate and the branch surface.

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

* fix(handler,daemon): migration renumber, cancel reasons, branch on every terminal path (MUL-5707)

Third review round.

1. Rebased onto main and renumbered the migration to 285 — main had taken
   273 and is now at 284. sqlc regenerated on top of the rebased queries;
   the conflicts were confined to generated files.

2. The claim gate now persists why it cancelled. It called CancelTask,
   which writes only status='cancelled', and returned the reason as a 4xx
   that reaches the daemon's log and nothing else — batch claim drops the
   task from its response entirely. A run refused for a durable reason the
   user has to act on cannot present as a bare "cancelled", so this adds
   CancelAgentTaskWithReason and records error + failure_reason on the row.

3. branch_name now survives every terminal path, not just the two direct
   callbacks. Three were dropping it:
   - the context-exhausted complete→fail reroute did not copy it;
   - the daemon's complete→fail fallback (server rejected the complete,
     agent had succeeded) did not set it;
   - both cancellation paths discarded the result wholesale, even though
     the worktree is finalized — and its work committed — before the daemon
     learns of the cancel. The branch now rides the cancel-ack and is
     recorded by SetAgentTaskBranchName, which never overwrites a name a
     terminal callback already wrote.

4. Rollout/rollback compatibility. Isolation is enforced by two server-side
   gates, so a daemon downgraded at any point after the resource was saved
   is caught. The gap they cannot close is rolling the SERVER back past this
   change while sub-floor daemons are still connected: an old server has no
   gate and an old daemon ignores execution_mode. That constraint is now
   documented where an operator will meet it (the migration and the docs),
   and the version floor is pinned across server and frontend by test so the
   two gates cannot silently drift apart.

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

* fix(server,views): route claim-gate cancels through TaskService, make cancel reasons visible, harden cancel-ack branch delivery (MUL-5707)

Closes the fourth-round review findings on worktree mode's cancellation
chain:

- Claim gate: cancel through a new TaskService.CancelTaskWithReason instead
  of a raw CancelAgentTaskWithReason query, so a gate-cancelled task gets the
  full cancellation side effects — audit capture, chat settle, agent status
  reconcile, task:cancelled broadcast, NotifyTaskFinished. Added REAL claim
  integration tests: singular claim (422 + row cancelled with reason), batch
  claim (task omitted + row cancelled with reason), and a current-runtime
  control.
- Cancel reasons are now user-visible: execution log rows, agent activity
  rows, and the transcript dialog label a SERVER-cancelled task (persisted
  error/failure_reason) like a failed one, with the actionable text on hover
  and a Reason row in Run details. User-initiated cancels stay a plain
  "Cancelled". New cancelReasonLabel helper + local_directory_error label;
  details_reason locale key in en/zh-Hans/ja/ko; widened
  AgentTask.failure_reason to the open string the wire actually carries.
- Cancel-ack is now a terminal delivery: the daemon retries it on transient
  errors (same schedule as complete/fail), the server fails LOUD (5xx) when
  persisting the branch/error fails instead of warn-and-200, and re-broadcasts
  task:cancelled after a successful write so clients that already refetched a
  branchless row pick up the delivery. Replays are idempotent (never
  overwrite).
- Cancel + Finalize-abort no longer swallows the preserved-worktree pointer:
  the finalize error is wrapped in worktreePreservedError (joined onto an
  existing run error, never displacing it), extracted on the cancel path, and
  delivered via the ack into agent_task_queue.error through the new
  SetAgentTaskErrorIfEmpty query — never overwriting a reason recorded by a
  fail callback or the claim gate. Other run errors on a cancelled run stay
  discarded as before: they are expected noise.
- Docs: the self-hosted rollback constraint callout now exists in zh/ja/ko,
  not just English.

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

* fix(handler): guard cancel-ack writes to cancelled rows; requeue when the claim gate's cancel fails (MUL-5707)

Fifth-round review fixes:

- SetAgentTaskBranchName / SetAgentTaskErrorIfEmpty now carry a
  status='cancelled' CAS. The daemon acks on EVERY terminal status it
  observes, so a late or replayed ack from a stale run could previously
  backfill its branch or preserved-path error into a completed/failed row
  whose own callback — the authoritative channel — recorded nothing, without
  any event invalidating the change. A CAS-refused write is a deliberate
  no-op (still 200: nothing to retry), and the rebroadcast stays guarded by
  the same status check. Regression tests: a late ack against a completed row
  with empty fields and against a failed row leaves both untouched.
- When the worktree claim gate's CancelTaskWithReason fails, the claimed row
  no longer strands in dispatched (daemon refused, nothing to release it but
  stale reclaim). The gate requeues via RequeueTaskAfterClaimFailure — the
  next claim re-runs the gate and retries the cancel — and reports 5xx so the
  singular daemon reads a transient server problem, not a claim refusal; the
  batch path omits the task as before, now with the row requeued. Fault
  injection tests (row-scoped trigger raising on the cancel UPDATE while
  letting the requeue through) cover singular and batch.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-13 14:52:01 +08:00
28a7176328 MUL-6053: fix(daemon): give Hermes conversations a session that survives the task (#6845)
* fix(daemon): give Hermes conversations a session that survives the task

Hermes keeps every ACP session in `<HERMES_HOME>/state.db`. Because the
daemon takes over HERMES_HOME to inject bound skills, that database lived
inside the per-task overlay, so a conversation lasted exactly one task.

The failure was silent, which is what made it expensive. `session/resume`
against a database that does not hold the session does not fail: Hermes
creates a fresh session and answers with an ordinary success frame, and
ACP's ResumeSessionResponse has no `sessionId` field to contradict it. The
daemon therefore believed it had resumed, stored the same dead id again,
and every turn restarted from zero with nothing in the logs (GH #6806).

Three changes, in the order they matter:

- The backend now reads `_meta.hermes.sessionProvenance.acpSessionId`, the
  only place Hermes reports the session it actually served. A turn that
  landed on a different session continues under the new id and carries the
  existing ResumeContinuityNotice, so the agent can read the transcript
  back instead of pretending it remembers.

- `state.db` is linked to a per-conversation store at
  `<profile dir>/hermes-sessions/<agent>/<hermes profile>/<conversation>/`.
  Sharding per conversation is what makes this safe where #6693 left it out
  of scope: tasks of one conversation are serial, so a shard has a single
  writer, while two issues never share a database. SQLite resolves the link
  when deriving the WAL paths, so the journal sidecars live in the store and
  a task teardown cannot take the write-ahead log with it. Windows without
  symlink privileges keeps the database task-local rather than copying it.

- `gateResumeToReusedWorkdir` no longer treats a matching workdir as proof
  the session is reachable. The two keys came apart in the local_directory
  flow, where workdir reuse is disabled but envWorkDir still equals
  PriorWorkDir, so the gate forwarded an id that could never resolve.

Stores are GC'd on their own retention (`MULTICA_GC_HERMES_SESSION_TTL`,
default 14d, matching the Codex session store) and held against the GC for
the duration of a task.

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

* fix(daemon): never destroy a Hermes session database on a failed mount

Review of #6845 found three ways the new session store could lose or
misreport a conversation. All three are on failure and reclaim paths that
the original tests did not reach.

- **The degradation path was destructive.** The mount migrated the
  task-local state.db into the store and deleted it, and only then tried
  to create the symlink. On a host that cannot symlink, that turned a
  conversation which previously survived via workdir reuse into an empty
  one — a regression on exactly the hosts the fallback exists to protect.
  The link is now created under a staging name first, and nothing
  destructive runs until it exists; a host that cannot link is left byte
  for byte as it was found.

- **The migration was not atomic.** It copied state.db and its journal
  sidecars straight into the store, so a failure after the main database
  landed left a store that looked migrated. The next attempt skipped the
  migration, deleted the source, and took the un-checkpointed tail of the
  conversation with it. It now stages the whole family and publishes with
  one rename, reusing the memory store's staging helpers (renamed to drop
  their `memory` prefix now that both stores share them).

- **"Mounted" was read as "resumable".** A store the GC reclaimed between
  turns, a switched Hermes profile, an operator's `rm`, or a dangling link
  from an older overlay all mount cleanly onto nothing, and the resume gate
  forwarded a dead session id into them — the same silent restart this
  change set out to fix. The mount now reports two separate facts, and the
  gate reads whether the store actually holds a transcript, not whether the
  plumbing succeeded. A zero-length database counts as no history.

Tests cover each path with injected failures rather than host-dependent
skips: a link failure preserves the database and its WAL and still mounts
once linking works again, a migration that dies between the database and
its WAL publishes nothing and loses nothing on retry, and a mounted-but-
empty store (fresh, reclaimed, dangling, zero-length) reports no history.
Verified non-vacuous — reverting each fix reproduces the reviewed symptom.

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

* fix(daemon): make the Hermes session store agree with itself on "empty"

Review of the previous commit found a certain data-loss branch left between
the migration and its publish step. The two used different definitions of an
occupied store:

- the migration asks hermesStoreHasSessionDB — a zero-length state.db, or
  journal sidecars with no database, carry no transcript, so it proceeds;
- the shared promote asked hermesStorePopulated — any directory entry at
  all — so the same remnants read as "a competitor published first".

With a non-empty source and a zero-length state.db in the store, the staging
copy completed, os.Remove(storeDir) failed on the non-empty directory,
promote reported a lost race, the migration returned success, and the caller
deleted the source database and its un-checkpointed WAL. The link then
pointed at a database with nothing in it.

Occupancy is now the caller's predicate: the memory store keeps "any entry",
the session store passes its own "holds a real database". The session publish
clears the remnants it has already classified as nothing before the atomic
directory rename, and a store it cannot clear fails closed — the migration
returns the error and the source stays put — rather than passing for a lost
race.

The regression test drives the whole matrix through prepareHermesHome: a
zero-length database, orphan sidecars, a real database that must never be
overwritten, and an unclearable store that must fail closed with the source
intact. It fails on the previous commit with the reviewed symptom
(HistoryPresent=false after the source was deleted).

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

* fix(daemon): never delete a competitor's freshly published Hermes transcript

publishHermesSessionStaging decided the store held no history, then deleted
the state.db family by path name. Between those two steps a competing task
could publish its real transcript, and the stale decision would remove the
winner's database — after the winner had already reported success and
discarded its source.

The check, the remnant cleanup and the promote now hold one process-wide
mutex. In-process exclusion is the real thing here, not an approximation:
the daemon is the store tree's only writer (even the GC guard is in-memory
refcounts on the Daemon), so two publishers can only ever be two goroutines
of this process. A deterministic barrier test parks a publisher inside the
critical section and proves a competitor cannot enter the window.

The family cleanup also stops using RemoveAll: regular files and symlinks
are unlinked (never followed), while a directory or any other irregular
type under a state.db name is content this code cannot identify, so it
fails closed and the caller keeps its source.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Steve Jobs (Multica Agent) <[email protected]>
2026-08-13 14:20:52 +08:00
Xichang(Seacen) Zhao 90eadd940a MUL-5905: feat(wecom): send the files an agent produced into the chat (#6604)
* feat(wecom): send the files an agent produced into the chat

An agent that produced a file and answered in WeCom delivered only the
sentence. `multica attachment upload` had already run, the object was in
storage, and CompleteTask had bound the row to the assistant message — but
nothing downstream of that bind knew how to reach a WeCom conversation, so
the file stopped there. The user read "here is the forecast" and got no
forecast, with no error anywhere to explain it.

Worse when the agent said nothing at all: an empty completion with a file
bound to it returned early, so the artifact was thrown away silently.

This adds the last hop. media_upload.go carries a file up the long
connection — WeCom has no REST endpoint for this, so it goes as
aibot_upload_media_init / _chunk / _finish over the same socket, and all
three read their verdict through the request() layer #6590 generalized.
outbound_media.go is the caller: after the answer is out, it reads the bound
attachment out of object storage, decides what to call it, and pushes it in
as its own message.

Three decisions worth naming.

The answer goes first and the file never blocks it. An upload is megabytes
and round trips and it can fail; delivery runs after the words are out, on
its own goroutine and a budget of its own, and its worst outcome is one
extra line saying a file did not make it.

A lost ack and a refusal are opposite failures. A chunk whose verdict never
came is offered once more — the protocol makes re-sending idempotent, and
losing one slice of a hundred must not cost the file. A chunk the server
refused is not, because the refusal is its answer and will be its answer
again. The push of the finished media follows the same rule in reverse: a
timeout is reported as-is rather than retried, because the same media_id
sent twice puts the file on the user's screen twice with nothing to undo.

WeCom validates bytes against the msgtype, so what to call a file is a
decision. A kind past its ceiling is demoted to a plain file rather than
sent and refused — a card the user can open beats a photo the server
rejected.

WithAttachments is what turns this on, and router.go passes it only when the
deployment configured object storage. Without it the adapter behaves exactly
as it did before.

* fix(daemon): stop telling a WeCom agent its conversation is text-only

The per-turn chat prompt and the runtime brief both decided file delivery by
asking "is there a channel at all". Any non-empty ChatChannelType got the
text-only paragraph: `attachment upload` does not apply here, describe the
file in words, never write its local path. That was true of every IM channel
when it was written, and it stopped being true for WeCom, whose adapter now
reads the bound attachment out of object storage and sends it into the room.

So an agent on WeCom was instructed not to use the one command that would
have delivered its file. The capability shipped and the agent was told it
did not exist.

The delivery question is per-adapter, not "is there a channel", and
ChannelCarriesFiles is where that answer now lives. Both call sites branch
three ways instead of two.

The new copy is deliberately built from main's existing web-chat branch
rather than written fresh. Both open on the same clause — "To include a file
or image you produced in your reply, run `multica attachment upload
<local-path>`" — and diverge exactly where the platforms diverge: web chat
says the file "appears as an attachment card below it" and offers a markdown
snippet to place it inline; WeCom says Multica "sends it into the WeCom
conversation as a separate message right after your text" and that "there is
no way to place it inline". That last clause is the one worth having. An
agent told only that files work here writes "see the chart below" and then
nothing appears below, because the long connection has no msg_item and a
picture cannot be embedded in the reply. Slack and Lark keep their existing
text-only wording untouched.

Two comments are rewritten rather than carried over, because they asserted
the binary the same commit falsifies — "keys off 'is there a channel at
all'" in prompt.go, and "any non-empty channel type means the reply leaves
Multica for an external IM platform, where `attachment upload` has nothing
to bind to" in the brief. A comment that contradicts the code beneath it is
worse than none.

ChannelCarriesFiles names every channel type this package knows rather than
leaving Slack and Lark to the default, so a fourth adapter surfaces here as
a decision to make. An unrecognized type answers false: the agent is told to
describe its file in words, and the worst case is a deliverable that could
have been sent was not. False the other way would have it promise a delivery
nothing performs.

* fix(wecom): stop claiming more certainty than the system has

Three review blockers, one shape: a promise made in one place that another
place cannot keep.

Capability came from the channel type. The daemon answered "this conversation
takes files" from `chat_channel_type == "wecom"`, but the last hop only exists
where the server wired object storage — so a WeCom deployment without storage
had its agents told to send files nothing would carry, and a new daemon on an
older server inferred a capability that build has no code for. The server now
answers it per turn on the claim (`chat_channel_delivers_files`), declared by
the same `if store != nil` branch that gives the adapter the storage, and
absent/false everywhere else.

Upload limits came from the framing. 50 MB is what 100 chunks of 512 KB can
describe; WeCom's own OpenClaw plugin puts the hard file ceiling at 20 MiB, so
that is now the cap, enforced against the attachment's recorded size before any
byte is read. Concurrency was a fixed 4 regardless of size; WeCom's own SDK
steps it down — all at once up to 4 chunks, 3 up to 10, 2 past that, because
their backend answers a burst of chunks on a large upload with a system error.

Failure handling had two words for three outcomes. A send whose ack never came
was reported as a definite failure although the file may well be in the chat,
and the local failures — a full pending queue, a slot wait that expired, a
lookup that errored — were reported to nobody at all. There are now three
states: delivered says nothing, definitely_failed keeps the definite wording,
and unknown gets wording that holds either way and is never resent, since a
duplicate file cannot be taken back. The attachment lookup moved ahead of the
rationing so a shed delivery knows there was a file to apologise for, and a turn
with nothing attached still says nothing.

* fix(wecom): bound the attachment lookup, and stop calling an attempted write a failure

Two gates, not one better place for one gate. The pending counter is claimed
after the lookup so a shed can name a real file, which is also why it cannot
bound the lookup: at the point it would have to be claimed, whether the turn
carries a file is exactly what is not yet known.

admitAttachmentDelivery is claimed before the spawn and bounds the goroutines
and the queries they run. Its cap is twice the pending cap, so a backlog meets
the honest post-lookup shed first and only reaches the silent one when the
system is already well past it. A refusal there is logged and not spoken —
telling a user their file was dropped on a turn that may have carried none is
the false alarm the post-lookup gate exists to prevent.

Separately, a WebSocket write error stopped being read as proof of
non-delivery. wsSender.write already distinguished the stages for tracing; it
now carries that out in the error. Past WriteMessage the frame may be at the
peer — a half-closed connection reports "broken pipe" to the writer for bytes
the reader has — so those classify as unknown and are never resent. What fails
before the write is provably local and stays definite.

Tests drive the real path rather than the counters: one blocks the lookup,
submits past the cap, and counts the goroutines and queries that actually
happen; one hands the frame to a connection that records it and then fails.
Both fail without the change. fakeObjectStore takes a lock, because it is no
longer only ever driven inline.

* docs(wecom): cite the size limits to WeCom's docs, not a third-party constant

The upload ceilings were sourced to Tencent's OpenClaw plugin. That file
carries the right numbers but cites nothing itself, so it corroborates rather
than establishes them. WeCom documents all four on aibot_upload_media_init's
body.total_size row, and repeats them on the classic media upload API. Point
at those.

* fix(wecom): drop the two media helpers #6605 brought in ahead of this branch

This branch carried its own cleanShort filename and extension helpers because
nothing else in the package had them when it was written. #6605 has since
merged, and it brings both — so the package now declares each twice and does
not compile against current main. GitHub still reported the branch mergeable
and CI was green, because both were measured against a main that predated that
merge by six hours.

Upstream's copies are the ones to keep: its cleanMediaFilename also strips
control characters out of a name, and its mediaExtension drops a charset
parameter before matching. Neither of those was in the copies here.

* fix(daemon): keep the file-delivery verdict out of the cached brief

chat_channel_delivers_files arrives on every claim and answers a deployment
question: does this server carry a file the last hop into the room. Upgrading a
server so it starts sending the field, or turning object storage on or off,
flips it under a chat session that is already running — and the brief branched
on it, so one resumed session rendered two different briefs. The brief is the
prompt-cache prefix (MUL-5377), which makes that a miss on every turn after the
flip. The two rows in the delivery test asserting different briefs for the two
verdicts were the exposure written down: they are what one session sees in
sequence.

The brief's channel-backed chat copy now names the platform and says the answer
is stated per turn. It holds on either verdict and contradicts neither. The
verdict has a home already — the per-turn chat prompt, whose branch was correct
and is untouched — and that message is emitted on every turn, so nothing is
lost by the brief declining to answer.

Slack and Lark take the same copy. Scoping it to WeCom would put a channel-type
decision back into the brief, which is what ChannelCarriesFiles exists to
prevent, and would hardcode a denial a server-side change can falsify.

Coverage moves rather than goes. TestBriefByteIdenticalAcrossRunsForEveryKind
gains a WeCom kind and a variant that flips the field; both fail without this
change, and the existing Slack kind fails on the variant too. The delivery
test's two WeCom rows assert the inverse property now — one brief for both
verdicts — and the upload/describe split they used to hold is pinned by
TestBuildChatPromptTwoLayerChannelPolicy on the per-turn prompt, which has
covered both branches all along.

* docs(wecom): correct what the admitted cap implies, and note that delivery cannot answer back

The comment on the two counters said reaching the admitted cap means the
pending cap is full and as many turns again are still in their lookup. It does
not. Admission is held for a goroutine's whole life, including turns whose
lookup finds no file and which therefore never claim a pending slot —
TestDeliverAttachments_AdmissionBoundsTheLookupStage fills admission with
pending at zero. The two-counter split and the ordering it buys are unchanged:
a backlog of file-carrying turns still meets the honest post-lookup shed first.

The package doc gains the fact that an agent cannot learn its delivery was
refused. `multica attachment upload` returns once the object is in storage and
bound to the reply, while the send into the room runs on EventChatDone, after
the run has ended, so everything the adapter knows about a failed delivery goes
to the person in the chat and to nobody else.
2026-08-11 19:01:34 +08:00
e0d0b38153 fix(daemon): keep the runtime owner's Reasonix deny rules in a task (MUL-6021) (#6764)
* fix(daemon): keep the runtime owner's Reasonix deny rules in a task

The per-task reasonix.toml denies the `ask` tool, but Reasonix resolves
the project file over the user config, so `deny = ["ask"]` replaced the
runtime owner's own deny list instead of extending it: an owner who had
denied `bash` globally got it back inside every Multica task.

The task config is now rendered from the owner's own [permissions] table
with `ask` merged into deny. The whole table is restated, not just deny,
so the policy survives whether Reasonix layers these files per key or per
table, and the table is read from the same REASONIX_HOME the child gets
(the agent's custom_env override, else the daemon's env, else the
default).

When that config cannot be read or restated faithfully — unreadable,
malformed, or a deny list that is not an array of strings — the daemon
writes no project config at all rather than one that would drop the
owner's rules. The task then runs with `ask` available, exactly as it
already does when a repository ships its own reasonix.toml.

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

* fix(daemon): resolve the Reasonix user config the way Reasonix does

Restating the owner's permissions only helps if the daemon reads the file
the Reasonix child actually loads. The first cut assumed
$REASONIX_HOME/config.toml with a ~/.reasonix default, which misses:

  - Windows, where the home is %AppData%\reasonix (falling back to
    %USERPROFILE%\AppData\Roaming\reasonix), not %USERPROFILE%\.reasonix;
  - the legacy OS-support and XDG locations Reasonix reads when the
    primary config does not exist and REASONIX_HOME is unset;
  - an agent that sets REASONIX_HOME to empty, which sends the child to
    the platform default while the daemon kept reading its own.

Each miss reads "no owner permissions" and writes a task config holding
only deny = ["ask"] — the exact widening this change set exists to stop.

reasonix_user_config.go now ports Reasonix's own resolution (upstream
internal/config/paths.go): REASONIX_HOME with ${VAR} / ${VAR:-default}
expansion, ~ handling and absolute normalization, the per-platform home
defaults, and userConfigLoadPath's legacy fallbacks with the isolation
rule that switches them off once REASONIX_HOME is set. The lookup runs
against the sanitized custom_env layered over the daemon's environment —
the same env the child is launched with — so presence, not just value,
decides where to read.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-11 18:15:53 +08:00
8e96329196 refactor(daemon): drop the MULTICA_HERMES_TASK_MEMORY rollback switch (#6760)
The switch let an operator restore the pre-MUL-5932 behaviour of a fresh
task-local memories/ per task. Hermes agent memory migrated correctly, so
it no longer earns its keep — and it never covered the failure it looked
like it covered: the overlay already depends on directory links to mirror
the user's ~/.hermes, so a host that cannot create them fails at the
mirror step regardless of this env.

Flipping it was also lossy in both directions: with the switch on the
daemon stopped marking the store in-use and refreshing its mtime, so the
GC could reclaim the agent's memory after GCHermesMemoryTTL; flipping it
back discarded whatever the task-local dir had accumulated, because a
populated store is never migrated into.

The task-local path itself stays — it is still what a task with no agent
to key on, or an unresolvable Multica profile dir, gets.

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-11 17:28:13 +08:00
807b8706bb MUL-5992 fix(daemon): deny the Reasonix ask tool per task (supersedes #6745) (#6746)
* Revert "fix(agent): keep Reasonix off the ask tool in unattended tasks (#6745)"

This reverts commit 8eaa0d229f.

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

* fix(daemon): deny the Reasonix ask tool per task

`ask` puts a multiple-choice question to a human mid-turn. An unattended
task has no human, and Reasonix wires an asker for every ACP session, so
its own "no interactive user — decide for yourself" fallback is
unreachable: the daemon can only refuse the question, and Reasonix reads
a refusal as a dismissal and cancels the whole turn.

Write a project-scoped reasonix.toml into the task workdir that denies
the tool. Reasonix resolves config as flag > project reasonix.toml > user
config.toml, rooted at the session cwd, so this binds to the task and
leaves the runtime owner's interactive sessions alone. It also covers the
whole turn: deny rules are checked before the read-only allow
fallthrough, and the executor, the planner — which keeps `ask`
deliberately — and sub-agents all decide against the same policy. The
model gets "denied by permission policy" back as an ordinary tool result
and keeps working.

A reasonix.toml that came with the repository is left untouched, since
sidecar cleanup must stay a pure deletion of paths the daemon created;
that task keeps today's fail-closed question handling.

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

* fix(daemon): stop excluding reasonix.toml from managed checkouts

The exclude was at the wrong layer and hid the wrong file. The daemon
writes reasonix.toml at the WorkDir, while a managed checkout is a
directory inside the WorkDir, and this list only reaches the checkout's
.git/info/exclude — so it could never hide the daemon's own copy. What it
did hide, for every provider, is a project config an agent legitimately
creates inside the repository: invisible in git status, impossible to
commit.

The sidecar needs no exclude. For a repo task it sits outside the
checkout entirely; for a local_directory task it is untracked in the
user's repo for the duration of the run and removed by CleanupSidecars,
which is how AGENTS.md and the other WorkDir sidecars already behave.

Also stop the deny-list comment implying that --workspace-only and the
ACP permission policy make the replaced global deny list equivalent —
they don't; it is an accepted widening.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-11 15:15:06 +08:00
Bohan Jiang 673104d26e docs(daemon): correct the Hermes overlay's skill-less claim (#6703)
The Hermes memory docs said an agent with no skills bound gets no overlay and keeps using the host's ~/.hermes. The daemon does gate the overlay on the task carrying skills, but the server appends the platform's built-in skills to every agent (LoadAgentSkillBundles), so a claimed task's skill set is never empty and that branch is unreachable in production.

Two consequences the old wording got wrong: every Hermes agent gets the overlay, and therefore the persistent memory store from #6693, not just ones with user-bound skills; and clearing an agent's skill list is not a way to keep using the host's memories. Reported on #6638, where the same sentence was read as a contradiction.

Docs only, plus a note at the gate so the next reader does not take the skill-less branch for observable behaviour.
2026-08-10 18:49:02 +08:00
Bohan Jiang f26b97e395 MUL-5932: fix(daemon): give Hermes agents memory that survives the task (#6693)
Hermes discovers skills only from its own home, so Multica takes over HERMES_HOME to inject an agent's bound skills. The overlay inherited the decision of where memory lives and made it task-local, so a skill-bound agent forgot everything between tasks while a skill-less one shared the host's memories with every other agent on the runtime (#6638).

Link the overlay's memories/ to a store Multica owns, keyed on (hermes profile, agent), mounted by directory link with its own GC TTL and the shared active-store guard. Memory now survives across tasks and issues for one agent, never touches the user's home, and is invisible to other agents.

Session history (state.db) stays task-local, and skill-less agents are unchanged. MULTICA_HERMES_TASK_MEMORY=1 reverts; MULTICA_GC_HERMES_MEMORY_TTL bounds retention (90d default).

Migration of a pre-store memories dir copies into a staging sibling and publishes with a single atomic rename, fails closed on any I/O error or entry it will not carry, and never infers a lost race from a failure — the source directory is only removed once its contents are confirmed in the store.
2026-08-10 18:26:00 +08:00
rongchenzheng 586a624aa5 fix(cli): isolate task profile state (#6407) 2026-08-10 12:20:17 +08:00
7c7ed8bf55 MUL-5937 fix(daemon): persist direct chat Codex sessions (#6644)
Co-authored-by: 전영진 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-09 16:40:07 +08:00
93fa120c4c MUL-5925 fix(execenv): expand Windows ~\ in the openclaw active config path (#6630) (#6643)
`openclaw config file` shortens the home prefix with its host OS's
separator, so Windows reports `~\.openclaw\openclaw.json`. The tilde
expansion matched only the `~/` form, so the Windows path fell through
unexpanded; not being absolute, it was then joined onto the daemon's
working directory into a path that can never exist.

The resulting stat miss is indistinguishable from a fresh install, so the
per-task wrapper was written without the `$include` of the user's config
and without an include root. Every task then booted OpenClaw with no
model providers and no auth profiles, failing with `Unknown model:
openai/gpt-5.5` or `No credentials found for profile ...`.

Accept both separators regardless of GOOS so the Windows shape stays
testable from the Linux/macOS test job, and log the discovery outcome:
discovery was previously silent, which is why this could only be
diagnosed by reading the generated file. A missing active config now
warns that the task will not see the user's models or auth profiles.

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-09 16:00:51 +08:00
73ab498d1d MUL-5811: fix(squad) derive leader role from wire fields, not instructions text (#6519)
* fix(squad): derive leader role from wire fields, not instructions text (MUL-5811)

The daemon decided whether a task ran in the squad-leader role by grepping
the agent's Instructions for the "## Squad Operating Protocol" heading that
the server-injected briefing starts with. Role detection therefore depended
on user-writable Markdown: any ordinary agent whose own instructions happened
to contain that heading was promoted to leader and handed the leader rules —
mandatory `multica squad activity` calls, a licence to exit silently on
no_action, and the squad-maintenance CLI section in its brief.

The correct signals were already on the wire the whole time. The claim
handler decides injection from explicit fields (`task.is_leader_task` +
`squad_id` for issue-bound runs, the quick-create `squad_id` for the picker
path) and forwards both to the daemon. Read those instead:

- `taskIsSquadLeader` returns `IsLeaderTask || SquadID != ""`; brief assembly
  now shares that helper instead of repeating the substring check.
- The claim handler clears `is_leader_task` on the response whenever its
  defensive gate withholds the briefing (NULL squad_id, squad hard-deleted,
  leader swapped after enqueue). The flag now means "briefing injected", so a
  leader task with no roster and no protocol degrades to an ordinary agent
  turn instead of booting into a leader role it cannot perform.

Every legal path is byte-identical: the server sets these fields on exactly
the claims it injects into, so real leader runs and ordinary runs produce the
same brief and prompt as before. The only behavior change is the misdetection
case, which is the bug.

Also corrects two comments claiming IsSquadLeader is agent configuration
rather than a per-task role, and notes the flag contract in the squad
source map.

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

* fix(squad): gate leader-role field reads behind a server capability (MUL-5811)

Review catch (#6519): reading is_leader_task / squad_id unconditionally
breaks the new-daemon → old-server direction the repo deliberately supports
(claimTasksLegacy, MUL-4257). is_leader_task only reached the claim response
in #4951, while briefing injection predates it — so against an older server a
real issue-bound leader task arrives with the full briefing and both fields at
zero, and field-only detection would silently demote that leader to a plain
worker, dropping the entire operating protocol.

Add `leader_role_resolved`, a claim-only wire capability the server sets on
every claim response (leader or not). Its absence is what identifies a server
too old to have answered the role question:

- capability present → the explicit fields decide, and an ordinary agent whose
  instructions contain the briefing heading is no longer promoted to leader;
- capability absent → keep the legacy briefing-marker inference, which is
  exactly today's behavior against those servers. That branch goes away once a
  minimum server version is enforced.

The field is claim-only and never rendered into a prompt, so it costs no
prompt tokens and changes no brief or prompt bytes on any path.

Tests: the role table now covers both server generations, including legacy
real-leader (heading, no capability), current ordinary-agent-with-heading, and
current withheld-briefing; an end-to-end legacy prompt test pins that an old
server's leader keeps its rules; and every claim test now asserts the server
actually advertises the capability — losing it would send all daemons back to
text inference.

Also rewords two comments per review: the brief byte-stability tradeoff is
owner-accepted and recorded in MUL-5811, not an open tracking item.

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

* docs(squad): state the pre-capability server shapes precisely (MUL-5811)

Review nit: the comments said every server without leader_role_resolved
"never sent is_leader_task", which is only true before #4951. Servers between
#4951 and this change do send the flag — they just never promised it implies
an injected briefing, which is the actual reason the fallback covers them too.

Names both shapes at each site (daemon types, handler response, helper doc,
role-table doc, squad source map) so a future cleanup of the fallback reads
the version window correctly. Comments only; no behavior change.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-06 21:05:21 +08:00
86f255658f MUL-5730 feat(server): expose chat audience without bloating prompts (#6428)
Tell a chat agent whether it is answering in a shared room or a 1:1, without
splitting the cached brief prefix: the room shape rides the existing channel
binding row to the claim response, and is stated once per turn in the chat
prompt rather than in the static runtime brief.

Originally contributed as #6390; re-rolled to move the audience fact to the
per-turn prompt and to minimize the copy it renders.

Co-authored-by: Seacen Zhao <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-05 16:12:17 +08:00
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 <[email protected]>

---------

Co-authored-by: niudakok <[email protected]>
Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-04 18:47:10 +08:00
98766cc06a feat(daemon): remove the Linux Codex per-task HOME; default Linux to danger-full-access (MUL-5578) (#6233)
* feat(daemon): default Linux Codex to danger-full-access on the real HOME (MUL-5578)

Linux Codex tasks ran under the `workspace-write` Landlock sandbox with a
generated per-task HOME: the daemon rewrote HOME/XDG_*/npm_config_cache into
`<envRoot>/home`, symlinked a hand-maintained allowlist of seven credential
paths back into it, and granted that directory as a `writable_roots` entry.

That mechanism could not converge. Any host CLI outside the allowlist (aws,
kubectl, gcloud, glab, rclone, cargo, …) started every task as if unconfigured
even though it works in the daemon user's shell, and three open issues asked to
extend it in three incompatible directions (#5636, #5573, #3867). The
containment it bought was also narrower than it looked: workspace-write
restricts writes only — reads and network were already unrestricted, and `.ssh`
was seeded into the task home — so credentials under the real HOME were
readable and exfiltratable regardless.

Linux now runs `danger-full-access` on the daemon user's real HOME and inherited
XDG environment, matching macOS and Windows. The task filesystem boundary is the
boundary the daemon itself runs inside (VM, container, or dedicated Unix user),
which is what the other providers already assumed — Claude Code runs with
`--permission-mode bypassPermissions` today.

This also removes the split-brain the mechanism could enter: the task-HOME
decision read the platform default only, so a `-c sandbox_mode=danger-full-access`
override (which passes arg filtering and wins over config.toml) left a task
running unsandboxed while the daemon still redirected HOME and emitted
writable_roots for a sandbox that was not in effect. With no HOME rewrite there
is only one environment contract left to disagree about.

Removed: prepareTaskHome, prepareCodexSandboxHome, TaskHomeEnv, Env.TaskHome,
both seed allowlists, and the now-unfed WritableRoots plumbing through
codexSandboxPolicy / CodexHomeOptions. Env roots created by older daemons keep
working; their leftover `home/` directory is simply ignored and reclaimed with
the env root.

Task-scoped CODEX_HOME is untouched — it is managed Codex state, not the Unix
HOME. macOS, Windows, and non-Codex providers are unchanged.

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

* docs: document the agent execution security model (MUL-5578)

The docs had no page describing what a task can reach on the machine that runs
it — the sandbox posture was only discoverable from source. Adds one, stating
plainly that tasks run with the full permissions of the daemon user and that
isolation must come from a dedicated Unix user, container, or VM.

Also separates what Multica genuinely isolates (per-task workdir, task-scoped
CODEX_HOME, agent+task-bound API tokens) from what is not a boundary (the coding
tool's own sandbox and approval settings), and links it from step 5 of the
self-host quickstart, where the daemon is first installed.

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

* fix(daemon): address review on the Linux full-access default (MUL-5578)

Docs, both must-fix:

- The Chinese page rendered the product concept as `Agent`; the repo glossary
  in developers/conventions.zh.mdx mandates 智能体. Fixed all nine occurrences.
  ja/ko already used エージェント / 에이전트 and needed no change.
- The security page asserted without qualification that every task runs with
  the daemon user's full permissions and that the Codex filesystem sandbox is
  always off. codexSandboxPolicyForWindows still keeps workspace-write when a
  user explicitly opts into a native windows.sandbox, so an authoritative
  security page contradicted the code. It now states that Multica makes no
  filesystem-sandbox guarantee, names the Windows opt-in as the one current
  exception, and says which combinations sandbox anything is a compatibility
  detail that moves with tool versions. All four locales updated, plus the
  historical callout which now says Linux matches the macOS/Windows *default*.

Both stale comments from the non-blocking notes:

- prepareCodexHome no longer claims to assume workspace-write + network_access;
  it pins GOOS=linux, which now resolves to danger-full-access.
- ensureCodexSandboxConfig's warn-level logging is no longer described as a
  macOS-only fallback; it fires for every danger-full-access resolution.

Adds TestCodexTaskShellEnvInheritsRealHome at the daemon env-assembly layer:
HOME and the XDG base dirs must reach a Codex task's shell tools from the
inherited daemon environment. Verified it fails when that pass-through breaks.
It guards the pass-through, not runTask's decision not to inject a HOME of its
own — that decision is inline in runTask and has no unit seam.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-31 15:42:06 +08:00
a3fe6d91dd MUL-5150: add project context to Chat (#5765)
* feat(chat): add project context

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

* fix(chat): resolve MUL-5150 review blockers

- Renumber project-context migrations to unique prefixes after current main:
  206_chat_session_project -> 212 (column), 207_chat_session_project_index ->
  213 (concurrent index). 206/207 collided with 206_agent_disabled_runtime_skills
  and main's 207-211 client_usage_daily set.
- Add the 4 missing chat input.project_context keys to ja/ko locales so the
  locale parity test passes (en/zh-Hans already had them).
- Lock the project-context control while a send is in flight (isSubmitting),
  not just while the agent is running. A brand-new chat creates its session
  lazily during send bound to the project at click time; switching project
  mid-send would create the session against the stale project and clear the
  editor as if the send landed on the new selection. Add a regression test.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(chat): complete project context handling

* fix(chat): pin fresh chat to open session's agent on project switch

Switching an existing session to a different project opens a fresh chat but
only cleared the active session, dropping selection back to the stored
`selectedAgentId`. When that preference was stale (open session belongs to
agent B while the persisted pick is still agent A), the lazily-created session
and its first send bound to the wrong agent (agent A).

Extract the project-switch decision into a shared `planProjectContextChange`
pure helper in use-chat-controller.ts and route both chat surfaces (the chat
tab controller and the floating ChatWindow) through it, so the fresh chat is
pinned to the open session's agent and the rule cannot drift between the two
copies. Add a dual-entry regression test (pure-fn guard + controller
integration) covering the stale selectedAgentId case.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* chore(ci): re-trigger required checks on latest head

The prior push updated the branch ref but GitHub did not emit a pull_request
synchronize for it (PR head-sync lag), so CI/Mobile Verify never ran on the
commit carrying the stale-agent project-switch fix. Empty commit to force a
fresh synchronize on a head that includes it.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(chat): renumber project migrations to 213/214 after main added 212

Current main added 212_agent_service_tier; the PR's 212/213 chat migrations
collided with it on the merge ref, failing TestMigrationNumericPrefixesStay
UniqueAfterLegacySet. Merge current main and move the chat column migration to
213 and the concurrent index migration to 214 (column before index preserved).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(chat): lock ProjectPicker clear control during send (keyboard path)

The send-pending lock only put pointer-events-none on the wrapper, which
blocks the mouse but leaves ProjectPicker's inline clear button in the tab
order — a keyboard user could Tab to "Remove from project" and press Enter
mid-send, detaching the project after the lazily-created session already went
out with the old one (reopens the mid-send retarget path via keyboard).

Add an explicit `disabled` capability to the shared ProjectPicker that locks
the trigger, the menu (forced closed), and the inline clear button (disabled +
out of the tab order). Defaults to false, so issue/create/autopilot callers
keep their hover/keyboard clear. ChatInput passes disabled while the project
selection is locked.

Tests: real-ProjectPicker regression (keyboard activation of the clear control
is inert when disabled; still works when enabled) + ChatInput wiring assertion
that the picker is disabled mid-send.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Walt <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: Naiyuan Qing <[email protected]>
Co-authored-by: NevilleQingNY <[email protected]>
2026-07-24 11:30:27 +08:00
5d9295ac65 feat(agents): add per-agent runtime skill controls (#5686)
* feat(agents): add per-agent runtime skill controls

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

* fix(agents): renumber runtime-skill migration and broadcast agent:status on toggle

Address the MUL-5101 review blockers on PR #5686:

- Rebase onto main and renumber the runtime-skill-disable migration
  202 -> 203. main added 202_runtime_profile_add_qwen, so the pair
  collided on prefix 202 and migrations_lint_test would reject the
  duplicate. 203 is the next free prefix.
- Publish an "agent:status" event after persisting a
  disabled_runtime_skills override, mirroring the workspace-skill toggle
  in writeUpdatedAgentSkills. The realtime layer keys off this event to
  invalidate workspaceKeys.agents, so other open web/desktop/mobile
  clients now drop their stale toggle state instead of only the
  initiating tab refreshing. Reload junction-table skills before the
  broadcast so it doesn't signal cleared skills (#3459).
- Add a handler regression test proving the broadcast fires on both
  disable and enable.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Walt <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-07-22 15:38:38 +08:00
fbe00ca164 fix(daemon): run Codex unsandboxed on Windows to stop reject-by-policy (MUL-4957) (#5672)
* fix(daemon): run Codex unsandboxed on Windows to stop reject-by-policy (MUL-4957)

Windows has no Landlock/Seatbelt-equivalent filesystem sandbox that the
daemon configures, so the per-task `sandbox_mode = "workspace-write"` it
wrote was unenforceable. Worse than having no sandbox, it pushed Codex
into rejecting non-safe mutation commands "by policy": `multica issue
create` fails with "was rejected by policy" because Codex can neither
sandbox the command nor (under approval_policy = "never") escalate it to
the daemon's auto-approver, so the request never reaches the approver.

Mirror the existing macOS fallback and give Windows danger-full-access so
those commands run. Also generalize the danger-full-access warn log so it
no longer hardcodes "on macOS" and only surfaces the macOS-specific
upgrade hint on macOS (new codexSandboxPolicy.Hint field).

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

* fix(daemon): correct Windows sandbox rationale and respect user windows.sandbox (MUL-4957)

Addresses two review must-fixes on #5672:

1. Correct a false security fact. The comments and log Reason claimed
   Windows has no filesystem sandbox backend. Codex 0.144.5 does ship a
   native Windows sandbox (windows.sandbox = "unelevated"/"elevated"); it
   is experimental with open upstream reliability bugs, so the daemon
   defaults to danger-full-access as a deliberate compatibility choice.
   Enabling the native sandbox is tracked as separate follow-up work.

2. Stop silently downgrading users who opted into isolation. The fallback
   was unconditional. Add codexSandboxPolicyForConfig: on Windows an
   explicit windows.sandbox = unelevated|elevated keeps workspace-write so
   Codex enforces task isolation with the user's chosen backend;
   danger-full-access applies only when windows.sandbox is absent,
   disabled, or unparseable. This is also the branch point for a future
   native-sandbox rollout (flip the default; callers unchanged).

Adds fixture tests locking the priority (user opt-in kept vs. unconfigured
fallback) plus predicate coverage for codexSandboxPolicyForConfig.

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

* fix(daemon): fail closed on undecidable Windows sandbox config, honor -c windows.sandbox (MUL-4957)

Second review round on #5672. Two must-fixes.

1. Undecidable config no longer fails open. The old bool detector collapsed
   "unparseable / invalid value / failed copy" into "unconfigured" and then
   loosened to danger-full-access. Replaced with a tri-state
   (absent/native/undecidable): only exact-lowercase unelevated|elevated (the
   sole values Codex accepts — verified: any other value makes Codex refuse to
   load the config) counts as native; any other present value, unparseable
   TOML, a read error, or a missing per-task config when a shared
   ~/.codex/config.toml exists (i.e. the copy failed) is undecidable and fails
   closed to workspace-write — it never loosens — logged at error level.

2. windows.sandbox set via `-c`/`--config` custom args is now honored. Such
   args never land in config.toml, so config-only detection silently
   downgraded those users' isolation. The effective Codex args (daemon
   defaults + profile-fixed + per-agent custom_args) are threaded through
   PrepareParams/ReuseParams/CodexHomeOptions into the sandbox decision and
   scanned for a windows.sandbox override (inline, two-token, quoted, spaced;
   last-wins).

Also drops issue-status-bound source comments (openai/codex#24098 has since
closed). Adds unit coverage for config/args classification, the fold
precedence (undecidable > native > absent), and the copy-failed fail-closed
path.

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

* fix(daemon): fail closed on config-sync errors and honor shell-quoted -c windows.sandbox (MUL-4957)

Round-3 review must-fixes:

1. resolveWindowsSandboxState now takes the config.toml sync error and a
   tri-state shared-config presence instead of re-stat-ing inside. A failed
   sync (stale/absent per-task copy) or an un-stat-able shared source is
   undecidable and keeps workspace-write, closing the fail-open where a failed
   sync was read as "unconfigured". Splits IO from the decision so the paths
   are unit-testable without faulting the filesystem.

2. The Windows sandbox decision consumes agent.NormalizeCodexLaunchArgs (the
   shared helper buildCodexArgs now uses) so a shell-quoted -c windows.sandbox
   opt-in is normalized identically to launch, instead of being missed by a
   raw-token scan and silently downgraded.

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

* fix(daemon): abort when the Codex sandbox block cannot be written (MUL-4957)

Round-4 review must-fix: ensureCodexSandboxConfig failures were warn-and-continue,
so a computed fail-closed workspace-write policy could stay only in memory while
config.toml kept a stale danger-full-access from a prior run — the decision
failed closed but the effective config failed open.

prepareCodexHomeWithOpts now returns the error, which blocks startup on both
paths: fresh Prepare fails the task, and Reuse leaves env.CodexHome unset, which
configureCodexTaskShellEnvironment already refuses to start.

Regression covers the full reuse scenario (stale danger-full-access + failed
config sync + failed managed-block write); it fails with "got nil" without the
fix.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Co-authored-by: J <[email protected]>
2026-07-21 15:18:44 +08:00
9e2cfe098c MUL-5038: fix project resource preparation decoding (#5688)
* fix(execenv): decode project resources in helper

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

* fix(execenv): preserve OpenClaw gateway pin in helper

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

---------

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-20 19:56:11 +08:00
152d82e42e MUL-4999: reclaim managed Codex sandbox task caches
* fix(daemon): reclaim Codex sandbox task caches

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

* refactor(daemon): tighten managed cache GC signals

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

---------

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-20 15:30:47 +08:00
1507997272 fix(agent): stop agents shipping local-path links, make Desktop 404 recoverable (MUL-4899) (#5557)
Agents were writing runtime-local paths into deliverables as clickable
links (`[screenshot](/Users/agent/work/shot.png)`). Two root causes, both
fixed here.

A. The brief never stated the delivery contract. Add an always-on delivery
invariant (outside writeOutput's kind switch, so no task kind can inherit
none) plus a per-surface file-delivery line for each of the five surfaces.
Chat splits into two: `attachment upload` works only on web/mobile chat,
never on an IM channel, so ChatChannelType is now threaded into
TaskContextForEnv.

The claim path only ever looked up Slack bindings, so a Feishu session
reported as a web chat and got upload guidance for a channel that cannot
carry attachments. Probe every channel type. The chat policy is two
independent layers and stays that way: delivery keys off "is there a
channel at all"; the `chat history` / `chat thread` commands stay
Slack-only because both endpoints are hardwired to h.SlackHistory and
there is no Feishu reader — ChatInThread only selects between those two
commands, so it stays Slack-only too.

Add a CLI hard-fail lint on `issue comment add` / `issue create` /
`issue update` as the enforcement backstop. Scoped narrowly, since a false
positive blocks a real deliverable: agent task context only (a human's PAT
run is untouched), real CommonMark link/image/autolink destinations only
via goldmark (a path in a code span or fence — how an agent quotes a path
it is discussing — is structurally invisible), and three high-confidence
signals only (`file://`, inside the workdir, or an existing local file).
A bare `/foo` is a valid origin-relative URI and is deliberately allowed.
`issue update` has no --attachment flag, so its hint redirects to
`comment add` rather than naming an argument it rejects.

B. Desktop presented the resulting router 404 as an unknown crash. 8 of 18
desktop_route_error reports were users clicking such a link and being told
the app broke and to file a bug. Split the 404 into a first-class Not Found
view: no crash framing, no Report error. Its recovery entry comes from the
tab store's active workspace, never from the failed pathname — deriving a
slug from `/Users/me/shot.png` yields "Users" and a button to `/Users/issues`,
a second 404.

Also add a will-navigate trusted-origin guard via the shared loadRenderer
(main + issue windows). This is origin hardening only, NOT the mechanism for
in-app links: client-side routing never fires will-navigate, so app paths
never reach it. Issue windows need no 404 work — their router only accepts
paths validated by parseIssueWindowPath and they do not listen for
multica:navigate, so a bad path cannot reach them.

Server-side completion observation is metric/log only and never blocks: it
is lexical (`file://` + task work_dir prefix) because the server cannot stat
the daemon's filesystem, and the metric label is a closed enum so no path or
reply text reaches Prometheus.

Verified: pnpm typecheck/lint/test (3582 tests), go vet, full Go suite
including new claim-path integration tests. cmd/multica was verified outside
the daemon workdir — inside one, 93 of its tests fail identically on
origin/main because the suite walks up and finds the runtime's own task marker.

Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-17 13:40:23 +08:00
Wangjue YaoandBohan 3547201bbe fix(daemon): reuse managed workdirs for squad leaders (#5429)
Squad-leader follow-ups on the same issue now reuse the prior daemon-managed workdir and provider session instead of starting fresh, while never binding or locking a user-provided local_directory. Reuse eligibility is keyed off a Prepare-time .managed_env.json provenance marker, so it does not race the completion→GC-metadata write.

Closes #5535

Co-authored-by: Bohan <[email protected]>
2026-07-17 12:12:47 +08:00
Bohan Jiang b13b6d06bc fix(daemon): writable per-task HOME for Linux Codex sandbox (MUL-4856) (#5508)
Give Linux codex tasks a writable per-task HOME (+ XDG/npm_config_cache) so npm/Prisma stop hitting EROFS on the read-only sandbox home. Gated to Linux; macOS/Windows and non-codex providers unaffected.

Note: does NOT resolve the worktree git-metadata read-only problem tracked in multica-ai/multica#2925 — Codex workspace-write resolves the worktree .git pointer and force-protects the real gitdir read-only even inside writable_roots, so that needs a separate Codex metadata-write permission path.
2026-07-16 13:01:43 +08:00
6cc553e5a3 fix(daemon): isolate Codex sessions per task to unblock initialize (MUL-4424) (#5360)
* fix(daemon): isolate Codex sessions per task to unblock initialize (MUL-4424)

Codex 0.143+ backfills a per-home session-state DB by enumerating every
rollout visible under sessions/ during `initialize`. The per-task
CODEX_HOME symlinked the shared ~/.codex/sessions in, so a machine with a
large accumulated history (one reporter: ~2000 rollouts / ~22 GiB) stalled
`initialize` for tens of seconds — the app-server started but the task
produced no output before it was cancelled (github #5273).

Give each task its own local sessions/ directory instead:

- Fresh task: create an empty local sessions/ so backfill is trivial.
- Reused task with a real sessions/ dir: it is authoritative — leave it.
- Reused task still holding a legacy symlink (older build): migrate in
  place. Replace the symlink with a real dir; when resuming, symlink only
  the single rollout being resumed (never copy — a rollout can be GiB and
  this is on initialize's critical path); and drop the stale, rebuildable
  session-state DB (state_*.sqlite*, session_index.jsonl) so Codex
  re-indexes the task-local sessions. Unrelated per-task DBs (goals_*,
  logs_*, memories_*) are left intact.

Also point the token-usage fallback scan at the backend's per-task
CODEX_HOME instead of the daemon-global home, so usage isn't lost now that
sessions are isolated there.

Complements the #5319 handshake watchdog (which turns a silent stall into a
loud, phased timeout); this removes the underlying cause.

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

* fix(daemon): address Codex session isolation review (MUL-4424)

Resolves the three blockers from Elon's review of #5360:

1. local_directory context loss. local_directory tasks get a fresh
   codex-home per task ID (the daemon never reuses their workdir), so
   task-local isolation stranded every follow-up run with an empty
   sessions dir and silently restarted the conversation. Their only
   stable, GC-safe cross-task store is the user's own ~/.codex/sessions
   (a persistent store under WorkspacesRoot would be orphan-GC'd), so keep
   the shared-sessions symlink for them (IsLocalDirectory). Managed tasks
   stay isolated.

2. Migration resume robustness.
   - Rollout lookup now covers the flat layout and background-compressed
     .jsonl.zst rollouts, not just nested YYYY/MM/DD *.jsonl — both are
     legitimate Codex 0.144 history that were previously judged "not
     found", silently dropping resume.
   - Exposure hard-links first, then symlinks, never copies — hard links
     need no privilege and work on Windows within a volume, so the
     zero-copy path is exercised identically on CI.
   - The daemon now verifies the rollout is actually present in the task
     CODEX_HOME (execenv.CodexResumeRolloutPresent) before the brief is
     generated; if absent it clears the resume from both the backend and
     the brief instead of telling the agent it is continuing a lost thread.

3. session_index.jsonl is no longer deleted during migration — Codex uses
   it as the authoritative thread-id -> name store (not rebuildable from
   rollouts). Only the rebuildable state_*.sqlite* is reset.

Tests: 2-round local_directory resume across task IDs; compressed/flat
lookup; hard-link zero-copy (os.SameFile); session_index preserved;
CodexResumeRolloutPresent + the daemon gate helper (present keeps /
absent drops / non-codex + empty no-op).

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

* fix(daemon): scope Codex sessions to a per-issue store; disclose lost resumes (MUL-4424)

Addresses the three blockers from Elon's second review of #5360.

1. local_directory still enumerated the whole machine history. The prior
   fix re-linked the entire ~/.codex/sessions into every fresh local_directory
   codex-home, so Codex still backfilled from thousands of unrelated rollouts on
   `initialize` (measured ~8.3s with 3450 rollouts; the reporter's 22 GiB could
   exceed the 30s watchdog). Point sessions/ at a persistent, per-(agent, issue)
   store under the shared Codex home (multica-sessions/<agent>/<issue>) that holds
   only this issue's rollouts. It is keyed stably across task IDs and lives
   outside the task-scoped envRoot the GC reclaims, so follow-up runs resume it
   while `initialize` only ever sees this issue's history.

2. Windows cross-volume resume was lost. Exposing a single rollout by hard-link
   (same-volume only) then file symlink (needs Windows privilege) can't cross a
   volume boundary. The store now lives on the shared Codex volume, so the resume
   rollout is hard-linked there zero-copy, and sessions/ is exposed to the task
   home via a directory link — a symlink on Unix, a junction on Windows — which
   crosses volumes without privilege and never copies a (possibly GiB) rollout on
   initialize's critical path. There is no remaining per-file cross-volume link.

3. An unavailable resume was a silent downgrade. Both resume gates
   (gateResumeToReusedWorkdir, gateCodexResumeToRolloutPresence) now set
   PriorSessionResumeUnavailable, and the runtime brief renders a Session
   Continuity Notice telling the agent to disclose to the user, up front in its
   reply, that the previous conversation context could not be restored and this
   run starts fresh — turning a silent restart into a user-visible one. The task
   is not failed: it can still do useful work without the prior context.

Managed fresh / reused-real-dir tasks keep their task-local, GC-collected
sessions dir unchanged; only the legacy-symlink migration with a resume routes
through the store (cross-volume-safe), and a home already linked to the store is
treated as authoritative on reuse.

Tests: local_directory per-issue store (only this issue's history, no whole-
machine leak); no-key fallback to an empty dir; two-round resume across task IDs
through the store; legacy migration routed through the store with a zero-copy
hard link; reused store link stays authoritative; both gates set the
resume-unavailable flag; brief renders the continuity notice only when a resume
was lost. execenv + daemon + pkg/agent packages, go vet, and gofmt all pass.

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

* fix(daemon): disclose live resume-RPC loss; bound Codex session store lifecycle (MUL-4424)

Addresses the two blockers from Elon's third review of #5360.

1. A real thread/resume failure was still a silent new session. The brief's
   Session Continuity Notice only covers losses the daemon detects before launch
   (workdir not reused, rollout absent). But when the rollout is present yet
   Codex rejects the live thread/resume (corrupt/incompatible rollout, server-side
   thread GC, schema drift), startOrResumeThread falls back to thread/start and
   the run succeeds on a fresh thread with no user-facing signal. Carry the
   original resume intent into the backend as ExecOptions.ResumeExpected (set from
   the post-gate PriorSessionID, so a pre-flight drop still routes through the
   brief and never double-notifies), and when a resume was expected but the
   backend landed on a fresh thread, prepend the same continuity notice to the
   first turn/start input. This also covers the daemon's transport-error
   fresh-session retry, which clears ResumeSessionID but not ResumeExpected.

2. The persistent per-issue store had no data lifecycle. multica-sessions stores
   live outside the task-scoped envRoot the GC reclaims (so resume survives across
   task IDs), which meant a done/abandoned issue's prompts and full rollouts (one
   reporter: a single 1.5 GiB rollout) accumulated forever and were never freed on
   issue/agent/workspace deletion. Add PruneCodexSessionStores: the daemon GC loop
   reclaims any store untouched for GCCodexSessionTTL (default 14 days, configurable
   via MULTICA_GC_CODEX_SESSION_TTL, 0 disables). A store's newest rollout mtime is
   its last activity, so an active or recently-resumed task keeps its store fresh
   and is never reclaimed, while a deleted issue's store ages out — an eventual
   reclamation guarantee without needing deletion events.

Tests: codexTurnInput discloses on resume fallback and stays silent on success /
fresh start (paired with the existing live-RPC fallback test); store pruning
reclaims aged stores, keeps recent ones, isolates issues, cleans empty agent
dirs, and is disable-able. execenv / daemon / pkg/agent, go vet, gofmt all pass.

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

* fix(daemon): protect a reopened Codex session store from GC mid-mount (MUL-4424)

Addresses Elon's fourth-review blocker: reopening an issue idle past
GCCodexSessionTTL could lose context, because mounting its per-issue session
store (MkdirAll + rollout lookup + task-home link) never refreshed the store's
mtime, so a GC cycle firing before the resumed turn wrote its first rollout saw
a >TTL-old store and reclaimed it — a stat->remove race with no in-use guard.

Two complementary defenses:

- Activity refresh: linkCodexSessionsToStore now os.Chtimes the store to now
  after linking, so codexStoreStat (which reads the newest mtime as last
  activity) sees a just-used store. This fixes the sequential repro — a mount
  immediately followed by a prune keeps the store.

- In-process active-store guard: the daemon marks the per-issue store in-use
  (execenv.CodexSessionStorePath) from before Prepare/Reuse mounts it until the
  task ends, and PruneCodexSessionStores now takes an isActive predicate and
  skips any store a live task holds. Because prepare and prune run in the same
  process, this closes the remaining concurrent stat->remove window the mtime
  refresh alone cannot. Reference-counted, mirroring the env-root guard.

Tests: a reopened >TTL store survives a GC cycle after remount and stays
resumable; an idle-on-disk store marked active is skipped, then reclaimed once
inactive; the existing idle-reclaim / isolation / disable / empty-agent-dir
cases still pass. execenv + daemon, go vet, gofmt all pass.

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

* fix(daemon): make Codex store delete atomic with mark-active (MUL-4424)

Addresses Elon's fifth-review blocker: the active-store guard's check and delete
were not one atomic step. PruneCodexSessionStores called isActive (which locked,
read, and unlocked) and only then RemoveAll'd, leaving a window where a task
could markActiveCodexStore between the check and the removal and still lose its
store — the exact mark-then-delete interleaving Elon reproduced.

Replace the point-in-time isActive predicate with a reserve-for-deletion
protocol that shares one lock with mark-active:

- reserveCodexStoreForDeletion(store) atomically refuses when a live task holds
  the store (or another delete already reserved it) and otherwise marks it
  reserved, all under one activeCodexStoresMu acquisition. PruneCodexSessionStores
  reserves before RemoveAll and commits after, so confirm-inactive and remove are
  effectively atomic against a concurrent mark.
- markActiveCodexStore now waits (on a sync.Cond) while a store is reserved, so a
  task never mounts a store mid-removal; committing the removal wakes it and the
  store is recreated fresh by Prepare (with the continuity notice).

So mark-before-reserve keeps the store (reserve refused); reserve-before-mark
removes it and blocks the late mark until the removal commits. The genuinely
idle case still reclaims.

Tests (daemon, run under -race): mark-then-reserve is refused; reserve blocks a
concurrent mark until commit then the store reads active; a second reserve is
refused mid-flight. The execenv prune tests move to the reserve seam; the
activity-refresh / reopen-then-prune / isolation / disable cases still pass.

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

* fix(daemon): namespace Codex session stores per profile for cross-daemon safety (MUL-4424)

Addresses Elon's sixth-review blocker: the in-process reservation guard cannot
span processes, but Multica supports multiple profile daemons on one machine
(e.g. production + staging) that share the same ~/.codex. Each daemon's GC
scanned the whole multica-sessions root, so a staging daemon could reclaim a
store a production task was actively resuming — its reservation lived only in the
other process's memory.

Isolate by profile instead of trying to lock across processes:

- Store path is now <shared>/multica-sessions/<namespace>/<agent>/<issue>, where
  namespace is the daemon's profile (empty -> "default"). PrepareParams/ReuseParams
  carry Profile; codexSessionStoreKey and CodexSessionStorePath fold it in.
- PruneCodexSessionStores takes the profile and scans ONLY that namespace, so a
  daemon never even sees another profile's stores, let alone deletes them. The
  per-profile trees are disjoint, so the in-process guard is sufficient within a
  namespace (profiles get separate daemon state, so no two daemons share one).

Test: a "staging"-owned idle store is untouched by a default-profile prune and
reclaimed only by staging's own prune. Existing prune/guard/reopen tests move
under the namespace. execenv + daemon under -race, go vet, gofmt all pass.

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

* fix(daemon): make the Codex store profile→namespace map injective (MUL-4424)

Addresses Elon's seventh-review blocker: the per-profile namespace was derived by
dropping unsafe characters, which is not injective. The CLI treats the empty
(default) profile and a profile literally named "default" as separate daemons,
yet both mapped to namespace "default"; likewise "staging.prod" and "stagingprod"
both mapped to "stagingprod". Two distinct daemons then shared one store tree, so
one could again reclaim the other's live session — the cross-process blocker
reopened for those profile names.

Make codexSessionStoreNamespace injective: the empty profile gets a reserved
bare literal "default", and every named profile is hex-encoded (bijective,
filesystem-safe) under a "p_" prefix a bare literal can never collide with. So
"" -> "default" while "default" -> "p_64656661756c74", and "staging.prod" /
"stagingprod" get distinct hex segments. sanitizeCodexPathSegment stays for the
UUID agent/issue segments (injective for real UUIDs); only the user-controlled
profile needed the encoding.

Tests: codexSessionStoreNamespace is distinct for "" vs "default", punctuation
variants, case variants, and an encoded-looking name; and end-to-end, pruning one
profile never reclaims the other's store for the "" vs "default" and
"staging.prod" vs "stagingprod" pairs. execenv + daemon under -race, go vet,
gofmt all pass.

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

* fix(daemon): fixed-length Codex store namespace so long profiles fit (MUL-4424)

Addresses Elon's eighth-review blocker: hex-encoding the full profile doubled the
namespace segment length. A profile can be as long as a filesystem segment allows
(~255 bytes) and the CLI persists it as its own config dir, but the store
namespace "p_" + hex(profile) reached 2 + 127*2 = 256 bytes at 127 chars,
overflowing the 255-byte single-segment limit — the profile's own dir created
fine, then the session store failed with "file name too long".

Derive the namespace from a fixed-length hash instead: a named profile is now
"p_" + hex(sha256(profile)) — a constant 64 hex chars (66 with the prefix),
filesystem-safe and collision-resistant. The empty (default) profile keeps its
reserved bare literal "default", which the "p_"-prefixed 66-char segment can
never equal. Still injective across the CLI's distinct-daemon cases; just no
longer length-expanding.

Test: the namespace stays <=255 bytes and creatable for profiles up to the
255-byte segment limit (127- and 255-char cases that overflowed under hex); the
prior injectivity and cross-profile prune-isolation tests still hold. execenv +
daemon under -race, go vet, gofmt all pass.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-15 00:48:30 +08:00
Rusty Raven ef9b334408 MUL-4398: fix Hermes bound-skill discovery with per-task overlay (#5308)
* fix(execenv): overlay per-task HERMES_HOME so Hermes discovers bound skills

Hermes has no workspace-relative skill discovery — it scans <HERMES_HOME>/skills
first, then skills.external_dirs from config.yaml (verified against the bundled
agent/skill_utils.py). The daemon wrote assigned skills to the generic
.agent_context/skills/ fallback, which Hermes never reads, so they silently never
took effect (#5242).

When (and only when) an agent has skills bound, redirect HERMES_HOME to a minimal
per-task compatibility overlay; a skill-less Hermes task keeps its real home and
original behavior:

- mirror every top-level entry of the shared home via symlink except the
  overlay-owned ones (denylist), reconciling entries deleted from the shared home;
- derive a task-local config.yaml whose skills.external_dirs references the shared
  skills dir plus the user's existing external_dirs, expanded against the
  sanitized effective child env (unknown vars preserved, blocklisted keys resolved
  to the process value) and normalized to absolute paths;
- write only the bound skills into the task-local skills/ dir (home skills scanned
  first, so they win); global skills are referenced, not copied;
- keep memories/ overlay-owned (fresh per-task dir) AND disable the external
  memory.provider, so neither on-disk memory nor a shared backend crosses tasks;
- keep active_profile/profiles out of the overlay so Hermes can't follow a sticky
  profile and redirect past it at startup.

Profile handling mirrors hermes_cli.profiles: the daemon reads -p/--profile with
agent.HermesProfileFromArgs and seeds the overlay from that profile's home via
ResolveHermesSourceHome (default/invalid -> base, valid name -> <base>/profiles/
<name>, validated; a missing named profile fails closed). The profile flags are
stripped from the acp argv ONLY when the overlay is active (hermesLaunchArgs), so
a skill-less task's profile passes through unchanged. HERMES_HOME is no longer
custom_env-blocklisted: no skills -> user value passes through; skills -> overlay
overrides after layering. Fail closed — Prepare errors, Reuse returns nil.
Task home 0700, derived config 0600 via atomic replace. Platform-native default
home (%LOCALAPPDATA%\hermes, incl. the LOCALAPPDATA-missing fallback, on Windows).

Tests span execenv/daemon/agent: no-skill no-op, child-env layering + env
sanitization, profile parse/unquote + conditional strip + final args/env per
scenario, custom/profile/default/invalid/missing/Windows source home, sticky-
profile not mirrored, memory dir isolation + external provider disable, mirror
reconciliation, external_dirs rebasing + sanitized/unknown-var expansion,
local-precedence slug, perms, fail-closed, resume teardown. Docs (en + ja/ko/zh).

Fixes #5242

* fix(hermes): make profile selection one resolver contract matching Hermes

Round 5 review: the profile chain approximated Hermes' semantics in three
separate places (argv parsing, source-home selection, arg filtering), so it
diverged from native Hermes in several merge-blocking cases. Collapse it into
one authoritative resolution:

- agent.ParseHermesProfileArgs replaces HermesProfileFromArgs/
  FilterHermesProfileArgs. It reproduces _apply_profile_override step 1/1b
  (first occurrence, value-flag skipping, `--` and `mcp add --args` boundaries,
  space-form profile-id guard) and returns the exact argv occurrence to consume;
  StripHermesProfileArgs removes only that occurrence.

- execenv.ResolveHermesProfile replaces ResolveHermesSourceHome. It derives the
  Hermes root exactly like get_default_hermes_root (an already-profile-scoped
  HERMES_HOME roots at its grandparent), selects an explicit profile first,
  otherwise trusts a profile-scoped home (step 1.5) and only then the sticky
  <root>/active_profile (step 2), and validates via normalize/validate_profile_name
  (reserved hermes/test/tmp/root/sudo and empty inline `--profile=` are hard
  errors). Profiles always resolve under the root, so `-p default` re-roots and
  `-p <sibling>` is a sibling, never nested.

- The daemon runs one parse + resolve, fails the task closed on a reserved/
  invalid selection (matching Hermes' sys.exit(1)), and exports the selected
  source home as the effective env's HERMES_HOME so ${HERMES_HOME} in a profile's
  skills.external_dirs expands against the selected profile home (as native
  Hermes does before loading config.yaml), not the root or the overlay.

Regressions added: root + sticky named profile selection; already-profile-scoped
home with no flag; that home with -p default and -p <sibling>; reserved and empty
inline profile values; and a selected profile whose external_dirs contains
${HERMES_HOME}.

* fix(hermes): overlay-owned derived .env + symlink-resolved root

Round 6 review, two remaining overlay-bypass paths:

1. A source `.env` could redirect HERMES_HOME after profile resolution. Hermes
   runs `_apply_profile_override()` then `load_hermes_dotenv()`, which loads
   `<HERMES_HOME>/.env` with override=True — so a mirrored source `.env` carrying
   an out-of-band `HERMES_HOME=` overwrote the overlay's home, repointing skill
   discovery and memory back at the source. `.env` is now overlay-owned and
   DERIVED (writeDerivedHermesEnv): it preserves the source's credentials/settings
   but strips any `HERMES_HOME` assignment and pins `HERMES_HOME` to the overlay
   last (single-quoted, literal), written 0600 via atomic replace. It is written
   even when the source has none, so Hermes' project-`.env` fallback (override=True
   only when no user `.env` loaded) can't relocate the home either.

2. Root derivation was lexical-only, diverging from `get_default_hermes_root`,
   which compares `env_path.resolve()` with `native_home.resolve()`. A HERMES_HOME
   symlinked into `<native>/profiles/<x>` was treated as its own root, so
   `-p default`/`-p <sibling>` resolved wrong. `hermesRootFromHomeFor` now resolves
   symlinks (Path.resolve(strict=False)-style best effort) for the containment
   decision while keeping the returned root unresolved, matching Hermes.

Regressions: source `.env` with HERMES_HOME replayed through the override=True
dotenv order (bound skill + task memory stay on the overlay; creds preserved);
minimal overlay `.env` created when the source has none; and a symlinked profile
home resolving `-p default`/`-p <sibling>` to the native root.
2026-07-14 15:05:18 +08:00
f599333f97 [MUL-4348] Route coalesced replies per root thread (#5202)
* feat(daemon): route coalesced replies per root thread (MUL-4348)

When a busy agent coalesces multiple @mentions into one run, the run used
a single --parent (the newest trigger), so questions raised in separate
root threads were answered in one merged comment while the other threads
were left unanswered.

Group the trigger + coalesced comments by root thread server-side in the
prompt builder (commentReplyThreads). When the run spans >=2 distinct
threads, emit a per-thread reply plan (BuildMultiThreadCommentReplyInstructions)
that instructs one reply per thread with the exact --parent, explicitly
overriding the general 'one comment per run' rule. Multiple @mentions from
the SAME thread collapse to a single group upstream, so same-thread
follow-ups keep the ordinary single --parent=trigger path and can never be
split into duplicate replies. Single-thread / non-coalesced runs are
unchanged.

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

* fix(daemon): sync workflow-brief reply step with per-thread fan-out (MUL-4348)

Review of #5202 found the cross-thread fan-out was injected only into the
per-turn prompt (buildCommentPrompt), while the persistent workflow brief
(writeWorkflowComment step 7) still emitted the single --parent=trigger
cookbook for every comment task. A cross-thread run therefore got two
slightly conflicting reply instructions, so the fan-out guarantee rested on
prompt wording/precedence rather than structure.

Carry the computed thread targets on TaskContextForEnv.CommentReplyTargets
(populated from the same commentReplyThreads() the prompt uses, so the two
surfaces cannot drift). When >=2 targets, the workflow reply step now emits
the per-thread fan-out plan too; same-thread follow-ups collapse to a single
group upstream and keep the single-parent cookbook, so they still can never
be split. Also clarified the multi-thread cookbook to show a distinct file
per reply (reply-1.md / reply-2.md).

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

* fix(daemon): reply under the specific mentioning comment per thread (MUL-4348 review nit #1)

Non-trigger threads previously replied under the thread root, while the
trigger's thread replied under the trigger comment — asymmetric, and it put
the answer at the top of the thread instead of next to the actual question
when the mention was a mid-thread reply. Reply under the NEWEST triggering
comment in each thread instead (inputs are chronological, so last-write-wins
per thread), making every thread consistent and nesting each answer beside
its question.

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

---------

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-10 15:03:05 +08:00
ZIce fe46dfdbf6 MUL-4203: Fix Cursor MCP auth source seeding (ZIC-52)
Merge approved PR.
2026-07-10 12:59:56 +08:00
beast aecd47b59f fix(daemon): mark workspaces root so escaped subprocesses still fail closed (#5044)
Write a persistent daemon-task marker at the workspaces root so a subprocess that lost all MULTICA_* env vars and escaped above its workdir still fails closed instead of falling back to the user's config PAT. Includes daemon-startup pre-ensure, per-task and reuse-path self-heal, torn-marker reclaim, atomic write, and non-fatal degrade. Fixes #5043.
2026-07-09 14:35:11 +08:00
cb68669c73 feat(composio): gate MCP apps behind feature flag (#4876)
* feat(composio): server-side connect flow + connections REST (Notion MVP) (MUL-3720) (#4608)

* feat(composio): server-side connect flow + connections REST (Notion MVP) (MUL-3720)

Compose the merged server/pkg/composio SDK into a user-facing connection
manager: signed-state connect handshake, local user_composio_connection
mirror, idempotent disconnect, and a per-user MCP session helper (not yet
wired into task dispatch).

- migration 127_user_composio_connection (no FK/cascade, per DB rules)
- sqlc queries: upsert (idempotent on user_id+connected_account_id), list
  active, owner-scoped get, mark revoked
- internal/integrations/composio: signed HMAC-SHA256 state, BeginConnect,
  CompleteCallback (idempotent upsert), ListConnections, Disconnect
  (upstream 404 = idempotent success), CreateMCPSession (no-op when empty,
  pins connected_accounts per toolkit), CallbackRedirect
- REST handlers under /api/integrations/composio (user-scoped, 503 when
  COMPOSIO_API_KEY unset): connect/init, callback (302), connections list,
  delete
- router wiring gated by COMPOSIO_API_KEY; COMPOSIO_AUTH_CONFIGS_JSON maps
  toolkit->auth_config (MVP: notion); state secret from COMPOSIO_STATE_SECRET
  or derived from JWT_SECRET; callback base from COMPOSIO_CALLBACK_BASE_URL
  or MULTICA_PUBLIC_URL
- tests: state (expire/tamper/wrong-secret), service (mapping, callback
  idempotency, non-success, disconnect owner/404 idempotency, MCP pin),
  handlers (httptest), redact regression for Bearer mcp_ tokens

MVP scope: Notion only; no task-dispatch overlay, sharing, or webhook
event handling (later stages).

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

* fix(composio): bind callback account to user + idempotent revoked disconnect (MUL-3720)

Address PR 4608 review (CHANGES_REQUESTED):

- callback: verify connected_account_id with Composio before mirroring it.
  The signed state only proved user/toolkit/exp, so a valid state paired with
  a tampered connected_account_id would be written verbatim. CompleteCallback
  now calls ListConnectedAccounts and fails closed (ErrAccountVerification)
  unless the account belongs to the state's user (composio_user_id == multica
  user id) and was created under the toolkit's auth config. No row is written
  on mismatch / unknown account / upstream error.

- disconnect: short-circuit to a no-op when the local row is already revoked,
  before touching upstream. Previously a second DELETE re-hit Composio and a
  non-404 upstream error surfaced as a 502, breaking the 204-idempotent
  contract.

- CreateMCPSession: document the v1 single-active-connection-per-(user,toolkit)
  constraint and make duplicate selection deterministic (newest-wins, rows are
  connected_at DESC) instead of order-dependent map overwrite. Stage 3 owns the
  real single-account-enforcement vs multi-account-shape decision.

Tests: tampered/wrong-auth-config/unknown-account callback rejection, revoked-row
disconnect no-op (asserts upstream not re-hit). composio pkg 85% coverage; all
green.

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

* feat(composio): list all toolkits + dynamic auth-config resolution (MUL-3720)

Yushen's follow-up to the Notion MVP: surface the full Composio toolkit
catalog, render it in Settings, and drop the static env mapping in favor of
dynamic auth-config discovery.

Config correctness (per Composio docs):
- Remove COMPOSIO_AUTH_CONFIGS_JSON entirely. The toolkit→auth_config mapping
  is now resolved at request time from the project's /auth_configs (cached,
  5-min TTL), so enabling a toolkit is a dashboard action, not a redeploy.
- Do NOT add COMPOSIO_PROJECT_ID. The project API key (x-api-key) authenticates
  to exactly one project; the project is resolved from the key. Only org-level
  endpoints use x-org-api-key, which this integration never calls.

Backend:
- SDK: server/pkg/composio/auth_configs.go — ListAuthConfigs (toolkit_slug,
  is_composio_managed, show_disabled, limit, cursor).
- service: dynamic resolver (authConfigMap cache; betterAuthConfig prefers a
  custom/white-label config over Composio-managed, newest wins); BeginConnect
  and CompleteCallback resolve via it; ListToolkits fetches the full catalog
  (paginated, capped) annotated with connectable = has an enabled auth config,
  connectable-first ordering.
- handler + route: GET /api/integrations/composio/toolkits (user-scoped, 503
  when COMPOSIO_API_KEY unset) returning slug/name/logo/category/connectable.

Frontend:
- core: ComposioToolkit/ComposioConnection types, api client methods, and
  composio query options (@multica/core/composio).
- views: Settings → Integrations now has a Composio section rendering every
  toolkit as a card with search. Connect is gated on `connectable`;
  non-connectable toolkits show a muted "not configured" hint instead of a
  dead button. Connected toolkits show a badge + Disconnect (with confirm).
- i18n: composio block added to en/zh-Hans/ja/ko settings.

Tests: SDK + service (dynamic resolution, custom-over-managed preference,
connectable flag, resolver-error soft-degrade) and handler toolkits endpoint;
composio pkg 85.7% coverage. go build/vet/gofmt clean; core+views typecheck,
core+views lint, and core tests (691) all green.

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

* fix(composio): close cross-toolkit callback fail-open by signing auth_config_id into state (MUL-3720)

Re-review blocker: CompleteCallback resolved the toolkit's auth config at
callback time and ignored a resolve error/empty result, while
verifyAccountOwnership skipped the auth-config comparison when the expected
value was empty. A user could then pass another toolkit's connected_account_id
into this toolkit's callback — the owner check passed and it was written under
the wrong toolkit_slug/account binding.

Fix: the auth_config_id is already resolved in BeginConnect (before the state
is signed), so sign it into the state and compare it exactly at callback. No
re-resolve, no fail-open. verifyAccountOwnership now fails closed when the
expected auth config is empty (rejects instead of skipping) and requires an
exact match — closing the cross-toolkit binding gap.

Tests: state round-trips auth_config_id; BeginConnect signs it; callback
rejects wrong/cross-toolkit auth config and an empty (no-mapping) auth config
fails closed. composio pkg 85.2% coverage, all green.

Frontend (non-blocking): the Composio settings tab now surfaces an error when
the connections query fails instead of silently rendering everything as
unconnected.

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

* fix(composio): hide Settings section entirely when integration unconfigured (MUL-3720)

Decision (option 2, hide-then-merge): don't show a card that leaks the internal
COMPOSIO_API_KEY env-var name to every end user. IntegrationsTab now gates the
whole Composio section (heading + body) on the toolkits query — a 503 means the
key is unset, so the section is withheld instead of rendering the not-configured
card. Admin-only setup guidance is a later, role-gated affordance.

Removed the notConfigured card (and now-unused ApiError import) from
ComposioTab; it only mounts when configured. views typecheck + lint clean.

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

---------

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

* feat(composio): Stage 2 frontend polish — callback toast, last_used & expired UI, e2e (MUL-3718) (#4688)

* feat(composio): callback toast + refresh, last_used & expired UI, e2e (MUL-3718)

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

* fix(composio): real callback redirect route + StrictMode-safe toast dedup (MUL-3718 review)

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

---------

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

* fix(composio): callback endpoint should not require Multica auth (MUL-3843) (#4709)

* fix(composio): move OAuth callback out of the Auth group (MUL-3843)

Composio 302-redirects the browser to /api/integrations/composio/callback
at the end of the OAuth flow, but PR #4608 mounted it inside the cookie-auth
middleware group. When the session cookie is absent (expired session,
SameSite=Strict / Safari ITP, private window, self-hosted callback subdomain)
the Auth middleware returned a hard 401 and a JSON blob instead of the
settings redirect, breaking the flow.

Identity never came from the cookie anyway: it is carried by the HMAC-signed
state param that CompleteCallback verifies (signature, expiry, replay) and
cross-checked by verifyAccountOwnership; h.Composio == nil still 503s. So the
callback is registered alongside the other public OAuth/webhook routes; the
other four composio endpoints stay session-gated.

Refs MUL-3843, MUL-3715.

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

* fix(composio): correct stale callback routing comments (MUL-3843)

The package header and ComposioCallback doc comments still described the
callback as sitting under the Auth middleware group. After the route was
moved out (this PR), update both to state it is a public route whose identity
comes from the signed state — addressing review nit from 张大彪.

Refs MUL-3843.

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

---------

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

* feat(composio): inject MCP overlay into agent runtime at task dispatch (MUL-3721) (#4704)

Stage 3 of the Composio epic. Wires the per-user Composio MCP session into
every agent task so the agent process sees the initiator's connected tools
without any prompt-time plumbing.

Server side
  - Migration 128 adds agent_task_queue.runtime_mcp_overlay JSONB plus a
    BEFORE-UPDATE trigger that wipes the column on any transition into a
    terminal status (completed / failed / cancelled). A trigger is the single
    source of truth — future queries that flip status cannot bypass it.
  - composio.Service.BuildTaskOverlay(userID) reuses CreateMCPSession and
    emits the Claude-style { mcpServers: { composio: { type: http, url,
    headers } } } shape the daemon's existing sidecar generators consume.
    Returns (nil, nil) on zero active connections so we never burn a
    Composio session for a user with nothing to call.
  - TaskService grows a Composio ComposioOverlayBuilder seam, wired in
    router.go after composiointeg.NewService succeeds. Five enqueue paths
    (issue / mention / quick-create / chat / auto-retry) attach the overlay
    after CreateAgentTask returns and before the daemon is notified — so
    every claim reads a settled row, with no second daemon hop. Best-effort:
    a builder failure logs and proceeds with no overlay.
  - resolveInitiatorFromTriggerComment derives the initiator user from the
    trigger comment when it was authored by a member. Agent-authored
    triggers are not treated as initiators (their connected-apps view is
    empty by construction).

Daemon side
  - handler/daemon.go claim path merges task.runtime_mcp_overlay onto
    agent.mcp_config via mergeMCPOverlay before populating
    TaskAgentData.McpConfig. Overlay wins on server-name collisions
    because it carries the live user-scoped session URL. Errors fall back
    to the agent config unchanged — a bad overlay must not surprise-disable
    saved MCP tools. The existing execenv sidecar generators (cursor /
    codex / openclaw / opencode / hermes-kiro) need no changes: they keep
    consuming the merged result through TaskAgentData.McpConfig.

Tests
  - 9 merge cases (mcp_overlay_test): both-nil short-circuit, agent-only
    pass-through, overlay-only canonicalization, two-side merge, name
    collision (overlay wins), top-level key preservation, malformed agent
    fallback, malformed overlay fallback, non-object server rejection.
  - 4 dispatch cases (composio): zero-connections returns nil without
    CreateSession, happy-path emits the right shape with the right user
    id, empty-URL defensive branch, SDK error surfacing.
  - 4 TaskService helper cases: nil Composio is a no-op (Queries-safe),
    invalid initiator does not call the builder, nil overlay skips the
    UPDATE, builder error swallowed without panic.
  - Migration 128 verified to roll up + down + up cleanly against the test
    database.

Out of scope (deferred): assignment-triggered enqueue paths with no
trigger comment get no overlay attached today (no initiator UUID flows
through enqueueIssueTask in that case). Retry paths recompute the overlay
fresh from the parent's initiator_user_id instead of inheriting the bearer
from the parent row, so a stale token can never resurface on a retry.

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

* feat(composio): per-agent allowlist + originator-scoped MCP overlay (MUL-3869) (#4736)

* feat(composio): per-agent allowlist + originator-scoped MCP overlay (MUL-3869)

Stage 3.1 of the Composio epic (MUL-3721 parent). PR #4704 wired in the
runtime_mcp_overlay column and a per-task dispatch hook; this change
inverts the default from "all-on" to opt-in and locks the overlay to the
agent owner's own connected apps:

- Agents carry composio_toolkit_allowlist TEXT[]. NULL or [] => no MCP.
  Owner-only read/write; non-owner GET/PUT silently redacts/drops the
  field (same shape as mcp_config).
- agent_task_queue carries originator_user_id UUID. Set from the
  top-of-chain HUMAN at every enqueue path:
    * issue/mention comment by member  -> author_id
    * issue/mention comment by agent   -> inherit via comment.source_task_id
                                          -> parent task originator_user_id
    * quick-create                     -> requester_id
    * chat                             -> initiator_user_id
    * retry                            -> SQL-inherited from parent row
    * autopilot                        -> NULL (system-driven)
- BuildTaskOverlay (composio dispatch) now takes (ctx, originatorUserID,
  agent) and short-circuits on five gates: invalid originator,
  originator != agent.owner_id, empty allowlist, empty intersection of
  allowlist ∩ active connections, defensive empty session URL. Composio
  CreateSession is called with BOTH `toolkits.slugs` (the intersection)
  AND `connected_accounts` (the pinned account ids), narrowing the
  tool-router twice.
- The originator-vs-owner gate closes the agent-fanout privacy hole: any
  workspace member who can @-mention a public agent used to project the
  owner's connected apps into their run. Now the overlay only mounts
  when the human at the top of the chain IS the agent owner.

Tests:
- dispatch_test.go covers all 5 gates plus uppercase/whitespace slug
  normalisation.
- task_runtime_mcp_overlay_test.go covers the no-op gates of the new
  applyRuntimeMCPOverlay signature.
- agent_composio_allowlist_test.go (handler): owner roundtrip
  (list/empty/null), workspace-admin silent-drop, owner-only GET
  visibility, pure normaliseComposioToolkitAllowlist.
- resolve_originator_test.go (service, DB-backed): member-authored,
  agent-authored inherits via comment.source_task_id, invalid id.

Migration 129 up/down/up verified against docker postgres.

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

* chore(composio): gofmt + regenerate sqlc with v1.31.1 (MUL-3869 review nits)

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

---------

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

* fix(composio): accept nested connected account auth config

* feat(views): creator-only MCP tab for per-agent Composio allowlist (MUL-3870) (#4743)

Stage 3.2 frontend on top of the Stage 3.1 backend (MUL-3869, 4708dba97).
Adds an agent-detail tab that lets the agent owner pick which of their own
active Composio connections this agent may mount as MCP servers, writing the
selection to agent.composio_toolkit_allowlist via the existing PUT /api/agents.

- core/types: composio_toolkit_allowlist (+ _redacted) on Agent; tri-state
  composio_toolkit_allowlist on UpdateAgentRequest (omit/no-change, null/clear,
  array/replace), matching the backend contract.
- core/agents: useUpdateAgentAllowlist - optimistic mutation hook (patches the
  cached workspace agent list, rolls back on error, invalidates on settle).
- views: AgentMcpTab renders the owner's active connections as checkboxes;
  empty state links to Settings -> Integrations; defensive redacted state.
- views: wired into AgentOverviewPane as tab "composio_mcp", labeled "MCP Apps"
  to disambiguate from the existing raw-JSON "MCP" (mcp_config) tab. The entry
  is gated to the creator (currentUserId === agent.owner_id), matching the
  backend's owner-only read/write of the allowlist.
- i18n: tabs.composio_mcp + tab_body.composio_mcp.* in en/ja/ko/zh-Hans.
- tests: agent-mcp-tab.test.tsx (gating, toggle->allowlist body, active-only,
  empty, redacted); e2e/agent-mcp.spec.ts (creator sees tab + PUT body,
  non-creator hidden) with Composio + agent endpoints mocked at the boundary.

Note: the product spec says "creator"; the schema has no creator_id - the
backend gate and redaction are keyed on owner_id, so the tab uses owner_id.

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

* fix(composio): mount remote MCP for codex

* feat(agents): agent invocation permission system (MUL-3963) (#4844)

* feat(agents): agent invocation permission system (permission_mode + invocation targets)

MUL-3963: split who may INVOKE an agent out of the overloaded visibility
column into an explicit, extensible model on feature/composio-integration.

- DB: agent.permission_mode (private|public_to) + agent_invocation_target
  table (workspace/member/team targets) + lossless backfill from visibility
  (migration 130).
- canInvokeAgent: owner-only for private (NO admin bypass, NO A2A bypass);
  public_to honours the allow-list; A2A judged by the top-of-chain originator.
- All trigger paths rewired: issue assign, comment @agent/@squad, chat,
  quick-create, autopilot, squad leader, child-done.
- Agent API: permission_mode + invocation_targets on responses and
  create/update (owner-only writes); legacy visibility kept as a derived field
  so old clients never see a permission widening.
- Composio: BuildTaskOverlay now FOLLOWS invocation permission and uses the
  agent OWNER connection (removed the originator==owner gate); front-end warns
  when a shared agent enables Composio apps.
- CLI: --permission-mode / --public-to-workspace / --public-to-member (legacy
  --visibility still mapped).
- Frontend: AccessPicker (Private / workspace / specific people / team soon),
  permission rules mirror canInvokeAgent, Composio warning banner.
- Tests: migration backfill, admin cannot invoke others private, public_to
  workspace/member whitelist, A2A by originator, Composio overlay uses owner
  connection.

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

* feat(agents): stackable, mixed public_to invocation targets (MUL-3963)

Follow-up on PR #4844: public_to now supports selecting MULTIPLE, MIXED
targets on one agent (e.g. Public to workspace + specific people + team),
with canInvokeAgent admitting on ANY matching target (OR).

- Frontend AccessPicker: reworked from a single exclusive kind into a
  stackable multi-select — an "Everyone in workspace" toggle, a member
  multi-select checklist, and a (disabled, v1) team placeholder can be
  combined freely. Emits the full union of selected targets; empty union
  collapses to Private. Existing team targets are preserved across saves.
  Added the access.public_group locale string (en/zh-Hans/ja/ko).
- Backend already supported this (agent_invocation_target is multi-row per
  agent; create/update take a target ARRAY and batch-replace the whole
  allow-list; canInvokeAgent OR-matches). Added tests to lock it in:
  mixed member+team targets, overlapping-member batch replace, and
  workspace+member stacking then narrowing.

Refs MUL-3963.

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

* fix(agents): address review on invocation permission (MUL-3963)

张大彪 review on PR #4844 — three blockers + product ruling + nits:

1. Migration 130: drop the FK/cascade on agent_invocation_target
   (agent_id, created_by) per the Multica no-FK rule; relationships are now
   maintained in the app layer (matching MUL-3515 §4). Added
   DeleteAgentInvocationTargetsByArchivedRuntimeAgents and call it before
   DeleteArchivedAgentsByRuntime in all three runtime-delete paths
   (runtime.go x2, runtime_profile.go) so hard-deleting agents can't orphan
   target rows.
2. revokeAndRemoveMember: prune the leaving member's member-target grants
   (DeleteAgentInvocationTargetsByMember) in the same tx as the member-row
   delete, so a re-invited user can't reclaim a stale invocation grant.
3. Empty public_to is a phantom — parsePermissionInput now normalises a
   public_to with no resolvable targets to a single workspace target, so
   `--permission-mode public_to` alone (and any empty target array) means
   "public to workspace" instead of "shared but nobody can run it".

Product ruling: the system/no-human-originator → workspace-target path in
canInvokeAgent is a deliberate, documented exception (webhook/system/
workspace-wide automation); member/team targets still fail closed without a
resolved originator. Documented in code + locked with a test.

Nits: refreshed the stale "originator must be owner" comments — models.go
(via migration 130 COMMENT ON COLUMN + sqlc regen for composio_toolkit_allowlist
and originator_user_id) and agent-mcp-tab.tsx — to the owner-connection +
invocation-permission rules.

Tests: member remove/re-add regression, system workspace exception + member
fail-closed, empty public_to → workspace (plus the earlier mixed/overlap/
batch-replace suite). Migration 130 applied to the test DB; Go handler/service/
composio suites green; views typecheck clean.

Refs MUL-3963.

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

* fix(agents): scope member invocation-target cleanup to one workspace (MUL-3963)

张大彪 3rd review — cross-workspace permission bug + comment nits:

- DeleteAgentInvocationTargetsByMember was a GLOBAL delete by user id, so
  removing a user from workspace A also wiped their member-target grants on
  agents in workspace B. Scoped it to a single workspace by joining through
  agent.workspace_id; revokeAndRemoveMember now passes (workspaceID, userID).
- Regression test TestRevokeMember_InvocationTargetCleanupIsWorkspaceScoped:
  same user allow-listed by agents in two workspaces; removal from one leaves
  the other workspace's target intact.
- Nits: refreshed the remaining stale "originator == agent.owner_id" /
  "owner-vs-originator" comments — CreateRetryTask (agent.sql, regenerated),
  and the AgentResponse allowlist doc + ListAgents/UpdateAgent redaction
  rationale in agent.go — to the owner-connection + invocation-permission rule.

Migration 130 applied to the test DB; Go handler/service/composio suites green;
go vet clean.

Refs MUL-3963.

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

---------

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

* fix(agents): agent access owner-only editable, read-only for others (MUL-3963) (#4853)

* fix(agents): make agent access owner-only editable, read-only for others (MUL-3963)

Interaction bug: a non-owner (incl. workspace admin) could open the AccessPicker
and set an agent public — the backend silently ignored it and the UI bounced
back to private. Access is owner-only, so non-owners must see a read-only state
and the backend must reject real changes explicitly.

Frontend:
- AccessPicker renders a static, non-interactive read-only state when the
  viewer is not the owner: the current access value + a lock affordance + a
  tooltip "Only the agent owner can change who can run this agent." No clickable
  trigger is rendered, so a non-owner can never open a control the backend would
  reject (the GitHub/Notion pattern for permission settings you can see but not
  edit). The editable multi-select picker is unchanged for the owner.
- agent-detail-inspector gates the picker on ownership specifically
  (currentUserId === agent.owner_id), NOT the general canEdit (which also admits
  admins, who may edit other fields but not access).
- New locale key access.owner_only_readonly (en/zh-Hans/ja/ko).

Backend:
- UpdateAgent now returns an explicit 403 when a non-owner submits a REAL
  permission change (permissionInputChangesAgent compares requested mode +
  target set against the persisted state); a no-op resubmit (admin PATCH-as-PUT
  echoing unchanged permission) is still tolerated so admin edits of other
  fields keep working. Replaces the previous silent-drop that caused the bounce.

Tests:
- access-picker.test.tsx: non-owner gets a non-interactive read-only display
  with the owner-only tooltip; owner gets an interactive picker; owner can pick
  a member and stack workspace + member.
- TestUpdateAgent_AccessChangeIsOwnerOnly: admin real change → 403; admin no-op
  resubmit → 200; admin editing other fields → 200; owner change → 200.

Incidental: fixed a pre-existing base typecheck break in
slash-command-suggestion.test.tsx (stray `signal` arg not in the suggestion
items type) that otherwise fails the whole @multica/views typecheck.

Refs MUL-3963.

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

* fix(agents): compare legacy visibility, not expanded permission, for no-op detection (MUL-3963)

PR #4853 review: permissionInputChangesAgent expanded a legacy-only
visibility:"private" into a real private permission and compared it against the
agent's actual permission. A member-only public_to agent derives legacy
visibility "private", so an admin PATCH-as-PUT echoing visibility:"private"
while editing another field was misread as a public_to→private downgrade and
rejected with 403 — contradicting the "unchanged permission no-op is allowed"
contract.

Fix (per review): when a request carries ONLY legacy `visibility` (no
permission_mode / invocation_targets), derive the agent's CURRENT legacy
visibility from its real targets and compare the legacy string values. Equal =
no-op (allowed); a real legacy change (e.g. "workspace") still returns 403.
Requests that carry permission_mode / invocation_targets keep the precise
mode+target comparison.

Regression test TestUpdateAgent_LegacyVisibilityNoOpForMemberOnlyPublicTo:
member-only public_to agent — admin submitting visibility:"private" + a
non-permission field → 200 with targets unchanged; admin submitting
visibility:"workspace" → 403.

Go handler/composio suites green; migration 130 applied; go vet clean.

Refs MUL-3963.

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

---------

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

* feat(composio): brief agents on connected apps

* feat(composio): gate MCP apps behind feature flag

* fix(mobile): parse agent invocation permissions

* fix(tests): update agent fixtures for access fields

---------

Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Multica Eve <[email protected]>
Co-authored-by: Eve <[email protected]>
Co-authored-by: Eve <[email protected]>
2026-07-03 14:18:43 +08:00
beast 20eecfb093 fix(projects): honor repo resource checkout refs (MUL-3593) (#4470) 2026-06-24 16:25:17 +08:00
4ab335b8a5 MUL-3416: Issue pre-trigger preview + Handoff Note (#4383)
* feat(issues): unify run-enqueue decision behind WillEnqueueRun + preview endpoint

Collapse the issue update/batch enqueue copies into one service predicate
service.IssueService.WillEnqueueRun, shared verbatim with a new dry-run
endpoint POST /api/issues/preview-trigger so the four entry points stop
drifting (squad/self-loop/batch omissions, MUL-3375). The private-agent gate
stays at the HTTP boundary: write paths inject allow-all, preview injects the
real gate so it never leaks a private agent's readiness.

Add suppress_run to issue update/batch: the change applies but no run starts.
Remove the now-dead handler mirrors shouldEnqueueSquadLeaderOnAssign /
isSquadLeaderReady. service.Create and the comment trigger chain are untouched.

Tests: preview behavior, preview<->write-path match, batch aggregation,
member no-trigger, suppress_run skip, malformed-body 400.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(issues): inject handoff note into assigned runs via first-class task field

Add an optional handoff_note carried by issue assign/promote into the run's
opening prompt and issue_context.md, via a dedicated agent_task_queue column
(migration 122) and a daemon assignment-handoff render branch — never a
fabricated comment, never trigger_comment_id (MUL-3375 §6.1).

Thread the note through enqueueIssueTask/enqueueMentionTask + WithHandoff
public variants and dispatchIssueRun; suppress_run or a parked write drops it
(no run = nothing to inject). Soft version gate: MinHandoffCLIVersion +
HandoffSupported, surfaced per-trigger as handoff_supported in the preview so
the UI can gray the note box on old daemons; the assignment never hard-fails.

Tests: daemon prompt + issue_context render via the assignment branch (not
quick-create/comment), version helper matrix, note persists on the task,
suppressed assign enqueues nothing.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(issues): leave a display-only handoff record on the timeline

When an assign/promote with a handoff note starts a run, write one
type='handoff' timeline record via TaskService.RecordHandoff — a direct
Queries.CreateComment + timeline event that bypasses Handler.CreateComment, so
it never reaches triggerTasksForComment and cannot start a second run
(MUL-3375 §6.2, the must-not-retrigger invariant). Author is the actor who
handed off; body is the note. Migration 123 admits the 'handoff' comment type.
Recorded only on a real run start: suppress_run or a parked write writes
nothing. enqueueSquadLeaderTask now reports whether it enqueued so the trace
is gated on an actual dispatch.

Test: exactly one handoff record on assign-with-note, exactly one task (no
re-trigger), and no record when suppressed.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(issues): frontend plumbing for issue-trigger preview + handoff (core)

Add api.previewIssueTrigger + IssueTriggerPreviewSchema (zod parseWithFallback),
the use-issue-trigger-preview hook, issueKeys.issueTriggerPreview(+All) with WS
queue-state invalidation, suppress_run/handoff_note on UpdateIssueRequest, the
'handoff' CommentType, and stripping of the control fields from optimistic
update/batch cache patches (MUL-3375 §9).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(issues): exclude handoff records from new-comment counting

type='handoff' is a display-only timeline record, not conversation. Exclude it
from CountNewCommentsSince so a handoff note never inflates the count of
"new comments to catch up on" fed to a claiming agent (MUL-3375 §12). Analytics
already excludes it (RecordHandoff is a direct write that emits no analytics
event), and the comment-trigger path is already bypassed.

Test: a handoff record does not bump the new-comment count; a real comment does.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(issues): pre-trigger preview UI, handoff note, timeline card (web/desktop)

Wire the §9 frontend onto the preview endpoint + handoff fields:
- Delete the backlog blocking dialog (backlog-agent-hint*) and its modal type;
  the over-eager nag is gone. Backlog awareness is now a passive label.
- RunConfirmModal: single assign + batch assign/status route here. Shows the
  backend predicate's verdict ("将启动 @X" / "将启动 N 个" / parked), an optional
  handoff note (assign only, soft-gated by handoff_supported), and 暂不启动 —
  then applies via update/batch. No frontend guessing.
- create modal: passive CreateRunHint ("将启动 @X" / backlog parked).
- single status change stays a direct apply (unchanged).
- timeline: render type='handoff' as a distinct, non-interactive handoff card.
- i18n run_confirm + handoff_card across en/ja/ko/zh-Hans; drop backlog action
  keys; locale parity green.

Tests: use-issue-actions (assign → run-confirm modal, member → direct),
create-issue + comment-card suites updated/green; views typecheck + lint clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* test(issues): use a valid anchor in the handoff count-exclusion test

CountNewCommentsSince filters id <> @anchor_id; SQL id <> NULL is NULL and
excludes every row, so an empty anchor made the control assertion read 0. The
production caller always passes a real anchor — mirror that with a non-matching
sentinel uuid.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* test(issues): RunConfirmModal apply logic (start/suppress/note-gate/batch)

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* test(core): preview schema malformed/missing/null fallback coverage

Cover IssueTriggerPreviewSchema via parseWithFallback (MUL-3375): well-formed
parse, top-level + item default fills (empty/older backend), and fallback to
{ triggers: [], total_count: 0 } for malformed shapes, a dropped required
issue_id, a wrong-typed total_count, and null/non-object bodies — so the four
entry points degrade to "nothing will start" instead of throwing.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* refactor(issues): remove display-only handoff timeline record (留痕)

The handoff "留痕" timeline record (type='handoff' comment written on run
start) was judged superfluous and dropped per product call. This removes
only the display-only trace; the handoff NOTE injection into the run's
opening prompt + issue_context.md is untouched.

- backend: drop RecordHandoff + its call in dispatchIssueRun
- db: drop the `type <> 'handoff'` exclusion in CountNewCommentsSince and
  migration 123 (comment_type_check reverts to the 4-type set from 001);
  no production data exists for this unreleased feature
- frontend: drop the "handoff" CommentType, HandoffCard, and handoff_card
  i18n (all locales)
- tests: drop handoff_count_test.go and the record-write assertions in
  issue_trigger_preview_test.go (note-injection tests retained)

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(issues): dismissable run-confirm modal + team-handoff copy

Two fixes to the pre-trigger confirm modal (MUL-3375).

1. Dismissable: switch RunConfirmModal from AlertDialog to the standard
   shadcn Dialog so it has the close (X) button + Esc + click-outside.
   Previously the only choices were "start" / "don't start now" with no
   way to abort the action entirely; dismissing now cancels with no write.

2. Copy: rework the action-surface wording away from the backend term
   "run" toward team-handoff voice — 指派 / 开始 / 交接 (run stays only on
   record surfaces). Unifies the note's three names to "交接说明", and
   parallels the rewrite across en/ja/ko.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* chore(agent): bump handoff note min CLI version to 0.3.28

The daemon release that renders handoff notes ships in 0.3.28 (0.3.27
was the prior tag), so move the soft-gate threshold up. Below this the
note is silently dropped and the frontend grays the note box — assignment
is never blocked.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* fix(issues): skip run-confirm when batch-moving issues to backlog

A move into backlog never starts a run (service/issue_trigger.go), so the
pre-trigger confirm modal degenerated to an empty "won't start" box with a
single Apply button — pure friction. Apply directly instead, matching the
single-issue status path. Other target statuses still route through the
modal.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* feat(issues): refine pre-trigger preview hint and copy

- Move the create-issue run hint to a reveal band (grid 0fr→1fr) above the
  property toolbar. It was sharing the footer button row and, lacking a
  width constraint, reflowed the submit buttons whenever it appeared.
  Restyle to a borderless, comment-style avatar+caption that is purely a
  caption (non-interactive avatar).
- Distinguish squad from agent in the pre-trigger copy: a squad's leader
  evaluates and delegates rather than "starting work" itself. Add
  will_start_named_squad / will_start_squad / create_will_start_squad across
  en/zh/ja/ko (reusing the squad_leader_* evaluate→arrange vocabulary) and
  branch run-confirm + the create hint on squad assignees.
- Bold the assignee name in the run-confirm headline via a language-safe
  sentinel split (no per-language prefix/suffix keys).
- Align zh "开始处理" → "开始工作" on the single-assign copy.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* test(issues): stub ActorAvatar in create-issue suite

CreateRunHint now renders an ActorAvatar for agent/squad assignees, which
pulls in getActorInitials/getActorAvatarUrl + the workspace/presence/navigation
hook tree. This form-focused suite only stubbed getActorName, so the
squad-forwarding test crashed with "getActorInitials is not a function". Stub
the avatar inert — its own behavior is covered elsewhere.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

---------

Co-authored-by: Walt <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-06-23 13:17:13 +08:00