Files
3191de1930 MUL-8385 feat(wecom): show the run's steps and reasoning in the bubble while it works (#9696)
* feat(wecom): show the run's steps and reasoning in the bubble while it works

The bubble used to open, spin for the whole run and close with the answer.
It now fills in while the run is going: each tool call as a sentence in the
reader's language, the agent's own reasoning under it, and how long it has
been. Only the bot's principal sees any of it, and only in their own 1:1 —
a group or anyone else gets the plain spinner, decided again for every round.

- Steps come from task:message (the transcript), plus the daemon's first
  task:progress milestone, which is said as "starting the agent" in the
  bubble's language rather than the daemon's English. Only runs asked over
  WeCom (a WeCom channel_task_delivery row) show steps.
- Progress reaches the replica that holds the round, whichever replica
  served the daemon's request. The bus subscriber only classifies the event
  and queues it on a per-run lane, so the daemon's request does no database
  read and no socket write. The holder reads the run's new transcript rows
  past a per-run watermark (ListTaskProgressSince), redacts, throttles and
  writes the frame. A replica without the round publishes a progress relay
  frame that carries ids and a seq, no text; it takes no claim and never
  enters the dispatcher's queues, so it cannot delay or crowd out an answer.
  Duplicates, replays and out-of-order frames read nothing new, and do not
  count as a sign of life for the rotation guard.
- Credentials are redacted from steps and reasoning: labelled values, known
  key prefixes, long high-entropy runs, and the same checks on each part of
  a URL or of a word glued to shell punctuation, plus webhook URLs whose path
  is the secret. Nothing the renderer strips afterwards can join two pieces
  into a key the redactor never saw. Reasoning arrives in
  500ms increments, so redaction and the </think> defence run over the
  joined buffer, where a key and its value are seen together, and a run that
  may still be the start of a key is held back until it has finished.
- A bubble near the server's ten-minute window is handed over to a fresh
  stream; a hand-over the server refuses stops rotation, and any stream the
  server has disowned takes no further frame — every closer falls back to a
  plain message instead of spending another refusal. Every frame a step
  writes, and both frames of a hand-over, take the reply write guard.
- The identity lookup is wired on the real boot path, and its absence logs
  a warning, since without it no bubble ever shows a step. A relay with no
  typing indicator attached warns once when progress frames arrive.

* fix(wecom): show a step the throttle held back without waiting for the next report

A step that landed inside the 1.5s refresh window was only shown when the
run reported again. The step most worth showing is often the last before a
long silence (a file read, then a test suite that runs for minutes), so the
bubble sat on the earlier step for the whole wait.

A held step now schedules one trailing refresh per feed for when the window
ends. It goes through the run's lane like any report, is not counted as a
sign of life for the rotation guard, is never published to other replicas,
and finds nothing once the round has ended.

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

* fix(wecom): keep the reasoning buffer across a bubble hand-over

Redaction runs over the joined reasoning buffer so a label and its value
split across two increments are seen together. A rotation started that
buffer over, so `password=` at the end of the old bubble and `hunter2` at
the start of the new one were judged apart and the value went out in the
clear.

The new bubble now starts with no step lines and a fresh throttle but the
same raw reasoning buffer. The old bubble is sealed with the hand-over line,
so the reasoning is not shown twice.

Also gofmt the two-replica test.

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

---------

Co-authored-by: multica-eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-10-31 01:39:14 +08:00

361 lines
12 KiB
Go

// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: task_message.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createTaskMessage = `-- name: CreateTaskMessage :one
INSERT INTO task_message (id, task_id, seq, type, tool, content, input, output, output_truncated, call_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, task_id, seq, type, tool, content, input, output, created_at, output_truncated, call_id
`
type CreateTaskMessageParams struct {
ID pgtype.UUID `json:"id"`
TaskID pgtype.UUID `json:"task_id"`
Seq int32 `json:"seq"`
Type string `json:"type"`
Tool pgtype.Text `json:"tool"`
Content pgtype.Text `json:"content"`
Input []byte `json:"input"`
Output pgtype.Text `json:"output"`
OutputTruncated pgtype.Bool `json:"output_truncated"`
CallID pgtype.Text `json:"call_id"`
}
func (q *Queries) CreateTaskMessage(ctx context.Context, arg CreateTaskMessageParams) (TaskMessage, error) {
row := q.db.QueryRow(ctx, createTaskMessage,
arg.ID,
arg.TaskID,
arg.Seq,
arg.Type,
arg.Tool,
arg.Content,
arg.Input,
arg.Output,
arg.OutputTruncated,
arg.CallID,
)
var i TaskMessage
err := row.Scan(
&i.ID,
&i.TaskID,
&i.Seq,
&i.Type,
&i.Tool,
&i.Content,
&i.Input,
&i.Output,
&i.CreatedAt,
&i.OutputTruncated,
&i.CallID,
)
return i, err
}
const createTaskMessages = `-- name: CreateTaskMessages :many
WITH incoming AS (
-- Several single-argument unnest calls in one SELECT list expand in
-- lockstep (PostgreSQL 10+ set-returning-function semantics), which is the
-- same row-wise zip the multi-argument unnest(a, b, ...) form gives — but
-- sqlc's analyzer only knows the single-argument signature, so this is the
-- shape that survives code generation.
SELECT
unnest($1::uuid[]) AS id,
unnest($2::int4[]) AS seq,
unnest($3::text[]) AS type,
unnest($4::text[]) AS tool,
unnest($5::text[]) AS call_id,
unnest($6::text[]) AS content,
unnest($7::text[]) AS input,
unnest($8::text[]) AS output,
unnest($9::text[]) AS created_at,
unnest($10::text[]) AS output_truncated
), inserted AS (
INSERT INTO task_message (id, task_id, seq, type, tool, content, input, output, created_at, output_truncated, call_id)
SELECT
m.id,
$11::uuid,
m.seq,
m.type,
NULLIF(m.tool, ''),
NULLIF(m.content, ''),
NULLIF(m.input, '')::jsonb,
NULLIF(m.output, ''),
COALESCE(NULLIF(m.created_at, '')::timestamptz, now()),
NULLIF(m.output_truncated, '')::bool,
NULLIF(m.call_id, '')
FROM incoming AS m
ON CONFLICT (id) DO NOTHING
RETURNING id, task_id, seq, type, tool, content, input, output, created_at, output_truncated, call_id
)
SELECT id, task_id, seq, type, tool, content, input, output, created_at, output_truncated, call_id FROM inserted ORDER BY seq ASC
`
type CreateTaskMessagesParams struct {
Ids []pgtype.UUID `json:"ids"`
Seqs []int32 `json:"seqs"`
Types []string `json:"types"`
Tools []string `json:"tools"`
CallIds []string `json:"call_ids"`
Contents []string `json:"contents"`
Inputs []string `json:"inputs"`
Outputs []string `json:"outputs"`
CreatedAts []string `json:"created_ats"`
OutputTruncations []string `json:"output_truncations"`
TaskID pgtype.UUID `json:"task_id"`
}
type CreateTaskMessagesRow struct {
ID pgtype.UUID `json:"id"`
TaskID pgtype.UUID `json:"task_id"`
Seq int32 `json:"seq"`
Type string `json:"type"`
Tool pgtype.Text `json:"tool"`
Content pgtype.Text `json:"content"`
Input []byte `json:"input"`
Output pgtype.Text `json:"output"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
OutputTruncated pgtype.Bool `json:"output_truncated"`
CallID pgtype.Text `json:"call_id"`
}
// Batch variant of CreateTaskMessage: persists a whole daemon-reported batch in
// ONE statement — therefore one round trip and, more importantly, one commit
// instead of one per message. Commit acknowledgement (IO:XactSync) is ~94% of
// this SQL's load in production, so the commit count is the thing being
// optimized; the round trip is a bonus.
//
// The rows arrive as parallel arrays rather than as one jsonb document, even
// though a jsonb document is the tidier Go side. content and output routinely
// carry tens of KB and occasionally megabytes, and wrapping them in JSON makes
// the server escape every byte a second time and Postgres parse the whole
// envelope back out — measured at 1.2x the old per-row insert at 128KB and
// ~1.8x at 1MB, i.e. a regression on exactly the most expensive requests, which
// are also the ones least likely to be batched. Native text[] elements are
// length-prefixed by the wire protocol, so they cost neither pass.
//
// NULLIF is what makes per-row NULL expressible through a non-nullable []string
// (the Go type sqlc gives a text[] parameter): it reproduces, exactly, the
// `pgtype.Text{Valid: x != ""}` mapping the single-row query carries — empty
// string means SQL NULL. input is passed as text and cast here for the same
// reason; it is the one column that genuinely has to be parsed as JSON, because
// it is a jsonb column. created_at also rides this transport so an older daemon
// can omit its event time and keep the database-time fallback. output_truncated
// uses the same trick because it is a genuinely tri-state boolean (NULL = the
// reporting daemon did not measure it), and a bool[] parameter becomes a Go
// []bool, which has no way to spell the third state.
//
// Callers MUST still run the Postgres text sanitizer first. A NUL anywhere in
// the batch fails the whole statement (GH #7098) — that is inherent to batching
// into one statement, not to the parameter shape.
//
// Atomicity is a deliberate side effect, not just a speedup: the per-message
// loop this replaces could persist part of a batch and then fail, leaving the
// transcript with a prefix of the batch and no way to complete it — the daemon
// does not retry this endpoint. One statement makes the batch all-or-nothing,
// which buys consistency. Callers that supply a task-scoped idempotency key
// derive a stable message UUID; the primary-key conflict below deduplicates
// retries without changing the append-only behavior of legacy random IDs.
//
// The ORDER BY is a contract, not decoration. A bare `INSERT ... RETURNING`
// has no defined row order, and the caller republishes these rows as realtime
// events in the order they arrive — the per-row loop this replaces implicitly
// published in request order, so the ordering has to be restored explicitly or
// subscribers can see a batch out of order. seq is assigned by the daemon and
// increases within a batch, so it is the request order.
func (q *Queries) CreateTaskMessages(ctx context.Context, arg CreateTaskMessagesParams) ([]CreateTaskMessagesRow, error) {
rows, err := q.db.Query(ctx, createTaskMessages,
arg.Ids,
arg.Seqs,
arg.Types,
arg.Tools,
arg.CallIds,
arg.Contents,
arg.Inputs,
arg.Outputs,
arg.CreatedAts,
arg.OutputTruncations,
arg.TaskID,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []CreateTaskMessagesRow{}
for rows.Next() {
var i CreateTaskMessagesRow
if err := rows.Scan(
&i.ID,
&i.TaskID,
&i.Seq,
&i.Type,
&i.Tool,
&i.Content,
&i.Input,
&i.Output,
&i.CreatedAt,
&i.OutputTruncated,
&i.CallID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const deleteTaskMessages = `-- name: DeleteTaskMessages :exec
DELETE FROM task_message
WHERE task_id = $1
`
func (q *Queries) DeleteTaskMessages(ctx context.Context, taskID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteTaskMessages, taskID)
return err
}
const listTaskMessages = `-- name: ListTaskMessages :many
SELECT id, task_id, seq, type, tool, content, input, output, created_at, output_truncated, call_id FROM task_message
WHERE task_id = $1
ORDER BY seq ASC
`
func (q *Queries) ListTaskMessages(ctx context.Context, taskID pgtype.UUID) ([]TaskMessage, error) {
rows, err := q.db.Query(ctx, listTaskMessages, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []TaskMessage{}
for rows.Next() {
var i TaskMessage
if err := rows.Scan(
&i.ID,
&i.TaskID,
&i.Seq,
&i.Type,
&i.Tool,
&i.Content,
&i.Input,
&i.Output,
&i.CreatedAt,
&i.OutputTruncated,
&i.CallID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTaskMessagesSince = `-- name: ListTaskMessagesSince :many
SELECT id, task_id, seq, type, tool, content, input, output, created_at, output_truncated, call_id FROM task_message
WHERE task_id = $1 AND seq > $2
ORDER BY seq ASC
`
type ListTaskMessagesSinceParams struct {
TaskID pgtype.UUID `json:"task_id"`
Seq int32 `json:"seq"`
}
func (q *Queries) ListTaskMessagesSince(ctx context.Context, arg ListTaskMessagesSinceParams) ([]TaskMessage, error) {
rows, err := q.db.Query(ctx, listTaskMessagesSince, arg.TaskID, arg.Seq)
if err != nil {
return nil, err
}
defer rows.Close()
items := []TaskMessage{}
for rows.Next() {
var i TaskMessage
if err := rows.Scan(
&i.ID,
&i.TaskID,
&i.Seq,
&i.Type,
&i.Tool,
&i.Content,
&i.Input,
&i.Output,
&i.CreatedAt,
&i.OutputTruncated,
&i.CallID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTaskProgressSince = `-- name: ListTaskProgressSince :many
SELECT seq, type, tool, content, input FROM task_message
WHERE task_id = $1
AND seq > $2
AND type IN ('tool_use', 'thinking', 'error')
ORDER BY seq ASC
`
type ListTaskProgressSinceParams struct {
TaskID pgtype.UUID `json:"task_id"`
AfterSeq int32 `json:"after_seq"`
}
type ListTaskProgressSinceRow struct {
Seq int32 `json:"seq"`
Type string `json:"type"`
Tool pgtype.Text `json:"tool"`
Content pgtype.Text `json:"content"`
Input []byte `json:"input"`
}
// The rows a WeCom bubble shows while a run works: tool calls, reasoning and
// errors past the last seq the bubble has folded in. Read by the replica that
// holds the bubble, which is how a step reported to any replica reaches it
// without the step's text crossing the realtime relay. Only the columns the
// bubble renders: output (a tool's result) is the block the bubble never
// shows, and routinely the largest column in the table.
func (q *Queries) ListTaskProgressSince(ctx context.Context, arg ListTaskProgressSinceParams) ([]ListTaskProgressSinceRow, error) {
rows, err := q.db.Query(ctx, listTaskProgressSince, arg.TaskID, arg.AfterSeq)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListTaskProgressSinceRow{}
for rows.Next() {
var i ListTaskProgressSinceRow
if err := rows.Scan(
&i.Seq,
&i.Type,
&i.Tool,
&i.Content,
&i.Input,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}