mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 09:26:50 +08:00
admin-workspace-create2
91
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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]>
|
||
|
|
3bacc41f84 |
MUL-7573 fix(lark): clear the Typing reaction without trusting in-process state (#8656)
* fix(lark): persist and reconcile typing cleanup across replicas - preserve terminal trigger targets and per-input cleanup ownership - reconcile late Adds and source deletion without delaying replies - recover interrupted ledger indexes and cover lifecycle boundaries Refs: #8655 * fix(lark): restrict typing cleanup to the current application - match both app operator type and the current App ID - preserve foreign-app, human and unknown-owner reactions - cover ownership filtering in fast and durable cleanup paths Refs: #8655 * fix(lark): bound typing recovery and credential retention - recover taskless indicators after lost debounce timers - cap workspace ledger admission and redact terminal credentials - isolate maintenance budgets and test retention and quota boundaries Refs: #8655 * docs(lark): document typing cleanup recovery and rollout limits - explain ownership, retention, quota and observable terminal outcomes - document migration upgrades and safe application rollback Refs: #8655 * fix(deps): require patched Go 1.26.9 and x/net 0.60 - Require the patched Go standard library to clear reachable vulnerabilities. - Upgrade x/net to the matching HTTP/2 security release. - Align local development prerequisites with the minimum Go version. |
||
|
|
da139a7c97 |
fix(wecom): name the two exits that leave a reply unaccounted for (MUL-7318) (#8348)
The WeCom outbound path had two silent exits: a completion whose delivery row named another platform, and one with no delivery row at all. From outside they were one quiet return, so an answer that reached the transcript but never the chat could not be told apart from ordinary traffic on the shared bus. Both now name themselves in the closed outcome set: not_wecom_turn (DEBUG), no_delivery_row (WARN, the one skip worth an alert — in practice a task enqueued before an upgrade), and route_unattributable (DEBUG) for a turn with no route and no batch owner to attribute it to. The metrics Help and logs draw the drop/skip line by obligation rather than by whether a frame was written, and tests pin that wording. The origin gate is one keyed read (GetTaskChannelOrigin) in place of GetAgentTask plus TaskInputIsChannelIngested. It also reports whether the batch owner is known, so an owner-less row is delivered when a route exists and stays quiet when none does — which keeps the auto-retry of a legacy channel turn answering its room. Co-authored-by: J <[email protected]> |
||
|
|
f39debf8d1 |
MUL-7344: skip the issue read and the comment scan when the server already answered them (#8377)
* feat(daemon): read the comment delta with one --since, not a roots scan (MUL-7344) A resumed follow-up turn whose claim carried a server-computed issue-wide comment delta was still told to run the workflow's two-step scan: a `--roots-only --summary` pass to discover which threads moved, then a `--thread ... --since ...` expansion per thread. That rediscovers, in two or more calls, an answer the server already has. `--since <anchor>` without `--thread` IS that answer. ListCommentsSinceForIssue returns every comment on the issue created after the anchor, in every thread and at every nesting level, with no folding, on the same `created_at > since` predicate CountNewCommentsSince counted with. It is a strict superset of what the roots scan could surface: roots-only `last_activity_at` is MAX(created_at) over a thread, so any comment able to move a thread's activity is already in the `--since` result. So BuildNewCommentsHint now hands over exactly one wide read and says it answers step 2's scan — the same rule BuildResumedCommentsHint already applies to a server-computed EMPTY delta. The optional second command stays a THREAD read on `--tail 30` for the reply itself; it is never the wide read, and it never combines `--thread` with `--since`, which drops the thread root. Brief step 2 and the multica-platform skill's bounded-reads rule both gain the matching static sentence, so an agent is not left choosing between the hint and two surfaces that contradict it. Cold start, dropped resume and unknown-delta paths are untouched, byte for byte: only an affirmative server report may stand in for the scan. Co-authored-by: multica-agent <[email protected]> * feat(claim): compare the issue against the last run so a resumed turn can skip the read (MUL-7344) A follow-up turn that resumes its provider session already holds the issue it read minutes ago, and was still told, unconditionally, to run `multica issue get` before doing anything. On an issue that did not move, that read returns bytes the session already has. The claim now answers the question instead. Each claim records the comparable state of the issue it built its payload from — status, assignee, priority, and sha256 of title and description — in a new `agent_task_queue.issue_snapshot` JSONB column, and compares against the snapshot the SAME agent's previous run on the SAME issue recorded. The response carries `issue_state_delta_known`, `issue_changed_fields`, and the current `issue_status` / assignee. Why a per-claim snapshot and not a `content_revision` on `issue`: a revision column has to be bumped by every writer that touches one of those five fields, and the first one missed reports "unchanged" forever with no symptom. A snapshot taken at claim time has exactly one writer. It also rides the anchor the comment delta already uses (agent_id, issue_id, started_at DESC), so both deltas on a claim mean "since your last run" in the same sense. The daemon renders one of three things, gated on a REAL resume: - unchanged → reported as workflow step 1's answer; the read survives only as "if resumed memory is not enough". - changed → the changed field NAMES plus the read, so the agent knows what to look for instead of diffing blind. - otherwise → the pre-existing instruction, byte for byte. `issue_state_delta_known` carries the same contract as `new_comments_delta_known` (MUL-6984), and for the same reason: a cold start, a missing prior snapshot, a read error, a foreign shape version and an old server all produce an empty changed-field list, identical to a genuine "nothing changed". Only the flag separates them, and only the affirmative one may stand in for the read. The compared set is named in full in the prompt rather than summarised as "the issue": labels, parent, due date, stage, project and metadata are NOT compared, and an agent told "unchanged" has to be able to see that. The snapshot write is NOT gated on the task being comment-backed — an assignment run that recorded nothing would leave the next run with no baseline. It shares the receipt's CAS, so a stale handler cannot write over a newer reclaim or after execution starts, and a snapshot that fails to encode is simply not recorded: the next run reports "not compared" and reads the issue. Co-authored-by: multica-agent <[email protected]> * fix(claim): renumber the migration past main and fold in review nits (MUL-7344) CI caught a real collision: main landed 471_comment_deleted_at through 474 while this branch was open, so 471_agent_task_issue_snapshot shared a numeric prefix and TestMigrationNumericPrefixesAreUnique failed. Renumbered to 475 after merging main; no other migration references it. Review nits from the PR review, all non-blocking, all cheap to take now: 1. Brief step 2's closing sentence told the agent that a resumed run's scan surfaces moved threads via `last_activity_at`. That is now true only on the path where the delta is UNKNOWN — when the server hands over a `--since` delta there is no scan to read it from. Rewritten to attach the `last_activity_at` guidance to the scan itself, wherever the scan happens. Done in this PR rather than deferred because this PR already moves brief bytes, so it costs no extra prompt-cache invalidation. 2. The two per-claim anchor reads are now one. GetLastTaskStartedAtForIssueAndAgent and GetLastTaskIssueSnapshotForIssueAndAgent selected the same row with the same WHERE and ORDER, differing only in projection; they are replaced by GetLastRunAnchorForIssueAndAgent returning started_at and issue_snapshot together. A comment-triggered claim is back to ONE anchor read, as before this branch. The saved round trip is the smaller half. The real gain is that "since your last run" is now a single resolved fact on a claim instead of two independently-resolved ones that could disagree. The shared read also shares a failure mode, and both consumers already degrade the same safe way: comment scan required, issue comparison unknown. 3. The hint's `%d new comment(s)` excludes the injected trigger and the agent's own comments; the `--since` read it hands over has no such filter, so the row count is legitimately higher. Narrowing the read to match would mean a second count-shaped query for no gain, so the text now states the mismatch and its direction outright instead of leaving the agent to reconcile two numbers. New test pins the merge: one claim, one prior run, both deltas resolved from that single anchor row. Co-authored-by: multica-agent <[email protected]> * fix(migrations): renumber the issue-snapshot migration to 478 (MUL-7344) main landed 475_issue_status_key_not_reserved through 477 while the previous renumber was in flight, so 475_agent_task_issue_snapshot collided in turn. Merged main again and moved to 478. TestMigrationNumericPrefixesAreUnique passes, and the full ledger applies cleanly from scratch through 478. Co-authored-by: multica-agent <[email protected]> * fix(claim): measure both deltas from the run whose session is resumed (MUL-7344) Review found a real correctness bug, not a performance one: the claim could tell a resumed agent "the issue is unchanged" while the session it handed back predated the change. Both deltas were anchored on the most recently STARTED run for (agent, issue). The session the daemon resumes is chosen by a different rule, and the two are not the same row: - GetLastTaskSession skips poisoned and retired sessions, so a newer run that died on an unprocessable conversation is passed over for an OLDER healthy one. - A manual rerun resumes the exact source task the operator clicked. So: an old session remembers description A, the user edits it to B, a newer run records B and then dies unresumable. The next follow-up resumes the OLD session but compares B against B, reports "unchanged", and the agent keeps working from a context that still says A. PriorSessionResumeUnavailable does not catch this — it flags a missing Codex rollout, which is only one of the ways the resumed session can be older than the newest run. The fix binds both deltas to the run actually being resumed. GetLastTaskSession now returns that row's started_at and issue_snapshot alongside its session, the rerun path takes them from its source task, and the delta computation moves after resume resolution so it can only ever read the adopted row. Where no session is adopted — no prior session, a session on another runtime, a forced-fresh retry, a rerun whose source failure poisoned the conversation — no delta is reported and the daemon performs the reads it always has. That makes the standalone anchor query unnecessary: the anchor is now a projection on a row the claim already fetched, so a claim reads no extra row at all (GetLastRunAnchorForIssueAndAgent is removed). The precondition is now visible in the fixtures: a prior run has to be resumable for a delta to exist, so the comment-delta tests seed a session_id. The regression test covers both paths and was verified to fail against the previous anchor — both sub-cases report an empty changed-field list, which is exactly the wrong "unchanged". Also renumbers the migration 478 → 491; main landed 478_issue_status_category_expand through 490 while the branch was open. Co-authored-by: multica-agent <[email protected]> * fix(claim): a never-started anchor row may date neither delta (MUL-7344) Final review found the last corner of the resumed-run bug. Binding the anchor to the resumed SESSION was necessary but not sufficient: GetLastTaskSession returns that session's latest TERMINAL row, and that row is not always a run that ran. A retry child can inherit the session, be claimed — which writes its snapshot — and then fail before the agent ever starts: runtime offline during prepare, cancelled while dispatched. Its snapshot describes the issue as of ITS claim, while the session's memory stops at the last run that actually executed. If the description was edited in between, the current issue matches that snapshot and the claim reports "unchanged" to an agent whose context predates the edit. The started_at gate now covers both deltas rather than only the comment one. The comment delta was already immune because it read StartedAt directly; the issue delta read only the snapshot, so it had no such guard. Hoisting the check makes the row's requirement explicit — a row that never ran dates nothing — and the inner duplicate is dropped so no reader has to wonder what each guards. Regression test reproduces the exact fixture and was verified to fail without the gate: known=true with an empty changed-field list, i.e. the false "unchanged". Also from review: - Adopt the no-adopted-session table (missing session, session on another runtime, force-fresh retry, poisoned rerun source). Each seeds a usable snapshot AND an unread comment, so a regression has something to report and the assertion cannot pass on an empty fixture. - The resumed-run regression test asserted a comment anchor that never executed: both sub-cases created only the trigger comment, which the count excludes, so NewCommentsSince was empty and the branch was dead. A comment between the two runs now makes it run and pins known/count/since. - Rewrite the migration header, which still described the old "(agent_id, issue_id, started_at DESC)" anchor and an index rationale for a column that is never a lookup key. Co-authored-by: multica-agent <[email protected]> * refactor(claim): compare title, description and status only (MUL-7344) The snapshot compared five fields. The comparison answers exactly one question — must the agent run `issue get` again? — so a field earns a place only if changing it alters what the agent does AND its current value is not already in the per-turn message. Assignee fails the second test: the claim ships the current assignee outright, and "is this mine now" is answered by that value, not by whether it moved. Priority fails the first: it is reachable only by reading the issue, but it does not change what the agent does. Both are dropped. Status stays even though it is also shipped as a current value, so comparing it is strictly redundant for "what is it now". It earns its place as a signal rather than a value: "changed: status" is the clearest evidence that somebody intervened between the runs — a push back from in_review to todo means the delivery was rejected — and it costs nothing to carry. Narrowing the set means a narrower claim, so the prompt now says "compared title, description and status". An issue whose only change is assignee or priority is reported as unchanged, which is correct and now pinned by a test that also asserts the current assignee still ships — without that second assertion the fixture's UPDATE could silently not apply and the test would pass on nothing. Snapshot version goes to 2, because the rule is that the version tracks the compared SET. No deployment has written a v1 row, but the gate is what makes narrowing safe in general: a v1 row read by v2 code is "not compared", which degrades to the unconditional read. Test fixtures now build snapshots at issueSnapshotVersion rather than a literal 1, so a future bump cannot quietly turn them into not-compared tests while they keep passing. The foreign-version case stays explicit at +1. Also renumbers the migration 491 -> 495; main landed 491 through 494 while the branch was open. Fifth renumber. Co-authored-by: multica-agent <[email protected]> * refactor(claim): keep the snapshot at v1 (MUL-7344) The previous commit bumped issueSnapshotVersion to 2 when it narrowed the compared set. That was the version rule applied past its purpose. The rule — "bump when the compared SET changes" — exists to stop a stored snapshot from being compared against a differently-shaped one. It protects DEPLOYED rows. This column is introduced by this same unmerged PR: the migration exists only on this branch, main has neither the column nor the writer, and no environment has ever stored a row with the wider set. There is nothing for the bump to protect, and shipping v2 as the first version the column has ever held would leave a reader hunting for a v1 that never existed. Back to 1. The `v` field and its unknown-version gate stay exactly as they were — those are what make a real narrowing safe later. The comment now says which version this is and why the review-time narrowing did not earn a bump, so the rule still has a stated precedent without a burned number. No behaviour change: the compared set is still title, description and status, and fixtures build at issueSnapshotVersion rather than a literal, so they followed the constant back down. Co-authored-by: multica-agent <[email protected]> * fix(claim): only a completed run may date a delta (MUL-7344) Final review found a window one step later than the never-started case. A snapshot is written at CLAIM time. The daemon then writes started_at (TaskService.StartTask) and only afterwards launches the provider. So a run can carry both a snapshot and a started_at while its prompt never reached the session: the daemon exits during prepare, the runtime drops, orphan recovery marks the row failed. Anchoring there compares the current issue against a snapshot describing an edit that session never saw — reporting "unchanged" across a real change, and dating the comment delta late enough to hide every comment older than that row's start. Reproduced on the real CreateRetryTask -> claim -> StartTask -> RecoverOrphanedTasksForRuntime -> follow-up claim path. Nothing on the row separates "failed after the provider ran" from "failed before it ran". session_id is inherited by CreateRetryTask at insert, so it does not mean the session was fed; started_at precedes the launch, so it does not mean the turn began. The only terminal status that PROVES delivery is 'completed' — a run cannot finish a turn it never started. So failed and cancelled sources now date neither delta. Their SESSION is still resumed exactly as before, and GetLastTaskSession still accepts them on purpose; those runs simply perform the context reads they always did. That is the cheap side of the trade: a follow-up after a failure or a stop is already recovering from something, while the case the optimization exists for — an ordinary follow-up after a healthy run — is untouched. Both conditions now live in newResumedRunAnchor rather than at the use site, so "which run may date a delta" is answered once, and GetLastTaskSession carries the status needed to answer it. Regression test is the reviewer's repro, verified to fail without the rule: known=true with an empty changed list AND a computed zero comment count, i.e. both a false "unchanged" and a false "no new comments". Also: - Renumber the migration 495 -> 499; main landed 495 through 498. Sixth renumber. - The migration's content_revision rationale named the old five-field set while arguing about hypothetical issue-row writers; it now refers to "any compared field", which is what the argument actually depends on. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
361edd90a1 |
MUL-7342: index chat session delete lookups (#8375)
* fix(db): index chat session delete lookups Co-authored-by: multica-agent <[email protected]> * fix(db): address migration review Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
5132ac99eb |
MUL-7235: show cancellation actor in run history (#8249)
* MUL-7235: show cancellation actor in run history
Co-authored-by: multica-agent <[email protected]>
* fix(issues): simplify failed run recovery UI
Co-authored-by: multica-agent <[email protected]>
* Revert "fix(issues): simplify failed run recovery UI"
This reverts commit
|
||
|
|
c784d539b0 |
MUL-7247: bound cancelled chat pointer lookup (#8259)
* perf(db): index cancelled chat session guard Co-authored-by: multica-agent <[email protected]> * test(db): remove planner-coupled migration test Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
028f3b4437 |
feat(issues): show agent execution inline in comment threads (#8131)
* feat(issues): show agent runs inline with comment replies * fix(issues): unify run comment projection and preserve disclosure focus * refactor(issues): replace nested run cards with inline status rows * fix(issues): align thread roots with reply content * style(issues): remove thread toggle background * style(issues): group reaction picker with comment actions * feat(issues): show readable issue references in run summaries * style(issues): move completed run logs into comment headers * style(issues): balance comment block vertical spacing * style(issues): shimmer live agent progress text * style(issues): unify comment action hover feedback * style(issues): reveal comment actions on block hover * fix(issues): move published reply activity into the header * fix(issues): settle comment UI as soon as replies are published * fix(issues): reveal comment log buttons on hover * fix(issues): defer run placement until trigger comments arrive * fix(issues): isolate agent comment queues by thread * fix(issues): display pending runs in execution order * fix(ci): align integration tests with thread-scoped queues * fix(issues): fit run actions to narrow comment columns |
||
|
|
9e2d9b6d1c |
MUL-6661 feat(channels): add /new and /clear conversation controls (#7468)
* feat(channels): add new and clear conversation controls * fix(dingtalk): resolve bot identity before dispatch * feat(chat): expose channel-created chats consistently * refactor(channels): snapshot task delivery routes |
||
|
|
1cc46b2697 |
MUL-6516 fix(channels): enforce durable context boundaries for /new (#7362)
* fix(channels): enforce new context boundaries * fix(channels): address context boundary review findings * fix(channels): enforce causal new context boundaries * fix(channels): scope outbound provenance to target --------- Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
5e2d349ec4 |
MUL-6426: refactor(dingtalk): safely retire group routing and enhance discovery (#7241)
Retire mutable DingTalk group routing: inbound messages always target the installation's configured Agent, and observed groups become read-only inventory in dedicated tables with persisted activity counters. Migrations 382-389 clean stale cross-Agent bindings, create and backfill dingtalk_group_presence and dingtalk_bot_identity, and install a mirror trigger so writes from an older process during a rollback window are not lost. The legacy dingtalk_group_route table and its indexes are retained for expand/contract; a later release drops the trigger and table together. Closes #7239 |
||
|
|
bae3a324fc |
MUL-6504 fix(daemon): stop chat tasks holding the local_directory mutex they never write to (#7378)
* fix(daemon): stop chat tasks holding the local_directory path mutex (MUL-6504) A chat turn bound to an in_place local_directory queued on that path's mutex, so every message to a squad leader sat behind whatever coding task was building in the same repo — measured at 23-67 minutes on a self-hosted install, with cancelling the chat by hand as the only way out (#7344). The carve-out that was supposed to cover this keyed on IsLeaderTask, and its own comment describes a chat turn exactly ("may create child issues or comments, but should not hold the path mutex"). It never fired: the server does not write is_leader_task on the chat-task insert path, so it is false on every chat turn ever dispatched. The reported one-line fix — returning no assignment for chat — would have freed the lock by also moving chat out of the user's directory: that same return value drives the agent's working directory and the GC-meta stamp. A "look at my repo" chat would have stopped seeing the repo. So split the two decisions instead. localDirectoryAssignmentForTask keeps answering where a task runs; the new localDirectoryLockExempt answers whether it queues for it. Chat resolves, validates and binds exactly as before, and skips only the mutex. What the mutex protects is unchanged: two coding runs interleaving edits into one tree. It was never a general write barrier — the user's own editor and any external script write to that tree unsynchronised — so a conversation whose occasional write is the same risk class as Cmd+S does not need a slot in it. An exempt turn is told its directory is shared, via a per-turn block that stays after the cached prompt prefix. Tests: chat skips the mutex while a coding task holds it AND still resolves an assignment; worktree chat keeps its assignment; two issue tasks still serialise; the leader carve-out is unchanged. Co-authored-by: multica-agent <[email protected]> * feat(chat): explain a waiting_local_directory hold in the status pill (MUL-6504) wait_reason was written end-to-end except for the last hop: the daemon stamped the path and holder, the column stored it, the realtime payload carried it, and the UI read only the status — as the type comment said outright. A parked chat showed "Waiting for local directory" with a timer and no way to tell a one-minute wait from a forty-minute one, or whether cancelling was safe. That ambiguity is what pushed the reporter into cancel-by-id and cost them 36 minutes of a worker's progress. Surface it. The daemon now names the directory by its resource label or basename instead of its absolute path: this text reaches every client on the session and lands in screenshots, which is the same contract handler.relativeWorkDir already enforces for the sibling work_dir chip. The absolute path stays in the daemon's logs, where an operator debugging a wedged lock looks for it. wait_reason is written once on the way into the hold and never cleared, so both the server DTO and the client cache gate it on the status still being waiting_local_directory — otherwise a task running fine for ten minutes keeps claiming to be held by another one. Servers predating the field send nothing and render today's bare label. Co-authored-by: multica-agent <[email protected]> * fix(server): strip legacy absolute-path wait reasons before clients render them Surfacing wait_reason created a leak that did not exist while the field was being ignored. Daemons before the display-name change send `local_directory /Users/<name>/repo (held by task abc12345)`, and a new server paired with one of those would now put a home path — and the account name in it — into the StatusPill and any screenshot of it. Sanitize at the write boundary rather than at each reader. It is the only place wait_reason is written, so both the REST payload and the websocket event read through it, and the column itself never stores a path. While here, put wait_reason on the task:waiting_local_directory event. The payload never carried it, so the field the client type has always declared was undefined in practice and the reason only arrived on the follow-up refetch — the pill spent that round-trip saying nothing useful, which is the gap the hint exists to close. Already sanitized, so the socket cannot carry what the REST payload would withhold. Dropping the whole string rather than editing it: an absent reason is a case clients already handle — older servers never sent one — and it renders as the plain waiting label, which is the pre-change behaviour. Also notes the known mixed-script rough edge in the holder clause: it is English inside a localized "Waiting for {reason}" label. Fixing it means structured fields and a client-side join, which is not worth it for one parenthetical. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
b4bfe083be |
MUL-6407: mint UUIDv7 primary keys for append-heavy tables (#7230)
* perf(db): mint UUIDv7 primary keys for the append-heavy tables
Random v4 primary keys scatter every insert across the whole primary-key
B-tree, so each row dirties a different page. On the tables that grow
continuously — the task queue, messages, comments, activity and audit rows —
that is pure write amplification. UUIDv7 puts a big-endian millisecond
timestamp in the leading 48 bits, so consecutive inserts share a page.
The 12 tables converted here are the append-only hot paths:
agent_task_queue, activity_log, inbox_item, comment, issue, chat_session,
chat_message, autopilot_run, webhook_delivery, task_token,
channel_inbound_audit, sys_cron_executions
Ids come from the new pkg/dbid, which returns a pgtype.UUID ready to assign to
a sqlc params struct. Upsert-dominated tables (task_usage, agent_runtime, ...)
are deliberately left alone: v7 only buys locality for rows that are actually
inserted. Lease and claim tokens keep gen_random_uuid() — they must stay
unpredictable and carry no timestamp.
No migration, and no backfill. Every converted INSERT binds its id as
COALESCE(sqlc.narg('id')::uuid, gen_random_uuid()), so the column default stays
reachable: a caller that passes no id inserts exactly as it does today. Tables
will hold a mix of v4 and v7 ids permanently, which the uuid type and every
parser in the repo already accept.
Verification: - pkg/dbid: version/variant, uniqueness, and a non-decreasing timestamp
prefix; a shape test asserting every converted query keeps the COALESCE
fallback; and a source scan asserting every production params literal mints
an id, so a call site added later cannot silently fall back to v4.
- internal/handler: round-trips issue, comment, activity_log, inbox_item,
chat_session, chat_message, agent_task_queue, task_token and
channel_inbound_audit against Postgres and asserts the stored id is v7,
plus one case proving an unset id still inserts (as v4).
- internal/scheduler: covers the one hand-written INSERT, asserting the
execution id is v7 while lease_token stays v4.
Co-authored-by: multica-agent <[email protected]>
* fix(db): address UUIDv7 review feedback
Co-authored-by: multica-agent <[email protected]>
* fix(db): cover Telegram audit UUIDv7
Co-authored-by: multica-agent <[email protected]>
---------
Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
|
||
|
|
7fdc854c26 |
fix(issues): copy durable local path for worktree tasks (MUL-6378) (#7176)
A worktree-mode local_directory task runs in a disposable git worktree that Finalize removes on every exit path, so the task's `work_dir` pointed at a directory that no longer existed. "Copy local workdir path" handed the user a path they could not `cd` into. The daemon now reports a `durable_work_dir` handoff instead of having clients infer one: - `removeLocalWorktreeDir` returns an error and confirms removal with `Lstat`, so a broken symlink cannot be mistaken for an absent directory. Finalize fails when removal cannot be confirmed. - `durable_work_dir` is written only after Finalize succeeds and the worktree is verifiably gone. A preserved worktree, an in-place run, a running task, a squad-leader task, and any older daemon all leave it unset, keeping the real `work_dir` authoritative. - The field travels on the complete, fail, and cancel-ack callbacks and is persisted on the task row (migration 362, additive nullable column). Writes use COALESCE for replay idempotency; the cancel-ack path CASes on `status = 'cancelled'` so a late ack cannot touch a completed or failed row. - The server derives `relative_durable_work_dir` through the same home-stripping helper as `relative_work_dir`; the absolute value is reserved for explicit clipboard actions and is never rendered. - Clients select the durable path only for a terminal task that carries the field. The issue actions menu and the transcript dialog share one resolver, and the label and toast say the task worktree was reclaimed, naming the delivery branch when one exists. The stored path is a point-in-time delivery snapshot: later renames or detachments of the project resource do not rewrite historical tasks. |
||
|
|
76a90f602c |
MUL-6324: Reduce chat resume query load (#7151)
* fix(server): reduce chat resume query load Co-authored-by: multica-agent <[email protected]> * fix(server): address chat resume review Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
6a17c00144 |
MUL-6350: rebuild the plugin system (1/4) — cleanup and foundations (#7144)
* MUL-6350 refactor(plugins): reset the plugin system to Action / Hook / Resource foundations The V1 plugin system shipped 14 tables, an append-only revision log per relationship, a capability-snapshot compiler, a per-task execution manifest pinned by an INSERT trigger on agent_task_queue, release signing, trust tiers, and a bundled catalog — and never left its feature flag. It holds no production data, so this removes it outright instead of migrating it forward. What replaces it, per the design in MUL-6338: - A plugin relates to Multica in exactly three ways. Action (plugin calls Multica), Hook (Multica calls plugin), Resource (a static contribution with no call at all). "Who triggers" and "what capability is called" are orthogonal: a hook is declared once and lists which triggers may invoke it. - We never execute third-party code. A plugin runs only in a sandboxed iframe in the user's browser or on the author's own server. - Distribution is "install by URL, admin reads the scopes". No signature, no trust tier, no publisher verification — the scope list IS the trust decision. This change is the foundation, with no user-visible surface yet: - Decouples remote MCP first. RemoteMCPConnection/RemoteMCPTool/DigestBytes move into pkg/remotemcp, where they belonged; the protocol layer and daemon broker survive intact and dormant, ready to be rewired for the agent trigger. - Drops all 14 plugin_* tables, their triggers, the manifest-composition function, and agent_task_queue.plugin_execution_manifest_id. Adds three: plugin_installation (one row per workspace+plugin, holding the manifest snapshot the admin consented to), plugin_storage, plugin_secret. - Defines the manifest schema in full, once — surfaces, hooks with their triggers and events, resources, and a closed scope list. Parsing stays strict (DisallowUnknownFields, no trailing JSON). Contribution kinds this build does not ship yet fail installation loudly rather than installing half-working. - Two-step install: preview parses and returns the scopes without writing, then install requires consent matching the manifest exactly. Manifest fetches reuse the remote MCP SSRF guard; MULTICA_PLUGIN_DIR adds a local source for self-hosting and plugin authors. - Storage quotas are enforced on write and never by eviction. Secrets live in a separate encrypted table with no read path that returns a value. Co-authored-by: multica-agent <[email protected]> * MUL-6350 fix(migrate): register the plugin index builds for invalid-index cleanup Every migration that builds an index CONCURRENTLY must be registered in concurrentIndexCleanups, or an interrupted build leaves a permanently INVALID index that the `IF NOT EXISTS` retry records as success. TestEveryConcurrentUp\ BuildHasCleanup caught all three new plugin index migrations. Co-authored-by: multica-agent <[email protected]> * MUL-6350 fix(plugins): address review — byte quotas, version bound, secret pruning, script entry From the review on #7144, plus one contract change the surface design forced. - Storage quotas were accounted in characters and enforced in bytes. The usage query and the size_bytes it reports now use octet_length, and so do the plugin_storage column checks. A UTF-8 value could otherwise spend up to a quarter of the budget it appeared to. - manifest version had no length bound while plugin_installation.version caps at 64, so a legal-but-long semver failed at INSERT as an opaque install error instead of a parse error naming the field. - Upgrading pruned config values the new manifest dropped but left the matching plugin_secret rows. Ciphertext nothing can reach is exactly the residue pruneConfig exists to prevent; upgrade now prunes both in one transaction. - SetConfig wrote secrets and config across two tables with no transaction, so a failed config write could leave a saved form half-applied. - A duplicate first install mapped a unique-index violation to 502; it is 409. - net: is now an exact host match. It was a suffix match in the hook transport check while the same scope becomes an exact-host CSP connect-src in the surface runtime — one scope string cannot mean two things across the two places the consent screen claims to describe. - surfaces[].entry must be a .js/.mjs script, not an HTML document. The host generates the surface's document so it can attach the CSP derived from net:; a plugin-authored document would carry its own server's policy and net: would be a claim rather than a control. Tests: the three storage limits get a canonical unit matrix plus DB-backed coverage of the byte counting and the exclude-the-candidate-key replacement semantics; upgrade-time secret pruning is pinned end to end (verified failing without the fix); and the admin-only routes now have a real middleware-level 403 check, which every direct-handler test bypassed. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Lambda <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
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]> |
||
|
|
8060aa7277 |
MUL-5947: feat(dingtalk): route groups to different agents (#6660)
Route each DingTalk group discovered by one Stream robot to its own agent, with a revision-fenced cutover so a reassigned group never inherits the previous agent's session, transcript, or pending outbound card. Co-authored-by: mxbao063-bao <[email protected]> |
||
|
|
d467cc9069 |
MUL-6075: complete Plugin V1 execution chain (#6853)
* feat(server): add Plugin V1 lifecycle foundation Co-authored-by: multica-agent <[email protected]> * feat(server): complete Plugin V1 execution chain Co-authored-by: multica-agent <[email protected]> * feat(web): add temporary plugin settings demo Co-authored-by: multica-agent <[email protected]> * refactor(plugin): keep foundation free of demo content Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Lambda <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
c4f0d8cc8c |
MUL-5999: make workspace teardown index-driven, bounded and fenced (#6754)
* perf(workspace): make teardown task discovery index-driven
Workspace teardown found a workspace's tasks with a three-way
`OR agent_id IN (…) OR issue_id IN (…) OR runtime_id IN (…)`, three times
per delete, and swept task_token with the same shape. PostgreSQL costs
that as a Seq Scan on agent_task_queue — the largest table in the
database (5.08M rows / 8.3 GB in production) — so deleting even a tiny
workspace paid for the whole table, inside the transaction that holds the
workspace row lock and global advisory lock 4246.
Resolve the task set one owner key at a time instead, in the handler, and
reuse that snapshot for every task-keyed step. Measured on a 1.2M-row
synthetic copy of the production shape (937 agents / 109 issues / 12
runtimes): the OR form and a 937-element `= ANY(array)` form both Seq
Scan; single-key equality is a Bitmap Index Scan. A UNION of parent-key
joins is worse than the OR (three hash joins, each seq scanning the
table), which is why that earlier suggestion was dropped.
All three ownership paths are preserved: nothing enforces that a task's
agent, runtime and issue share a workspace, so dropping a path would turn
a performance fix into a tenant-cleanup bug.
task_token needs no loop. Its workspace_id has been NOT NULL since
migration 108, so the workspace key alone covers this workspace's rows,
and the two extra OR arms only ever matched tokens owned by another
workspace whose task or agent lives here — both NOT NULL FKs with
ON DELETE CASCADE, so the later task/agent deletes remove exactly those.
Two indexes make the new shapes index-eligible:
- 273 agent_task_queue(runtime_id): every existing runtime_id index is
partial, so all-status consumers had none. This also covers the FK's
own ON DELETE CASCADE probe, which runs per deleted agent_runtime row
on the ordinary delete path.
- 274 task_token(workspace_id): the column has never been indexed.
Measured block usage for the same workspace, before → after:
agent_task_queue discovery 25,665 → 14,485 blocks; task_token sweep
4,086 → 3 blocks. The point is not the ratio at this size but that the
cost now scales with the workspace being deleted instead of with the
global table.
Refs MUL-5999
Co-authored-by: multica-agent <[email protected]>
* fix(workspace): fence, bound and un-cascade the teardown task sweep
Addresses all four blocking findings from the MUL-5999 review.
1. Concurrency (correctness / tenant isolation). The task set is no longer
a lock-free snapshot reused across steps. Teardown now locks every row
through which a task can belong to the workspace — its agents, issues
and runtimes — FOR UPDATE, and holds them for the rest of the
transaction. Inserting an agent_task_queue row takes FOR KEY SHARE on
each referenced owner, which conflicts with FOR UPDATE, so a concurrent
enqueue (or a reassignment onto one of these runtimes) can no longer
slip a task in behind the sweep. Locking the tasks alone could not do
that: a row that does not exist yet cannot be locked. This is the same
technique the handler already uses on the workspace row to keep
CreateChatSession out of the delete window.
The per-batch page query also takes FOR UPDATE, so under READ COMMITTED
PostgreSQL re-checks ownership after each lock: a task a concurrent
commit already moved elsewhere is skipped rather than deleted on a
stale claim, and a task we locked cannot move before we delete it.
Deadlock (40P01) joins lock_timeout (55P03) as a retryable 503, because
the owner locks can be taken in the opposite order by a concurrent task
update.
2. Cleanup ownership (no cascade reliance). task_token keeps all three
explicit paths, split into separate index-driven statements instead of
one OR: workspace_id in DeleteWorkspaceLeafData, task_id in
DeleteTaskBatch, agent_id in DeleteTaskTokensByAgent. Nothing in the
teardown graph now depends on ON DELETE CASCADE to finish the job.
3. Bounded memory. The whole-workspace task array is gone. The sweep pages
through one owner key at a time with LIMIT 1000 and deletes exactly the
page it read, so process memory, the array parameter and each DELETE's
row count are bounded by the batch size rather than by a workspace's
lifetime task count. The task-keyed arms moved out of the leaf-data
statement into DeleteTaskBatch, which runs per page, and the separate
whole-workspace task delete is gone.
4. Migration retryability. The concurrent-index cleanup hooks are now
derived from a version -> index-name map that covers 257, 261 and
273-277, so an interrupted CREATE INDEX CONCURRENTLY cannot be mistaken
for success on retry.
Three more indexes, each an unindexed FK referencing column that the
per-batch statements need and that the FK's own ON DELETE action already
scanned per row on ordinary deletes: task_token(agent_id),
chat_draft_restore(task_id), autopilot_run(task_id).
Plans re-verified on a 1.2M-row synthetic copy of the prod shape with the
child tables populated: page query is a Bitmap Index Scan under LockRows;
task_usage, chat_draft_restore, task_token and autopilot_run arms are all
index scans.
Tests: the ownership-path test now asserts the sweep is self-sufficient
inside the transaction, before any owner row is deleted, so a cascade
cannot stand in for teardown. New tests cover the enqueue / reassignment
fence (blocked writers, unrelated writer unaffected), multi-page sweeps
and the page bound, plus a migration-retry repair test and a check that
every cleanup hook names the index its migration builds.
Refs MUL-5999
Co-authored-by: multica-agent <[email protected]>
* fix(workspace): keyset-page the teardown sweep and fail closed on leaks
Addresses the second review round on MUL-5999.
1. Bounded scan, not just bounded result. The page query was
`owner = $1 ORDER BY id LIMIT n` against indexes that cannot produce id
order, so the executor had to read the owner's whole task set before
applying LIMIT — quadratic in pages for a busy owner, which is the case
this issue is about. Measured on a 550k-row probe: the old shape spent
11,630 buffers per page (planner chose a full pkey index scan; on other
statistics it chose an explicit Sort), the keyset shape spends ~2,000
and a later page costs the same as the first.
Pages are now `owner = $1 AND id > $cursor ORDER BY id LIMIT n` against
composite indexes: 273 becomes (runtime_id, id), 278 adds (agent_id, id),
279 adds (issue_id, id) and 280 drops the single-column issue_id index it
supersedes so the hot table does not carry both.
2. Owner enumeration is bounded too. The three LockWorkspaceTaskOwner*
statements no longer return ids at all — they are a set-based write fence
and now cost the API process nothing. Owners are walked separately in
keyset pages of 500 (281-283 add the (workspace_id, id) indexes those
pages need), so a workspace with a huge agent or issue set never has its
whole owner list held in memory.
The keyset column is id rather than a natural key on purpose: a cursor
column must be immutable, and renaming an owner mid-teardown would
otherwise let it sort behind the cursor and escape the sweep.
3. The fence is no longer the only guarantee. Blocking a concurrent enqueue
still relies on an INSERT taking FOR KEY SHARE on the owner rows it
references, which is a property of the legacy FKs rather than a protocol
every enqueue path opts into. Two things now backstop it:
- every owner is re-swept until a pass finds nothing, and teardown fails
closed (rolling the whole transaction back) if tasks keep arriving,
instead of committing a partially cleaned tenant;
- a schema-contract test asserts the three owner FKs still exist, so a
migration that removes them turns the missing fence into a red build
rather than a silent hole.
An explicit deleting-state protocol shared with all seven enqueue paths
remains the right long-term fix and is out of scope here.
End-to-end teardown, real handler, identical synthetic data, main vs this
branch (1.2M-row agent_task_queue, 200k task_token, 300k task_usage):
small workspace, 5k tasks: 29,247 ms -> 1,013 ms (4.38M -> 0.27M blocks)
busy workspace, 60k tasks: 243,863 ms -> 1,724 ms (5.76M -> 2.23M blocks)
Refs MUL-5999
Co-authored-by: multica-agent <[email protected]>
* fix(workspace): fence task writes in-transaction, independent of the FKs
Addresses the third review round on MUL-5999.
1. The re-scan did not close the window, and the reviewer is right that it
cannot: a pass that comes back empty returns success, and a task
inserted after that final scan but before COMMIT is not caught by any
number of passes. The only thing that closed that window was the
FOR KEY SHARE an INSERT takes on the agent / issue / runtime rows it
references — a side effect of foreign keys that workspace_delete.sql
itself documents as an expand-phase net a later contract removes.
Migration 284 replaces it with an explicit fence: a BEFORE INSERT OR
UPDATE OF (agent_id, issue_id, runtime_id) trigger on agent_task_queue
that takes FOR KEY SHARE on the workspace row behind every owner the row
references, and raises if one is gone. That conflicts with the FOR UPDATE
LockWorkspaceForDelete already holds for the whole teardown transaction,
so a task write against a workspace being torn down blocks until teardown
finishes and then either proceeds (rolled back) or fails closed
(committed).
A trigger rather than a per-call-site check because the fence has to be
in the same transaction as the write: there are seven INSERTs and several
ownership-changing UPDATEs across agent.sql, autopilot.sql, chat.sql and
runtime.sql, several of them single autocommit statements, so a separate
"take the fence" statement from the caller would be its own transaction
and protect nothing. In-transaction by construction, covers every current
path, and cannot be forgotten by a path added later.
TestTaskWriteFence_BlocksOnWorkspaceLockAlone isolates the fence from the
FKs: the holder locks ONLY the workspace row — no agent, issue or runtime
row — so no FK on agent_task_queue has anything to block on, since none of
them references workspace. Inserts through all three ownership paths and a
reassignment all block; an unrelated workspace's insert and a status-only
update still pass. Dropping the trigger fails that test.
Cost, measured with 5,000 bare inserts: 147 ms without the fence,
238 ms with it — about 18 us per inserted row for up to three indexed
lookups and a shared row lock. It does not fire on the hot claim /
dispatch / complete path, which updates status and timestamps rather than
ownership.
The verification passes stay as a self-check, no longer described as the
concurrency guarantee.
2. Migration 280 is withdrawn. Its down path rebuilt the superseded index
with CREATE INDEX CONCURRENTLY, and the runner only runs INVALID-index
cleanup hooks in the up direction, so an interrupted rollback would leave
an invalid index that a later `down` skips — and rolling back 279 too
would then leave no usable issue_id index at all. The redundant
single-column index stays for now; dropping it needs its own migration
with a direction-aware cleanup. This also settles the stale comment in
task_usage.sql, which still names the old index and remains correct.
3. Keyset cursors no longer start from the all-zero uuid, which is itself a
valid uuid and would have made a row with that id invisible forever.
Every walk now has an explicit first-page query with no cursor predicate.
Refs MUL-5999
Co-authored-by: multica-agent <[email protected]>
* fix(workspace): move the task-write fence into the write statements
Addresses the fourth review round on MUL-5999.
1. The trigger is gone. Migration 284 now defines
lock_task_owner_workspaces(agent_id, issue_id, runtime_id), a predicate
that every statement writing agent_task_queue ownership calls in its own
WHERE clause:
INSERT INTO agent_task_queue (...)
SELECT ... WHERE lock_task_owner_workspaces($1, $3, $2)
Nine statements now carry it: the seven task INSERTs (agent.sql x5,
autopilot.sql, chat.sql), LinkTaskToIssue, and ReassignTasksToRuntime —
the last through a MATERIALIZED CTE so a bulk UPDATE evaluates the
VOLATILE predicate once instead of per row. Clearing runtime_id is
exempt: it cannot move a task into another workspace.
Calling it from the writing statement keeps the lock in the writer's own
transaction, which a separate caller-issued statement could not do for
the autocommit enqueue paths. TestTaskOwnershipWritesCallTheFence is the
trade for dropping the trigger: it scans the query files and fails the
build if a statement writes ownership without the fence.
2. The fence no longer defers to the foreign keys. The trigger version
counted only owner references that still resolved, so a deleted owner
made itself invisible: with all three gone it returned early, and with
one of three gone the count still matched. The predicate now compares
resolved owners against the number of owner arguments supplied and
returns false on any mismatch, so a stale write is rejected on its own
terms. TestLockTaskOwnerWorkspaces_RejectsUnresolvableOwner covers each
shape, including the one-of-three case.
3. Deterministic lock order. The workspace rows are locked with
ORDER BY w.id, so mirrored cross-workspace writes cannot take the same
pair in opposite orders. Honest note on the test: 25 mirrored concurrent
pairs never deadlock either way, because the current plan happens to
return the rows in a stable order — the ORDER BY is what makes that a
guarantee rather than an accident of the plan, and the test is a
regression test for the symptom.
4. The fail-closed test now tests what its name says. The previous one only
inserted a neighbour-owned task and could not have failed. The
replacement runs the actual race: a writer calls the real
CreateAgentTask, blocks on the workspace lock, teardown deletes the
workspace and commits, and the writer must come back with pgx.ErrNoRows
— the fence's own verdict. An FK doing the work instead surfaces as
SQLSTATE 23503, which the assertion rejects explicitly.
Cost: 5,000 predicate calls in 53.7 ms, about 11 us per fenced write, for
up to three indexed lookups and a shared row lock. Status, dispatch and
completion updates do not call it.
Refs MUL-5999
Co-authored-by: multica-agent <[email protected]>
* fix(runtime): stop the legacy merge when the task fence refuses
Addresses the P1 from the fifth review round on MUL-5999.
ReassignTasksToRuntime returned only a row count, so a fenced refusal was
indistinguishable from "the old runtime had no tasks". mergeLegacyRuntimes
read both as success and went on to DeleteAgentRuntime(old), where
agent_task_queue's ON DELETE CASCADE silently took the history the
reassignment exists to preserve — and once that FK goes away, the same path
would instead leave tasks pointing at a deleted runtime. Either way the
merge was leaning on the cascade, which is what this PR set out to stop.
The query now returns the fence verdict alongside the count:
SELECT (SELECT ok FROM fence) AS fence_ok,
(SELECT count(*) FROM reassigned) AS reassigned_tasks
and the merge is a single helper, mergeLegacyRuntime, that aborts with
errRuntimeMergeFenced before touching anything else when fence_ok is false.
Tasks now move FIRST, because that is the step carrying the fence: nothing
else in the merge — agent reassignment, legacy daemon_id bookkeeping, the
old runtime delete — runs unless the fence agreed. The next daemon
registration re-runs the merge, by which time the workspace is either gone
or its teardown rolled back.
TestMergeLegacyRuntime_KeepsOldRuntimeWhenFenceRefuses covers it: with a
vanished merge target the old runtime and its task both survive and the task
stays on the old runtime, while a merge onto a live runtime still completes.
Removing the abort fails that test — and fails it with SQLSTATE 23503 from
agent_runtime_id_fkey, which is precisely the "an FK is doing the work"
shape the review objected to.
Also replaced the fixed 300 ms sleep in the queued-writer race with a poll
on pg_stat_activity for a backend actually parked on a lock, so the test
cannot pass without the race being set up (it now fails on timeout instead).
Refs MUL-5999
Co-authored-by: multica-agent <[email protected]>
* fix(runtime): make the legacy runtime merge a single transaction
Addresses the P1 from the sixth review round on MUL-5999.
The merge ran four autocommit statements, so the fence's workspace lock
ended with the statement that took it. Two ways that left a tenant
inconsistent while daemon registration still reported success:
* a transient failure on the agent reassignment committed the task move
and nothing else, leaving tasks on the new runtime and their agents on
the old one until some later registration happened to retry;
* in the gap after the task commit, a workspace teardown could lock and
delete the target runtime — and its UnbindTasksFromRuntime step clears
runtime_id on exactly the tasks that had just moved. The next merge
looks tasks up by the OLD runtime id, so that history was detached for
good.
mergeLegacyRuntime now opens a transaction and runs the whole merge on one
qtx: fenced task reassignment, agent reassignment, the legacy daemon_id
audit write, and the old-runtime delete, then commits. The fence stays the
first statement, so the workspace lock it takes is held until COMMIT — a
teardown cannot start deleting the target mid-merge — and the lock order
against teardown is unchanged. Any failure returns an error and rolls the
whole thing back.
The audit write is no longer best-effort: inside a transaction a failed
statement poisons the rest, so it either lands with the merge or the merge
is abandoned.
TestMergeLegacyRuntime_RollsBackWhenAStepFails injects the failure in the
database rather than through a code seam — a trigger makes the agent move
raise — and asserts the task, the agent, both runtime rows and the audit
column are all exactly as they were, then that the same merge completes
once the injection is removed. Reverting to autocommit fails it with
"task moved to <new runtime>; the task reassignment was not rolled back".
Also took the non-blocking suggestion: the queued-writer race now has the
writer report its own pg_backend_pid() over a channel and waits for that
specific backend to park on a lock, instead of matching statement text
against every backend on a shared test database.
Refs MUL-5999
Co-authored-by: multica-agent <[email protected]>
* fix(runtime): fence late writes against the runtime a merge is collapsing
Addresses the P1 from the seventh review round on MUL-5999, which the
reviewer reproduced rather than inferred.
The fence locked the workspace row and nothing else. A task write takes
FOR KEY SHARE there, and so did the legacy runtime merge — and two KEY SHARE
locks do not conflict. So an enqueue against the OLD runtime went straight
through after the merge had scanned its tasks, and DeleteAgentRuntime then
removed that brand-new task through agent_task_queue's ON DELETE CASCADE.
Making the merge one transaction widened the window rather than creating it:
the agent's new runtime_id is invisible until COMMIT, so concurrent writers
keep resolving the old runtime for the whole merge.
Both sides of the contract now lock the owner rows too, in one order:
workspace rows (by id), then agent, issue, runtime.
* lock_task_owner_rows (migration 284, renamed from
lock_task_owner_workspaces) takes FOR KEY SHARE on each owner row after
the workspace rows, and re-checks that they still exist. That is what
conflicts with a delete.
* The merge takes FOR KEY SHARE on the workspace and then FOR UPDATE on
both runtime rows in id order, before the task scan, and holds them to
COMMIT. FOR UPDATE is the level that conflicts with a writer's KEY
SHARE; both runtimes in one ordered statement so two merges running in
opposite directions cannot take the pair in opposite orders. Locking
fewer rows than asked for means one disappeared, and the merge is
abandoned.
A late writer therefore either commits before the merge takes the lock — in
which case the scan that follows sees its task and moves it — or waits, and
then finds the runtime gone and writes no row.
TestRuntimeMerge_LateWriteToOldRuntimeIsFencedNotCascaded drives the merge
statement by statement so it can stop where the window was (after the agent
update, before the old runtime is deleted), enqueues against the old runtime
through the generated query, and requires that the writer is parked on a
lock, that it comes back as pgx.ErrNoRows, and that the merge's own history
ends up on the target. It explicitly rejects both 23503 and a row that
lands and then vanishes.
Both halves are load-bearing: dropping the runtime lock from the fence, or
the FOR UPDATE from the merge, fails that test — the latter with "the late
write was rejected by a foreign key (23503), not by the fence".
Cost: 5,000 predicate calls in 74.4 ms, ~15 us per fenced write (was ~11 us
with the workspace lock alone).
Refs MUL-5999
Co-authored-by: multica-agent <[email protected]>
* docs(workspace): correct the fence comment and drop a trailing newline
Two review nits, no behaviour change.
lockWorkspaceTaskOwners still described migration 284 as a BEFORE trigger on
agent_task_queue. It became a called predicate, lock_task_owner_rows, in
|
||
|
|
0c69f1f95f |
fix(daemon): recover in-turn when a resumed session cannot resolve provider auth (#6798)
A resumed Hermes session whose persisted provider identity can no longer resolve its credentials fails with the SDK's "Could not resolve authentication method" message. The server-side resume guards already blacklist that session, so the NEXT turn starts fresh and succeeds — which is exactly why Chat alternated success / failure instead of staying broken (GH #6777). Curing the failing turn needs the daemon's in-turn fresh-session retry, which never fired: nothing identified this error as fatal-to-resume. Add it as a third form of positive evidence in shouldRetryWithFreshSession, alongside ResumeRejected and UnresumableHistory. Deciding in the shared gate rather than in each ACP adapter is what makes it correct and narrow: - priorSessionID != "" already proves the run was a resume, which is the whole distinction between "the session's provider copy is broken" (curable) and "the credentials are wrong" (not curable). An adapter cannot tell those apart from the error text alone, which is why Result.ResumeRejected still documents auth errors as out of scope. - The failure surfaces at session/resume, session/set_model (MUL-5029 provider re-routing, the likelier path on a resumed session) and session/prompt; only two carry any resume-failure signal today. The final error text carries the phrase on all three. - tools == 0 is untouched, so a turn that already acted is never replayed. The phrase now has a single home in taskfailure.AuthMethodUnresolved, shared with ResumeUnsafeFailure. The SQL guards stay as-is: they are the only protection for rows written by a daemon too old to have this retry. Co-authored-by: Eve <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
2a19f9ba2c |
MUL-5873 fix(channels): improve /new and /issue feedback (#6578)
A bare /new now returns an explicit confirmation and persists the fresh-start intent on channel_chat_session_binding.pending_fresh until the next chat task is successfully queued. A bare /issue returns usage guidance and skips issue creation, agent execution, and media resolution instead of inferring a title from chat history. Closes #6577 |
||
|
|
cdf93e4914 |
MUL-5892 fix(chat): cancel queued tasks when a session is archived, and stop telling non-Slack sessions they have no channel (#6595)
* fix(chat): cancel queued tasks when a session is archived, and stop telling non-Slack sessions they have no channel Two channel-agnostic fixes found while auditing WeCom; both help Lark and DingTalk equally. **Archiving left queued tasks claimable.** SetChatSessionArchived deletes the channel binding but never cancelled the session's in-flight tasks, and ClaimAgentTask does not read chat_session.status. So the daemon still ran the turn — and with the binding row already gone, the runtime brief described a private web chat while the channel adapter still held the chat id and posted the answer into the group. The person who archived the conversation never saw it happen. The cancel now runs first, in the same transaction. DeleteChatSession has always done this, for the reason its own query comment gives — "so the daemon doesn't keep running work whose result has nowhere to land" — and archiving means the same thing. **The empty-history note lied to every non-Slack session.** The reader is Slack-only, so WeCom, Lark and DingTalk all land on "this conversation is not connected to a chat channel", which is false. The reader is an agent deciding who can see its answer: told it is in a web-only conversation it reasons differently than told it is in a group whose backlog it cannot read. The note now names the platform, and the response carries channel_type. Written channel-agnostically — a per-platform lookup here would go blind the next time a channel is added, which is how this came to be wrong. Two tests against a real database. Both drive the real paths, not the helpers: task status after archive = "queued", want cancelled — the daemon will still run this turn, and with the channel binding already gone its answer goes to the room while the brief says private a WeCom-bound session was told it is not on a channel: "This conversation is not connected to a chat channel, so there is no channel history to read." * fix(chat): broadcast the archive cancellation instead of only writing it Review follow-up on the archive-cancel path. SetChatSessionArchived dropped the rows CancelAgentTasksByChatSession returns, so the cancellation reached the database and stopped there. None of the post-commit lifecycle ran: no captureTaskCancelled (the tasks' 24h mat_ tokens stayed live), no ReconcileAgentStatus (an agent whose only task was cancelled kept showing 'working'), no task:cancelled (other clients kept the turn on screen until something else forced a refresh), and no notifyTasksFinished (the runtime waited for the daemon's next poll before claiming the next task). The query is not limited to never-started work — it also matches dispatched, running, waiting_local_directory and deferred — so this is most visible when a running turn is cancelled. Keep the rows and hand them to TaskService.BroadcastCancelledTasks after the commit, which is what DeleteChatSession has always done twenty lines below. The empty-history response also read the binding twice, once for channel_type and once inside noHistoryNote. An archive landing between the two reads could answer channel_type "wecom" next to a note saying the conversation is not connected to a chat channel. Resolve the channel type once and derive both from it; noHistoryNote becomes a pure function of that value, so the two cannot disagree. TestArchivingAChatSessionCancelsItsQueuedTasks now queues a running turn alongside the queued one, marks the agent 'working', mints a task token, and asserts the post-commit side as well as the status column: agent back to 'idle', task:cancelled for both rows, token revoked. Without the broadcast it fails on all three. The history test drives the real response path on both legs and pins channel_type against the note. * fix(chat): scope the archive cancel to sessions that were on a channel Review follow-up. SetChatSessionArchived cancelled on every archive, with no check for whether the session had ever been bound to a channel. That is wider than the bug: the failure this fixes is a channel-bound session answering into a group room after its binding was dropped, and a plain web chat has no room, no adapter and no late answer. Cancelling there was a new product behaviour riding along with the fix, and it contradicts what the UI says archive is. A session row offers Stop or Archive, never both (chat-thread-list.tsx:364-377). Stop is `danger` behind a confirmation row; Archive fires immediately with none. Archive is the reversible step: unarchive exists, and hard delete is offered only once a chat is archived (chat-session-header.tsx:169-186). The two also cost the user different things. Stop goes through CancelTaskByUser, which hands the typed prompt back as cancelled_chat_message.restore_to_input or a durable chat_draft_restore row; CancelAgentTasksByChatSession is a bare UPDATE with neither, and BroadcastCancelledTasks does capture, reconciliation, the event and the runtime wakeup but no draft restore. So an unconditional cancel let one unconfirmed click destroy a prompt that unarchiving cannot bring back. The cancel now runs only when GetChannelChatSessionBindingBySessionAny finds a binding, read before DeleteChannelChatSessionBindingBySession erases it. A read error that is not ErrNoRows fails the archive rather than silently skipping the cancel — "not bound" and "cannot tell" must not take the same branch. This also settles the header's Archive item having no isRunning gate: archiving a running web chat from the dropdown now cancels nothing, so it no longer bypasses the confirmation the Stop button requires. Gating that item is a frontend question and stays out of this PR. The tests are one fixture archived twice. The bound leg is the previous test with the binding it was always describing — its failure message named an answer reaching the room, but it never created a room. It keeps every post-commit assertion (queued + running cancelled, agent back to idle, task:cancelled for both, token revoked) and adds that the binding is still deleted. The web-only leg asserts the opposite: both turns still in flight, agent still working, no task:cancelled, token still live, session still archived. Dropping the cancel fails the first; making it unconditional again fails the second; reading the binding after the delete fails the first. * fix(chat): refuse a debounced chat enqueue onto an archived session Review follow-up. The archive cancel only reaches tasks that already exist, and the channel run trigger is debounced by DefaultChatRunBatchWindow: the message row is persisted on arrival, the task row is created a window later when flushChatRun reloads the session and calls EnqueueChatTask. Archive inside that window and the cancel finds nothing, drops the binding and commits — then the flush enqueues onto a conversation the user closed. ClaimAgentTask does not read chat_session.status either, so the daemon runs it: runtime and quota spent on a closed conversation, assistant messages written into an archived chat, the agent flipped back to 'working', and with the binding already deleted the brief describes the run as a private web chat. EnqueueChatTask was the one enqueue path without the guard the direct-send path and OpenMikaOnboardingChat already take: lock the chat_session, re-read it, refuse a status that is not 'active' with ErrChatSessionArchived. It now takes it as its first statement, so the chat_session -> agent_task_queue order is unchanged. The lock is LockChatSessionForEnqueue (FOR NO KEY UPDATE), not the FOR UPDATE the send, delete and draft paths take, and the difference is deliberate. Those paths want to block INSERTs that reference the row: FOR UPDATE conflicts with the FOR KEY SHARE an FK insert takes on its parent, which is how LockChatSessionForDelete keeps a send from slipping a task in between its cancel and its delete. Here that conflict is the cost, not the point — every inbound channel message is an INSERT into chat_message FK'd to this row, so FOR UPDATE would make a room's next message wait for the previous message's enqueue. Measured on pg17 while the row is held: an append blocks under FOR UPDATE and does not under FOR NO KEY UPDATE, while SetChatSessionArchived's UPDATE (status is not a key column) blocks against FOR NO KEY UPDATE in both directions. Serialisation against the archive is kept, ingestion is not serialised behind it. flushChatRun needs no change: it clears the typing indicator before the error switch, and this error is not one of the two the switch names, so it falls to the default log branch and posts nothing into a room the archive just unbound. Three tests. The handler one is the case the existing archive tests cannot reach — message persisted, debounce not yet flushed, archive, then flush — asserting no task row and no task:queued; removing the status check fails it naming the run. The service one holds the lock mode from inside the enqueue transaction: a concurrent archive must block, a concurrent append must not. The router one pins that an archived-session flush clears typing and says nothing. Also carried over from review: sessionChannelType returned "" for every binding-lookup failure, so a transient database error answered 200 with the "not connected to a chat channel" note this PR removes — telling a WeCom or Lark session it is web-only because a query failed. It returns (string, error) now, with only pgx.ErrNoRows mapped to empty, and the caller reports the rest. And the bug's own description was wider than the bug: the outbound senders resolve their destination through the binding row the archive deletes, so a late answer has no route left and does not reach the room. The handler comment, the test file header and the failing assertion now say what actually happens — a wasted, mis-briefed run against a closed conversation. |
||
|
|
f3a7fce8d8 |
fix(chat): a predecessor's reply must not seal the next message out of its own batch (#6611)
On a self-hosted tenant a user sent a WeCom message at 18:31:17.7 while the
previous agent turn was finishing. The engine enqueued a task for it at
18:31:20.7 and cancelled the task 27ms later:
ERR chat claim: task-owned direct task has no user input; cancelling
task_id=bdc0947e chat_session_id=2fccd3c9 chat_input_task_id=bdc0947e
The database afterwards:
chat_message "挺好的 正常了" (18:31:17.726997) -> task_id = NULL
every other user message in that session -> task_id set
select count(*) from chat_message
where task_id='bdc0947e' and role='user' -> 0
The user's message was accepted, a task appeared and instantly vanished, and
no answer ever came. On WeCom the typing bubble also spun forever, because the
indicator only unsubscribed on task:failed, not task:cancelled.
Mechanism
Channel messages (Slack / Lark / WeCom) are written durably and unowned on the
ACK path; the 3s silence debouncer later fires one flush per session, and
EnqueueChatTask creates the task, stamps chat_input_task_id = id, and seals the
waiting messages into it with LinkUnownedChannelChatMessagesToTask. That seal
skipped any unowned user row that had a non-user row after it. The rule was
carried over from the pre-ownership trailing-message selector, and it does not
survive the debounce window: a predecessor completing inside that window writes
its reply AFTER the waiting message, so the reply to an already-sealed batch
became the boundary for a message it never saw.
18:31:04.6 user "测试下" unowned
18:31:07.6 task 6a4ae7f8 created, seals it
18:31:17.7 user "挺好的 正常了" unowned, 3s window arms
18:31:20.2 6a4ae7f8 completes, writes its reply <- newer than the message
18:31:20.7 window expires, task bdc0947e created, seal matches ZERO rows
18:31:20.7 claim finds no input, cancels bdc0947e
Every timestamp above is from the tenant's own tables. The claim guard at
internal/handler/daemon.go:2347 is right to fail closed on an empty input
batch; the batch should never have been empty. Its comment blamed a send path
that does not commit message and task together — SendDirectChatMessage does,
and the channel path does too. Neither was the problem. The seal predicate was.
Fix
The boundary is "already answered", not "something newer exists". A reply can
only be a reply TO a message if the turn that wrote it started AFTER the
message arrived. A turn already running when the message landed sealed its own
input before the message existed, so its reply — however late it lands —
answers an earlier batch and must not move the boundary. The seal now qualifies
each candidate boundary row by its turn's creation time, resolved through
COALESCE(chat_input_task_id, id) so an auto-retry clone is judged by the root
batch it inherited rather than by its own fresh id.
The check is kept, not dropped, and deliberately stays conservative: a non-user
row whose task is NULL or gone still counts as a boundary, so pre-ownership
transcripts are never swept into a new batch, and a message already stranded by
this bug is not resurrected into a turn minutes later — a following turn that
ran past it holds the boundary.
Tests
internal/handler/chat_channel_debounce_boundary_test.go drives the whole
failure: message in, claim, run, second message while it runs, predecessor
completes, window expires, claim. Before the fix:
--- FAIL: TestChannelChat_ReplyLandingInsideDebounceWindowStillAnswersTheNextMessage
chat_channel_debounce_boundary_test.go:134: claim = 500 {"error":"chat task has no user input"}
(task <nil>); task 2a67492b-cd61-45e6-afd6-156079971152 owns 0 user messages and is now "cancelled". The user's message was accepted and never answered.
internal/service/task_channel_seal_race_test.go pins the same boundary at the
seal, twice — the sequential ordering the tenant hit, and the tighter window
where the reply commits on another connection after the enqueue transaction
has begun (same raceInjectTxStarter the media-deferral race test uses), so
the seal statement's READ COMMITTED snapshot is the first to see it. Before
the fix:
--- FAIL: TestEnqueueChatTaskSealsMessageStrandedByPredecessorReply
task_channel_seal_race_test.go:78: waiting user message task_id = "", want the new turn "34b62bad-955c-46f0-99e9-2194999d2c10" — a predecessor reply that landed after the message must not seal it out
--- FAIL: TestEnqueueChatTaskSealsMessageWhenReplyCommitsDuringEnqueue
task_channel_seal_race_test.go:142: waiting user message task_id = "", want the new turn "989a83e4-e945-4b36-a5b7-69720449e08c"
The third, TestEnqueueChatTaskLeavesAlreadyPassedMessagesUnowned, passes both
before and after by design: it is the guard against over-correcting, and only
a fix that swept too much could break it.
The two transactions cannot interleave more finely than this: CompleteTask
takes chat_session FOR UPDATE and the task INSERT takes the FK's FOR KEY SHARE
on the same row. "Predecessor commits, then the flush runs" is the reachable
interleaving, and it is the one the tenant hit.
Claude-Session: https://claude.ai/code/session_01MKFf7wYM9Q2iaPtoFhG8MV
Co-authored-by: Claude Opus 5 <[email protected]>
|
||
|
|
47f6e970f6 |
feat(onboarding): write Mika's opening on the server instead of running an agent (MUL-5827) (#6520)
* refactor(onboarding): drop the cross-workspace returning-member branch (MUL-5827) Mika's onboarding kickoff carried a `returning` flag, set by the frontend whenever the member was creating an additional workspace. It told the model "this member has onboarded elsewhere; keep the introduction to one line". Every workspace onboards from scratch. A member's second workspace may carry entirely different work, different collaborators, and a different reason for existing — compressing its opening on the strength of an unrelated workspace only makes this one worse. The flag was also the single place where one workspace's state reached into another's first conversation. Removed end to end: the request field, the prompt note, the API client type, the bootstrap input, and the `returning: isNewWorkspace` call site. Older desktop builds that still send `returning` are unaffected — encoding/json ignores the unknown field, which is exactly the new behavior. The built-in skill never branched on this, so SKILL.md needs no change. Co-authored-by: multica-agent <[email protected]> * feat(onboarding): write Mika's opening on the server instead of running an agent (MUL-5827) The first message a new member ever read cost a runtime cold start plus two model round trips: the opening was produced by a full chat task, so they watched a spinner for tens of seconds, and a run that failed introduced Mika as a red error bubble. Nothing in that reply needed an agent. The multica-onboarding skill already pinned it to four beats under a length budget, it reads no workspace state and calls no tool, and every input it personalizes on — language, workspace name, agent display name — is already in the request. It also never generated quick actions (starter cards replace them), so the run bought nothing. The endpoint now writes two rows in one transaction and enqueues nothing: - the opening the member reads, final on arrival, kind onboarding_opening so the starter cards still render under it; - the hidden kickoff, deliberately WITHOUT a task. The member's first real message adopts that kickoff into its input batch. That is what carries the onboarding skill instruction and the profile block into the run that does the first real work, and — because the kickoff quotes the opening verbatim — what stops Mika introducing herself a second time. She has no memory of an opening the server wrote. The cold start does not disappear; it moves to the member's first real request, where they are waiting on something they asked for with content and three one-click starter cards already in front of them. Three shared invariants that a two-row input batch newly exposes: - Both rows are written in one transaction, so DEFAULT now() gives them an identical timestamp. The session-list LATERAL picks the last message with no tiebreaker and ids are random UUIDs, so a tie could select the kickoff — whose kind makes buildChatLastMessage return nil, reporting no last message and bringing back the "Start with Mika" card after a perfect onboarding. The opening is written one microsecond after the kickoff. - DeleteUserChatMessageByTask deleted every user row of a cancelled turn, so cancelling the first message would destroy the only copy of the onboarding context and could hand the member the product's internal prompt as their restored draft. It now excludes the kickoff, and callers release it back to unowned so the next send re-adopts it. - Both reanchor queries would move the kickoff to dispatch time, putting the product's context AFTER the member's message inside one batch. Both now exclude it. The regression test fails without that guard. Also drops the completion-path rule that stamped a kickoff-input turn's reply as the opening: with the kickoff riding into the first real turn, it would render the starter cards a second time under a reply that is not an opening. Co-authored-by: multica-agent <[email protected]> * fix(onboarding): keep stamping the opening for a pre-deploy kickoff turn (MUL-5827) The server writes the opening now, so no new task produces one — but a kickoff task enqueued by the previous server can still be claimed by this one during a rolling deploy, and its reply IS that member's opening. Dropping the stamp outright left it a plain 'message', and message_kind is persisted with nothing to recompute it: that member's onboarding session would render without the starter cards forever. Gated on "the input batch is a kickoff and nothing else", which is exactly the old shape. The new kickoff always rides in alongside a real member message, so the member's first answer still cannot be mistaken for an opening. Deletable once no pre-deploy kickoff task can still be in flight. Co-authored-by: multica-agent <[email protected]> * fix(onboarding): release the kickoff when the first turn fails terminally (MUL-5827) Caught in a live environment, not by a test. The member's first turn died in preparation, the adopted kickoff stayed bound to that dead task, and their next message reached Mika with no onboarding context and no record that she had already greeted them — so she introduced herself a second time, in a conversation she had already opened. That is the exact failure this design exists to prevent. The cancel path already released the kickoff. The failure path did not, and a first-turn failure is entirely ordinary: runtime offline, agent CLI missing, timeout, prepare error. Released in FailTask under the existing retried == nil branch, which is the terminal-failure condition. Deliberately not on the retry path: a retry child inherits the root's chat_input_task_id and the kickoff stays bound to that root, so the retry still reads it — releasing there would strip the context off a turn that is about to run. Co-authored-by: multica-agent <[email protected]> * fix(onboarding): hand the kickoff to a queued successor, not to nobody (MUL-5827) Review catch. Adoption happens inside a send's transaction, so a message the member queues WHILE the kickoff's turn is still running finds nothing to adopt and never gets another chance. Releasing the kickoff to NULL when that turn died therefore left the already-sealed successor to execute with no onboarding skill, no profile block, and no record that Mika had already greeted them — the double introduction this design exists to prevent. Worse, a message sent later could adopt the orphan instead, delivering the context to a turn that had nothing to do with the opening. ReleaseOnboardingKickoffFromTask now re-targets the session's next un-started turn, falling back to NULL only when there is none. Three restrictions on the target, each load-bearing: - status = 'queued' only. A dispatched/running successor has already built its prompt from its input batch, so joining it now would consume the kickoff without ever delivering it; it waits unowned for the next send. - chat_input_task_id = id selects roots that own their input batch. A retry child names its root, and the kickoff is already reachable through that root, so retries must not be re-targeted. - regenerate_quick_actions_for IS NULL skips background suggestion passes, which carry no user input. Ordering matches the shared visible-head selector, so the kickoff lands on the turn the member will actually see run next. Covers both directions: A-owns/B-queued/A-fails hands off in the right order, and an already-dispatched successor is skipped. The first test fails against the previous NULL-only release. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Lambda <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
318d51377f |
MUL-5870: fix(chat): hide channel issue commands from Chat
Hide shared channel /issue commands from public Chat projections while preserving issue creation, ordinary conversations, and cleanup paths.\n\nCloses #6571. |
||
|
|
7a809ddf80 |
feat(channel): materialize inline media references (MUL-5835) (#6529)
Replace adapter-generated inline media placeholders in channel message bodies with authenticated attachment references, preserving original image positions. Closes #6528 |
||
|
|
3c9b861de3 |
MUL-5765 feat(chat): starter cards under Mika's onboarding opening (#6505)
* MUL-5765 feat(chat): starter cards under Mika's onboarding opening Replace the LLM quick-action chips on Mika's onboarding opening with three product-fixed starter cards (board / delegate / digest), so a new member never starts from a blank canvas. Clicking a card sends its fixed prompt as a visible member message through the existing quick-action path; cards follow the chips' disabled rule while a task runs and stay clickable in history. Older clients keep the LLM chips untouched. The multica-onboarding skill gains matching starter plays (one-question budget each) and a digest-only exception to the no-autopilot rule. Co-authored-by: multica-agent <[email protected]> (cherry picked from commit |
||
|
|
5a618e0925 |
fix(agents): stop resuming a session that can't resolve its provider auth (MUL-5803) (#6482)
* fix(agents): stop resuming a session that can't resolve its provider auth A Hermes agent on a self-hosted install can get permanently stuck on a single issue while every other issue on the same agent keeps working. The task terminates with: hermes provider error: "Could not resolve authentication method. Expected either api_key or auth_token to be set. Or for one of the X-Api-Key or Authorization headers to be explicitly omitted" and no amount of retry / Rerun recovers it. Root cause (resume-pointer poisoning): every daemon version classifies this text as agent_error.unknown, which is resume-safe. So GetLastTaskSession / GetLastChatTaskSession keep returning the same failed session to every retry and Rerun on that (issue, agent) pair, deterministically reproducing the auth error forever. Other issues use fresh sessions, so they're unaffected — the codebase's repeated "(agent, issue) permanently stuck" pattern (GH #6066 / #5760 / #6360, MUL-5722), with this error falling through every prior defense. The fix rests entirely on text guards; the classifier is deliberately left untouched. Reclassifying this under missing_config would flip freshSessionMayHelp to false and silently disable the in-turn fresh-session retry on the five ResumeRejectionUndetectable backends — contradicting the (correct) diagnosis that a fresh session cures it: - service/task.go ResumeUnsafeFailure: text guard so manual Rerun and the fallback claim path start fresh rather than replaying the dead session. - GetLastTaskSession / GetLastChatTaskSession SQL: ILIKE exclusion so already-wedged issues recover on their next trigger without a daemon upgrade. Tests pin both halves: a freshSessionMayHelp regression (must stay true for this error), the Go ResumeUnsafeFailure cases, and SQL exclusion + narrowness regressions for both query families. Co-Authored-By: Claude Fable 5 <[email protected]> * chore: address PR review nits Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: qiushihao279-cloud <[email protected]> Co-authored-by: Claude Fable 5 <[email protected]> Co-authored-by: Eve <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
b99b04bb86 |
feat(onboarding): Mika issue-first onboarding (#6378)
* feat(onboarding): Mika issue-first onboarding Replaces the starter-agent welcome with one conversation: onboarding creates Mika, the workspace's built-in Chief of Staff, and opens a real chat whose first turn is a product-authored kickoff hidden from the transcript. Every workspace — first and subsequent — is created through this flow. Mika is a system agent, not an agent-template instance. Her product prompt is //go:embed-ed and composed at claim time, so a release updates it without touching any workspace's row; the row holds only the workspace's own notes. Creation is server-owned and idempotent under a per-workspace advisory lock, and archiving a system agent is rejected. This is the pre-merge half of the branch, squashed while rebasing onto main. Replaying its fifteen commits individually meant re-deriving each one against a main they were never written for; the net change reconciles against today's main in four files, so it is reconciled once, here. Three of those four are main moving under the branch: MUL-5573 took quick-actions generation server-side and dropped QuickActionsDisabled / RegenerateQuickActionsFor from the task payload and the SendDirectChatMessage signature, so this takes main's shape and keeps only the onboarding entry point. The fourth keeps main's OnboardingLogoutButton wrapper around the flow's new mode/onCancel props. Co-authored-by: multica-agent <[email protected]> * feat(onboarding): let quick-action chips carry the opening's examples Chat now renders agent-suggested follow-up actions as buttons under a reply (MUL-5149), and the onboarding kickoff qualifies for that suggestion pass with no extra wiring — it is a direct chat turn on a web session with a non-empty reply. That made the opening's fourth beat redundant: Mika wrote a three-to-five line menu of example tasks, then three chips appeared underneath offering the same thing. The prose menu is the worse half — a member has to retype a line they read, but can send a button — so the beat is gone and the opening budget drops with it. Measured on a real local run: the first reply went from 307 to 208 characters, and the chips still arrived 21s after the kickoff. The questionnaire profile stays in the kickoff. The suggestion pass resumes the same provider session, so the profile now steers the chips as well as the reply; only the sentence naming its purpose changes. Co-authored-by: multica-agent <[email protected]> * refactor(onboarding): cut the step rails down to what the screen can't already show The right rail carried more than twice the words of the column members were actually working in — 42 vs 36 on the workspace step, 91 vs 34 on the runtime step — and it got heavier the further in the flow went, which is backwards. Step 3's rail was the clearest case. Its "Good to know" section promised the runtime was swappable and that more could be added later; the step's own lede already said both in nine words rather than forty-two, on the same screen. Its 60-word definition of "agent runtime" sat beside a list of named, online runtimes under a headline reading "This computer is connected" — by then it answers a question the member has stopped asking. What survives is the one thing the screen does not show: what that background process is. Step 2's rail keeps the workspace preview card, which does show something not otherwise visible, and drops the bullet lists — promises the product is about to keep on its own. The freed words did not move to the rail; one moved into the main column. On the create path "Mika" was never introduced before Step 3 used the name twice, once on the primary button, so the lede now names the role in an appositive right above that button. Mika stays ungendered, as everywhere else in the product. Net across the three regions: 167 words to 89. Co-authored-by: multica-agent <[email protected]> * refactor(onboarding): drop the right rail from the workspace and runtime steps Every remaining rail item was either something the screen already showed or something the product was about to do anyway, so the column was costing a member's attention without answering a question they had. Removing it leaves each step a single full-width column — the shape the questionnaire step has always had, and the only step nobody has complained reads as sparse. Gone with it: RuntimeAsidePanel, the workspace preview card and its entity rows, and 27 copy keys per locale. The two runtime paths (desktop runtime-connect, web platform-fork) shared that panel, so both lose it in one move and stay identical. The welcome step keeps its column — it holds an illustration, not prose. Co-authored-by: multica-agent <[email protected]> * fix(onboarding): put every step's header and content on one measured axis Removing the right rail left the four steps geometrically inconsistent in four ways, all of which the member sees as things moving while they advance. The header carried the horizontal padding itself, so it ran flush to the window edge while the content column stayed centred. A 480px rail had been absorbing that difference; without it the two were 267px apart on a 1283px window, and since StepHeader is justify-between the step indicator floated off at the far right, ~270px from anything it labelled. The rest compounded it: the measure changed between steps (920px on the questionnaire, 620px after), the header bar and content block used different vertical padding per step, and padding living inside a max-w box made the reading width jump from 508px to 620px at the lg breakpoint. All four now come from step-shell.tsx. Padding belongs to the gutter, never to a measured box, so the reading width is constant from ~700px up. The header measures on STEP_FRAME on every step, so the one element that survives each transition never moves; content picks STEP_FRAME or STEP_COLUMN by what it holds, and both centre, so a step that needs the width still sits on the header's centreline. Vertical rhythm is one value. The header was near-identical in four files, which is how it drifted in the first place, so it is now one component. Its test pins the invariant that broke — padding out of the measured box, header measured on the frame — and fails if either is put back. Co-authored-by: multica-agent <[email protected]> * fix(onboarding): give the runtime step the frame measure and cut its copy Nine runtimes in a 620px column meant truncated names, five rows, and scrolling to reach the last four, with ~330px of dead space on each side. This is the case step-shell's wider measure exists for, so the step takes STEP_FRAME — which also puts it on exactly the header's measure — and the card grid gains a third column at lg. Nine runtimes now land in three rows. Copy went with it. The headline was two sentences over two lines; the first one, "This computer is connected", is already said louder by the "9 agent runtimes · all online" row directly beneath it, so only the instruction remains. The lede was 44 words and five lines — I had grown it myself adding Mika's introduction — and is 20 now, still naming the role. The remote-computer note drops from 25 words to 15. Prose stays capped at 620px inside the wider frame; a 920px measure is for the card grid, not for reading. The found-phase test keyed on the headline copy, so a copy edit read as a behaviour regression. It now asserts the runtime count row, which is the signal the test is actually about. Co-authored-by: multica-agent <[email protected]> * refactor(onboarding): put the workspace step on the shared frame It was the only step still measuring at STEP_COLUMN, so its eyebrow, headline and footer CTA started and ended ~150px inside where every other step's did — the page margins visibly moved when you advanced from step 1 to step 2. The step now sits on STEP_FRAME like the other two, with a new STEP_MEASURE capping the prose and the form inside it. Matching the frame is not the same as widening the field: a workspace name does not want an 800px input, so the form keeps its reading measure and left-aligns to the frame instead. The footer row spans the frame, which is what puts the CTA in the same place on all three screens. STEP_MEASURE deliberately does not centre — centring would pull the content off the frame's left edge, undoing the alignment. Its test pins that. Co-authored-by: multica-agent <[email protected]> * fix(onboarding): close the gap above the step CTA and put Log out back on one row Two defects that read as one thing on screen: a large empty block sitting directly above the primary button. The card list was capped at STEP_MEASURE along with the form. That cap exists so a workspace name does not get an 800px input — a good reason for a text field and a bad one for selection cards, which are a list like the runtime grid. Capped, they stopped 299px short of the CTA, leaving a void above the button. The cards take the frame now; only the form keeps the reading measure. Log out came in from main as `fixed right-8 top-8`, pinned to the window corner. Its own comment says the fixed position exists to survive the flow's full-bleed layouts — which is what the measured frame replaced, so it landed outside the measure and above Back / Step N of N as a second header row. It now rides the header row on the frame. StepShellHeader takes it as a `trailing` slot rather than rendering it: calling useLogout inside the shared header forced a QueryClient into five step test files just to render a header bar. The flow injects it, matching how runtimeInstructions is already threaded, and the header stays presentational. Co-authored-by: multica-agent <[email protected]> * feat(onboarding): make step 3 about Mika, with the runtime as a sub-decision The step was titled after its dependency — "Pick an agent runtime" — while the thing actually being created was named only in the grey lede. A member reached a button reading "Start with Mika" without having been told who that is, and said so: being confused there is not a failure to read carefully, it is the page putting the lead role in a footnote. So the subject changes rather than the step count. The headline names the outcome, a card carries the introduction with a mark on it, and the runtime list drops to a labelled sub-section under "Where should Mika run?". Reading order becomes: who you are getting, she needs a machine, pick one, start. MikaIntro sits above the phase switch so the subject holds still while the runtime block below cycles through scanning / found / empty, and each phase's own heading drops from h1 to h2 now that the page has a real h1. Mika does not exist yet at this point — she is created on commit — so the card cannot render her stored avatar. It reuses the mark the Runtimes page already uses for "Start with Mika", so the two entry points read as the same thing. No fourth screen: the introduction and the only decision on this screen are one beat, and splitting them would add a step between finishing setup and the payoff. The defect was never a missing screen, it was an invisible introduction. Co-authored-by: multica-agent <[email protected]> * feat(onboarding): pick the runtime and the model from dropdowns Nine runtimes as cards took three rows and left nowhere to put a second decision. Two dropdowns fit both on one screen, and the model is a real choice the card grid had no room for. Both controls already existed on the agents surface — RuntimePicker and ModelDropdown, the same pair create-agent-dialog uses — so this reuses them rather than growing a parallel picker. ModelDropdown owns its own discovery, grouping and unsupported-runtime states, and selecting a different runtime clears the model because models are per-runtime. The model now reaches the agent: POST /api/agents/mika takes an optional model, CreateSystemUserAgent writes it to the column the agent table already had, and empty still means "whatever the runtime defaults to" — which is what every deployment without per-agent model support gets anyway. Also drops the "No local runtime, or prefer a remote computer?" note. It said in twenty-five words what the Skip button next to it says by existing, and it appeared on all three phases. currentUserId comes in as a prop rather than from the auth store: reading the store inside the step broke six tests that render it without one, the same coupling the header slot avoided. Co-authored-by: multica-agent <[email protected]> * fix(onboarding): stop gendering Mika in the intro card Mika is ungendered everywhere else in the product — the agent instructions, the onboarding skill and every other locale string avoid a pronoun. The intro card I added last round reintroduced one in two languages ("she turns it into an issue" / "她会把它变成一个 issue"), which the Chinese screenshot made obvious. All four locales now read around it. Co-authored-by: multica-agent <[email protected]> * feat(ui): port the ReUI stepper primitive Groundwork for rebuilding onboarding on the @reui/onboarding-3 interaction model: a persistent vertical stepper with named steps and click-to-return navigation, which our horizontal "Step N of 3" dots cannot express. Routed to components/ui rather than the vendor's components/reui namespace — it is our code now — and rewritten to the role-named type scale (text-xs -> text-caption, text-sm -> text-label / text-caption, dropping the leading-none the token already supplies). No other convention fixes were needed: shadcn had already rewritten the imports, "use client" survived because main's components.json now sets rsc: true, and it pulls no npm dependency we do not already have (@base-ui/react is declared). The block's other twelve registry dependencies are primitives we already own, so only this one was installed. Co-authored-by: multica-agent <[email protected]> * feat(onboarding): rebuild the step shell as a named progress rail Onboarding's chrome was a row of dots and a "Step 2 of 3" counter. It told a member how much was left but never what was coming, so every step arrived unannounced -- the same reason the runtime step read as a surprise even after it was retitled "Meet Mika". Naming all three steps up front makes onboarding legible as a whole before the first field is filled in. StepShellHeader becomes StepShell, and it owns the window rather than a strip at the top: rail on the left, the step's own content scrolling on the right. That lets the four steps drop an identical wrapper/DragStrip/header/<main> preamble, including the scroll-fade wiring that was duplicated verbatim in all four and is now set up once. The rail is built on the ported ReUI stepper, but deliberately not as a tablist: that component defaults to role=tablist with each trigger owning aria-controls on a panel id, which is right for a stepper that renders its own panels and wrong here, where the panel is a routed step and those ids would dangle. It uses the presentational slots and marks position with aria-current instead. Only completed steps are clickable -- moving forward has to run the current step's validation and submit, so the rail would skip it. New-workspace mode gets no rail navigation at all: it enters at the workspace step and, once that workspace exists, every step behind it is gone. Same invariant runtimeStepBack already enforced for the Back button. step_header.step_of is replaced by step_nav across all four locales; Mika stays ungendered in each. Co-authored-by: multica-agent <[email protected]> * feat(onboarding): standardise the steps on the shared UI primitives Follow-up to the rail, fixing what the rail exposed. The workspace form was three hand-rolled `flex flex-col gap-1.5` stacks with their own label sizing and a bare <p> for the slug error -- exactly what Field/FieldLabel/FieldError/FieldDescription standardise. The manual version had already drifted: its labels were caption-sized and muted while every other form in the product labels at body weight. The platform fork wrapped on STEP_COLUMN while the steps before it wrapped on STEP_FRAME. Both measures centre, so 620px and 920px put their left edges ~150px apart and the headline visibly jumped right on arrival. It now sits on the frame and caps its own content, which is the pattern step-shell already documents. The About you eyebrow read "About you" -- now the rail's label for that very step, so the page said its own name twice, once in grey caps and once in the headline under it. Dropped, along with the locale key. The other steps keep theirs because they say something the rail doesn't ("Connect a computer", "Workspace creation is disabled"). Log out is `inline` on every step, and inline now means "on the rail", which is an inverted surface -- the muted/destructive pair it used unqualified is mixed from the light palette, so it was rendering as near-invisible grey on black. e2e: the smoke spec asserted "Step 1 of 3", text the rail replaced. It now asserts the rail's named steps and which one is aria-current, and carries on into the runtime step so all three get captured. Two pre-existing bugs in that spec surfaced while fixing it: the zh-Hans case pinned its locale cookie to a hardcoded port, and it advanced with getByRole("button").first(), which is the pinned Log out button -- so that case had been signing the user out and asserting against a login redirect. Co-authored-by: multica-agent <[email protected]> * feat(onboarding): rebuild the rail as the ReUI inset panel The first pass kept the block's idea -- a named vertical rail -- and dropped most of its structure. Side by side with onboarding-3 the gap was obvious: no brand lockup, no inset panel, no texture, steps jammed against the top edge, numbered chips instead of the ring/check/dot progression, stub separators instead of one continuous track, and a bare Log out where the block has a footer row. This ports the structure properly: - Inset panel -- the aside carries the padding and the panel is rounded with a hairline ring, instead of a dark rectangle bleeding to the window edge. - Brand lockup top-left (the existing MulticaIcon plus a wordmark), Back demoted to an icon button top-right, which is where the block puts it. - Step list centred in the remaining height rather than stacked under Back. - Indicators follow the block: filled + check when done, ring + dot when current, faint ring when upcoming. Numbers move to sr-only text, since the ring already encodes position and the digit was redundant next to a label. - One continuous hairline behind each row instead of a stub between rows. - DotSphere ported to packages/ui as the panel's texture. It is decorative, reads no product state, and already honours prefers-reduced-motion. Two fixes fell out of doing it properly: `.dark` is a plain class selector in our token sheet, so scoping it to the panel redefines the custom properties for that subtree and `bg-background` / `text-muted-foreground` mean the right thing inside it. That replaces the hand-mixed `text-background/60` shades of the first pass -- which is what had made Log out near-invisible -- and it is why the button is back on ordinary tokens here. StepperSeparator hardcodes a 3rem height for vertical navs, so an absolutely positioned track overshot its row and drew the line straight through the next indicator. Overridden with the same variant rather than !important, so tailwind-merge drops theirs. DotSphere cycles three constant arrays by `index % length`. Under noUncheckedIndexedAccess that is `T | undefined`, so they are typed as non-empty tuples and index 0 is the fallback -- no non-null assertions. Co-authored-by: multica-agent <[email protected]> * feat(onboarding): put the content pane on the block's type and layout The rail matched onboarding-3 and the pane it sat next to did not, so the two halves read as different products: serif display headlines and a grey uppercase eyebrow on the right, the block's sans hierarchy on the left. Typography now maps onto the block exactly, and it lands on our scale without rounding -- ReUI's `text-xl/7` heading is our `text-title-lg` (20/28) and its `text-sm/5` supporting line is our `text-body` (14/20). StepHeading owns both, so no step hand-rolls a headline again. The eyebrows are gone. The block has no such slot, and with the rail naming every step, a grey uppercase label above a headline saying the same thing was the third name for one screen. The workspace step's disabled-state wording was the only eyebrow carrying information the headline lacked, and that variant already exists as its own headline copy. Geometry collapses from three competing measures -- a 920px frame, a 620px column, an in-frame cap -- to one 28rem column. Three measures is what let the platform fork sit ~150px right of every other step. STEP_MEASURE stays for capping a single control inside the column. Actions move into StepFooter: full-width, stacked, pinned to the bottom of the column. In a 28rem column the old right-aligned inline bar left the primary action floating mid-screen instead of where the eye finishes the form. The column is `min-h-full` rather than centred by the pane, because `items-center` on a scroll container clips the top of anything taller than the viewport and these steps do overflow on short windows. Questionnaire options become the block's wrapping chips. They were full-width cards in a 4-column grid; inside a 28rem column that grid had nowhere to go, and stacking all 18 as rows turned one screen into a long scroll. Chips are the block's own answer for a many-option question. This also reaches the workspace source-backfill prompt, which shares the component -- intentionally, it is the same question in the same style. MikaIntro moves onto StepHeading + Item for the same reason. Co-authored-by: multica-agent <[email protected]> * fix(onboarding): drop the rail clear of the macOS traffic lights The rail is a dark inset panel and it started at the window's top-left corner, which is exactly where macOS draws the traffic lights -- so the close/minimise/zoom buttons sat on top of the dark surface. The underlying mistake was structural. The desktop shell rule asks for a single DragStrip as the first flex child of a full-window view; this had two hand-rolled strips instead, one inside each pane, and neither was first. The sidebar's was 28px of internal padding trying to duck under the traffic lights from inside a panel that had already begun above them, which cannot work -- the panel's own background was the thing being overlapped. One DragStrip now spans the window above both panes. The panel starts below it (48px strip + the aside's inset, measured at 64px on a wide window against traffic lights that end around 32px), the whole band stays draggable, and the two ad-hoc strips are gone. Co-authored-by: multica-agent <[email protected]> * fix(onboarding): make every block in the column share one width Reported on the workspace step: the name field ended short of the description above it. Measured rather than eyeballed -- the heading, description and footer all render at 448px, the form at 384px, because the FieldGroup still carried STEP_MEASURE. STEP_MEASURE made sense against the old 920px frame, where a full-width input would have been absurd. Against a 28rem column it does nothing but misalign, so both remaining uses are gone -- the workspace form and the runtime picker -- and the constant with them. A single measure that no step can locally narrow is the whole point of the column; leaving the knob exported invites the same drift back. Two stale measures went with it. The runtime phase views still capped their ledes at max-w-[620px], inert inside a 448px column, and sized them text-body-lg against StepHeading's text-body; their h2 was text-title-lg, the same size as the h1 above it. Both now match the shared scale. Guarded in e2e rather than a unit test. The shell test renders a stub child, so asserting "no narrow cap inside the column" there would pass whatever the real steps do. The new spec walks all three steps and compares rendered geometry -- it fails on the reported bug and passes after the fix, which is what makes it worth keeping. Co-authored-by: multica-agent <[email protected]> * fix(onboarding): stop the whole window re-fading on every step change Every step rendered its own <StepShell>. Because each step is a different component type, React tore the shell down and built a new one on each transition: the "persistent" rail remounted, its canvas restarted, and the shell replayed `animate-onboarding-enter` -- a 0.4s fade from opacity 0 across the entire window. That full-window re-fade is the flash. Measured before changing anything: tagging the live <aside> and switching steps showed the attribute gone, and the shell root still reporting animation-name `onboarding-enter` after the switch. The upstream block has no step-change motion at all -- no animate-*, no transitions, no framer-motion, no AnimatePresence. Its sidebar and section live in one component and only the step body swaps. This does the same: the flow owns a single StepShell and the steps render content. The shell's entrance fade now runs once, on entering onboarding, which is what it was for. `backDisabled` was the one thing blocking the hoist -- the shell needs it but only the workspace step knows the create request is in flight. It reports upward through `onBusyChange`, with an unmount cleanup: a successful create advances immediately, so without clearing the flag the next step would open with Back and the rail dead. Guarded in e2e by tagging the rail and content nodes and asserting they survive a step change, plus a single DotSphere canvas rather than one per visited step. Co-authored-by: multica-agent <[email protected]> * chore: drop a stray commit-message file Co-authored-by: multica-agent <[email protected]> * i18n(zh): retranslate onboarding and fix cross-file inconsistencies Reported as "生硬" on the onboarding Chinese. Reviewed all 217 onboarding strings against the Chinese voice guide in conventions.mdx, then swept the other 24 locale files for the same classes of problem. Three were mistranslations, not stiffness. The worst: an agent runtime was described as the AI coding tool "我们接管的" — take over — where the English says "we connect to". Also a "推荐" that exists in no source string, and "12 calls" (user interviews) rendered as "12 通电话". Punctuation, decided from the repo's own zh docs rather than guessed: 「」 is forbidden by the guide and appeared 16 times; 破折号 spacing was split 26 spaced vs the rest unspaced, and the docs run 152 unspaced to 35, so unspaced wins. Four strings had a stray space inside Chinese text ("正在跳转到 工作区"). 67 English strings had two or more Chinese translations. Most are legitimate — weekday pickers use single characters where labels spell them out, 飞书/Lark are deliberate regional variants, and "Name" is 姓名 for a person and 名称 for an object. 42 were arbitrary and are now unified. ja/ko got the same consistency pass on the clear-cut cases only, kept parallel with the zh choices. Their punctuation was deliberately left alone: 「」 is standard Japanese quoting, so the zh rule does not transfer. Edits are applied to the raw file text rather than through a JSON round-trip, which reformatted compact one-line objects and turned ~20 real edits into a 97-line diff. Co-authored-by: multica-agent <[email protected]> * chore: drop a stray commit-message file Co-authored-by: multica-agent <[email protected]> * feat(mika): use the unicorn emoji as Mika's placeholder avatar Mika shipped with a hand-rolled data-URI SVG — a sparkle glyph on a dark rounded square — which only that one constant knew how to produce. Agents already have an emoji avatar convention (`emoji:` marker in avatar_url, owned by agentEmojiAvatarPrefix on the server and parseAvatarEmoji on the client), so this reuses it instead: ActorAvatar renders the emoji as text and no surface needs to special-case her. The two cards that stand in for Mika before the agent row exists move with it — the onboarding intro card and the Runtimes "Start with Mika" card. Both were drawing the same sparkle mark, and leaving them would mean a member sees one face during onboarding and a different one the moment Mika is created. They now share MIKA_PLACEHOLDER_EMOJI, which carries a pointer to the server constant so the two cannot drift apart silently. The dark square went with the sparkle: it was built to frame a white line-art glyph, and an emoji on it reads badly. These use bg-muted, matching how ActorAvatar already frames an emoji avatar. Placeholder until Mika has real artwork. Co-authored-by: multica-agent <[email protected]> * i18n: stop the skip path promising things it does not do Two claims in the runtime-skip copy that the code does not back, both found while walking the flow end to end. "Enter your workspace in read-only mode" — there is no workspace read-only concept on the server. The only READ ONLY in handlers is Postgres transaction isolation on the issue-table and search queries. What actually exists is admission.go's ReasonAgentRuntimeRequired, which blocks dispatch when no runtime is connected. So the sentence's second half was already true and enforced; the first half promised a restriction that does not exist — a member who skips can create issues, comment, edit fields and invite people exactly as normal. Same over-claim in cloud_waitlist.intro_warning. "We've added one task" — the skip path creates an *issue* (it lands on the Issues board as NOR-1), and task/issue are distinct entities in this product. Now says issue. All four locales. Co-authored-by: multica-agent <[email protected]> * i18n: reframe Mika as your first agent teammate Requested copy change on the step 3 headline. The rail's description for that step moves with it. It is the step's own subtitle and sits on the same screen as the headline, so leaving it saying "Your Chief of Staff" would have put two different framings of Mika side by side on one page. Three "Chief of Staff" references are deliberately untouched, because dropping the title everywhere is a positioning call rather than a copy fix: the role chip on the intro card (name + title reads fine under the new headline), and the web fork's lede, which is a different screen. Chinese follows the glossary (Agent -> 智能体) rather than the mixed-language phrasing in the request. Co-authored-by: multica-agent <[email protected]> * fix(runtimes): let the member pick the runtime before Mika is created "Start with Mika" provisioned immediately on `runtimes.find(online) ?? runtimes[0]`. One machine commonly registers every agent CLI installed on it — nine on the box this was reported from — so "the first online one" is an arbitrary pick, and Mika could end up bound to a CLI the member never intended to run their Chief of Staff on. Rebinding after the fact is more work than choosing up front. The action now opens a dialog with the same two controls onboarding already uses for this decision, RuntimePicker and ModelDropdown, so the same choice reached from a different entry point is asked the same way. The old heuristic survives only as the dialog's initial selection, which makes it visible and changeable instead of silent. Model resets when the runtime changes, because models are per-runtime and a value picked for the previous one may not exist on the next. Co-authored-by: multica-agent <[email protected]> * refactor(runtimes): one component for "which runtime should Mika use" Three surfaces asked this same question and had drifted apart: desktop onboarding and the Runtimes page offered a runtime plus a model, while the web CLI dialog offered only a runtime. `step-platform-fork` called `onNext(picker.selected)` with no second argument, so connecting through the web terminal path silently created Mika on whatever model the runtime defaulted to, while the desktop step let you choose. Two of the three also re-implemented "changing the runtime clears the model"; the third simply lacked it. MikaRuntimeChoice now owns the pair and that reset rule, so no caller can forget it and the three entry points cannot drift again. The web CLI path gains model selection, which is the behaviour change here. `layout` is a prop rather than a single unified presentation because the difference is real: the CLI dialog lists machines because that is the moment they appear one at a time after `multica setup`, and a collapsed dropdown hides exactly the feedback that dialog exists to give. Everything below the list is identical. compact-runtime-row moves from onboarding/ to runtimes/ so imports only flow onboarding -> runtimes rather than both ways. Creation is deliberately left alone. A and B still funnel through `handleRuntimeNext`, which also runs saveQuestionnaire, completeOnboarding and onComplete; the Runtimes page must not do any of that, since that member is already onboarded. Merging those would leak onboarding completion into a non-onboarding surface. The platform-fork test now needs a QueryClientProvider, because the dialog renders a model dropdown that queries the runtime's model list, and its onNext assertion moves to the two-argument signature. Co-authored-by: multica-agent <[email protected]> * fix(onboarding): stop the download card claiming a result it cannot know Clicking "Use this computer" set `downloaded` unconditionally, which flipped the card to "Opening the download page..." / "Opened in a new tab." Neither is knowable: `window.open` is called with `noopener`, and per spec that returns null whether the tab opened or a popup blocker ate it, so a blocked click still produced a card asserting a tab had opened. The same flag was also write-once, so the transient "Opening..." became a terminal state — come back to the tab later and it still says the page is opening. And because the swapped title wraps to two lines, the card grew 10px and pushed the two cards under it down, which is what made the click read as a page refresh in the first report. The card now states its intent up front — "Opens in a new tab — pick your platform there" — which is true before the click, after it, and when the popup never appears. The state, both `_after` strings and `hint_downloaded` are gone from all four locales. Its test asserted the flip, so it now asserts the opposite: the mocked window.open returns null (the blocked case) and the card must be unchanged. Measured after the change: primary card 330px before and after, and the cards below it do not move. Co-authored-by: multica-agent <[email protected]> * fix(desktop): stop one worktree in a thousand booting into a blank window (#6436) Worktree renderer ports are `5174 + cksum(path) % 1000`, a 5174-6173 window that contains exactly one port Chromium refuses to navigate to: 6000, the X11 port on its restricted list. A worktree whose path hashes to offset 826 gets a healthy Vite server on 6000 and an Electron window that fails the load with ERR_UNSAFE_PORT -- so it reads as a renderer bug, not a port one, and the only way out was setting DESKTOP_RENDERER_PORT by hand. Restricted ports in the window are now remapped into the block immediately above it (6000 -> 6174). Sending them past the end rather than shifting them by one keeps the offset -> port mapping injective, so two worktrees still cannot land on the same port and race for it. Co-authored-by: Lambda <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(onboarding): close the Mika multi-member and failure-recovery gaps From Emacs's review of #6378. All seven findings reproduced. The two blocking server bugs were the same shape: Mika is one agent per workspace but sessions and ownership are per member. - StartMikaOnboarding required the agent owner, so the member who lost the CreateMikaAgent race — the race that handler's advisory lock exists to survive — got a valid Mika, opened a valid session, and then a 403. Mika is created workspace-visible and workspace-invocable; the session gate and canInvokeAgent were already the checks that matter. - The onboarding session was resolved client-side by listing sessions and creating one on a miss, matched on the localized title. LockWorkspaceForChatSessionCreate is FOR KEY SHARE precisely so concurrent creators do not block, so two tabs each opened their own conversation with its own kickoff, and switching language between a failed attempt and its retry opened another. It is now get-or-create server-side under a per-(workspace, member) advisory lock, keyed on (workspace, creator, agent), returned alongside the agent. Also: - The skipped-runtime welcome dismissed itself silently when provisioning the guide issue failed. The signal is not persisted and onboarding is already complete, so a blip was terminal. It now offers a retry. - The Runtimes recovery card gated on `agents.length === 0`, so creating any ordinary agent hid the only surface that can mint a Mika — the generic endpoint accepts no system_key. Gated on Mika's absence. - CompactRuntimeRow ignored `disabled`; the CLI dialog was already passing it, so the runtime could change mid-submit. It is a real <button> now, which also gets focus and Enter/Space for free. - The rail never went below 15rem while the content pane kept its gutter, leaving ~87px of form at 375px. It is hidden under md, where a compact bar carries the step name and the Back button instead. - Dropped a stray __pycache__ artifact I had committed by accident. Each new test was checked against the bug it covers: reinstating the owner gate, the title-keyed lookup, or the dropped disabled prop makes the corresponding test fail. Co-authored-by: multica-agent <[email protected]> * fix(onboarding): make the Mika entrypoint survive a partial bootstrap From Emacs's second review of #6378. Bootstrapping Mika is three server steps — provision the agent, open the member's session, enqueue the opening turn — and the last two can fail after the agent commits. Two things then conspired: the server reported success with the session omitted, and the Runtimes card gated on the agent existing. The agent's own `agent:created` broadcast invalidates the agent list, so the card (and its open dialog) was torn down the instant step one succeeded, and never came back on reload — the agent is durable, the rest was not. The member was left holding a Mika they could not start. - The endpoint now fails when the session cannot be resolved. Every step is idempotent, so a retry converges; handing back a half-built flow the caller cannot distinguish from a finished one does not. - The entrypoint is gated on the member's own state — does this member have a Mika conversation that was actually kicked off — rather than on the workspace having an agent. That is the question the card answers, and it is true again for every partial state above. Also restores the Log out escape hatch below `md`. Hiding the rail last round took its footer with it, which stranded every step but Welcome with no way out on a narrow screen; the compact bar now renders the same slot, so `sidebarFooter` is `chromeFooter`. Each new test was checked against its bug: the old agent-only gate fails three of the memberNeedsMikaSetup cases, and dropping the footer prop fails the chrome test. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Lambda <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
c896f677d5 |
fix(agent): recover from a codex resume that overflows the reader (MUL-5722) (#6420)
Layer 1 (#6383) raised the shared scanner cap to 32 MiB, which moved the cliff without removing it — a codex rollout is append-only, so a thread that outgrows any fixed cap still fails its resume forever. This adds the recovery path. Layer 2: an oversized thread/resume response is reported as Result.ResumeRejected rather than a crash, which is the positive evidence shouldRetryWithFreshSession needs and reconnects the recovery #5715 had unintentionally cut off. Reaching it required one real fix — the reader wrapped the scanner error with %v, so bufio.ErrTooLong never survived to a caller. Layer 3: a new codex_resume_oversized reason marks the session resume-unsafe, and both resume lookups block by TIME rather than by matching the failed row. That shape is required, not stylistic: the failure happens before the turn starts, so the row lands with session_id NULL and is dropped by latest_per_session before any error-text filter runs. The block expires once a thread terminates after the overflow, so an issue recovers instead of starting cold forever. Also splits the MUL-4424 continuity notice by what each surface can still read — issue comments and Slack channel history can be re-read, web chat and Feishu cannot — so only the last group tells the user a loss happened. The backend no longer holds any of that wording; it receives ExecOptions.ResumeContinuityNotice from the caller and stays silent when the prompt already carries it, which makes the duplicate injection on the retry path structurally impossible. Known gap, tracked not claimed: for chat, the claim handler reads chat_session.session_id before the fallback query, so a daemon predating this PR leaves that pointer naming the oversized thread. Clearing it needs the attempted session recorded at claim time, which is a schema change. |
||
|
|
521a63671c |
fix(chat): preserve visible queue-head ordering (MUL-5751) (#6431)
* fix(chat): keep visible queue heads in transcript order Co-authored-by: multica-agent <[email protected]> * test(chat): cover cancelled follow-up ordering Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Eve <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
b3fa17f0e0 |
fix(chat): preserve follow-up transcript order (#6419)
Co-authored-by: Eve <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
73283556d7 |
MUL-5750: fix(chat): keep idle sends out of follow-up queue (#6418)
* fix(chat): keep idle sends out of follow-up queue Co-authored-by: multica-agent <[email protected]> * fix(chat): preserve the positional queue head Co-authored-by: multica-agent <[email protected]> * fix(chat): polish deferred queue states Co-authored-by: multica-agent <[email protected]> * fix(migrations): resolve pending index prefix collision Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Eve <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
c1be369f6d |
[MUL-5736] fix(channel): make issue commands terminal outcomes (#6398)
* fix(channel): make issue commands terminal outcomes * fix(channel): stop handled command media from gating later chat tasks A `/issue` turn is now answered synchronously and excluded from every later input batch, but it still persisted a media deadline, and both session-scoped media gates counted it. The next, unrelated chat message was therefore deferred until the command's attachments bound — or for the full fallback budget on the create-failure path, where no binder ever runs to clear the marker. Give GetChannelMediaPendingUntil and PromoteChannelChatTasksIfMediaReady the same population as the batch seal, so only a turn that can join a batch can gate one. DeferChatTaskForSealedPendingMedia already scopes by task id and needs no change. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
a4dc54f2ed |
MUL-5570 / MUL-5493: feat(chat): add a managed follow-up queue (#6211)
* feat(chat): queue follow-up messages during active runs * fix(chat): preserve queued message compatibility * fix(chat): harden queued task consistency * fix(chat): protect legacy and mobile queue state * fix(mobile): refresh stop handler on task promotion * fix(mobile): preserve queued chat state * fix(chat): hide queued prompts from legacy clients * test(chat): read queued channel input from paged transcript * fix(chat): protect legacy and mobile queue state * fix(mobile): refresh stop handler on task promotion * fix(mobile): preserve queued chat state * fix(chat): hide queued prompts from legacy clients * test(chat): read queued channel input from paged transcript * fix(chat): hide queued inputs from paged transcripts * test: prove queued messages do not consume cursor pages --------- Co-authored-by: TeAmo <[email protected]> |
||
|
|
9fb46adc43 |
fix(agent-builder): serialise draft autosave against session delete (MUL-5642) (#6376)
SaveAgentBuilderDraft read the chat session, then upserted agent_builder_draft as a separate statement, with no lock between them. DeleteChatSession takes LockChatSessionForDelete for its whole transaction, so a save could pass its checks, block on nothing, and land its INSERT after the delete committed. agent_builder_draft carries no chat_session FK (repo rule), so nothing rejected that write. The surviving row held the configuration the user had just confirmed discarding. It is invisible to the UI — the drafts list joins through chat_session — and no prune can reach it: DeleteAgentBuilderDraft and the runtime teardown both key off a session that no longer exists, leaving only the workspace teardown. The client autosaves on an 800ms debounce and the conversation is addressable by URL, so a second tab can autosave at any moment while this one discards. Add LockChatSessionForDraftWrite, the same row and lock mode the delete and runtime-bind paths take, and run the upsert in a transaction that acquires it first and re-reads the session under it. Existence and status are the only two things a concurrent writer can change, and both are now decided inside the lock; workspace, creator and carrier are immutable for a session and stay on the cheap unlocked read. Either ordering is now correct: the save commits first and the delete prunes it, or the delete commits first and the save returns 404. The same lock closes the archive variant, where the last autosave after "create agent" could write a draft onto an already read-only session. Both regression tests drive the interleaving deterministically — hold the session row, prove the save blocks, then commit — and fail on the pre-fix handler with a 204 that writes the orphan. Co-authored-by: multica-agent <[email protected]> |
||
|
|
eea687d461 |
MUL-5686: fix(chat): resume the cancelled turn's session instead of starting cold (#6352)
* fix(chat): resume the cancelled turn's session instead of starting cold
Stopping a chat turn the agent had already begun answering, then sending
the next message, produced a reply with no memory of the conversation.
The cancelled turn's provider session is real and recorded — the daemon
pins it onto the task row mid-flight and cancellation keeps it there —
but nothing could hand it back:
- GetLastChatTaskSession / GetLastTaskSession only considered
'completed' and 'failed' rows, so a cancelled row's session was
invisible to resume resolution;
- chat_session.session_id is written only by CompleteTask / FailTask,
and a cancelled task reaches neither: the daemon discards its result
and sends a cancel-ack. On a chat whose first turn was cancelled the
pointer therefore stayed NULL and the next turn started cold, since
buildChatPrompt injects only the current user message.
Let cancelled rows into both resume lookups, and advance the chat-level
pointer at cancel time so a provider that mints a new session id per
resume does not rewind past the cancelled exchange. The mid-flight pin
may now also fill an EMPTY session slot on a just-cancelled row, which
is what a Codex pin waiting on its rollout needs when the cancel wins
the race; occupied slots and completed/failed rows stay untouchable.
Retired sessions, poisoned-failure filters and the rollout-present guard
are unchanged. A transcript killed mid-tool-call that the provider later
refuses is still caught by taskfailure.UnresumableHistory, which retires
the session and starts the next turn fresh.
Fixes #6340
Co-authored-by: multica-agent <[email protected]>
* fix(chat): close the two cancel/pin races on the chat resume pointer
Review found the pointer advance had the same shape of hole it was meant
to fix, on both sides of the cancel.
1. The status flip and the pointer advance were separate statements. In
the gap the row is already `cancelled` while the pointer still names
the PREVIOUS turn, so a follow-up the user had queued could be claimed
there and resume the older session — and a failed pointer write only
logged, still reporting the cancel as successful. Both now commit in
one transaction and the error propagates.
2. The mid-flight pin may land AFTER the cancel (for Codex it waits for
the rollout), so the cancel transaction sees no session to publish and
the pin only fills the task row. On a chat that already had history
the stale pointer kept shadowing it, which is precisely the case the
pin change was added for.
Both paths now run one guarded statement,
AdvanceCancelledChatSessionPointer. It reads the task row itself rather
than trusting an in-memory copy, ignores anything that is not a cancelled
chat task, and refuses to move the pointer when a NEWER task on the chat
already recorded a session — so a straggler pin cannot drag the
conversation back onto the interrupted turn.
Regression tests, both failing before this commit: a cancel whose pointer
write is blocked must not expose `cancelled` to another connection, and a
late pin on an already-cancelled row must reach the next real claim. The
newer-turn guard is covered too.
Co-authored-by: multica-agent <[email protected]>
* fix(chat): make the pin atomic and restore the chat->task lock order
Second review round found the remaining half of the same class of bug.
1. PinTaskSession still committed the session onto the task row and
advanced the chat pointer as two statements, and the pointer failure
only logged while the endpoint still answered 204. A follow-up claimed
in that window resumes the previous turn — the exact case the pin
change was added for. Both writes now share one transaction and every
failure is reported.
2. Adding the pointer write gave the cancel transaction two rows to hold,
and it took them in the wrong order: agent_task_queue first, then
chat_session. DeleteChatSession takes them the other way round, so the
two could deadlock (40P01, and runInTx has no deadlock retry). The
repo's documented order is chat_session -> agent_task_queue; both the
cancel and the pin now open with LockChatSessionForTask, the same
helper FinalizeDeferredCancelledChat uses. ErrNoRows there means a
non-chat task or an already-deleted session — nothing to lock and
nothing to advance.
Regression tests, all three failing before this commit (the concurrency
one with a real `deadlock detected`): the pin must not expose a session
on the task row while its pointer write is blocked; a cancel waiting on
the chat session must hold no lock on the task row (FOR UPDATE NOWAIT
probe); and cancel racing a chat delete must never come back with 40P01.
Co-authored-by: multica-agent <[email protected]>
* fix(chat): put every chat-task terminal write behind the same lock order
The cancel and pin paths took chat_session before agent_task_queue; the
terminal reports still took them the other way round, so the two could
deadlock (40P01) — reproduced deterministically as cancel-vs-complete.
Choosing per-path was never going to work: either every writer that holds
both rows agrees on an order or none of them are safe.
CompleteTask, FailTask and the cancelled-chat finalize now open with the
same LockChatSessionForTask the cancel, pin, DeleteChatSession and
FinalizeDeferredCancelledChat paths use, via a shared helper that
documents the invariant in one place.
Also de-flakes the concurrency tests this PR added. They held a row lock
and then read other rows on a FRESH pooled connection; the suite shares
one database, so a sibling package's DDL could queue an ACCESS EXCLUSIVE
request in between, park our later ACCESS SHARE request behind it, and
wedge the package until the 10-minute timeout (observed in a parallel
`go test ./internal/...` run). Those transactions now take their table
locks up front and read on the connection that already holds them, and
every racing call is bounded so a stall fails loudly instead of hanging.
Regression tests, all failing before this commit: complete and fail must
hold no lock on the task row while waiting for the chat session (FOR
UPDATE NOWAIT probe), and cancel-vs-complete plus pin-vs-fail must never
come back with 40P01.
Co-authored-by: multica-agent <[email protected]>
* test(chat): fail the race tests on any unexpected error, bound every call
Review nits on the concurrency tests.
Matching only *pgconn.PgError(40P01) let the pin side through: a pin that
loses a deadlock is reported by the HTTP handler as a plain 500 with no
PgError to unwrap, so the check skipped exactly the failure the test
exists for. Both racers now fail on anything except the one benign
outcome — whoever finalized the task first leaves the loser matching no
row (pgx.ErrNoRows).
The remaining racing calls still used an unbounded context.Background(),
which this PR had already claimed were bounded. They now share the same
raceTimeout as the rest.
Co-authored-by: multica-agent <[email protected]>
---------
Co-authored-by: multica-agent <[email protected]>
|
||
|
|
a5c1d44701 |
MUL-5642: fix(agents): stop the creation studio polling, and stop it losing work (#6307)
* feat(agents): make AI agent creation resumable (#6246) Leaving the Agent Creation Studio destroyed the conversation. The unmount cleanup called deleteChatSession, so a sidebar click, a tab close or a route change deleted the builder session and every message in it — the bug external users reported. Archiving instead (PR #6247) would have stopped the deletion without giving anyone a way back in: builder sessions hang off a hidden `kind = 'system'` carrier agent, which the `kind = 'user'` filter keeps out of every chat list, so an archived one is unreachable rather than recoverable. A creation conversation is now a durable object with its own address. Server: - GET /api/agent-builder/sessions lists the caller's unfinished creations. Creator-scoped like every other chat read. It reports the CARRIER's runtime, not chat_session.runtime_id — the latter is the daemon's resume pointer and is deliberately left stale after a switch, so resuming from it would put the picker on a runtime that executes nothing (MUL-5163). - PUT /api/agent-builder/sessions/{id}/draft stores the configuration, including the edits the user typed but never sent. Migration 251 adds agent_builder_draft (no FK per repo rule; pruned explicitly by DeleteChatSession, the runtime teardown and the workspace teardown, and registered in the workspace-deletion manifest). - The payload is opaque to the server: its shape is the studio's AgentDraft, validated client-side. Teaching Postgres and the handler about it would create a second definition to keep in sync for no gain. Client: - The session id lives in `?session=`, so a refresh, a back/forward and a reopened tab land back in the same conversation. - Leaving no longer deletes anything. The only destructive path is an explicit "discard", confirmed in a dialog, next to the create button. - Creating the agent archives the conversation instead of deleting it: it is the record of how that agent was designed, and an idle carrier costs nothing since usage is booked per task. - The configuration autosaves (debounced) and restores on arrival, with the applied-assistant-message marker stored alongside it so a restore cannot re-apply the last reply over edits made after it. - The 1.5s polling of messages and pending-task is gone. The global realtime sync already invalidates both per session id, exactly as it does for the main chat window, which has never polled. - The `<agent_draft>` block collapses to one "configuration updated" line. The regex now also swallows an unterminated block, which is what streaming produces — the raw payload used to scroll past on every turn. The 2185-line agent-creation-studio.tsx is split into three routes (`/agents/new`, `/agents/new/manual`, `/agents/new/ai`), its pure logic moves to packages/core/agents/ with its tests, and the unreachable template flow — `setMode("templates")` had no caller — is removed. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> * fix(agents): let the builder panes resize The conversation / configuration split was not draggable. Two structural reasons, both fixed by giving the group the same shape the chat page uses: - The panels reached the group through BuilderWorkspace's fragment, so they were not children the group could measure. - The group's children alternated between one panel (runtime setup) and two (conversation), under one persisted layout id. The group now lives inside BuilderWorkspace with its two panels as its only children, and the setup screen renders no group at all — it has nothing to split. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> * fix(ui): give the resize handle a cursor on hover The separator had no cursor of its own, so the only signal that a split was draggable arrived after the drag started — the library writes a global `cursor: ... !important` while dragging, and nothing before it. Fixed on the shared handle rather than at one call site: every split surface (chat, inbox, issue detail, project detail, the agent builder) was missing the same affordance. The library's drag-time rule still outranks this one, so the cursor keeps narrowing to `e-resize` / `w-resize` once a panel hits its bound. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> * feat(agents): render the builder's draft block as an inspectable row Every builder reply ends in an <agent_draft> block that rewrites the form on the right. Flattening it to a line of prose said that something changed but not what, and the payload — the only record of what the builder actually claimed — was unreachable. A settled reply now carries a full-width row saying the configuration was updated, which opens the exact payload. A streaming one keeps a text line instead: the block is still being written, so there is nothing complete to open, and without the line the half-finished JSON scrolls past. ChatMessageList gains an optional `renderAssistantAddon`. It is opt-in per surface and undefined everywhere but this one, because no other chat speaks this protocol — the alternative was to keep pushing an embedded protocol through `transformContent`, which can only ever produce prose. `extractBuilderDraftBlock` returns an unparseable payload verbatim rather than withholding it: a malformed block is exactly when someone wants to read it. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> * fix(agents): address review blockers on the resumable builder 1. Migration prefix collision. `251_agent_runtime_unbind` landed on main after this branch cut, so the backend's prefix-uniqueness guard failed. Renumbered to 252. 2. #6287 — the manual form still lost everything. The route split moved where you land, not what survives: the draft was `useState`, so a tab switch (the desktop shell mounts only the active tab) remounted it empty, and the beforeunload guard covered a hard reload and nothing else. It now persists through the repo's draft-store factory, scoped by what is being created — a blank agent and a copy of agent X are different work, and a copy of X is not a copy of Y — cleared once the agent is committed, and registered for logout / workspace-delete cleanup. 3. A saved draft with no messages was unreachable. The configuration form is editable from the moment a builder session exists and autosaves, so someone could open it, type a name and leave before the first turn; the list keyed "is this a draft" on messages alone, so that row existed and nothing could reach it. A session now qualifies on a message OR a stored draft, and sorts by whichever it has. 4. The debounce dropped the last edits. Its timer died with the component, so navigating away inside the 800ms window lost exactly the keystrokes the user had just made. The pending payload is now flushed on unmount. `useUnsavedDraftWarning` is gone with its last caller: both routes persist, so the browser prompt would have been warning about work that is already saved. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> * fix(ui): stop the resize cursor flipping mid-drag The library narrows its cursor the moment a panel hits a bound — col-resize while both directions are open, a one-way arrow once only one is. Truthful, but it reads as a glitch: the icon changes under your hand halfway through a drag you never stopped making. `disableCursor` turns that global rule off; the handle's own `cursor-col-resize` is now the only source. A drag captures the pointer and walks it across the panels, away from the 8px handle, so the group carries the same cursor for as long as a separator is active — otherwise it would fall back to a text caret the instant the pointer left the handle. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> * fix(agents): key manual drafts by owner, drop the draft-block rendering Two changes. One slot destroyed the other flow's work. The manual draft was stored under a single key: opening a blank form, or a copy of a different agent, refused to adopt the stored draft and then immediately wrote its own empty form over it — so a half-finished copy of agent A died the moment the user opened anything else, before typing a character. Drafts are now keyed by what is being created, the same shape the chat composer uses for its per-session drafts, and a slot is dropped when its content is gone rather than parked blank (which also stops the map growing a dead key per agent ever opened for duplication). Committing an agent clears that flow's slot only. The `<agent_draft>` block goes back to being hidden outright. Labelling it and opening its payload dressed up machinery as content: the block drives the configuration form, and the form is where its effect is already visible. `renderAssistantAddon` goes with it — ChatMessageList is back to what it was, since no surface needs the slot. The two-pattern strip stays: an unterminated block is what streaming produces, and without matching it the raw JSON scrolled past the reader on every turn. Also removed six barrel exports nothing imported through, and unexported five types only their own file used. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> * fix(agents): keep a manual draft whose only edit is a picker The "is this worth storing" predicate listed six fields by name, and the draft serializes eleven. A form whose only change was the model, the thinking level, the service tier, the access scope or a team grant read as untouched, so the next save deleted its slot — picking a model before typing a name and switching tabs lost the model. Enumerating was the mistake, not the specific omissions: the predicate stops covering every field added after it is written, and the failure is invisible because each field saves correctly as long as some *other* field is also set. It now compares the whole draft against a fresh one. The runtime stays outside that comparison, on the entry rather than in the draft, because the form seeds it on every visit and counting it would store a draft for a form nobody touched. Covered field by field, one edit at a time, so a future field cannot quietly fall out. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 5 (1M context) <[email protected]> |
||
|
|
b06af2ae17 |
feat(runtime): unbind agents on runtime delete instead of destroying them (#6220)
* feat(runtime): unbind agents on runtime delete instead of destroying them Deleting a runtime archived its agents and then hard-deleted the rows, so the agents and every conversation with them disappeared — while the confirmation dialog said "archive", which a user reasonably reads as recoverable. Retiring a laptop is an ordinary action; losing the agents configured on it is not an ordinary consequence. An agent is now a persistent business object and a runtime is replaceable execution capacity: deleting a runtime unbinds its agents. `runtime_id IS NULL` means unbound — orthogonal to archived — and the agent keeps its instructions, skills, chats, labels, channel installations, autopilots and task history. service.AgentReadiness already refused an agent with no runtime, so the scheduling safety gate needed no change. Two columns become nullable, not one. Without `agent_task_queue.runtime_id`, deleting the runtime still cascades the task history away (and task_message / task_usage / task_token with it), so the agents would survive with no record of anything they did — the same class of loss. A NOT VALID CHECK keeps NULL confined to history: an active task must always have a runtime, so claim / dispatch / delivery-CAS paths can never observe one without. It is written against completed_at rather than a status list so a future non-terminal status fails closed instead of slipping through. Two prerequisites this depends on: - 'deferred' (migration 128) was missing from CancelAgentTasksByRuntimeOrAgent. It went unnoticed because the delete used to cascade those rows away; with the new CHECK it would abort the delete and make the runtime undeletable. - The channel-installation / label / chat-pin / invocation-target / draft-restore cleanups were scoped to "archived agents on this runtime". Archived user agents now survive, so that scope is narrowed to kind='system' — otherwise the fix would produce a subtler loss: agent alive, configuration wiped. Also removes the squad guard that refused (409) when an active squad's leader was an archived agent on the runtime, plus the archived-squad delete that existed only to get past squad.leader_id's RESTRICT FK. The leader is no longer deleted, so nothing needs to be given up to retire a machine. Autopilots are no longer paused either: their assignee survives, and a rebind restores them without the owner having to remember to re-enable. Reason codes: an unbound agent reports agent_runtime_required, not runtime_offline. The copy for runtime_offline tells users to reconnect a machine; an unbound agent has no machine to reconnect, and the fix is to bind a runtime. Chat's bare 409 string gains the same code so the composer can offer that action. API: agents gain runtime_bound. runtime_id stays a string (empty when unbound) so installed clients keep parsing and no gated two-release rollout is needed. The confirmed-delete endpoint is /unbind-agents-and-delete; /archive-agents-and-delete still routes to it, and the compared expected_active_agent_ids set is unchanged — widening it would 409 every older client forever. Co-authored-by: multica-agent <[email protected]> * fix: make runtime unbinding recoverable Co-authored-by: multica-agent <[email protected]> * fix: address runtime unbind review nits Co-authored-by: multica-agent <[email protected]> * fix: resolve runtime unbind review blockers Co-authored-by: multica-agent <[email protected]> * fix(migrations): renumber runtime unbind after main merge Co-authored-by: multica-agent <[email protected]> * test(daemon): avoid late-request lease flake Co-authored-by: multica-agent <[email protected]> * test(autopilots): bind validation fixture runtime Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Eve <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
f13969b996 |
refactor(chat): generate quick actions server-side via the LLM layer (MUL-5573) (#6214)
* refactor(chat): generate quick actions server-side via the LLM layer (MUL-5573)
Follow-up suggestions were produced by a second, full provider CLI invocation
per chat turn: the daemon resumed the just-finished session and ran a
suggestion-only pass. That pass inherited the main turn's exec options, so its
20s budget had to cover process spawn, every MCP handshake, session replay, and
model reasoning at the agent's own thinking level — typically 8-15s of visible
skeleton, and every turn paid two provider cold starts.
Generate them here instead, through the same pkg/llm layer that backs chat
auto-titling. Suggestions need no tools, workdir, or agent identity — only the
tail of the conversation — so a bounded 8s call on the deployment's small model
replaces the whole resumed turn.
Quality changes that came with the move:
- The prompt now states the frame explicitly ("you write FOR THE USER"). The
old pass ran inside the agent's session and inherited the runtime brief's
identity, which drifted suggestions toward agent-operations actions.
- Previously-offered labels are replayed as ALREADY SUGGESTED. The old
architecture had the opposite effect: on providers that append on resume,
each pass saw its predecessor's JSON and anchored on it.
- A failed generation broadcasts failed=true. Before, a timeout delivered an
empty array — indistinguishable from "nothing worth suggesting", so every
slow pass read as a quality problem.
- The in-band footer is still stripped from replies but its actions are now
discarded, so a pre-upgrade session is not pinned to the retired
suggestions with the replacing pass suppressed.
The refresh path no longer enqueues an agent task: it validates the target and
calls the same generator, which also drops the not-resumable refusal — a session
whose runtime was rebound can now be refreshed. Client contract is unchanged
(chat:done pending flag, chat:quick_actions supplement); the only frontend
change is the pending window, resized from 30s to 12s to match the new budget.
Also removes the daemon's TMPDIR-after-cleanup hazard by construction: the old
pass started after runTask's defers had already deleted the temp dir it was
still pointed at.
Co-authored-by: multica-agent <[email protected]>
* refactor(chat): drop the quick-actions opt-out setting (MUL-5573)
Suggestions are always on. The Settings → Chat toggle is removed along with
the whole per-turn opt-out path it fed: the persisted client preference, the
quick_actions_enabled send field, the quick_actions_disabled task stamp, and
the eligibility gate that read it.
The toggle predates server-side generation, when it could only hide chips a
provider pass had already paid for. Now that generation is a bounded call the
server decides on, an off switch buys nothing a user would miss, and it was
the last piece of UI implying the feature might be unavailable.
agent_task_queue.quick_actions_disabled is no longer written (dropped from
CreateChatTask's INSERT; the column keeps its false default). Left in place
alongside regenerate_quick_actions_for for a later drop migration — removing
columns an already-running binary still inserts would break mid-deploy.
Co-authored-by: multica-agent <[email protected]>
* fix(chat): address quick-actions review findings (MUL-5573)
Four defects from review of the server-side generation change.
1. Automatic failures were reported as refresh failures. The generator
broadcast failed=true on any LLM error, but the client turns every
failed=true into a "couldn't refresh" toast — so an automatic timeout
popped a toast for an action the user never took. This also contradicted
ChatQuickActionsPayload.Failed, which documents false for the automatic
pass. The caller now passes its origin; only an explicit refresh reports.
2. Generation context was not bound to the target turn. The pass re-read the
session's newest messages while always writing to the task it was handed,
so a turn landing between the completion callback and the detached read
supplied the context for a reply it did not belong to. Worse, a user
typing a follow-up in the second after a reply left the window ending on
a user row, which the old code treated as "nothing to build on" — that
turn silently never got pills. The window is now anchored on the target
assistant message and queried strictly before it.
3. No concurrency or idempotency bound on generation. Refresh stopped
creating a task, so the busy check could not see a pass already running:
two refreshes both returned 202, spent two upstream calls, and raced to
write one row. Nothing bounded generation process-wide either. Adds a
per-session in-flight guard (refresh now 409s on a duplicate) and a
process-wide ceiling; a shed pass still resolves the client placeholder
so no skeleton hangs on work that never started.
4. A new daemon could not safely talk to an older server. The refresh task
discriminator was deleted, so a regenerate task from such a server fell
through to the ordinary chat path: no user message, but the agent would
answer anyway and the server would persist it as a real reply. The field
is restored as a refusal marker only — the task completes empty, which is
the shape the retired pass produced and which that server writes no row
for. Not a restored execution path.
Co-authored-by: multica-agent <[email protected]>
---------
Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
|
||
|
|
0fdc38704e |
MUL-5149: add agent-generated Chat quick actions (#5766)
* feat(chat): add agent-generated quick actions
Co-authored-by: multica-agent <[email protected]>
* fix(chat): preserve mid-response quick-action fences
Co-authored-by: multica-agent <[email protected]>
* fix(chat): drop quick actions on empty reply to keep no_response fallback
An actions-only completion — a quick-actions footer with no visible text —
wrote an empty-content assistant message (message_kind=message). Older
Desktop/mobile clients ignore the quick_actions field and render that as an
empty bubble, breaking the MUL-4351 contract that an empty turn always gives
old clients a visible no_response fallback.
Drop the quick actions when the visible body is empty so an actions-only turn
falls through to the visible no_response outcome, and revert the completion
switch to gate the message row on visible text only. Update the completion
test to pin the corrected behavior.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* feat(chat): generate quick actions via daemon suggestion pass
Replace the in-band runtime-brief instruction with a dedicated post-completion
provider turn: after a direct chat reply finishes, the daemon resumes the same
session with a JSON-only suggest prompt and forwards the raw output on the
complete callback. The server parses it leniently and reuses the existing
sanitize/redact/store/broadcast pipeline; the stripped in-band footer stays as
a fallback for older daemons and pre-upgrade sessions. The footer strip now
covers every chat completion, fixing the intro-turn protocol leak. Adds a
Settings → Chat toggle (client-persisted, default on) that hides the chips.
Co-Authored-By: Claude Fable 5 <[email protected]>
* feat(chat): deliver quick actions async with skeleton placeholders
Decouple suggestion generation from the turn: the daemon reports completion
immediately (chat:done carries quick_actions_pending as a per-turn capability
signal) and runs the suggestion pass in the background, delivering results
through a new supplement endpoint + chat:quick_actions broadcast. A new turn
on the same session cancels the stale pass. Clients render pill skeletons
under the finished reply until the supplement resolves them (entrance
animation on arrival, 30s safety timeout); older daemons never raise the flag
so no skeleton dangles. Suggest usage re-reports merged totals because
task_usage upserts replace per (task, provider, model). Prompt now asks for
exactly 3 actions.
Co-Authored-By: Claude Fable 5 <[email protected]>
* feat(chat): make the quick-actions toggle stop generation, not hide pills
The Settings → Chat toggle previously only hid rendered pills while the
daemon kept burning a suggestion call every turn. It now travels with each
send (quick_actions_enabled, absent = enabled for older clients), is stamped
on the chat task (migration 213), forwarded on the claim, and gates the
daemon's suggestion pass at the source — no call, no pending flag, no
skeleton. Existing suggestions stay visible; settings copy now says
'generate' instead of 'show'.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(migrations): renumber quick-action migrations onto current main
Merging current origin/main brought the vcs migrations to their canonical
216-221 prefixes, which collided with the quick-action migrations that were
sitting at 219/220 (backend CI red in
TestMigrationNumericPrefixesStayUniqueAfterLegacySet). Renumber them to the
next unused prefixes:
- 219_chat_message_quick_actions -> 222_chat_message_quick_actions
- 220_agent_task_quick_actions_disabled -> 223_agent_task_quick_actions_disabled
Contents are unchanged; sqlc regeneration produces no drift since the added
columns are independent of the vcs tables.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* fix(mobile): render async chat quick actions via chat:quick_actions
The daemon generates quick actions in a background pass after the turn
finishes, delivering them on a separate chat:quick_actions event. Mobile
only handled chat:done (which invalidates + refetches an actions-less
message list) and keeps the messages query at staleTime: Infinity, so an
active mobile session never rendered async-generated quick actions until a
manual pull-to-refresh or refocus.
Add applyChatQuickActionsToCache — mirroring web's patcher — which patches
the supplement onto the targeted assistant message in the flat messages
cache, and subscribe to chat:quick_actions in use-chat-session-realtime.
Patch-only (no invalidate), matching web and mobile's cellular
patch-over-invalidate rule; an empty supplement is a terminal no-op. Covered
by chat-ws-updaters.test.ts.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* fix(chat): cancel in-flight messages refetch before quick-actions patch
The chat:done invalidate can leave a messages refetch in flight that read the
assistant row before the daemon persisted the quick actions. If that refetch
resolves after the chat:quick_actions setQueryData patch, it overwrites the
freshly-patched actions with an actions-less row. Both message caches are
staleTime: Infinity, so the overwrite never self-heals and the actions vanish
permanently (MUL-5149, Howard review).
applyChatQuickActionsToCache now awaits cancelQueries for the affected caches
(web: flat messages + messagesPage, mobile: flat messages) before patching, so
a stale in-flight refetch is cancelled and cannot land after the patch. Cancel
must precede setQueryData because cancelQueries reverts to the pre-fetch state.
WS handlers call it via `void` (fire-and-forget).
Adds an active-query race regression test on both web and mobile that holds a
refetch open across the supplement and asserts the patched actions survive;
verified to fail without the cancel.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* feat(chat): quick-actions refresh/regenerate + review hardening (MUL-5149)
Co-authored-by: multica-agent <[email protected]>
* fix(chat): address quick-actions re-review (MUL-5149)
- Ack alignment: refresh request carries the target message_id; server
atomically confirms it is still the session's latest turn (409 stale
otherwise), so the client marker always matches the resolving
chat:quick_actions — no response reconciliation. Adds a regression test.
- Converge the pending marker on every terminal path: HandleFailedTasks
(sweeper/orphan) now resolves it, and the daemon reports a failed supplement
so FailTask resolves it instead of leaving a completed-but-unresolved task.
- Timeout fallback now clears the real query state (useQuickActionsPendingTimeout)
instead of a component-local flag that only masked the UI; drop the skeleton's
and pill row's local timers.
- frontend-test type-scale: text-xs -> text-caption. Strip EOF blank line.
Co-authored-by: multica-agent <[email protected]>
* fix(chat): close quick-actions refresh races and failure feedback (MUL-5149)
Third-round review of the refresh button surfaced three issues; all three
are addressed here.
§1/§2 Session-busy race + concurrent-refresh double-spend: a newer reply
that is queued/running but whose assistant row hasn't landed leaves the old
turn as latest-persisted, so the stale check passes and the regen resumes
the newer provider state — attaching suggestions to the wrong turn. And two
concurrent refreshes each enqueue a quota-spending pass. Add
HasActiveChatTaskForSession and refuse a refresh (ErrChatQuickActionsBusy →
409) whenever the session has any task in flight, checked under the same
session lock as the enqueue so no sibling insert slips past.
§3a Timeout re-arm on surface switch: the pending marker now carries an
absolute expires_at deadline instead of a per-mount timer, so switching
between the floating window and the chat tab resumes the same deadline
rather than restarting a fresh 30s window each remount.
§3b Generation failure masked as success: runChatSuggestPass now returns ok
so an explicit refresh distinguishes a failed pass (didn't start / didn't
complete / timed out) from a completed-but-empty one. On failure the regen
task reports failure, resolveFailedRegenerateQuickActions broadcasts a
FAILED chat:quick_actions, and the client resolves the spinner AND toasts
"couldn't refresh" instead of silently stopping on unchanged pills.
Co-authored-by: multica-agent <[email protected]>
* fix(chat): count deferred tasks in refresh busy check; solid refresh icon tone (MUL-5149)
Two re-review blockers on
|
||
|
|
9072cef12c |
Revert "MUL-5493: feat(chat): add a visible follow-up queue (#6133)" (#6171)
This reverts commit
|
||
|
|
b13657be71 |
MUL-5493: feat(chat): add a visible follow-up queue (#6133)
* feat(chat): add a visible follow-up queue Add a visible, manageable FIFO follow-up queue for Web and Desktop chat while preserving the existing per-session scheduler and backward-compatible pending-task response. * fix(chat): preserve queue after deferred cancellation --------- Co-authored-by: TeAmo <[email protected]> |
||
|
|
30318b79bc |
MUL-5426: fix(daemon): retire sessions whose history the provider refuses to replay (#6083)
* fix(daemon): retire sessions whose history the provider refuses to replay A run killed mid-reply (machine shutdown, force-quit, SIGKILL) can leave an empty assistant message in the agent CLI's transcript. Every later resume replays it, the provider rejects the request, and the (agent, issue) pair is bricked with no self-healing and no user-facing recovery. Multica already has the mechanism for this — poisoned-session classification — but its detector paired "400" with "invalid_request_error", which is the Anthropic wire shape. The same defect reported by any other provider carried neither token, so it classified as agent_error.unknown: resume-safe by omission. GetLastTaskSession kept handing back the dead session on every follow-up, manual Rerun resolved it through the same predicate, and the in-turn fresh-session retry never fired because ResumeRejected is false here (nothing rejected the resume — the transcript loaded and the provider refused to replay it). Add taskfailure.UnresumableHistory, which recognises the defect by what the provider says is wrong — some content is empty, and here is which message in the history — rather than by status code or provider name. Both signals are required, so a tool reporting "field must not be empty" does not match. Wire it into the four places that decide whether a session survives: - classifyPoisonedError, so the task is written as api_invalid_request - shouldRetryWithFreshSession, so the turn recovers on all 17 backends instead of the subset whose adapter learned to detect it; the tools == 0 gate is unchanged, so a run that already used a tool is never re-run - ResumeUnsafeFailure, covering the manual-Rerun path - both resume queries, as defense-in-depth for hosts whose daemon predates this (self-host daemons upgrade on their own cadence) Fixes #6066. Also covers the daemon half of #5760. Co-authored-by: multica-agent <[email protected]> * fix(session): close the Chat and fresh-retry paths that resurrect a poisoned session Review found the previous commit stopped short in two places, both of which put the dead transcript back in play. Chat never consulted the guarded query. The claim handler reads chat_session.session_id first and only falls back to GetLastChatTaskSession when it is empty, so a poisoned pointer there bypasses every filter that query applies. The fail path merely declined to OVERWRITE the pointer, leaving it in place. It now clears it in the same transaction, matched on session and runtime so a concurrent turn's newer pointer survives. The promote guard moves to ResumeUnsafeFailure as well — the reason-only check passed an un-upgraded daemon's agent_error.unknown row and re-pinned what the clear had just removed. GetLastChatTaskSession also kept the row-level filter the issue query dropped in GH #5975: it discarded the newest poisoned row and fell back to an older completed row carrying the same dead session. It now judges each session by its latest terminal state, matching GetLastTaskSession. A recovered turn could not retire anything. A terminal report carried one session_id, and an empty one meant both "nothing to report" and "forget the old session", so a fresh-session retry that SUCCEEDED left the id it retried away from selectable — through an older completed row on the issue, or through the chat pointer. agent_task_queue.retired_session_id records the abandonment itself, reported on every terminal path including completed, and both resume lookups exclude it. This is the contract gap the previous PR deferred; the fresh-retry path now runs on all backends, so deferring it is not safe. Also narrows what the cross-backend test claims: it pins the shared decision, not that all 17 adapters surface the error into Result.Error (#5760 is the counter-example), and says so. Co-authored-by: multica-agent <[email protected]> * test(session): require pgx.ErrNoRows in the resume-exclusion assertions The `if err == nil && prior.SessionID.Valid` form these tests shared is false-green: any real fault — undefined column, syntax error, dead connection — makes err non-nil, so the condition is false and the test passes. Run against a database missing this branch's new column, the exclusion tests reported PASS on a SQLSTATE 42703, meaning they could not have caught a broken query. requireSessionExcluded demands pgx.ErrNoRows specifically and fails loudly on anything else, so a green run now means the filter worked rather than the query never ran. Applied to all nine sites, not just the four this branch added: the other five guard the same GetLastTaskSession exclusion behaviour that this branch changes, so leaving them false-green would leave the change under-tested. All nine pass on a correctly migrated database. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
60048172a7 |
fix(lark): ingest inbound images and videos as chat attachments (MUL-4934) (#5580)
* fix: ingest feishu media as chat attachments * fix: ingest feishu post embedded media * fix(lark): make inbound media retries safe * fix lark media resource limit * fix(lark): move inbound media off ack path * fix(channel): make inbound media runs durable * fix(channel): close enqueue-vs-append race on media deferral EnqueueChatTask read the session-wide media deadline in one statement and sealed the input batch in a later one. Under READ COMMITTED a media message committing between the two got sealed into a task the deadline read had already decided was 'queued', so the daemon could claim it before its attachment bound — the agent received the bare placeholder, and the later media-ready promotion was a no-op against a non-deferred task. After the seal, re-derive the deferral from the sealed batch itself in the same transaction (DeferChatTaskForSealedPendingMedia): if any sealed message still carries an unexpired media marker, flip the task to deferred with fire_at aligned to the latest marker. The existing post-commit promote fence already covers the opposite direction (marker cleared mid-transaction). Adds a deterministic regression test that injects the media append between the deadline read and the seal via a wrapped pgx.Tx. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(channel): keep committed chat task out of enqueue error path The post-commit media-ready fence returned its error from EnqueueChatTask even though the deferred task was already durably committed. The router flush treats any enqueue error as "no task exists": it clears the typing indicator and logs an enqueue failure while the run still happens at its fire_at deadline. Log the fence failure instead — the claim-path deferred promoter re-queues the task regardless. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(channel): cap global media resolution concurrency Media jobs were serialized per session but unbounded across sessions: a burst could open arbitrarily many concurrent 45s Lark downloads, and each unknown-length upload may buffer up to the 100 MiB resource cap in memory. Gate resolveAndBindMedia behind a global slot semaphore (default 8, RouterConfig.MediaConcurrency). Per-session ordering is unchanged; on shutdown a job cancelled while waiting for a slot proceeds straight to the bounded DB finalize so marker clearing stays prompt. Also document that the per-message media budget spans queue/slot waits (it must match the persisted fire_at) and why timed-out uploads cannot leak unbounded orphans. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(chat): keep channel-sealed user messages on task cancel Sealing the channel input batch stamps task_id onto channel user messages, which exposed them to the cancel draft-restore path: an empty-transcript cancel would DeleteUserChatMessageByTask the sealed Feishu/Slack messages and detach their attachments. Those messages are the durable record of what the platform sender wrote — the sender has no Multica composer to restore a draft into. Gate the restore-delete on ChatSessionHasChannelBinding in both the synchronous finalize and the deferred finalize (the latter covers markers left by an older replica during a rolling deploy); a bound session now settles as "Stopped." instead. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(channel): skip the media pipeline for messages without media Every inbound message on a Media-enabled platform persisted a 45s media deadline and queued a resolution job, so a plain text message could wait behind the global media semaphore (its task deferred while other sessions download 100 MiB videos) and a crash between append and clear delayed a pure-text run to the full 45s fallback. Add MediaResolver.HasMedia — a pure in-memory probe the Router calls on the ACK path — and only persist the deadline / enqueue the job when the message actually references platform media. The Feishu resolver decodes the already-received payload and reports standalone image or video keys and post-embedded img/media spans. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(chat): gate cancel restore on immutable channel provenance The previous guard keyed the cancel restore-delete off ChatSessionHasChannelBinding, but a binding only proves routing exists right now: archiving a session and rebinding an installation both delete the binding while preserving chat history, so a still-cancellable sealed task could again restore-delete the original inbound messages. Persist provenance on the message instead: migration 203 adds chat_message.channel_ingested, stamped inside the channel append transaction and never mutated, and both cancel finalize paths now gate on TaskHasChannelIngestedMessages over the task's sealed batch. The binding-existence query is removed. Regression tests cover ingest -> archive/unbind -> cancel for a queued and a started task. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(channel): reclaim media uploads that never gain an attachment row Deadline expiry dropped already-resolved refs and a BindMedia failure was log-only, leaving uploaded objects with no attachment row and no reclaim path — the dedup mark commits with the message before media runs, so a redelivery is dropped as a duplicate and never re-resolves (and thus never overwrites) those keys, and workspace/session deletion only enumerates the attachment table. Add MediaResolver.DiscardMedia — a best-effort delete by StorageKey — and call it from both failure paths in resolveAndBindMedia. The Feishu resolver forwards to the storage backend's Delete. Tests cover a partial upload discarded at the deadline, discard on bind failure, and key-level deletion in the resolver. Co-Authored-By: Claude Fable 5 <[email protected]> * docs(server): refresh comments stale after detached media ingestion Channel tasks now seal a self-owned input batch, media ingestion is no longer out of scope for the flattener, and MediaRefs are filled by the detached resolver after append rather than by feishuChannel pre-engine. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(chat): stop keying channel empty-completion silence off chat_input_task_id Sealing gave channel tasks a self input-owner, which broke writeChatCompletionOutcome's discriminator: it treated any owned task as direct, so an empty channel completion wrote the no_response fallback row and the outbound patcher — which forwards any non-empty chat:done content verbatim — pushed the English fallback body to Feishu/Slack, violating the MUL-4351 contract. Silence is now decided by the immutable channel_ingested provenance of the task's input batch, looked up by the batch OWNER id (chat_input_task_id): auto-retry clones inherit the owner while their sealed messages stay tagged with the parent's id, so keying off the task's own id would misread a channel retry as direct. The cancel-path provenance gates switch to the same owner key via chatInputOwnerID. chat_input_task_id is back to meaning only "input batch owner". Co-Authored-By: Claude Fable 5 <[email protected]> * chore(migrations): renumber to 203/204 after upstream took 202 Upstream main merged 202_runtime_profile_add_qwen while this branch held 202/203, tripping TestMigrationNumericPrefixesStayUniqueAfterLegacySet on the CI merge tree. channel_media_pending becomes 203 and channel_ingested becomes 204; no content changes. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(channels): gate outbound delivery on channel provenance, not owner Merging main brought #5645 (keep direct chat replies in Multica), whose outbound gate assumed channel tasks leave chat_input_task_id NULL. Sealed channel tasks own an input batch too, so on the merge tree every channel reply and failure notice was classified as direct and silently dropped — agents stopped replying in Feishu/Slack. Both outbound gates now call engine.TaskInputIsChannelIngested: a NULL owner keeps #5645's deliver-by-default for pre-sealing tasks, an owned batch delivers only when it carries the immutable channel_ingested stamp (keyed by the owner id, so auto-retry clones inherit the verdict). Direct replies stay in Multica; sealed channel replies reach the platform. Tests cover both directions on both platforms. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(channel): discard media orphans on a fresh context, after finalize DiscardMedia shared finalizeCtx with BindMedia, so a bind that failed because the finalize deadline expired handed the storage deletes an already-dead context — the compensation silently no-opped and the orphans leaked anyway. The deadline path also ran S3 deletes before the marker clear, eating the same 5s budget the user-facing bind/promotion needed. Collect the refs from both failure paths, run bind + promotion on the finalize budget first, then delete on a fresh discard context. The bind-failure test now pins that discard receives a live context. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(channel): compensate result-uncertain media uploads and commits The compensation protocol treated "the call returned an error" as "the side effect did not happen", which is wrong in both directions across the result-uncertain windows: - An upload error can follow a server-side write (lost response, deadline mid-write). The attempted key never reached the router, so nothing could reclaim it and dedup guarantees no re-resolve. The resolver now idempotently deletes the deterministic key on a fresh budget right at the failure site. - A commit error is not a rollback guarantee: a lost ack can report failure after Postgres durably committed the attachment rows, and the router's discard would then delete objects those rows reference. BindMediaRefs now converges the ambiguity on a fresh budget — any of the batch's URLs present proves the atomic commit landed (bind reports success); none proves the rollback (discard stays safe); a failed verification returns ErrMediaBindResultUnknown and the router keeps the uploads, preferring a rare orphan over a broken attachment. Fault-injection coverage: an upload error deletes the attempted key; a lost-ack commit keeps the bound attachment and reports success; a verified rollback stays a discardable error; the router keeps uploads on the unknown-outcome sentinel. Co-Authored-By: Claude Fable 5 <[email protected]> * chore(migrations): renumber to 207/208 after upstream took 203-206 Co-Authored-By: Claude Fable 5 <[email protected]> * docs(channel): note DiscardMedia self-invocation and the unknown-outcome skip Co-Authored-By: Claude Fable 5 <[email protected]> * chore(migrations): renumber to 212/213 after upstream took 207-211 Co-Authored-By: Claude Fable 5 <[email protected]> * feat(channel): replace inline media compensation with an intent ledger and reconciler Inline best-effort compensation cannot answer "did my side effect happen?" at the moment it needs the answer — the DELETE/PUT reordering and the empty-read-vs-in-flight-COMMIT gaps were both instances of the same two-system atomicity problem. Persist the intent instead and let an asynchronous reconciler settle it: - channel_media_pending_object (migration 214; claim index 215 as its own single-statement CONCURRENTLY migration): a state machine row ('pending' -> 'deleting') with lease, attempt, and backoff columns. - The resolver upserts the row BEFORE each PUT, state-guarded so a key the reconciler owns is never resurrected (the resource is skipped). ObjectURL is a pure function of configuration, so the row carries the attachment URL pre-upload. - BindMediaRefs deletes the batch's rows INSIDE the attachment-insert transaction: commit landed <=> intents gone, atomically, so an ambiguous COMMIT never needs adjudication. A key already claimed to 'deleting' is skipped (placeholder stays). - Nothing is ever deleted inline. The reconciler — an independent worker so storage latency cannot starve other sweepers — claims due rows ('pending' past the settle delay, or expired leases) under a fresh lease, checks for a durable attachment reference only AFTER the claim (race-free: bind can no longer succeed on the key), deletes unreferenced objects outside any transaction, and backs off failed deletes with attempt-based retry. Crash windows converge for free. - The settle delay is a fixed constant carrying NO correctness weight; invariant tests pin it at >=10x every pipeline budget. Metrics cover deletes, referenced clears, delete failures, and ledger backlog. Removed: MediaResolver.DiscardMedia, ErrMediaBindResultUnknown, the post-commit verification, and both router discard branches. Tests: intent-before-upload ordering; upload error leaves the row and deletes nothing; bind-wins vs reconciler-wins on the same key; lost-ack and rolled-back commit injections (intent cleared iff the attachment landed); reconciler three-state settle; expired-lease reclaim; delete failure backoff and retry; settle invariants. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(channel): never build or sweep the media reconciler without storage store is nil when S3 is unconfigured AND the local upload dir fails to initialize, but the reconciler was constructed unconditionally and main only gates the goroutine on the reconciler pointer — the first unreferenced ledger row (rows can pre-exist from a boot where storage worked) would nil-pointer panic a bare goroutine and take down the process. Construct the reconciler only when a storage backend exists, and guard RunOnce defensively: with no deleter it skips the sweep without claiming, so rows are not stranded in 'deleting' until lease expiry. Test covers the pre-existing-row + missing-storage boot. Co-Authored-By: Claude Fable 5 <[email protected]> * chore(migrations): renumber to 213-216 after upstream took 212 Co-Authored-By: Claude Fable 5 <[email protected]> * refactor(channel): remove the dead pre-resolved MediaRefs ingress path lark.InboundMessage.MediaRefs and the resolver's early-returns for pre-populated refs were vestiges of the pre-detached synchronous design — no producer fills them before the router anymore. Worse, the intent ledger made the path actively misleading: refs arriving without ledger rows would be silently skipped at bind (with a log blaming the reconciler), contradicting the field's "already persisted" contract. Delete the field, its channelMessageFromLark mapping, and both early-returns; channel.InboundMessage.MediaRefs is now documented as what it actually is — ResolveMedia's output channel, always empty on ingress, attachable only through a claimed ledger intent. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(channel): enforce workspace tenancy on every ledger query The intent-ledger upsert's conflict branch guarded only on state, so a cross-workspace storage_key collision could rewrite the row's workspace/message/url ownership; release and delete keyed on (storage_key, lease_token) alone. The derived key embeds the workspace UUID so none of this is reachable today — but tenancy must be enforced by the workspace column in every query, never derived from the key string (MUL-3515 rule, restated in this PR's review). The upsert now updates only within the same workspace (a cross-tenant conflict updates nothing, returns no row, and the resolver skips the upload — the fail-safe direction), and release/delete take (workspace_id, storage_key, lease_token). Tests pin that a foreign workspace can neither steal, release, nor delete a row. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(migrations): build the ledger primary key via a concurrent index storage_key TEXT PRIMARY KEY created its unique index implicitly at CREATE TABLE, against the repo convention that every migration index — including a new table's unique index — is built CONCURRENTLY in its own single-statement migration (the exact three-step pattern client_usage_daily shipped in 207-209). The table now declares storage_key NOT NULL, 216 builds the unique index concurrently, and 217 attaches the primary key USING INDEX; the claim index moves to 218. ON CONFLICT (storage_key) still resolves against the constraint, and the full down/up round-trip is verified. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(channel): bound each reconciler object delete with its own timeout DeleteObject ran on the worker-lifetime context and the SDK's default HTTP client has no overall request timeout, so one black-holed connection would wedge the sequential sweep loop — and with it every later batch and the backlog gauge — forever; a single-replica deployment has no other worker to reclaim the lease. Each delete now gets a 30s timeout (well under the 2min lease), and a timed-out delete takes the existing release/backoff path. Covered by a blocking-deleter test with an injectable timeout. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(channel): anchor the media deadline to the DB clock and bound queue waits by it Two deadline gaps from review: - The persisted marker was an application-clock timestamp compared against SQL now() everywhere it is read, so a skewed app node could shrink the fallback window and hand the agent a placeholder before the resolver's local budget ended. The append transaction now anchors a relative budget (MediaPendingSeconds) with now() + make_interval, writer and readers sharing one clock; the local resolve budget stays monotonic app-side. A DB test pins that the remaining budget measured by the DB clock equals the requested one. - enqueueMedia's waits (per-session order, global slot) only watched shutdown, so in a burst an already-expired job kept its goroutine and payload until it reached the front. Both waits now also watch the message's deadline; on expiry the job skips the resolver entirely and runs only the empty finalize (marker clear + promotion), which also unblocks the session's later messages. Covered by a queued-expiry test that finalizes while the only slot is deterministically held. Co-Authored-By: Claude Fable 5 <[email protected]> * chore(migrations): renumber to 216-221 after upstream took 213-215 Co-Authored-By: Claude Fable 5 <[email protected]> * fix(channel): start the local media budget before the append transaction The DB anchors the durable fallback at insert-time now(), but the local monotonic budget started only after AppendMessage returned — so the resolver outlived the fallback by the append/commit latency, a window where the deferred task is already claimable while the resolver still runs and the agent reads a placeholder that binds moments later. Capture the local deadline before calling AppendMessage, restoring the ordering local-gives-up <= durable-fallback-fires. A slow-append test pins that the resolver's context deadline is measured from the pre-append instant. Co-Authored-By: Claude Fable 5 <[email protected]> * chore(migrations): renumber to 224-229 after upstream took 216-223 Verified against the merged tree: the numeric-prefix uniqueness test passes and the full migration set applies cleanly from scratch. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(channel): heartbeat the reconciler lease per row One claim covers up to 50 rows under a single 2-minute lease, but the batch is processed sequentially and each delete may run its full 30s timeout — a few stalled deletes could outlive the lease mid-batch, letting another replica reclaim the tail: duplicate concurrent deletes, inflated attempt/backoff on rows whose owner was alive, and skewed metrics. The lease is now renewed before EACH row's settle work, so it only ever needs to cover one row's worst case (invariant-tested: lease >= 2x the per-delete timeout). A renewal that matches no row means another worker reclaimed it after a genuine expiry — the row is skipped, leaving the new owner's state untouched. Test simulates a mid-batch reclaim and pins the skip. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(channel): dedup post media resources and make local writes atomic A rich post may reference the same image_key/file_key in several spans. The object key derives from (message, type, key), so duplicates uploaded to the SAME key twice: LocalStorage.UploadStream truncated the destination up front and removed it outright on a copy error, so a second failing attempt destroyed the object the first success had produced — leaving an attachment row pointing at nothing. A second succeeding attempt instead produced two attachment rows for one object. Collapse duplicate spans by (fetch type, platform key) before the upload loop, and write local uploads through a temp file renamed into place so a failed write can only discard its own temp file. Tests cover a duplicated span uploading once and a failed re-upload leaving the previous object intact. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(channel): fence late-materializing PUTs with a tombstone schedule A DELETE cannot be ordered against a PUT the client already abandoned: the store may materialize the object after the delete completes. The reconciler cleared the ledger row right after deleting, so such an object had no row and nothing to reclaim it — which made the settle delay the de-facto correctness barrier for the PUT/DELETE race, exactly what the design says it must not be. The row is now kept as a tombstone ('tombstoned' state, migration 226's CHECK) and re-deleted on a widening schedule (15m, 1h, 6h, 24h, the pass index carried in last_error), so a late materialization is reclaimed by a later pass; only after the schedule is exhausted is the row dropped. Claim, heartbeat, lease, and tenancy predicates are unchanged — a tombstone is claimed exactly like any other due row. A separate gauge reports tombstones so they cannot be mistaken for a backlog of objects awaiting reclaim, and the header comment now states precisely what state fences (bind/commit) versus what the schedule fences (late PUTs). Tests: the reviewer's interleaving — DELETE completes, the abandoned PUT materializes right after, and the object is gone by the end of the schedule — plus a full schedule walk asserting the object is counted once and the row clears at the end. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(channel): tombstones must re-delete, not re-ask the reference question A tombstone revisit ran the same reference check as a first settle, so an attachment carrying the same URL — a re-ingested copy of the object — sent the row down the "referenced, keep it" branch: the object was kept and the row cleared, abandoning the re-delete schedule that fences the ORIGINAL object against an abandoned PUT. A tombstone has already been judged unreferenced and deleted; it exists only to re-delete whatever materializes later, so it now goes straight to the delete + schedule tail (extracted as settleDeletedObject, shared with the first settle). Co-Authored-By: Claude Fable 5 <[email protected]> * fix(channel): keep the tombstone schedule position in its own column The re-delete pass index was encoded into last_error, which the failure path also writes: one failed re-delete erased the position and restarted the walk. A store failing intermittently could therefore keep a tombstone alive indefinitely — every recovery would resume at pass 1 and the row would never reach the end of the schedule to be dropped. tombstone_pass is now its own column (the table is introduced in this PR, so migration 226 carries it), advanced only by a successful delete, and the tombstone write clears the now-stale last_error. Test walks the schedule across a failed re-delete and asserts it resumes rather than restarts, and that the row still terminates. Co-Authored-By: Claude Opus 5 <[email protected]> * fix(lark): derive media object keys per chat message The object key was derived from the platform message alone, so a second ingest of the same Feishu message reused the first ingest's ledger row. That row can be a tombstone (up to ~31h while the re-delete schedule runs), and the intent upsert refuses anything that has left 'pending', so the second ingest skipped the upload and silently produced a placeholder with no attachment. A re-ingest is reachable: the inbound dedup claim is reclaimable once 60s stale and the dedup row is only vacuumed after 24h. Keying on the chat message the object will attach to keeps the two ingests independent, and nothing leaks: each one's objects are covered by its own ledger row. Co-Authored-By: Claude Opus 5 <[email protected]> * refactor(storage): route both local upload paths through one atomic write UploadStream wrote through a temp file and renamed into place, but the buffered Upload path still truncated the destination up front — the destructive shape the stream path exists to avoid, one caller away from coming back. Both now share writeAtomic, which also restores the 0644 the direct write used (CreateTemp makes files 0600). Co-Authored-By: Claude Opus 5 <[email protected]> * chore(channel): gofmt the media-pending append fields Co-Authored-By: Claude Opus 5 <[email protected]> * fix(storage): keep the local upload chmod best-effort The rename-into-place rewrite made a failed chmod fail the whole upload. CreateTemp's 0600 has to be widened to the 0644 the direct write used, but an upload dir on a mount that ignores chmod (SMB/NFS/FUSE) accepted the old direct write fine — turning those deployments' uploads into hard errors would be a regression for a cosmetic property. Log and continue. Tests pin 0644 on both upload paths, and that a failed buffered upload leaves no temp litter and no damage to a previous object. Co-Authored-By: Claude Opus 5 <[email protected]> * fix(channel): never re-delete an object an attachment references The tombstone pass skipped the reference check and deleted unconditionally, so a durable attachment carrying that URL lost the only object it can read — the dangling attachment the intent ledger exists to prevent, and the opposite of the posture every other path here takes ("a reclaimable orphan beats a broken attachment"). The check now runs on every pass. A positive result on a tombstone is unreachable by design — keys are per (chat message, resource) and a bind cannot attach a key that has left 'pending' — so reaching it means an invariant broke: keep the object, clear the row, log it, and count it on a dedicated reconciler_tombstone_referenced_total counter. The test's contract is flipped to assert the referenced object survives. Co-Authored-By: Claude Opus 5 <[email protected]> * fix(storage): make the local staging file reclaimable after a crash os.CreateTemp's random suffix meant a crash between the staging write and the rename left a file nothing could name: the ledger records only the final storage key, and DeleteObject removed only the object and its sidecar. Each leftover can approach the 100 MiB resource cap and they accumulate without bound. The staging path is now derived from the object key, so DeleteObject removes it alongside the object — which makes the media reconciler reclaim it too, since the intent row is written before the upload. Opening it 0644 directly also drops the chmod the previous commit had to make best-effort. Both read paths refuse the staging name (keys come from the request URL, and a half-written body should not be readable); a user-supplied ".tmp" extension is unaffected, since object keys are generated and never dot-prefixed. Co-Authored-By: Claude Opus 5 <[email protected]> * chore(channel): renumber the media migrations after merging main main took 224 (agent_task_session_rollout_missing), so the ledger group moves to 225-230 and the cross-references inside the table migration follow. main's CompleteTask also grew a sessionRolloutMissing parameter; the three call sites this PR added to chat_input_ownership_test.go pass false. Verified the way the numbering is meant to be verified: full migration set applied from scratch on the merged tree, and the whole server suite run against that database. Co-Authored-By: Claude Opus 5 <[email protected]> * fix(channel): let Postgres compute every reconciler deadline The reconciler built settle cutoffs, lease expiry, backoff and re-delete times from the process clock and compared them against the database's now(). A replica whose clock had drifted would therefore settle rows whose upload was still in flight (the object is deleted and the bind then refuses to attach — media silently lost), hand out leases that are born expired (rows churn between workers, attempt/backoff inflate), or compress the tombstone schedule that fences a late-materializing PUT. The four settle queries now take durations and derive their timestamps from now(), so every replica reads one clock. The parameter types are the guard: an app-side timestamp can no longer be passed. Test asserts the persisted lease, backoff and re-delete deadlines all track the database's now(). The generated code also picks up main's new agent_task_queue column in the two RETURNING task.* queries this PR adds. Co-Authored-By: Claude Opus 5 <[email protected]> * chore(lark): drop unrelated gofmt-only churn from this PR Six files carried whitespace/comment-reformatting with no functional change, unrelated to the inbound media pipeline. Reverting them to the base revision keeps the diff focused on the feature (75 -> 69 files): server/internal/service/empty_claim_cache.go server/internal/integrations/lark/markdown_detect.go server/internal/integrations/lark/ws_chunk_assembler.go server/internal/integrations/lark/ws_chunk_assembler_test.go server/internal/integrations/lark/ws_frame_test.go server/internal/integrations/lark/registration_test.go Verified: `git diff -w` against these files was already empty, so no behavior is affected. go vet clean; tests covering these files pass. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Claude Fable 5 <[email protected]> Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
85a14cde37 |
fix(daemon): gate codex session pointer writes on rollout presence (MUL-5305) (#5960)
* fix(daemon): gate codex session pointer writes on rollout presence (MUL-5305) Codex issue follow-ups on local_directory projects intermittently lost their session: the server sent a prior session whose rollout was not in the task CODEX_HOME, so the daemon dropped the resume and started a fresh thread (gateCodexResumeToRolloutPresence), losing the conversation. Root of the bad pointer: the daemon persists a Codex session id as the resumable pointer at two points -- the mid-flight pin and the terminal report -- before the rollout is guaranteed on disk. A task that exits early (crash / runtime offline / timeout) leaves a pinned/reported session id with no rollout; GetLastTaskSession (which accepts failed rows) then hands it to the next follow-up, which drops it. Enforce the invariant at write time: only record a Codex session as the resumable pointer once its rollout is present in the per-issue store, with a short bounded wait for flush. If it never lands, don't overwrite the last good pointer -- a blanked session_id becomes NULL server-side, so GetLastTaskSession falls back to the most recent session whose rollout is real. Non-Codex providers are unaffected; crash recovery is preserved because a present rollout still pins. - codexSessionResumable: shared write-time presence check (bounded wait) - runTask: gate the terminal session_id before reporting - executeAndDrain: gate the mid-flight pin (thread codexHome through) - tests: helper cases + behavioral pin test Co-authored-by: multica-agent <[email protected]> * fix(daemon): address review — don't silently downgrade completed sessions (MUL-5305) Follow-up to review feedback on #5960: - Must-fix 1 (silent downgrade): limit the write-time session withholding to NON-completed terminal states. A missing rollout means no resumable conversation was persisted, so a withheld non-completed attempt loses nothing; a completed session is authoritative and, if its rollout is anomalously absent, is still recorded so the next run's resume gate discloses the loss (PriorSessionResumeUnavailable, MUL-4424) instead of silently falling back to an older session. Extracted resumableTerminalSessionID. - Non-blocking risk: pin the mid-flight resume pointer with a per-status presence check instead of one fixed 2s window, and set sessionPinned only once the rollout is confirmed, so a rollout that lands shortly after the first status is still pinned this run. - Must-fix 2 (regression coverage): pin skipped when rollout absent (no /session call); terminal helper (completed keeps / failed withholds); and a DB-backed GetLastTaskSession test proving the next claim falls back to the older recorded session when the latest was blanked. Co-authored-by: multica-agent <[email protected]> * fix(daemon): disclose Codex session continuity gaps end-to-end (MUL-5305) Addresses review feedback on #5960. Must-fix 1 — a completed turn whose rollout is missing is exactly the #5934 case (the reporter waits for each turn to finish), so it can no longer be excluded from withholding. Withhold the session for ANY terminal state, and pair the withhold with a persisted continuity-gap signal so the next claim still discloses the loss even while resuming an older good session: - new agent_task_queue.session_rollout_missing column (migration 224) - daemon sends session_rollout_missing on the terminal report; the handler clears the resume pointer (MarkTaskSessionRolloutMissing, overriding FailAgentTask's COALESCE) and flags the row - claim reads GetLatestTaskRolloutMissing and sets a new prior_session_resume_unavailable response field, which the daemon ORs into the brief's PriorSessionResumeUnavailable disclosure Must-fix 2 — Codex reveals the session id on a single task_started status, so a one-shot presence check missed a rollout that flushed later and lost in-flight crash recovery. Pin via a background waiter bounded by the run's context that pins the moment the rollout lands. Tests: - completed + rollout missing -> next claim withholds the bad session AND flags the continuity gap (cross-layer DB test) - session pinned once its rollout appears after the status (mid-run) - pin skipped while the rollout is absent Co-authored-by: multica-agent <[email protected]> * fix(server): make continuity-gap write atomic + disclose on all claim paths (MUL-5305) Addresses review round 3 of #5960. Must-fix 1 — the previous handler-level marker ran AFTER the terminal transaction committed, and FailTask creates + wakes the auto-retry inside that same transaction, so a retry could claim the rollout-missing session before the marker cleared it (and a marker failure was swallowed). Move session_rollout_missing INTO the terminal write: CompleteAgentTask and FailAgentTask now force session_id NULL (overriding Fail's COALESCE that would keep a stale mid-flight pin) and set the flag in the SAME UPDATE, so the withhold + gap flag commit atomically with the retry creation. The flag is threaded through TaskService.CompleteTask/FailTask; the swallowed best-effort MarkTaskSessionRolloutMissing query is removed. Must-fix 2 — the daemon withholds for all Codex tasks, but only the issue non-rerun claim consumed the disclosure. Now every fallback path sets prior_session_resume_unavailable: the manual-rerun branch reads the source task's session_rollout_missing, and the chat branch reads a new GetLatestChatTaskRolloutMissing. Tests (cross-layer DB): - completed + rollout missing via the real CompleteAgentTask terminal write -> session withheld AND gap flagged - failed + rollout missing forces session_id NULL over the COALESCE- preserved mid-flight pin in ONE statement Deploy order: migration + server first, daemon second (new fields are omitempty and ignored by an old peer). Co-authored-by: multica-agent <[email protected]> * fix(handler): return 5xx on FailTask error + cover claim-response gap paths (MUL-5305) Addresses review round 4 of #5960. Must-fix 1 — the FailTask handler returned 400 on a service/DB error, but the daemon's terminal callback treats 400 as permanent (postJSONWithRetry / isTransientError bails without retrying). Since the fail transaction is now the sole persistence point for the withheld session + continuity-gap flag + auto-retry, a rolled-back fail must be retried, so return 5xx (an invalid request body still returns 400), mirroring CompleteTask. Regression: client.FailTask retries on a transient 5xx and eventually succeeds. Must-fix 2 — add claim-response-level regressions that drive the two new disclosure branches through buildClaimedTaskResponse: - chat: the latest terminal task on the session withheld -> the next chat claim sets prior_session_resume_unavailable - manual rerun: the source task withheld -> the rerun claim discloses These handler DB tests run under CI's fully-migrated database (the local workspace DB cannot set up the handler fixture). Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
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]> |
||
|
|
168620fc15 |
MUL-5163 fix(agents): rebind the Agent Builder carrier when the runtime is switched (#5780)
Switching the runtime mid-conversation in Build with AI only updated local React state, so the picker could show runtime B while every subsequent message still executed on the runtime frozen at session create time.
- Add PATCH /api/agent-builder/sessions/{id}/runtime to rebind the hidden builder carrier (runtime_id/runtime_mode, model cleared since model ids are per-runtime). Creator-only, builder carriers only, target must be in-workspace, usable by the member, and online; a reply in flight returns 409.
- Serialise rebind against send: both take LockChatSessionForRuntimeBind on the chat_session row and SendDirectChatMessage re-reads the agent inside that transaction, so a send blocked behind a rebind cannot resume and stamp its task with the runtime the switch moved away from.
- Leave chat_session.runtime_id stale on purpose so the daemon starts a fresh provider session on the new runtime while Multica-side history and the draft survive.
- Frontend updates the draft only after the server reports the bound runtime, blocks sending during a rebind, disables the Mine/All filter alongside the trigger, and explains why the picker is locked during a pending reply.
Closes #5773
|
||
|
|
ae68799c64 |
feat(task): reuse workdir on manual retry, gate session on error class (MUL-4869) (#5525)
* feat(task): reuse workdir on manual retry, gate session on error class (MUL-4869)
A manual retry (execution-log "retry" button -> POST /api/issues/{id}/rerun)
previously set force_fresh_session=true unconditionally, so the daemon threw
away the failed run's workdir and started from an empty one. For a transient
failure (network blip, provider 5xx / rate-limit, runtime_offline, timeout)
that discards work the agent already did.
New contract: a retry ALWAYS reuses the source task's workdir when it still
exists on disk; only the agent SESSION is gated, on whether the source task's
failure poisoned the conversation.
- RerunIssue derives force_fresh_session from the source task's failure_reason
via resumeUnsafeFailureReason instead of hardcoding true. Transient/cancelled
failures resume the session; conversation-poisoning failures start fresh.
- The daemon claim handler resolves the retry's session/workdir precisely from
rerun_of_task_id, not the most-recent (agent,issue) row that a parallel task
could hijack. PriorWorkDir is returned whenever present; PriorSessionID only
when resume-safe AND on the same runtime.
- force_fresh_session now gates only the session, never the workdir.
- Add agent_error.context_overflow to the resume-unsafe set (Go helper plus the
GetLastTaskSession / GetLastChatTaskSession blacklists): resuming an overflowed
context would immediately overflow again.
Objectively-unreusable cases (workdir GC'd, different runtime, failed before a
workdir was recorded) fall back to a fresh Prepare via the daemon's existing
execenv.Reuse / gateResumeToReusedWorkdir path, so no daemon change is needed.
Tests: RerunIssue force_fresh classification by failure_reason; claim-layer
workdir-always-reused plus session gating (same/different runtime, poisoned);
context_overflow classifier.
Co-authored-by: multica-agent <[email protected]>
* fix(task): make manual-retry workdir reuse rollback-safe and error-text aware (MUL-4869)
Addresses code review on #5525.
- Rollback safety: RerunIssue no longer writes force_fresh_session based on the
source failure. Rerun rows are pinned to force_fresh_session=true again, so an
OLD claim handler picked up mid rolling-deploy degrades to a clean start
instead of falling back to the (agent, issue) most-recent lookup and resuming
a *different* execution. The new claim handler ignores the flag for reruns and
computes session reuse from the exact source task (rerun_of_task_id).
- Legacy poison defense: add shared service.ResumeUnsafeFailure(failureReason,
errorText), which combines the failure_reason poison set with the same
400/invalid_request_error raw-error-text guard GetLastTaskSession applies. The
claim handler's exact-source path uses it, so legacy 'agent_error' /
deploy-window rows carrying a 400 marker are no longer resumed.
- CI: TestRerunIssueTargetsSourceTaskAgent passes again (rerun rows stay
force_fresh_session=true). Service test now asserts that rollback-safe
invariant across failure classes; the claim test drives session gating from
the source task and adds a legacy-400 case.
- Docs: tasks.{mdx,zh,ja,ko}.mdx and the GetLastTaskSession SQL comment now
distinguish execution-log per-row retry (task_id: reuse workdir, conditional
session) from CLI/API rerun (no task_id: fresh session + fresh workdir).
- Clarify cross-runtime workdir is best-effort: the daemon offers the source
workdir regardless of runtime (a shared mount may resolve it) and only the
per-cwd session is runtime-gated.
Co-authored-by: multica-agent <[email protected]>
---------
Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
|