7 Commits
Author SHA1 Message Date
importcpp 67cec3fe3f fix(transcript): pair parallel tool results by call identity (#8567) 2026-09-19 18:13:17 +08:00
aac4aa3f44 MUL-7248: fix agent log duration timestamps (#8271)
* fix(transcript): preserve agent event timing

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

* fix(transcript): keep timestamp fallbacks consistent

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

* fix(migrations): resolve duplicate 458 prefix

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-10 22:18:16 +08:00
e04465be0b feat(transcript): tell the reader when a tool output was truncated MUL-7168 (#8212)
* MUL-7166 MUL-7167 feat(transcript): tell the reader when a tool output is incomplete

A tool_result preview is cut at 8 KiB and the remainder is never uploaded.
Nothing recorded that, so the run transcript rendered a truncated preview
exactly like a complete one — a reader checking what an agent did could take
the first 8 KiB of a 300 KB log for the whole thing.

The daemon now reports whether the preview dropped bytes, and the viewer says
so. Two separate claims, kept separate:

- Per step: a "truncated" badge next to the tool name when the record
  provably lost bytes.
- Per run, once: a caveat when the run contains output whose completeness
  nobody measured.

The flag is tri-state end to end (NULL/absent = unknown, false = complete,
true = truncated) and `output_truncated` is nullable with no default and no
backfill. Historical rows cannot be reconstructed, so they stay unknown; the
same applies to messages from an older installed daemon, which is why the
batch insert carries the value through the query's text[]/NULLIF encoding
rather than a []bool that cannot express the third state.

Display clipping is now worded as what it is. The inspector's 8000-character
clip said "... (truncated)", the same word the badge uses for bytes that are
gone, when copy still yields the whole stored text.

Verified: go test ./internal/handler ./internal/daemon ./internal/util
./internal/migrations, pnpm test (4981 views + core), typecheck, lint. Both
states captured in a local environment against real API + DB rows.

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

* refactor(transcript): move the completeness note to the end of the output

The truncation indicator was a warning badge beside the tool name and a
run-level banner above the step list. Both announced the caveat before the
reader had a reason to care, and the badge's colour gave a routine 8 KiB cut
the weight of a failure.

"Was that all of it?" is a question you only have once you reach the bottom of
an output, so the answer goes there: one muted line at the end of the body,
in the same place the display-clip line already sits.

The unknown case follows the same rule rather than keeping its own banner —
the inspector shows one step at a time, so a note there appears once per
opened step, not once per turn in a list. That was the whole objection to the
per-turn disclaimer this design replaced; a run-level bar was one way to avoid
it, and putting the note where the evidence is another.

`outputCompleteness` replaces `hasUnknownOutputCompleteness`: the same rules
(tool results only, empty output excluded, false is a measurement) now answer
per item instead of per run.

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

* fix(transcript): stop the display-clip line promising the whole output

"copy to get the whole record" reads as "the full output is in the database".
It is not: the daemon cuts at 8 KiB before reporting, and copy returns the
stored record — the same bytes, minus the 8000-character render clip. The
first reader of this string took it the other way, which is the whole reason
the truncation note exists.

Says what copy actually returns: everything that was saved.

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

* fix(transcript): leave a stored tool result with one remark, not two

The render clip cut at 8000 characters, but a stored tool result is already
capped at 8192 bytes upstream — so on an ASCII output the clip could only ever
shave a further ~190 characters, and it did so under a note that already said
the rest was not saved. Two grey lines, the second reporting a rounding error
on the first.

The clip now sits above the source budget, where a tool result cannot reach it.
Tool input keeps it: input is persisted whole and has no server-side budget, so
it is the body that actually needs bounding — and it never carries a
completeness note, so that path stays a single remark too.

The marker also stops mentioning copy. It described the clip's reversibility,
which is only interesting next to a truncation note that is no longer beside it.

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

* fix(transcript): say why the output is short, not where it stops

"Output ends here" reads two ways: the command produced this much, or we cut
it. Those are opposite facts about the agent's run, and the note exists to
settle exactly that question.

States the cause instead: the output was too long, so only the beginning was
kept. "Kept" rather than "shown" — the rest was never uploaded, and wording it
as a display limit invites the assumption that the whole output is in the
database and merely hidden.

The unknown note loses the same phrase for the same reason.

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

* fix(transcript): address review on the completeness note

Three fixes from review of #8212, plus the frequency question it reopened.

A malformed `output_truncated` deleted the whole run from the screen. The
field parsed with `z.boolean().optional()`, so a string or a number failed its
row, the row failed the array, and `parseWithFallback` handed the viewer an
empty transcript. It now catches at the field, degrading to unknown. The old
test claimed this but passed a malformed top-level object, which exercised a
different path; the replacements fail without the catch.

The note appeared before the reader could have wanted it. `ToolDetailSurface`
collapses its own body, so a footer rendered outside it sat over a faded,
half-shown output — answering "was that all of it?" to someone still reading.
The surface now owns the note and withholds it until the body is revealed.

The render clip went back to 8000 characters for tool input. Raising it to
20000 was collateral: the goal was to stop re-clipping a tool result, which is
already capped at 8192 bytes upstream, and skipping the clip for `tool_result`
alone does that without touching how an unbounded input renders.

Unknown-ness moves back to the run. One daemon produces every message in a run,
so either it measured that run's outputs or it measured none of them — a fact
about the run, stated once, rather than a remark repeated on each step opened.

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

* fix(transcript): drop the notice for records that predate the flag

A record from before the flag existed now says nothing. The viewer speaks only
when a daemon measured an output and found bytes missing.

The column stays tri-state. Nothing renders differently for NULL and false
today, but they are not the same fact, and defaulting NULL to false would write
"this daemon measured the output and stored all of it" onto rows where nobody
measured anything. The original lengths are gone, so that claim could never be
corrected — a lie the schema would keep telling long after this UI changed
again.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-10 14:40:45 +08:00
115bfab759 MUL-6523: persist task message batches in one statement (#7377)
* perf(server): persist task message batches in one statement

/api/daemon/tasks/{id}/messages is the hottest write path in the system:
every running task POSTs its buffered transcript every 500ms. The handler
was paying 3 + N database round trips per batch — GetAgentTask, the
workspace lookup inside requireDaemonTaskAccess, a SECOND identical
GetIssue/GetChatSession in the handler body, and then one
`INSERT ... RETURNING` per message, each in its own implicit transaction.

Two changes, both on that path:

- Take the workspace id from requireDaemonTaskAccessWithWorkspace, which
  already resolved it to authorize the daemon, instead of re-querying it.
  Broadcast reach is deliberately unchanged: the id is still only used for
  issue- and chat-backed tasks, so autopilot / quick-create tasks keep
  their current (non-)streaming behaviour.
- Add CreateTaskMessages, which expands the whole batch from one jsonb
  document via jsonb_to_recordset and inserts it with a single statement.
  A jsonb document rather than parallel arrays because `input` is itself
  jsonb and a text[]/uuid[] column cannot express a per-row NULL from Go;
  it also stays clear of the ~64k bind-parameter ceiling a multi-row
  VALUES list would hit.

Per batch this takes 3 + N round trips down to 3, and collapses N commits
into 1. Measured locally against Postgres 17 (200 iterations per shape,
same rows): 2.1ms -> 0.7ms at 3 messages, 7.0ms -> 0.8ms at 10, 80ms ->
1.1ms at 50.

Atomicity is the other half of the fix, not a side effect: the daemon does
not retry this endpoint, so a loop that failed halfway left a permanent
hole in the transcript. One statement makes a batch all-or-nothing.

The NUL sanitizer calls stay exactly where they were and matter more now —
jsonb rejects \u0000 for the whole document, so an unsanitized byte would
fail the entire batch rather than one row (GH #7098).

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

* fix(server): guarantee task message publish order, test the real query

Review follow-ups on the batch insert.

Blocking:

- Realtime ordering had no contract. `INSERT ... RETURNING` defines no row
  order, but the publish loop walked the result as-is, so a batch could
  reach subscribers scrambled where the per-message loop always published
  in request order. CreateTaskMessages now returns through an ordered CTE
  (`WITH inserted AS (INSERT ... RETURNING *) SELECT * FROM inserted ORDER
  BY seq`), putting the ordering in the query rather than asking every
  client to re-sort. Verified it is not a no-op: with the ORDER BY removed,
  the new test reports the published order as [3 1 2] for a request listing
  seq 3, 1, 2 — i.e. the bug was real, not theoretical.

- The atomicity test copied the batch SQL by hand, so it could have passed
  while CreateTaskMessages was broken. It now calls the generated query.
  Both tests were also rewritten onto the repo's shared fixtures — dbfx for
  rows, testutil.JSONRequest / WithURLParams / Call(...).Want(...) for the
  handler — instead of open-coded INSERT/cleanup pairs and recorder
  quartets.

- New TestReportTaskMessagesPublishesInSeqOrder subscribes to the event bus
  and asserts the published seq order, with the request deliberately listing
  messages out of seq order so array order cannot pass for seq order.

Non-blocking:

- A failed `json.Marshal` of a message's input is no longer swallowed. A
  tool_use row that silently loses its arguments is worse than a 500 the
  daemon logs, because nothing anywhere records the loss.

- Corrected the atomicity claim in the query and test comments: one
  statement buys CONSISTENCY, not completeness. Without a retry a failed
  batch is now lost whole rather than leaving a prefix; actually closing the
  gap needs a retry plus a (task_id, seq) uniqueness rule, which is out of
  scope here.

The CTE makes sqlc name the result CreateTaskMessagesRow rather than reusing
the table model, so the publish site converts explicitly — a compiler-checked
cast that fails loudly if the query ever stops returning the whole row.

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

* perf(server): pass task message batches as arrays, not a JSON envelope

Second review round. The jsonb-document parameter was a regression on large
messages: content and output had just been decoded out of the request, and
wrapping them back into JSON made the server escape every byte a second time
and Postgres parse the envelope out again. Measured against the per-row
insert it replaced, at one message: ~1.0x at 8KB, 1.46-1.48x at 128KB,
~3.0x at 1MB — worst exactly where the old path was cheapest, and those
messages are the least likely to be batched.

CreateTaskMessages now takes parallel arrays. NULLIF(value, '') is what makes
a per-row NULL expressible through the non-nullable []string sqlc gives a
text[] parameter, and it reproduces the single-row query's
pgtype.Text{Valid: x != ""} mapping exactly. input stays a text parameter
cast to jsonb in SQL — it is the one column that genuinely has to be parsed.

The arrays are zipped by several single-argument unnest calls in one SELECT
list, which expand in lockstep (PG10+ SRF semantics). The tidier
unnest(a, b, ...) form is equivalent but sqlc's analyzer only knows the
single-argument signature and fails to generate for it.

Measured after the change, same harness, two runs:

  one message, 8KB      0.98-0.90x of the per-row insert
  one message, 128KB    0.99-1.04x   (was 1.46-1.48x)
  one message, 1MB      1.59-1.67x   (was 2.99-3.02x)
  3 messages, 512B      2.7-3.2x faster
  10 messages, 512B     7.7-8.2x faster

128KB is now parity. 1MB is still above parity because any batch shape makes
Postgres materialize the array and unnest it before the insert, which a
direct single-row parameter skips; closing that needs a len == 1 fast path,
which review explicitly preferred not to have for the extra code path. A real
1MB single message is also unlikely: tool_result output is truncated to 8KB
in the daemon (internal/daemon/daemon.go). The durable answer for oversized
payloads is a body/batch cap on this endpoint, which is still a follow-up.

Also from review:

- The conflicting row in the atomicity test is built with dbfx.Insert instead
  of CreateTaskMessage, so test setup no longer shares code with the code
  under test.
- The ordering test runs on its own event bus and restores the shared one, so
  it stops leaving a listener behind on a bus that has no unsubscribe.
- The per-batch probe is Debug, not Info. At Info it was ~2 lines/second/task
  in production; it was added at Info in #7371 only to count batch sizes,
  which is answered.

Re-verified the ordering test still fails without the ORDER BY (reports
[3 1 2]) against the new query shape.

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 15:17:02 +08:00
ea07a29138 perf(db): generate task message IDs as UUIDv7 (#7220)
Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 17:17:28 +08:00
LinYushenandmultica-agent 5bacfd9742 MUL-2526 feat: add member(user_id, workspace_id) index + upgrade sqlc to v1.31.1 (#3046)
- Add migration 106: CREATE INDEX CONCURRENTLY on member(user_id, workspace_id)
- Rewrite ListWorkspaces to drive from member table with explicit fields
- Regenerate all sqlc code with v1.31.1 (intentional version upgrade)

Co-authored-by: multica-agent <[email protected]>
2026-05-22 12:26:56 +08:00
Jiayuan 3c93ebaf1c feat(agent): stream live agent output to issue detail page
When an agent is working on an issue, users can now see real-time output
in the issue detail page instead of waiting for completion.

Backend:
- Add task_message table and migration for persisting agent messages
- Add POST /api/daemon/tasks/{id}/messages endpoint for daemon to report
  structured messages (tool_use, tool_result, text, error) in batches
- Add GET /api/daemon/tasks/{id}/messages for catch-up after reconnect
- Add GET /api/issues/{id}/active-task to check for running tasks
- Broadcast task:message events via WebSocket
- Daemon forwards agent session messages with 500ms text throttling

Frontend:
- Add AgentLiveCard component showing live tool calls, text output,
  and progress indicators with auto-scroll
- Wire into issue detail timeline with WS subscription and HTTP catch-up
- Card appears when agent is working, disappears on completion/failure
2026-03-30 22:53:28 +08:00