mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
* 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]>
361 lines
12 KiB
Go
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
|
|
}
|