Commit Graph
78 Commits
Author SHA1 Message Date
e909e9c89d MUL-7587 fix(daemon): show on-behalf-of identity in agent context (#8688)
* fix(daemon): expose original requester in agent context

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

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

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-24 15:35:54 +08:00
93ae552ba9 feat: add messages to active Claude Code and Codex runs (MUL-7565) (#8682)
* feat: add live run supplements (MUL-7565)

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

* fix: address MUL-7565 review feedback

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

* fix: resolve MUL-7565 supplement panel and receipt review

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

* refactor: remove MUL-7565 supplement ordinals

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

* fix: close supplement composer after run remount

* fix: exclude task supplements from completion reconciliation

* fix: build a persistent binary for local daemon launches

* feat: support Claude task supplements through SDK hooks

* refactor: simplify task supplement delivery

* test: remove redundant task supplement coverage

* fix: remove redundant supplement delivery notices

* refactor: extract local daemon launcher fix to its own PR

* fix: preserve ordinary comment size compatibility

* fix: accept empty task start acknowledgements

* fix: bound task start response decoding and retries

---------

Co-authored-by: Forge-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Co-authored-by: yushen <[email protected]>
2026-09-23 16:10:07 +08:00
2aa20b0353 ZIC-245: fix custom Oh-My-Pi runtime profile identity and discovery (#8576)
* fix(runtimes): preserve custom profile base runtime identity

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

* fix(runtimes): pass both url params to the profile identity test

TestUpdateRuntimeProfile_RejectsIdentityChange nested two withURLParam
calls. That helper installs a fresh chi route context each time and does
not copy what is already there, so the outer call for profileId dropped
id: the handler answered 400 "workspace_id is required" and never
reached the immutability check the test exists to cover.

Use withURLParams, which carries existing params forward and is what the
rest of this file already uses.

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

* fix(runtimes): name a runtime target the same way on every surface

The base-runtime picker spelled Oh-My-Pi with an inline ternary, so it
was the only place that used the product name. The catalog row, both
detail headers, the base-runtime detail row and the locked chip in the
form kept rendering the stored id under `capitalize` — a user picked
"Oh-My-Pi" and the next screen, and the saved profile, said "Omp".

Move the name into runtimeTypeLabel next to the target whitelist and
read it from every surface. profileRuntimeType replaces the repeated
runtime_type ?? protocol_family fallback.

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

---------

Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Bohan-J <[email protected]>
2026-09-20 12:53:52 +08:00
2b42c8e3f0 perf(server): slim daemon task status lookup (#8484)
Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-16 16:36:48 +08:00
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]>
2026-09-16 16:11:16 +08:00
1b3137a00d fix(server): MUL-7364 decide issue GC and recovery prefilter on lifecycle category (#8405)
* fix(daemon): decide issue GC on lifecycle category, not the status key (MUL-7364)

MUL-7240 collapsed issue_status.category to four values and changed
Effective() to leave nonterminal custom statuses as their stored key. The
two gc-check endpoints normalize through Effective(), so raw custom keys
started reaching every installed daemon, where isKnownIssueStatus() still
only recognizes the 7 built-ins. The GCCompletedTaskTTL full-cleanup path
requires a recognized status, so it silently switched off for any
workspace using a custom status: environment skeletons accumulate for as
long as the parent issue sits there. GCArtifactTTL is unaffected, so the
symptom is slow disk growth rather than a total stall.

Fix both layers:

- Both endpoints resolve the lifecycle category and re-encode it into the
  legacy seven-value `status` enum (WireCategory), which restores the
  v0.4.43 behavior for every daemon already installed.
- Both endpoints also return the four-value `category`, and the daemon
  decides on it when present, falling back to `status` for servers that
  predate this change. A status with no category (unresolvable key,
  Triage) is returned raw with no category so every daemon fails closed.

That removes the daemon's dependency on the 7 built-in keys instead of
leaving a comment to guard it -- a comment is what failed to guard it
this time, since it claimed the endpoints already answered with a
category.

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

* fix(server): prefilter unresumable recovery sources by key and category (MUL-7364)

ListPendingDelegatedFailureRecoveries excluded source issues via
COALESCE(issue_status.category, issue.status) NOT IN ('done',
'cancelled', 'backlog') -- reading the category column but comparing it
against the old seven-value key vocabulary. Since MUL-7240 a seeded
catalog (every workspace, per migration 469) makes COALESCE return the
category, so 'cancelled' arrives as 'closed' and 'backlog' as
'unstarted' and only 'done' was still excluded.

Nothing runs wrongly -- loadDelegatedFailureRecoveryTarget re-checks with
Effective() -- but those rows are discarded in Go WITHOUT stamping
recovery_settled_at, which is the very thing the query's own comment
says keeps the scan from growing with history. They are rescanned every
tick and spend part of maxPerTick forever, eventually crowding out real
recovery signals.

Split the predicate by vocabulary instead of widening the list: the key
test covers built-ins and does not depend on the catalog being seeded,
the category test covers custom terminal statuses. 'backlog' stays a key
test because parking is built-in behavior a custom unstarted status does
not inherit.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-14 19:03:43 +08:00
686957e743 MUL-7271: test(server): cut slow Go test time further (follow-up to #8295) (#8318)
* test(server): reduce slow unit test waits

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

* test(server): cut slow Go unit test time further

Follow-up to the first pass on MUL-7271. Test-only changes plus a few
package-private seams whose production values are unchanged.

- Re-executed race-built test binaries no longer pay the race runtime's 1s
  atexit sleep (GORACE=atexit_sleep_ms=0 for child processes only).
- pkg/agent: event-driven or shorter test-only bounds for handshake, drain
  and idle graces; redundant timeout variants merged.
- daemon/execenv: git fixtures copied from a template repo built once; tests
  that re-ran the same worktree lifecycle folded into their supersets;
  ForkLock-guarded fake-CLI writes so parallel tests cannot hit ETXTBSY.
- handler/service: cap and paging tests shrink their caps instead of seeding
  thousands of rows; lock waits probe pg_locks instead of sleeping; one
  shared pgxpool per service test binary; cheaper cron and RSA fixtures.
- wecom: the phishing-link sweep reuses one parser and derives CRLF from LF
  with a sampled cross-check; fake sockets acknowledge instead of waiting out
  the ack timeout.
- migrate: the migration corpus is parsed once; isolated DB tests run in
  parallel.

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

* test(server): remove transport timeout unit tests

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

* test(server): restore two non-timeout tests and make a bound load-bearing

Follow-up to the review of #8318. All test-only; no production code changes.

- daemon: restore the three read-liveness cases that the transport-timeout
  sweep took with it. They assert that a ping, a pong and an application
  frame each EXTEND the read deadline, so a healthy wakeup socket survives
  without traffic of our own — the deadline never fires on the happy path,
  which is why they are not transport-timeout tests. Verified they fail
  (all three) when the extension calls are removed from wakeup.go, and pass
  in 0.57s together. The pure "times out without peer traffic" case stays
  deleted.

- pkg/agent: restore the parent-DEADLINE half of the opaque-env redaction
  pair. codex.go treats a parent deadline and a parent cancellation as one
  ending; only the Canceled arm was left covered. The fake now writes the
  secret to stderr before reading the handshake, so the check no longer
  races our own initialize write, and the budget is 2s against a measured
  430-465ms launch. Verified it fails when the !contextEnded guard is
  removed.

- pkg/agent: TestRunCollectQuietDoesNotWaitWhenTheAnswerIsIn had its bound
  widened to a literal 5s, which the shrunk 750ms drain grace can never
  reach — the test could no longer fail. It now raises the drain grace to
  10s for its own duration and asserts under 3s: healthy runs take
  0.19-0.44s, and a broken short-circuit takes 10.19s and fails.

- wecom: gofmt the import block in relay_settle_budget_test.go.

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Co-authored-by: J <[email protected]>
2026-09-14 14:33:50 +08:00
7dafc0cd62 MUL-7240: Unify issue lifecycle into four stored categories (#8261)
* feat: simplify issue status categories

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

* feat: adopt four stored issue lifecycle categories

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

* fix: preserve visible work when upgrading saved category preferences

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

* docs: align lifecycle comments with stored and wire contracts

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

* test: align mobile grouping expectations with four categories

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

* fix(issues): group user views by concrete status keys

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

* fix(settings): simplify status editor copy and category selector

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

* feat(statuses): allow ordering built-ins and unify row actions

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

* feat(statuses): configure custom status icons independently of category

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

* fix(statuses): dismiss built-in status notice

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

* fix(issue-status): require moving issues before archive and polish controls

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

* fix(ci): renumber issue status migrations after main updates

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

* fix(statuses): isolate archive inspection and align catalog behavior

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

* fix(statuses): respect injectable catalog for table grouping

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

* refactor(statuses): simplify built-in skill and runtime guidance

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-14 12:57:24 +08:00
d44932089b refactor(daemon): drop the reused-workdir prompt block now that checkout keeps work (MUL-7034) (#8316)
The "Files from an earlier run" per-turn block was added in #8291 because a
fresh session in a reused workdir had no memory of the work there, and
re-running `multica repo checkout` reset an existing checkout and deleted it.
MUL-7284 (#8309) fixed that in checkout itself: an existing checkout holding
uncommitted, untracked or unpushed work is kept, and the CLI reports it
("Kept the existing checkout ... N uncommitted files, M unpushed commits").
The brief documents that behavior and --fresh, and the server's continuity
notice already says the earlier attempt's context did not come back. The block
only repeated those, so remove it, its two runTask insertion points, and its
tests. prompt.go and runTask are back to their pre-#8291 form.

The server still offers an automatic retry its parent's workdir only to
daemons that make that safe. The capability now names what they guarantee:
reused-workdir-notice-v1 becomes checkout-keeps-work-v1. Neither string has
shipped in a release, and a new daemon talking to an old server, or the
reverse, just gets a fresh directory as today.

The end-to-end runTask test keeps its reuse assertions: a fresh-session run
offered a prior workdir continues in it, and falls back to a fresh directory
once that one is gone.

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-11 17:18:03 +08:00
Bohan Jiang 676245af63 MUL-7034: fix(server): keep the parent workdir when an automatic retry must start fresh (#8291)
A task that failed with codex_semantic_inactivity is retried with a fresh
provider session, which is right: the conversation is what got stuck. But the
retry also lost the parent's workdir, so it started in an empty directory and
could not see anything the failed attempt had done. Manual Retry already keeps
the workdir for the same failure (MUL-4869); only the automatic path dropped it.

Server: CreateRetryTask stops nulling work_dir (session_id is still nulled and
force_fresh_session still set). The claim handler never read the child's own
work_dir, so on both the issue and chat branches it now hands it back as
prior_work_dir for an automatic retry (retry_of_task_id set) that must start
fresh, with prior_session_resume_unavailable. A force_fresh task without retry
lineage, such as the Lark fresh-session command, still inherits nothing.

Daemon: a fresh session in a reused directory has no memory of the work there,
and the brief's `multica repo checkout` resets an existing checkout, deleting
uncommitted changes. The daemon now appends a per-turn "Files from an earlier
run" block whenever it actually reused a prior workdir without resuming its
session, including the fresh retry after a rejected resume. It is keyed to what
the daemon did, so a GC'd directory gets no warning, and it never enters the
byte-stable brief.

The server offers the parent's workdir only to daemons advertising
reused-workdir-notice-v1; an older daemon keeps getting a fresh directory.

Closes #7998
2026-09-11 14:32:31 +08:00
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 1b337bbe46.

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

* fix(tasks): keep cancellation attribution consistent

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-10 21:24:35 +08:00
c03633d64a MUL-7202: fix(usage): distinguish unreported agent runs (#8241)
* fix(usage): distinguish unreported agent runs

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

* fix(usage): handle dashboard rollup lag

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

* fix(usage): preserve nonterminal usage totals

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

* fix(usage): expose truncated coverage details

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-10 16:26:38 +08:00
2efe1b2a43 MUL-6984 fix(daemon): let workflow step 2 alone decide the comment scan (#7972)
* MUL-6984 fix(daemon): let workflow step 2 alone decide the comment scan

The brief's step 2 called the catch-up scan mandatory while the per-turn
hints in the same context made it conditional ("Need cross-thread
background?", "Only if you need context from the other threads", "If your
reply depends on thread context"). Step 2 now owns the modality; the hints
carry facts and this turn's exact commands and hand the scan over as the
wide read. Hint dispatch keys on PriorSessionID before the delta so a run
whose resume the daemon dropped takes the fresh-session path instead of the
resumed delta hint. The assignment pointer drops the parenthetical that
implied only assignment turns treat the read as mandatory.

Measured on 537 comment-triggered runs: 1 in 10 scans opened a thread the
prompt had not named; 0 of 36 non-scanning runs did; scan median 434 B.

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

* fix(daemon): only waive the comment scan on a delta the server actually computed (MUL-6984)

Addresses review on #7972.

[P1] NewCommentCount == 0 was read as "nothing else was said on this issue",
and used to tell the agent its scan is already answered. The producer in
buildClaimedTaskResponse is best-effort: it writes the count fields only when
the anchor lookup and the count query both succeed AND the count is positive.
A failed anchor read, a failed count query, a cold start with no prior run, a
true zero, and an old server that never sends the fields therefore all arrive
as the same zero — and only the true zero looked at the issue. On a resumed
run a single failed count query was enough to delete the mandatory scan
exactly when the run could least afford it.

The claim now carries new_comments_delta_known, set on the success path
whatever the count. The count fields still stay suppressed at zero (there is
no delta hint to render from a zero), so the flag is the only thing that
separates a checked zero from an unchecked one. Absent on old servers, which
reads as "not authoritative" and keeps the scan — the safe direction in both
mixed-version pairings. The resumed path with no authoritative delta now
renders BuildResumedUnknownDeltaCommentsHint, which keeps the session facts
and hands the scan over as a command instead of claiming it is answered.
Workflow step 2 is tightened to match: only an explicit server report waives
the scan, and silence is not that report.

[P2] The warm path was selected on PriorSessionID != "" alone. MUL-5305 lets
the server withhold a more recent Codex session whose rollout is missing and
return an OLDER fallback session instead: PriorSessionResumeUnavailable is
true while PriorSessionID is non-empty. That older session may resume cleanly,
but it is not the turn this run continues from, so the run was taking the warm
path — and on an empty delta could skip both the full trigger-thread read and
the scan — on the strength of context it does not have. It now requires
!PriorSessionResumeUnavailable and reconstructs from the record instead, which
is what the continuity notice already tells the user happened.

Tests cover the states the existing suite could not see: an older fallback
session with every other warm-path precondition satisfied, resumed-with-delta-
unavailable versus resumed-with-authoritative-zero as siblings, and a
server-side computed zero carrying the flag. Each was verified against the
un-fixed code, where it fails. TestBuildPromptResumedNoDeltaDoesNotForceThread-
Read now sets the flag: its subject is the waiver branch, which is exactly the
branch that requires an authoritative answer.

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

* docs(handler): correct the new-comment delta's scope on the claim wire (MUL-6984)

Review should-fix on #7972. NewCommentCount's doc comment still described it as
"trigger-thread comments since last run", while the query that produces it
(CountNewCommentsSince: "ACROSS THE WHOLE ISSUE, every thread, not just the
triggering one"), the daemon-side mirror field, and the NewCommentsDeltaKnown
comment directly beneath it all say issue-wide. One struct carrying two
mutually exclusive readings of the same field is worse than either.

Also records on NewCommentsSince that it is suppressed together with the count
at zero, so the next reader does not mistake a non-empty anchor for the signal
that the server looked — NewCommentsDeltaKnown is that signal.

Comment text only.

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

* fix(daemon): stop calling an older-fallback resume a fresh session (MUL-6984)

Under MUL-5305 the server hands back an OLDER session with the continuity
gap flagged, and the daemon resumes it (ResumeSessionID is not gated on the
flag). The cold hint opened with "This run starts a fresh session on this
issue" and the readable continuity notices said "you are on a fresh one";
both were false in that state, and a test pinned the first.

The cold hint now states nothing about the session — none of the four runs
it serves (first run, dropped resume, explicit fresh, older fallback) makes
a different decision on it, and all four reconstruct from the issue record
with the same two reads. The three readable notices say the run "does not
continue" the lost session and that the memory gone is "from the turns
that did not come back", true in both the fresh and the fallback case.

TestBuildPromptOlderFallbackSessionRequiresReconstruction (renamed) asserts
the reconstruction commands and the notice, and bans "fresh session" and
"fresh one"; the resumed-path negative anchors now key on a phrase only
the cold hint carries.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-04 15:06:00 +08:00
571e61128d MUL-7002: remove database reads from WebSocket heartbeats (#7976)
* fix(runtime): remove websocket heartbeat lookups

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

* fix(runtime): preserve explicit deregistration

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-03 16:12:34 +08:00
603519d158 MUL-6977: test: remove implementation-only coverage (#7932)
* test: remove implementation-only coverage

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

* test: remove remaining implementation-only coverage

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

* test: restore compatibility and fragment navigation coverage

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

* test: preserve behavioral cleanup contracts

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-03 13:23:25 +08:00
cd169947b5 MUL-6686 feat(daemon): use readable workdir paths (#7316)
Create new task environments under sanitized, bounded readable segments:
<workspace-slug>-<workspace-short>/<issue-key>-<task-short>/workdir.

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

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

Closes #6063

Co-authored-by: iiwish <[email protected]>
Co-authored-by: J <[email protected]>
2026-08-26 16:34:10 +08:00
bbba9c5460 MUL-6697: enforce runtime access on task claims (#7571)
* fix(tasks): enforce runtime access on claims

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

* fix(tasks): settle invalid runtime claims explicitly

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

* test(tasks): cover runtime owner claim mismatch

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-26 15:55:05 +08:00
YYClaw 8c563b49be MUL-6555 feat(issues): preserve source context for sub-issues (#7461)
* feat(issues): preserve source context for sub-issues

* fix(issues): address source context review findings

* fix(migrations): guard source context rollback preflight
2026-08-25 12:00:49 +08:00
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]>
2026-08-22 11:27:39 +08:00
Bohan JiangandRAVN fa797d0063 MUL-6547 fix(daemon): resolve claim project context once, workspace-scoped (#7405)
Collapses the project-context resolution that had been copied across the
issue, chat and quick-create claim paths (and would have been copied a
fourth time) into one workspace-scoped, fail-closed resolver, and hydrates
run_only autopilot project context on top of it.

- Project and resource reads are both workspace-scoped, so a dangling or
  cross-tenant project reference can no longer lift another workspace's
  repository URL, local directory path, title or description into a claim.
- A failed read is no longer treated as "there is no project". Transient
  failures reject the claim and preserve the task for the stale-dispatched
  reclaim rather than silently falling back to workspace repos.
- Source rows (issue, chat session, autopilot run, autopilot) fail closed
  the same way. Previously a read error skipped the branch entirely and,
  because taskToResponse seeds the runtime workspace, sailed through the
  backstop isolation check — dispatching an agent with no issue context or,
  for chat, no input at all, bypassing the empty-input guard.
- Workspace isolation is resolved authoritatively per branch and checked
  before any context is hydrated. For run_only autopilots that check had
  been a tautology, comparing the runtime workspace against itself.
- run_only autopilots now receive their configured project's repositories
  and local_directory, the same contract issue-bound and quick-create tasks
  already had.

Absorbs #7396. Its inline implementation is replaced by the shared
resolver; the author's regression test is carried over unchanged.

Co-authored-by: RAVN <[email protected]>
2026-08-22 11:18:02 +08:00
fa34f0e134 MUL-6243 test(gc): pin that gc-check reports a status's category, not its key (#7327)
* test(gc): pin that gc-check reports a status's category, not its key

The daemon GC decides reclaim eligibility with literal string comparisons
— `result.Status == "done" || "cancelled"`, and an `isKnownIssueStatus`
switch over the 7 built-ins. Neither knows custom statuses exist, and
that is correct: an installed daemon has no catalog, and daemons
predating MUL-6243 must keep deciding correctly against an upgraded
server.

So the normalization belongs to the server, and both gc-check endpoints
already do it via issuestatus.Effective. Nothing pinned that. Dropping
either call compiles, passes every existing test, and silently means:

  - an issue on a `done`-category custom status is never terminal, so its
    workdir is retained indefinitely, and
  - isKnownIssueStatus rejects the raw key, disabling the
    GCCompletedTaskTTL full-cleanup path for that issue.

Adds the regression test over both endpoints (batch and the legacy
per-issue fallback older daemons still call), and records the invariant
on the daemon side, where a reader of those literals is most likely to
conclude the function needs teaching about custom statuses.

No behavior change.

MUL-6243

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

* fix(gc): resolve a gc-check batch with one catalog read, not one per row

Elon's review. BatchIssueGCCheck resolved each row through the
package-level issuestatus.Effective, which issues a
GetIssueStatusEntryByKey for every CUSTOM key it sees. With the batch
capped at 500 ids, one request could cost 500 catalog point lookups — on
the endpoint that exists specifically to replace per-issue requests, and
which every installed daemon runs on a timer. Enabling custom statuses
would have turned the GC loop into a query amplifier.

A request-scoped issuestatus.NewResolver reads the catalog lazily and at
most once, so cost is flat in the number of custom rows and still zero
when a batch holds only built-ins. The legacy per-issue endpoint keeps
the single-shot Effective — one issue, at most one lookup — but now goes
through the same injection point so its cost is observable too.

Pinned with the existing counting querier, extended to count per-key
lookups: that count IS the N+1, and nothing was watching it. Reverting to
the package-level call fails with "per-key catalog lookups = 4, want 0".

MUL-6243

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

---------

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

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

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

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

* fix(daemon): address task isolation review

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 15:30:19 +08:00
Jiayuan Zhang 877a71568f test(server): add internal/testutil and migrate the eight heaviest handler suites (MUL-6361) (#7160)
internal/handler held roughly 1000 hand-written `INSERT ... RETURNING id` blocks with a matching `t.Cleanup(DELETE ...)`, and roughly 1200 copies of the recorder / call / status-check / decode quartet. Each copy was an independent statement of the same contract, so changing a shared one meant editing every copy.

internal/testutil holds the two halves of that contract once: Fixture builds rows for a test workspace and deletes them again when the test ends; Call runs one handler and records what it wrote, with Want asserting the status and JSON decoding the payload.

The eight heaviest suites move onto it: 19,622 lines -> 16,984 (-13.4%). Assertions whose message says something the shared one cannot stay where the test wrote them.

Verified by comparing what actually ran: the package's test inventory is identical before and after, 2822 PASS / 44 SKIP / 0 FAIL over the same test names, and go test -race is green for both packages.
2026-08-19 03:09:59 +08:00
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]>
2026-08-18 19:15:56 +08:00
5f4ba41d13 MUL-6323 fix(views): stop blaming the daemon when the backend is the stale half (#7126)
* fix(views): stop blaming the daemon when the backend is the stale half (MUL-6323)

A self-hosted user on macOS Desktop could not pick worktree mode, and the
dialog explained why: "the Multica on that machine is v0.4.28; parallel needs
0.4.24 or newer". v0.4.28 was the newest release at the time. There was no
version left to upgrade to (#7113).

Nothing has gated on a version since MUL-5707 moved all three gates onto the
declared local-worktree-v1 capability. The floor survived only as the number
this copy interpolated, so every capability miss — whatever caused it — was
reported as a version shortfall the user had already cleared.

Underneath was a second problem the copy could not express. The capability is
recorded by the SERVER at registration, and servers before v0.4.25 write
runtime metadata with no capabilities key at all. Desktop ships its own
renderer AND its own daemon and updates both together, so the client routinely
runs ahead of a backend the operator upgrades by hand: the daemon advertises
the capability, the old backend drops it, and the UI blames the machine that
was already newest. Upgrading it changes nothing.

So the gate now answers with LocalWorktreeSupport instead of a boolean, and
the discriminator is the PRESENCE of the capabilities key rather than its
contents — a capability-aware server always writes it, null when the daemon
sent no header, so a row without it can only have come from an older server.
The two negatives get their own copy pointing at the half that can actually be
fixed, and no message quotes a daemon version any more; the server's own 422
was rewritten the same way in #6904.

Also records capabilities on runtime-profile registration failure rows. Those
are written after the successful runtimes of the same request, so until the
first heartbeat they are the newest row for the daemon — which is the row
every worktree gate reads. A failed custom profile briefly made a capable
daemon look incapable, and would now have made a current backend look stale.

Removes localWorktreeSupported and MIN_LOCAL_WORKTREE_CLI_VERSION from the
frontend: the first had no callers left, the second only fed the copy this
change deletes.

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

* fix(views): ask the live backend whether it records capabilities (MUL-6323)

Review follow-up. The first pass read a missing `capabilities` key as "this
backend predates the handshake", but the key's absence only proves the server
was old WHEN THE ROW WAS WRITTEN. Upgrading the backend does not rewrite
runtime metadata and heartbeats touch only last_seen_at, so an upgraded
deployment would keep being told it was too old — and upgrading it again would
change nothing. The daemon does not re-register on a timer, so that state can
sit there indefinitely.

The question is now asked of the server actually in front of us, via
/api/config's server_version, and the stale-row case gets its own answer: both
halves are fine, the machine just has to register again. An ABSENT version
reads as capability-aware, which is the load-bearing default — /api/config
omits the field on the managed cloud by design and leaves it empty for
unstamped dev builds, while the field itself shipped in v0.4.2, 23 patches
before the handshake. So every self-hosted release that could genuinely be
blind still names itself, and cloud users are never told to upgrade a backend
they do not run.

Replaces the cross-side version-floor test rather than deleting the contract.
It pinned MIN_LOCAL_WORKTREE_CLI_VERSION against a frontend twin that this
branch removed, and the number it guarded is one the UI no longer shows. What
the two sides must still agree on is the capability token: the daemon
advertises that exact string, the server stores it, the UI looks for it, and a
typo on either side disables worktree mode for everyone with no error anywhere.

Also corrects a comment that claimed no version numbers appear in this copy —
the backend floor does, deliberately, because it is what an operator upgrades
past.

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

* fix(views): stop assuming an unnamed backend records capabilities (MUL-6323)

Review follow-up. Reading an absent server_version as "capability-aware" was
right for two of its three sources and wrong for the third: a self-hosted
server older than v0.4.2 — where the field itself shipped — reports no version
AND predates the handshake, so it records no capabilities either. Such a
deployment got told to restart Multica on the machine, which re-registered
against the same blind server and wrote the same key-less row. A loop whose
only real exit, upgrading the backend, the copy never mentioned.

So awareness is three-valued now, and an unnamed server is resolved from
evidence rather than assumption: if ANY runtime row in the workspace carries a
capabilities key, some capability-aware server wrote it and servers move
forward, so the deployment is aware whoever the row belongs to. That settles
the managed-cloud shape — no version reported, several machines, one of them
registered before the handshake — without a version to read. The converse is
never inferred; every row missing the key is equally what an old self-hosted
server and a one-machine workspace look like.

When nothing identifies the backend the UI says so and names both remedies,
still failing closed. Guessing wrong in either direction sends someone to
upgrade a backend they do not run, or to restart a machine that was never the
problem.

Also rewrites the re-register copy, which explained itself in the vocabulary of
the handshake rather than the user's.

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

* refactor(views): let the server answer whether a machine can run worktree (MUL-6323)

The client-side gate kept growing because it was trying to predict an answer
the server already gives. Each round added a state: capability present or
absent, then the backend's own version to interpret the absence, then evidence
from other rows to interpret an unnamed backend. Three layers of inference over
data the backend wrote, to avoid asking the backend.

So it asks. The UI no longer blocks worktree mode on capability at all — only
on `not_git`, which is local, certain and already checked by the picker. The
mode stays selectable, the save is validated server-side, and the rejection
lands in the dialog's existing errorMessage slot with the wording #6904 wrote
for it. That deletes the awareness tri-state, the workspace-wide evidence scan,
the server-version plumbing, three UI reasons and four copy strings in four
languages.

This also fixes the reported case outright rather than explaining it: a
self-hosted backend older than the handshake runs the version gate it shipped
with, which accepts a v0.4.28 daemon, so the user simply gets worktree mode
instead of an instruction to upgrade something.

The capability signal survives for one job — deciding whether to PRESELECT
parallel mode — where being wrong costs a radio button rather than a blocked
user. A machine that has not advertised is not preselected into a mode whose
rejection would fail the whole project creation, but it stays selectable,
because an old row is not proof the machine cannot.

For any of that to be true the bundled create path had to actually ask: POST
/api/projects attaches resources in one transaction and never ran the worktree
gate that POST/PUT /resources have always run, so it would happily store a mode
the machine cannot run and leave the claim gate to cancel the task later. It
runs the same gate now, before the transaction opens.

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

* fix: refuse worktree mode until the server says it will honour it (MUL-6323)

Review caught a safety regression in the previous commit, and it is the worse
half of this issue rather than a variant of it.

Deferring to the server assumed the server would object. Releases before
v0.4.25 do not: `validateLocalDirectoryRef` unmarshals the ref into a struct
with no ExecutionMode field and re-marshals it, so `execution_mode: "worktree"`
is silently DROPPED and answered 201. The daemon then reads a ref with no mode,
treats that as in_place by design, and edits the working copy the user asked it
to stay out of. No save gate, no claim gate, no error — on the exact
new-Desktop-old-backend pairing this issue is about. My earlier claim that such
a server would run its own version gate was wrong: worktree shipped in v0.4.25,
so v0.4.24 has no concept of the mode at all.

The client cannot tell that success from a real one, so it asks first.
/api/config now carries local_worktree_supported, a property of the build: if
this code is running, the save gate is running with it. Absent — which is what
every too-old server sends — blocks the mode with copy naming the server, and
the schema parses the missing key to false rather than undefined so a consumer
cannot forget to coerce it.

That keeps the simplification: one declared boolean from the live server, no
inference over rows or version strings, and per-machine capability still left
to the server's save-time gate.

Also clears review nits: a comment still naming the removed localWorktreeSupport
and stray blank runs in two test files.

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

* fix(views): rename a local directory without resending its ref (MUL-6323)

Review follow-up, and the same failure through a door the last commit left
open. The client now refuses worktree mode when the server cannot promise to
honour it — but renaming an EXISTING worktree resource sent the whole
resource_ref back, capability check or not.

The server replaces the ref with whatever it can parse. On a backend rolled
back below v0.4.25 — documented as supported while the runtimes stay current —
that means execution_mode is dropped and answered 200, and the next task edits
the working copy the resource asked to isolate. The user changed a display
name; nothing told them isolation went with it.

So rename stops sending the ref at all. The update endpoint has always treated
an omitted resource_ref as "keep the stored one", including in v0.4.24, so the
mode survives on every server version and no capability check is needed for a
rename. It also closes the general shape of this: any unrelated edit that
resends a ref gets it re-normalised by whatever the server understands today.

The label therefore moves to the top-level column, which is what the API
already offered for exactly this, and display now reads that first and the
ref's copy second — so rows created before this keep the name they were given
until someone renames them.

Also corrects wording review flagged as misleading: the config flag is absent
on every server that predates the SIGNAL, not only on those that predate the
fix. Releases between the two do gate the save but cannot say so, and are
treated as unable — the client cannot tell them apart, and only one of the two
guesses is safe.

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

* fix(server): converge the two homes of a local directory's display name (MUL-6323)

Review follow-up. Renaming stopped resending the ref so old servers cannot
strip execution_mode — but that left the name with two writable homes: the
top-level label column (what newer clients write) and the copy inside
resource_ref (what desktop <= v0.4.28 both reads first and writes renames
into). One row could show different names on different devices, permanently,
and clearing a name resurrected the ref copy.

The server is the only writer that sees both client generations, so it now
converges the copies on every local_directory write:

- a label-only rename patches the ref's copy too — as a raw-map edit around
  the other keys, so execution_mode and fields written by a NEWER server
  survive byte-for-byte (re-marshaling through this binary's struct is the
  exact silent-drop this PR exists to close);
- a resent ref whose embedded label CHANGED is the legacy rename shape, and
  the column follows it; an unchanged embedded label (the mode dialog spreads
  the stored ref back) never overwrites a column rename — the write heals the
  row toward the column instead;
- clearing removes both copies, while a column that was merely never set
  keeps its hands off the ref: for rows created by older clients the ref
  copy IS the name.

The display-order helper moves to its own pure module shared by the resources
section and the chat hint (review nit), with the read-order matrix pinned in a
node-environment test beside it.

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

* fix(server): tell a rename apart from a ref that went stale (MUL-6323)

Review follow-up. Convergence read "the ref arrived with a different label" as
"an old client renamed this", but that is also what a stale snapshot looks
like. The execution-mode dialog captures the whole ref when it OPENS and sends
it back on save, so: device A opens the dialog, device B renames the folder,
device A saves a mode — and the server writes B's rename back to the old name,
from a dialog that has nothing to do with naming.

Only a ref whose SOLE difference is its label counts as a rename now. Anything
else is an edit carrying a name it happens to have been holding, so the row
keeps whatever it is called today — in both homes, which also heals the stale
copy the request brought. Compared as decoded values, and unknown keys count:
a difference this binary cannot interpret is still a difference, and reading
such a request as a pure rename would be a guess.

The same classification fixes a second complaint from the other direction. An
old client's rename resends the ref, so the worktree capability gate refused it
whenever the machine's runtime registration had drifted — failing an edit that
changes nothing about what runs, which is exactly what that gate's comment
already promised label-only updates would be spared. A rename skips it; the row
already says worktree, and the claim gate is what keeps it from running
somewhere that cannot.

TestProjectResourceUpdateLifecycle pinned the old reading for a request that
changes path, daemon and label at once. No client sends that shape — renames
change only the label, the mode dialog changes only the mode — and honouring an
embedded label there is indistinguishable from honouring a stale one, so it now
pins the conservative answer. Callers that mean to rename have the top-level
label field.

Also softens a claim review flagged as overstated: the label patch preserves
every unknown key and value, not the raw bytes, which JSONB does not promise
either.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-18 18:49:01 +08:00
4d495056e8 [MUL-6139] Expand Plugin skills and hosted MCP connections (#6920)
* feat(plugins): add remote MCP runtime

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

* chore(plugins): add private Exa example

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

* docs(plugins): remove private plugin guide

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

* fix(plugins): stabilize remote MCP tool digests

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

* fix(plugins): preserve remote MCP notification status

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

* fix(plugins): make Exa example runnable end to end

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

* fix(codex): record MCP tool calls in agent log

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

* chore(plugins): add community skills example

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

* feat(plugins): support multi-file skill bundles

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

* feat(plugins): add Mobbin MCP example

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

* chore(plugins): bundle full Matt Pocock skill set

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

* feat(plugins): add remote MCP OAuth connections

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

* fix(plugins): gate enable on remote MCP readiness

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

* fix(plugin): authenticate remote mcp credential broker

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

* fix(plugins): harden remote mcp oauth flow

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

* refactor(plugins): use a single feature flag

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

* fix(plugins): clean up remote MCP OAuth state

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

* chore(plugins): remove Matt Pocock example

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-16 04:54:32 +08:00
4fe94a6d40 revert: "MUL-5637 fix(mcp): agent mcp_config is an authoritative allowlist (#6292)" (#6314)
This reverts commit aa349fed02.

Preflight flagged the server/daemon mixed-version gate as a release blocker for
today's v0.4.17 window (MUL-5655): a managed, non-inheriting mcp_config claimed
by a daemon that does not advertise authoritative-mcp-v1 fails the task with
mcp_config_daemon_outdated, and that failure is not auto-retryable. Reverting to
unblock the release; the fix should return once daemon capability coverage in
production is confirmed.

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-03 17:43:03 +08:00
aa349fed02 MUL-5637 fix(mcp): agent mcp_config is an authoritative allowlist (#6292)
* fix(mcp): treat agent mcp_config as an authoritative allowlist

An agent's saved mcp_config was silently widened with the runtime host's
own user-level MCP servers, so an explicitly empty `{"mcpServers":{}}`
resolved to the COMPLETE host set instead of no servers at all — the
opposite of what the operator configured (GitHub #6283).

`--strict-mcp-config` was being passed correctly; the merge happened
before it, in the daemon, so strict mode constrained an already-widened
set. Introduced by #5277 and present in v0.4.16 through main.

Restore the three-state contract in resolveEffectiveMcpConfig:

  null / unset        -> inherit the provider's native MCP configuration
  {"mcpServers":{}}   -> strict empty, no host servers
  non-empty object    -> strict allowlist, exactly those servers

Two explicit inherit paths keep the additive behaviour reachable without
weakening the default:

- runtime_config.mcp.inherit_runtime = true opts an agent back in.
- The claim response now carries mcp_config_overlay_only so the daemon
  can tell an agent-authored config from a per-task Composio overlay.
  Without it, enabling an integration on an agent that never configured
  MCP would have stripped the host servers it was already inheriting.

Both decode paths fail closed: malformed runtime_config never enables
inheritance, and a failed runtime merge falls back to the agent's own
config.

The web MCP tab and the `agent create/update --mcp-config` help text
described the old additive behaviour, which is how a tightened config
could look correct while exposing every host server; both now state
which mode is in effect.

Note for rollout: the fix lives in the daemon, so self-hosted users must
upgrade the daemon — a server/UI upgrade alone does not apply it.

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

* fix(mcp): close review gaps in the authoritative mcp_config change

Addresses the four must-fix findings from review of #6292.

1. Deleting the last managed server no longer widens access.
   removeManagedMcpServer cleared the config to null, which now means
   "inherit the host's MCP servers" — so a delete took the agent from one
   allowed server to every server on the host. It now leaves an explicit
   `{"mcpServers":{}}`. Restoring inheritance moved to a separate
   clearManagedMcpConfig action behind its own confirmation that states the
   widening. The delete dialog no longer claims "Runtime servers are not
   affected", which was the opposite of the truth.

2. The UI no longer promises a boundary an old daemon does not enforce.
   The strict semantics live in the daemon, so a config saved against an
   older daemon is not yet in effect. Adds the authoritative-mcp-v1 daemon
   capability:

   - The daemon advertises it and reports authoritative_mcp on the
     runtime-capabilities response.
   - The claim path fails closed: a managed, non-inheriting mcp_config
     claimed by a daemon without the capability cancels the task and
     returns 412 with an actionable message, instead of letting that daemon
     merge the host's servers in. runtime_config.mcp.inherit_runtime is the
     documented escape hatch, and it is honest — it declares that the
     operator accepts the host's servers.
   - The MCP tab shows "needs upgrade" rather than "Not exposed" while the
     bound runtime lacks the capability.

3. Saving OpenClaw settings no longer drops the inherit opt-in.
   parseOpenclawRuntimeConfig discarded unknown keys and the tab persisted
   the result as the whole runtime_config, so one unrelated routing save
   silently deleted mcp.inherit_runtime. Unknown keys now round-trip
   through OpenclawRuntimeConfig.passthrough, excluded from the dirty check
   so they cannot make the form look edited.

4. Documents the new semantics in the built-in creating-agents skill and
   its source map: the three states, the persisted
   runtime_config.mcp.inherit_runtime field, and the claim-time capability
   gate.

Also corrects the PR's rollout claim: there is no database migration, but
this does add a persisted JSON field and change the meaning of an existing
one.

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

* fix(mcp): stop the authoritative-daemon gate from blocking valid claims

CI's backend job failed three handler claim tests with the new 412. Two
distinct problems, both real:

1. The gate fired for a non-object mcp_config. 66 handler fixtures seed
   `[]`, which is not a valid MCP config and cannot carry `mcpServers`, so
   it expresses no boundary to protect. An old daemon does not widen it
   either: mergeRuntimeAndAgentMcpConfig fails to unmarshal a non-object
   and falls back to the agent config alone (verified directly). Gating
   these blocked tasks with no security benefit, so the gate now requires a
   JSON object.

2. The shared daemon test-request helper advertised no capabilities, so
   every claim test was accidentally simulating a pre-#6283 daemon. It now
   defaults authoritative-mcp-v1 on, matching what every current daemon
   sends. Only that capability — skill-bundles / coalesced-comments / rpc
   are feature negotiations whose absence tests real legacy behaviour, so
   they stay opt-in per test.

Adds claim-level coverage for the gate itself, which is what the unit tests
alone could not catch: an outdated daemon gets 412 with an actionable
message and the task is cancelled; a capability-advertising daemon gets
200; the inherit_runtime opt-in lets an outdated daemon through; and an
unmanaged or non-object config is never gated.

Verified against a real migrated schema this time (throwaway Postgres),
which is how the three failures were reproduced locally and confirmed
fixed: `go test ./internal/handler ./internal/daemon` both ok.

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

* fix(mcp): surface the daemon-upgrade refusal and stop gating safe providers

Addresses the second review round on #6292.

1. The refusal is now visible wherever the operator looks. The default
   claim path is the machine-level BATCH endpoint, which skips build
   failures and still answers 200 {"tasks":[]}, so the previous bare
   CancelTask showed a task that vanished with no stated reason — turning an
   explicit upgrade requirement into an unexplained failure. The claim path
   now fails the task with a new classified reason,
   mcp_config_daemon_outdated, plus the actionable message. That reaches the
   user on all three claim paths and on any daemon version, which a new
   response field could not: the audience is by definition a daemon too old
   to read one. The per-runtime path keeps its 412.

   The reason is deliberately not auto-retryable — the same outdated daemon
   would claim the retry and fail it again.

2. The gate no longer cancels safe tasks. It applied to every provider, but
   only claude / codebuddy / codex / cursor / opencode / openclaw were ever
   merged with host MCP by an old daemon (loadRuntimeMcpServerConfigs).
   Qwen was never merged and already had strict semantics, so its tasks were
   being failed for a risk that does not exist. Scoped via
   providersOldDaemonsMergedRuntimeMcp; an unknown provider does not gate,
   because the gate should only fire where the old behaviour is concrete.

3. The new authoritative_mcp flag now goes through the API schema layer.
   Both local-skills responses were returning raw network JSON, so the flag
   that decides whether the UI may assert an MCP boundary rested on an
   unchecked type assertion. Adds RuntimeLocalSkillListRequestSchema with
   authoritative_mcp and mcp_supported defaulting to FALSE — the fail-closed
   direction — and a MALFORMED_ fallback that cannot express a guarantee.

Claim-level tests now cover all three paths, which is what the previous
helper-only tests missed: per-runtime 412, batch recording the refusal on
the task while still delivering the healthy tasks in the same batch, WS RPC
refusing and accepting, the qwen negative case, the inherit_runtime escape
hatch, and unmanaged / non-object configs.

Verified against a real migrated schema (throwaway Postgres):
go test ./internal/handler ./internal/daemon ./pkg/agent ./pkg/taskfailure
./internal/service all ok.

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

* fix(mcp): register the new failure reason and wire its copy into the UI

Addresses the third review round on #6292.

1. mcp_config_daemon_outdated was declared but never registered in
   taskfailure.allReasons, so metrics.NormalizeFailureReason missed the
   known-value map and fell through to free-text Classify() — relabelling a
   platform-side refusal as `agent_error.unknown` (verified directly) and
   leaving the Prometheus series un-pre-warmed. Registered it, canonical
   count 22 → 23 (platform 8 → 9), with the wire value and IsAgentError split
   pinned. New test pins the WHOLE canonical set through
   NormalizeFailureReason so forgetting the next reason fails a test instead
   of quietly mislabelling a metric; NormalizeFailureReason had no coverage
   at all before.

2. The upgrade copy was dead. The locale strings landed last round but
   neither consumer mapped the reason: chatFailureCopy fell back to generic
   failure text with the actionable detail buried in the collapsed raw
   error, and task-failure.ts rendered the bare wire value
   `mcp_config_daemon_outdated` in the agent activity list and issue
   execution log. Both are mapped now, with regression tests, plus the
   runtime class pinned in failure-class.test.ts. This directly contradicted
   the claim in the claim-path comment that every path reaches the user, so
   that is now actually true.

3. providersOldDaemonsMergedRuntimeMcp is documented as what it is: a FROZEN
   record of what pre-capability daemons merged, not a mirror of the daemon's
   current provider switch. The old "keep the two lists in lockstep" note was
   actively harmful advice — runtime MCP discovery for a new provider can only
   ship in a daemon that already advertises the capability (never gated), so
   adding it here would fail tasks on old daemons that never merged for it,
   re-creating the qwen false-positive. Pinned with a test.

Also corrects a stale count in task-failure.ts (7 → 9 platform reasons).

Verified against a real migrated schema (throwaway Postgres): full backend
suite green apart from the pre-existing environmental cmd/multica guard; all
9 TestMcpGate_* integration tests pass.

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

* docs(taskfailure): correct taxonomy counts and finish the reason registration

Non-blocking nits from the fourth review round on #6292.

- Taxonomy counts now say 23 reasons / 9 platform-side. Registering
  mcp_config_daemon_outdated last round updated the assertions but not the
  prose. Swept the whole repo rather than only the flagged lines, which
  turned up four more that were already stale at 21 and drifted further:
  handler/dashboard.go, daemon/poisoned.go, core/types/agent.ts, and the
  db/queries/task_usage.sql comment sqlc copies into the generated file.

  The generated file's comment was updated by hand to match its source.
  Running `sqlc generate` churned 58 lines across 47 unrelated files — the
  local sqlc version differs from the one that produced the checked-in
  output — so that churn was reverted and only the one intended line kept.

- failure_test.go's `required` list now includes
  ReasonMcpConfigDaemonOutdated. Length and label assertions already covered
  the reason, but the list is documented as the complete canonical set, so
  the omission contradicted its own comment.

- Restored the line break in chat-message-list.test.tsx that a previous edit
  of mine collapsed.

Comment, test-fixture and formatting only; no behaviour change.

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

---------

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-03 16:06:07 +08:00
18d41151eb feat(gc): batch issue reconciliation (#5534)
Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-17 12:33:36 +08:00
ed9adc2bbe feat: improve create issue field controls (#5532)
Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-17 11:11:52 +08:00
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]>
2026-07-16 16:38:18 +08:00
YYClaw 9eddcaff10 fix(chat): defer cancellation-time finalization until the task transcript is stable (#5246)
A quick Stop before the agent's first token no longer races a late reply. Started-but-empty cancellations defer the empty/non-empty judgment until the daemon acks its transcript flush (or a grace-period sweeper fires), then settle to a single outcome. Empty outcomes persist a durable, creator-authorized draft restore (fetched/consumed via a dedicated endpoint, reconnect-safe and at-most-once) instead of broadcasting the prompt over the workspace bus.

Closes #5219
2026-07-15 00:52:27 +08:00
ac62f72c2a MUL-4480: make daemon workspace sync event-driven (#5354)
* feat(daemon): make workspace sync event-driven

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

* fix(daemon): preserve trailing workspace changes

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

* fix(workspace): reconcile failed creates

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

---------

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-14 11:56:30 +08:00
a51ab4d551 feat(chat): Chat V2 — first-class IM-style Chat tab (MUL-4171) (#5076)
* feat(chat): Chat V2 — first-class IM-style Chat tab (MUL-4171)

Replace the floating chat FAB/window with a first-class Chat tab under
Inbox, laid out as an IM-style two-pane surface (thread list + conversation).

Highlights:
- New Chat page (packages/views/chat/chat-page.tsx) with URL-addressable
  session selection; web + desktop routing wired up. Removes the old
  chat-fab / chat-window / resize-handles / context-items paths.
- IM thread list: agent avatar + last-message preview + IM timestamp, red
  unread *count* badge (read-cursor model), presence-gated typing vs waiting.
  Rename lives only in the conversation header ⋯ menu (not the list hover).
- Per-session conversation header (rename / view agent / delete), agent-aware
  empty state (avatar + name + description + starter prompts), and a
  deterministic clean-title derivation from the first message.
- Server: read-cursor unread model (migration 145) and per-user pinned agents
  (migration 146, dedicated chat_pinned_agent table + handler/queries).
  New-agent welcome chat auto-enqueues a real agent run (LLM intro, no
  static template).
- Design: fade the global --border token; borderless list headers on
  Chat/Inbox, kept (faded) on the conversation header.

Verified: pnpm typecheck (all packages), go build ./..., go vet, gofmt.
Co-authored-by: multica-agent <[email protected]>

* feat(chat): make new-agent welcome read as an agent-initiated intro (MUL-4230)

The "meet your new agent" chat used to insert a fake user message
("👋 Hi! Please introduce yourself …") and have the agent reply to it, so
the thread looked like the creator prompting the agent.

Drop the persisted user message. Flag the auto-created session
is_agent_intro (migration 147) and drive the intro run server-side: the
daemon builds a proactive self-introduction prompt for such sessions
(buildChatPrompt) instead of a "reply to their message" prompt. The intro
stays LLM-generated; the thread now opens with the agent's own message, as
if it reached out first.

- migration 147: chat_session.is_agent_intro
- CreateChatSession carries the flag; sendAgentWelcomeChat no longer
  persists/publishes a user message
- daemon: ChatIntro threaded from session flag → intro prompt

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

* feat(chat): Settings toggle for the floating chat window (MUL-4235) (#5080)

* feat(chat): Settings toggle for the floating chat window (MUL-4235)

Re-introduce the floating chat overlay on top of Chat V2 as an optional,
Settings-gated surface instead of deleting it outright.

- Settings → Preferences → Chat: a switch (floatingChatEnabled, persisted
  client preference, default ON) to show/hide the floating window.
- FloatingChat wrapper owns the two gates: the preference, and the /chat
  route (hidden on the tab so the same activeSessionId isn't shown twice).
- ChatFab + a compact ChatWindow that reuse the shared useChatController and
  conversation components, so activeSessionId stays in lockstep with the tab.
- Restore use-chat-context-items so the overlay's @ surfaces the current
  issue/project (the 'current context' affordance) — the tab stays manual.
- i18n (en/zh-Hans/ja/ko), store unit tests.

typecheck: core/views/web/desktop green. tests: chat store 9, settings 82,
chat 39 pass.

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

* feat(chat): dedicated Chat settings tab, floating window opt-in (MUL-4235)

Address review: give Chat its own Settings tab instead of a section inside
Preferences, and default the floating window OFF (opt-in).

- New Settings → Chat tab (chat-tab.tsx) under My Account; moves the
  floating-window toggle out of the Preferences tab.
- floatingChatEnabled now defaults OFF — only an explicit enable from the
  Chat tab mounts the FAB/overlay.
- i18n: page.tabs.chat + a top-level chat block (en/zh-Hans/ja/ko);
  revert the Preferences chat section and its test mock; store tests updated
  for the opt-in default.

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

---------

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

* refactor(chat): drop starter prompts from chat empty state (MUL-4237) (#5081)

The three starter prompts (List my open tasks by priority / Summarize what
I did today / Plan what to work on next) read as filler more than help, so
remove them along with the now-unused returning_subtitle ("Try asking").

The empty state keeps its agent-aware header — avatar + "Chat with {name}"
+ optional description — and the composer stays the entry point. Locale
keys dropped across en/zh-Hans/ja/ko (parity preserved).

Based on the Chat V2 branch (parent MUL-4171, #5076), not main.

Co-authored-by: Lambda <[email protected]>

* feat(chat): pin a chat to the top of the Chat list (MUL-4240) (#5082)

Builds on Chat V2 (#5076). Adds a per-conversation pin so a user can
keep important chats at the top of the IM-style thread list, above the
activity-sorted rest.

Backend:
- migration 148: chat_session.pinned_at (nullable) + partial index; the
  timestamp doubles as the pinned-group sort key and the boolean flag.
- list queries order pinned-first, then by most-recent activity.
- SetChatSessionPinned query + PATCH /api/chat/sessions/{id}/pin handler;
  pinning never bumps updated_at, so an unpinned chat won't jump the list.
- ChatSessionResponse.pinned + chat:session_updated carries the new state.

Frontend:
- ChatSession.pinned; setChatSessionPinned API + useSetChatSessionPinned
  with optimistic re-sort; shared sortChatSessions comparator.
- thread list: pin indicator on pinned rows + pin/unpin hover action;
  list sorted pinned-first so it stays ordered after cache patches.
- realtime patch re-sorts on pin change; en/ja/ko/zh-Hans strings.

Tests: SetChatSessionPinned handler test, sortChatSessions unit tests.

* feat(chat): round send button, move file upload into a + menu (MUL-4250) (#5088)

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

* fix(inbox): match chat list selected-item style (inset padding + rounded) (#5093)

Wrap the inbox list in p-1 and give each row rounded-md/px-3 so the
selected bg-accent reads as an inset rounded card — same treatment the
chat thread list already uses — instead of a full-bleed, sharp-cornered
highlight. Content stays 16px-inset (p-1 + px-3 == old px-4).

MUL-4253

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

* fix(chat): rename + menu upload item to "Image or files" (MUL-4250) (#5092)

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

* fix(chat): stop welcome intro session repeating the same introduction (MUL-4259)

The is_agent_intro flag on chat_session is persistent, so every follow-up turn on a welcome session re-selected the self-introduction prompt in buildChatPrompt and the agent kept replying with the same intro instead of answering the user.

Gate resp.ChatIntro at claim time on the session still having zero human (role='user') messages via a new ChatSessionHasUserMessage query: the first, message-less server-driven turn introduces the agent; once the creator replies, later turns fall back to the normal reply prompt.

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

* fix(chat): address review findings + unbreak CI (MUL-4171)

- task:failed now refreshes the sessions list (invalidateSessionLists), so
  the thread-list preview / unread / sort stays correct after an agent
  failure — FailTask persists a failure chat_message but only broadcasts
  task:failed, mirroring the chat:done success path.
- Self-heal stale chat deep links: once the sessions list has loaded and a
  ?session= id isn't in it (deleted / no access / never existed) with nothing
  in flight, clear the selection instead of rendering an editable empty chat
  that would POST into a nonexistent session. Freshly-created sessions are
  exempt (they carry optimistic messages + a pending task).
- CI: add the new parameterless `chat` route to link-handler's
  WORKSPACE_ROUTE_SEGMENTS and to paths/consistency.test.ts (route set +
  expectedSegments) — keeps the two in sync, fixes the failing @multica/core
  test.
- Fix a MUL-4235/MUL-4237 merge collision that broke @multica/views
  typecheck: chat-window.tsx still passed the removed `onPickPrompt` prop to
  EmptyState.

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

* fix(chat): self-heal dangling session in shared controller, not just ChatPage (MUL-4171)

Re-review follow-up: the stale-session self-heal only lived in ChatPage, so
the floating ChatWindow still entered from a persisted activeSessionId and
would render an editable empty chat (then POST into a nonexistent session)
when the selected session was deleted / lost access off the /chat route.

- Move the self-heal into the shared useChatController so every surface (tab
  and floating window) drops a dangling activeSessionId once the sessions list
  has loaded and doesn't contain it.
- Harden ensureSession: trust the current id only when it's in the loaded list
  or is a just-created session still awaiting the refetch; a dangling id falls
  through to create a fresh session instead of POSTing into a 404.
- Exempt just-created sessions via an OPTIMISTIC-write signal
  (hasOptimisticInFlight: pending task or optimistic- message), not hasMessages
  — a session deleted elsewhere with real cached history stays eligible for
  self-heal. Add a unit test for the discriminator.

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

* test(views): fix app-sidebar useWorkspacePaths mock for the new chat nav (MUL-4171)

The AppSidebar personal nav gained a `chat` item, so it calls
`useWorkspacePaths().chat()` at render. The app-sidebar.test.tsx mock hadn't
been updated, so `p.chat` was undefined and every render threw
`TypeError: p[item.key] is not a function`, failing @multica/views#test in CI.

- Add `chat: () => "/acme/chat"` to the mocked useWorkspacePaths.
- Route the chat-sessions query key through a mutable `chatSessions` fixture.
- Add coverage for the Chat nav: renders the link, badges the summed
  unread_count, and hides the badge when all sessions are read — so this drift
  is caught next time.

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

* fix(chat): use the original floating window, not the rewritten one (MUL-4235) (#5102)

Follow-up to the merged #5080, which shipped a hand-written, simplified
ChatWindow and lost the original's animations / drag-resize / expand-minimize.
The floating window is just a quick entry point — it should be the original
UI, not a rewrite.

- Restore chat-window.tsx, chat-fab.tsx, chat-resize-handles.tsx and
  use-chat-resize.ts verbatim from main (0-diff): motion animations, drag
  resize, expand/minimize and the session dropdown are back.
- Restore the empty_state.returning_subtitle + starter_prompts i18n keys the
  original window renders (V2 had dropped them); drop the now-unused
  window.open_full_tooltip key the rewrite added.
- Settings gating is unchanged: FloatingChat still wraps the original FAB +
  window, gated by floatingChatEnabled (default off) and hidden on /chat.

typecheck: core/views/web/desktop green. tests: chat + settings views 126 pass.

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

* feat(chat): archive chats from the list, delete only from Archived (MUL-4263) (#5098)

Restore an archive flow as the reversible sibling of delete:
- Chat list hover now offers Archive (not Delete); pin/stop unchanged.
- A footer entry ('Archived · N') opens an Archived view listing archived
  chats; hard delete lives only there (hover -> unarchive + delete, with
  the existing inline confirm).
- Conversation header ⋯ menu mirrors this: active chats archive, archived
  chats unarchive/delete.

Backend: PATCH /api/chat/sessions/{id}/archive flips status active<->archived
(SetChatSessionArchived), broadcasts status on chat:session_updated so other
tabs re-sort into the right list. SendChatMessage already refuses archived
sessions, so archived chats stay read-only until unarchived.

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

* feat(chat): handle archived agents in Chat V2 list & conversation (MUL-4265) (#5100)

* feat(chat): handle archived agents in Chat V2 list & conversation (MUL-4265)

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

* refactor(chat): drop chat-list archive marker, keep conversation read-only (MUL-4265)

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

* feat(chat): apply archived-agent read-only to the floating chat window (MUL-4265)

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

---------

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

* fix(chat): address floating-window + archived-agent review blockers (MUL-4171)

Re-review follow-up on the restored floating ChatWindow + archive flow:

1. Floating stale-session self-heal. The restored ChatWindow doesn't use the
   shared controller, so its ensureSession trusted any non-empty
   activeSessionId and there was no dangling-session cleanup — a deleted /
   no-access persisted session could send into a nonexistent session. Ported
   the same guard used for the tab: a self-heal effect that clears a dangling
   activeSessionId once the sessions list has loaded, and ensureSession only
   trusts an id that's in the list or has an in-flight optimistic write
   (hasOptimisticInFlight, reused from use-chat-controller). handleSend seeds
   the optimistic message + pending task before setActiveSession, so a
   freshly-created session is never mis-cleared.

2. Floating dropdown bypassed archive-first safety. Its active rows offered a
   hard-delete, letting the floating window destroy active chats and skip the
   "archive first, delete only from Archived" model. Active rows now ARCHIVE
   (reversible, one-click) like ChatThreadList; the floating window offers no
   hard-delete — unarchive/delete live only in the full Chat page's Archived
   view (reachable via expand). Removed the now-dead delete-confirm machinery.

3. Orphan user message on archived-agent send. SendChatMessage created the
   chat_message before EnqueueChatTask, which rejects an archived / runtime-less
   agent — a stale client would land a user message then get a 500, orphaning
   it. Added a preflight that checks the session agent's archived / runtime
   state and returns 409 before any mutation, plus a handler test asserting the
   send is rejected with no message persisted.

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-08 21:58:16 +08:00
beast 20eecfb093 fix(projects): honor repo resource checkout refs (MUL-3593) (#4470) 2026-06-24 16:25:17 +08:00
5038c983c0 MUL-3281: Add daemon skill bundle refs (#4445)
* feat: add daemon skill bundle refs

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

* fix: tighten skill bundle resolve safeguards

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

* feat: add task prepare lease

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

* fix: isolate prepare lease concurrent index migration

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

* fix: keep prepare lease active through start

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-06-23 16:19:16 +08:00
12ea1f6a8c MUL-3495: support custom runtime args and registration errors (#4408)
* feat: support custom runtime args

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

* fix: address custom runtime review nits

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

---------

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-06-23 14:20:18 +08:00
da72e2fa22 feat(daemon): inject project description into the agent brief (MUL-3465) (#4395)
* feat(daemon): inject project description into the agent brief

Issues bound to a project only surfaced the project title in the runtime
brief; the project description (durable, project-wide context the owner
sets) was loaded but dropped. Carry it end-to-end:

- claim handler reads proj.Description onto the response (issue-bound and
  quick-create paths)
- new ProjectDescription field on AgentTaskResponse, daemon Task, and
  TaskContextForEnv
- rendered in the brief's `## Project Context` section and written to
  .multica/project/resources.json as project_description

Empty descriptions render nothing (no extra heading). Updated the
projects-and-resources built-in skill docs in the same change.

MUL-3465

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

* feat(projects): clarify project description is injected as agent context

The project description is now durable context injected into every task's
brief, but the UI still presented it as a plain "Description" field, so
existing descriptions could silently become agent input. Add a hint under
the description editor on the project detail page and in the create-project
modal, in all four locales, stating it is shared with agents as context for
every task in the project. No data-semantics change.

Addresses review feedback on PR #4395. MUL-3465

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

* test(handler): assert project description flows through task claim

The execenv tests cover brief rendering, but nothing pinned the claim
handler boundary where proj.Description is read onto the response. Add
two tests — issue-bound and quick-create paths — so a regression in that
assignment fails loudly instead of silently dropping the description.

Addresses review feedback on PR #4395. MUL-3465

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-06-22 23:39:27 +08:00
Hzzzzzx 745832b536 MUL-3433: fix(daemon): restore claim slow-log payload observability without gzip
Squash merge PR #4322 after review approval.\n\nMUL-3433
2026-06-21 23:41:05 +08:00
LinYushen e1c6754304 Revert "fix: gzip daemon claim responses (#4259)" (#4307)
This reverts commit 2f398c36ad.
2026-06-18 17:58:43 +08:00
2f398c36ad fix: gzip daemon claim responses (#4259)
Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-06-17 18:48:33 +08:00
LinYushen 77ac17ef49 Make custom runtimes appear immediately (#4234)
* Make custom runtimes appear immediately

* Scope daemon profile refresh by authorized runtimes

* Relay runtime profile refresh hints

* Localize runtime profile close label
2026-06-17 16:00:22 +08:00
Naiyuan Qingandmultica-agent 79394ee057 MUL-3310: disable bare issue key expansion in comments (#4190)
Co-authored-by: multica-agent <[email protected]>
2026-06-16 16:28:38 +08:00
f9c193e06b fix: fail closed on agent task auth tokens (#4142)
Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-06-15 16:34:35 +08:00
c8ab73d38d MUL-3244: Bind quick-create attachments to created issues (#4062)
* fix: bind quick-create attachments to created issues

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

* test: use real image markdown in quick-create attachment test

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-06-12 16:45:38 +08:00
24b162cdbc feat(daemon): surface the real task initiator to the agent runtime (MUL-2645) (#3899)
* feat(daemon): surface the real task initiator to the agent runtime (MUL-2645)

In a multi-person workspace the agent runtime only ever saw the runtime
OWNER identity: the brief's `## Requesting User` is sourced from
runtime.OwnerID and the task-scoped token is owner-bound, so every
requester (whoever commented, @mentioned, or chatted) appeared to the
agent as the owner. Agents that route by initiator for permission,
privacy, or audit all misjudged.

Resolve the real task initiator at claim time and surface it distinctly
from the owner:
- comment / mention trigger -> triggering comment's author (member or agent)
- chat task -> chat session creator (sessions are creator-only)
- on-assign / autopilot / quick-create -> no attributable initiator (omitted)

Adds initiator_{type,id,name,email} to the claim response, the daemon
Task, and TaskContextForEnv, rendered into the brief as a new
`## Task Initiator` section. The section documents the privacy boundary:
the agent's credentials stay owner-scoped, so this is an attested
identity for the agent's own routing/privacy logic, not act-as. No DB
migration — both paths are derivable from existing rows.

Tests: brief rendering (member/agent/omit/sanitize) + email guard unit
tests, and claim-handler tests for the comment and chat paths.

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

* fix(chat): store real sender as task initiator, not chat_session creator (MUL-2645)

Review fix (Niko, PR #3899). v1 resolved the chat task initiator from
chat_session.creator_id at claim time. That is correct for web chat and
Lark p2p (creator == sender), but WRONG for Lark group chats: the group
session creator is deliberately the installer (stable identity across
member churn), not the message sender. So in a Lark group, every member
who triggered the agent showed up in the brief as the installer/owner —
the exact bug this issue is about, still live at that entry point.

Capture the real sender at enqueue time instead of deriving it from the
session creator at claim time:

- migration 117: agent_task_queue.initiator_user_id (FK user, ON DELETE
  SET NULL); NULL for non-chat and pre-migration rows.
- EnqueueChatTask now takes an explicit initiatorUserID. Web chat passes
  the authenticated request user; the Lark dispatcher threads the inbound
  sender (binding.MulticaUserID) through scheduleRun -> flushChatRun. The
  debouncer keeps the latest scheduled flush per session, so in a multi-
  sender silence window the LATEST sender wins (documented + tested).
- claim handler resolves the initiator from task.initiator_user_id and
  drops the creator_id fallback entirely.

The Lark group session creator stays the installer (unchanged) — only the
task initiator is corrected, keeping the two concepts cleanly separate.

Tests: dispatcher group regression (initiator = sender, not installer),
latest-sender-wins, p2p initiator assertion; the chat claim handler test
now sets creator != initiator and asserts the stored sender wins.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-06-08 19:29:57 +08:00
3808049361 fix(codex): set semantic thread names (#3887)
Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-06-08 14:53:31 +08:00
9beb45b55b fix(chat): deliver every debounced user message to the agent (MUL-2968) (#3744)
The Lark short-window debounce (MUL-2968, #3742) can land several user
messages in a chat session before a single agent run fires. But the
daemon claim built the agent prompt from only the *single most recent*
user message (walk history backward, take first user message, break).

So 「看上海天气」then「还有青岛」debounced into one run, and the agent
received only 「还有青岛」— it answered Qingdao and never saw Shanghai.
The session itself was correct (both messages persisted); the gap was in
what the run delivered to the agent. Before debouncing this was masked
because each message got its own run.

Build the prompt from the whole unanswered set instead: the trailing run
of user messages after the last assistant reply (every completed/failed
run writes an assistant row, so the anchor advances one turn at a time —
the full burst on the first turn, only the new message(s) after a reply).
Attachments are collected from each included message. Extracted the
selection into a pure trailingUserMessages helper with table-driven unit
tests, plus a DB-backed claim test asserting both messages reach the
agent and that a post-reply message delivers alone.

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-06-04 13:12:32 +08:00
973a43923f fix(comments): revert since-delta to issue-wide, steer to parent thread first (#3535)
#3509/#3523 scoped the comment-trigger since-delta count to the triggering
thread, so an agent resuming a busy issue only saw "+N in this thread" and
lost visibility of new comments in other threads. Revert the count to
issue-wide (every thread), keeping the trigger-comment + agent-own
exclusions, and reshape the warm-path hint to:

  - report the issue-wide new-comment volume,
  - steer the agent to read the triggering (parent) thread FIRST
    (`--thread <trigger> --since`, or `--tail 30` for full context),
  - demote the issue-wide `--since` catch-up to an only-if-needed fallback
    ("don't read them all blindly").

Also fixes the now-stale "scoped to the triggering thread" wording in the
resumed-session no-delta hint (it's issue-wide zero now).

Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-05-29 20:13:23 +08:00
9616d78e47 MUL-2785: optimize resumed comment reads (#3509)
* feat(comments): skip default thread read on resumed comment sessions

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

* fix(comments): scope since delta to trigger thread

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

* chore(comments): address thread delta review nits

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

---------

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-05-29 14:57:14 +08:00