fix(btw): 修复长对话上下文预算与旁路输入布局

This commit is contained in:
ji ruo committed 2026-09-11 00:41:01 +08:00
1 parent 555539bc8e
commit d893861d00
17 files changed
+1295 -92

No files matched your search

+4
View File
@@ -1108,3 +1108,7 @@ CREATE TABLE IF NOT EXISTS side_question_requests (
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_side_request_running ON side_question_requests(session_key) WHERE status='running';
CREATE INDEX IF NOT EXISTS idx_side_requests_history ON side_question_requests(session_key,ordinal DESC);
-- Additive v3 archive fields; old archives restore these as empty objects.
ALTER TABLE side_question_sessions ADD COLUMN IF NOT EXISTS memory JSONB NOT NULL DEFAULT '{}';
ALTER TABLE side_question_requests ADD COLUMN IF NOT EXISTS context_info JSONB NOT NULL DEFAULT '{}';
+62 -6
View File
@@ -77,7 +77,7 @@ func (d *DB) SideSnapshot(ctx context.Context, key string) (*sidequestion.Snapsh
return &s, err
}
const sideCols = `id,ordinal,session_key,generation,client_id,question,answer,status,error,model,snapshot_at,created_at,sequence,usage`
const sideCols = `id,ordinal,session_key,generation,client_id,question,answer,status,error,model,snapshot_at,created_at,sequence,usage,context_info`
func (d *DB) ExistingSideRequest(ctx context.Context, key, client string) (*sidequestion.Exchange, error) {
e, err := scanSide(d.QueryRowContext(ctx, `SELECT `+sideCols+` FROM side_question_requests WHERE session_key=$1 AND client_id=$2 AND generation=(SELECT generation FROM side_question_sessions WHERE session_key=$1)`, key, client))
@@ -89,14 +89,17 @@ func (d *DB) ExistingSideRequest(ctx context.Context, key, client string) (*side
func scanSide(row interface{ Scan(...any) error }) (sidequestion.Exchange, error) {
var e sidequestion.Exchange
var model, usage []byte
err := row.Scan(&e.ID, &e.Ordinal, &e.SessionKey, &e.Generation, &e.ClientID, &e.Question, &e.Answer, &e.Status, &e.Error, &model, &e.SnapshotAt, &e.CreatedAt, &e.Sequence, &usage)
var model, usage, info []byte
err := row.Scan(&e.ID, &e.Ordinal, &e.SessionKey, &e.Generation, &e.ClientID, &e.Question, &e.Answer, &e.Status, &e.Error, &model, &e.SnapshotAt, &e.CreatedAt, &e.Sequence, &usage, &info)
if err != nil {
return e, err
}
if err = json.Unmarshal(model, &e.Model); err != nil {
return e, err
}
if err = json.Unmarshal(info, &e.Context); err != nil {
return e, err
}
err = json.Unmarshal(usage, &e.Usage)
return e, err
}
@@ -202,8 +205,12 @@ func (d *DB) UpdateSideRequest(ctx context.Context, e sidequestion.Exchange) (bo
if err != nil {
return false, err
}
r, err := d.ExecContext(ctx, `UPDATE side_question_requests r SET answer=$2,status=$3,error=$4,sequence=$5,usage=$6
WHERE r.id=$1 AND r.status='running' AND r.sequence<$5 AND EXISTS(SELECT 1 FROM side_question_sessions s WHERE s.session_key=r.session_key AND s.generation=r.generation)`, e.ID, e.Answer, e.Status, e.Error, e.Sequence, string(usage))
info, err := json.Marshal(e.Context)
if err != nil {
return false, err
}
r, err := d.ExecContext(ctx, `UPDATE side_question_requests r SET answer=$2,status=$3,error=$4,sequence=$5,usage=$6,context_info=$7
WHERE r.id=$1 AND r.status='running' AND r.sequence<$5 AND EXISTS(SELECT 1 FROM side_question_sessions s WHERE s.session_key=r.session_key AND s.generation=r.generation)`, e.ID, e.Answer, e.Status, e.Error, e.Sequence, string(usage), string(info))
if err != nil {
return false, err
}
@@ -217,7 +224,7 @@ func (d *DB) ClearSideHistory(ctx context.Context, key string) error {
return err
}
defer tx.Rollback()
if _, err = tx.ExecContext(ctx, `UPDATE side_question_sessions SET generation=generation+1 WHERE session_key=$1`, key); err != nil {
if _, err = tx.ExecContext(ctx, `UPDATE side_question_sessions SET generation=generation+1,memory='{}' WHERE session_key=$1`, key); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `DELETE FROM side_question_requests WHERE session_key=$1`, key); err != nil {
@@ -226,6 +233,55 @@ func (d *DB) ClearSideHistory(ctx context.Context, key string) error {
return tx.Commit()
}
func (d *DB) SideMemory(ctx context.Context, e sidequestion.Exchange) (sidequestion.Memory, error) {
var memory sidequestion.Memory
var raw []byte
err := d.QueryRowContext(ctx, `SELECT s.memory FROM side_question_sessions s JOIN side_question_requests r ON r.session_key=s.session_key
WHERE r.id=$1 AND r.generation=s.generation AND s.generation=$2 AND r.status='running'`, e.ID, e.Generation).Scan(&raw)
if err != nil {
return memory, err
}
err = json.Unmarshal(raw, &memory)
return memory, err
}
// Unlike SideReplay's UI-era 20-row window, this cursor visits all unsummarized
// successful exchanges, in bounded pages and only before the admitted request.
func (d *DB) SideReplayPage(ctx context.Context, e sidequestion.Exchange, after int64) ([]sidequestion.Exchange, error) {
rows, err := d.QueryContext(ctx, `SELECT `+sideCols+` FROM side_question_requests WHERE session_key=$1 AND generation=$2
AND status='completed' AND ordinal>$3 AND ordinal<$4 ORDER BY ordinal LIMIT 20`, e.SessionKey, e.Generation, after, e.Ordinal)
if err != nil {
return nil, err
}
defer rows.Close()
var out []sidequestion.Exchange
for rows.Next() {
item, err := scanSide(rows)
if err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func (d *DB) SaveSideMemory(ctx context.Context, e sidequestion.Exchange, memory sidequestion.Memory) error {
raw, err := json.Marshal(memory)
if err != nil {
return err
}
result, err := d.ExecContext(ctx, `UPDATE side_question_sessions s SET memory=$3 WHERE s.session_key=$1 AND s.generation=$2
AND EXISTS(SELECT 1 FROM side_question_requests r WHERE r.id=$4 AND r.session_key=s.session_key AND r.generation=s.generation AND r.status='running')`, e.SessionKey, e.Generation, string(raw), e.ID)
if err != nil {
return err
}
n, err := result.RowsAffected()
if err == nil && n == 0 {
return ErrSideParentGone
}
return err
}
func (d *DB) InterruptSideRequests(ctx context.Context) error {
_, err := d.ExecContext(ctx, `UPDATE side_question_requests SET status='interrupted',error='服务重启,回答已中断',sequence=sequence+1 WHERE status='running'`)
return err
+176
View File
@@ -2,6 +2,7 @@ package db
import (
"context"
"encoding/json"
"errors"
"fmt"
"sync"
@@ -101,6 +102,162 @@ func TestSideHistoryIdempotencyPagingAndRecovery(t *testing.T) {
}
}
func TestSideMemoryPagingClearAndRestart(t *testing.T) {
d, s := sideFixture(t)
ctx := t.Context()
var ordinal int64
for i := 0; i < 50; i++ {
e, _, err := d.StartSideRequest(ctx, s, fmt.Sprint(i), "history")
if err != nil {
t.Fatal(err)
}
e.Status = "completed"
e.Sequence = 1
e.Answer = "saved"
if i == 2 {
e.Status = "failed"
}
if _, err = d.UpdateSideRequest(ctx, *e); err != nil {
t.Fatal(err)
}
if i == 29 {
ordinal = e.Ordinal
}
}
e, _, err := d.StartSideRequest(ctx, s, "admitted", "question")
if err != nil {
t.Fatal(err)
}
memory := sidequestion.Memory{History: "old decision", Through: ordinal, SnapshotKey: "snapshot", SnapshotSummary: "main evidence", TailStart: 3}
if err = d.SaveSideMemory(ctx, *e, memory); err != nil {
t.Fatal(err)
}
var all []sidequestion.Exchange
for after := int64(0); ; {
page, err := d.SideReplayPage(ctx, *e, after)
if err != nil {
t.Fatal(err)
}
if len(page) == 0 {
break
}
if len(page) > 20 {
t.Fatal("unbounded page")
}
all = append(all, page...)
after = page[len(page)-1].Ordinal
}
if len(all) != 49 {
t.Fatalf("history missing/duplicated: %d", len(all))
}
if err = d.InterruptSideRequests(ctx); err != nil {
t.Fatal(err)
}
next, _, err := d.StartSideRequest(ctx, s, "restart", "continue")
if err != nil {
t.Fatal(err)
}
got, err := d.SideMemory(ctx, *next)
if err != nil || got != memory {
t.Fatalf("memory after restart: %+v %v", got, err)
}
page, err := d.SideReplayPage(ctx, *next, memory.Through)
if err != nil || len(page) != 20 || page[0].Ordinal <= ordinal {
t.Fatalf("summary cursor: %+v %v", page, err)
}
if err = d.ClearSideHistory(ctx, s.Parent.Key()); err != nil {
t.Fatal(err)
}
if err = d.SaveSideMemory(ctx, *next, memory); !errors.Is(err, ErrSideParentGone) {
t.Fatalf("late memory resurrected: %v", err)
}
fresh, _, err := d.StartSideRequest(ctx, s, "after-clear", "fresh")
if err != nil {
t.Fatal(err)
}
got, err = d.SideMemory(ctx, *fresh)
if err != nil || got != (sidequestion.Memory{}) {
t.Fatalf("memory survived clear: %+v %v", got, err)
}
if snapshot, err := d.SideSnapshot(ctx, s.Parent.Key()); err != nil || snapshot.Request.Messages[0].Text() != "main-only" {
t.Fatal("memory changed main snapshot")
}
}
func TestSideMemoryClearRace(t *testing.T) {
d, s := sideFixture(t)
for i := 0; i < 10; i++ {
e, _, err := d.StartSideRequest(t.Context(), s, "race", "question")
if err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
if err := d.ClearSideHistory(t.Context(), s.Parent.Key()); err != nil {
t.Error(err)
}
}()
go func() {
defer wg.Done()
err := d.SaveSideMemory(t.Context(), *e, sidequestion.Memory{History: "late", Through: e.Ordinal - 1})
if err != nil && !errors.Is(err, ErrSideParentGone) {
t.Error(err)
}
}()
wg.Wait()
var raw []byte
if err = d.QueryRow(`SELECT memory FROM side_question_sessions WHERE session_key=$1`, s.Parent.Key()).Scan(&raw); err != nil || string(raw) != "{}" {
t.Fatalf("late cache write: %s %v", raw, err)
}
}
}
func TestSideArchiveRowsWithoutNewFields(t *testing.T) {
d, s := sideFixture(t)
e, _, err := d.StartSideRequest(t.Context(), s, "legacy", "legacy question")
if err != nil {
t.Fatal(err)
}
tx, err := d.Begin()
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
rows := map[string]json.RawMessage{}
for _, table := range []string{"side_question_sessions", "side_question_requests"} {
var raw []byte
if err = tx.QueryRow(`SELECT json_agg(t) FROM `+table+` t WHERE session_key=$1`, s.Parent.Key()).Scan(&raw); err != nil {
t.Fatal(err)
}
var items []map[string]json.RawMessage
if err = json.Unmarshal(raw, &items); err != nil {
t.Fatal(err)
}
for _, item := range items {
delete(item, "memory")
delete(item, "context_info")
}
rows[table], err = json.Marshal(items)
if err != nil {
t.Fatal(err)
}
}
if _, err = tx.Exec(`DELETE FROM side_question_sessions WHERE session_key=$1`, s.Parent.Key()); err != nil {
t.Fatal(err)
}
for _, table := range []string{"side_question_sessions", "side_question_requests"} {
if err = insertArchiveRows(tx, table, rows[table]); err != nil {
t.Fatal(err)
}
}
var info, memory []byte
if err = tx.QueryRow(`SELECT context_info,memory FROM side_question_requests r JOIN side_question_sessions s USING(session_key) WHERE r.id=$1`, e.ID).Scan(&info, &memory); err != nil || string(info) != "{}" || string(memory) != "{}" {
t.Fatalf("legacy defaults: %s %s %v", info, memory, err)
}
}
func TestSideClearLateWritersAndDeletedParent(t *testing.T) {
d, s := sideFixture(t)
ctx := t.Context()
@@ -187,6 +344,8 @@ func TestSideTaskArchiveVersions(t *testing.T) {
t.Fatal(err)
}
var snapshots []sidequestion.Snapshot
memories := make(map[string]sidequestion.Memory)
contextInfo := sidequestion.ContextInfo{Phase: "answering", RecentExchanges: 20, HistorySummarized: true, SnapshotSummarized: true, EstimatedInputTokens: 12000, InputBudget: 16000, OutputTokens: 2048}
for _, intent := range []int64{0, iid} {
s := sidequestion.Snapshot{Parent: sidequestion.Parent{TaskID: task.ID, ExplorationID: task.ExplorationID, IntentID: intent}, RunID: 1, Version: 2, CapturedAt: time.Now().UTC(), Request: llm.CompletionRequest{Messages: []llm.Message{llm.UserText("archived main context")}}}
if err = d.SaveSideSnapshot(t.Context(), s); err != nil {
@@ -196,9 +355,15 @@ func TestSideTaskArchiveVersions(t *testing.T) {
if err != nil {
t.Fatal(err)
}
memory := sidequestion.Memory{History: "archived early decision", Through: e.Ordinal, SnapshotKey: s.Parent.Key(), SnapshotSummary: "archived evidence", TailStart: 1}
if err = d.SaveSideMemory(t.Context(), *e, memory); err != nil {
t.Fatal(err)
}
memories[s.Parent.Key()] = memory
e.Answer = "archive answer"
e.Sequence = 1
e.Status = "completed"
e.Context = contextInfo
if _, err = d.UpdateSideRequest(t.Context(), *e); err != nil {
t.Fatal(err)
}
@@ -263,6 +428,17 @@ func TestSideTaskArchiveVersions(t *testing.T) {
if err != nil || len(history) != 1 || history[0].Answer != "archive answer" {
t.Fatalf("restored history %+v %v", history, err)
}
if history[0].Context != contextInfo {
t.Fatalf("restored context metadata: %+v", history[0].Context)
}
next, _, err := d.StartSideRequest(t.Context(), *got, "after-restore", "continue")
if err != nil {
t.Fatal(err)
}
memory, err := d.SideMemory(t.Context(), *next)
if err != nil || memory != memories[s.Parent.Key()] {
t.Fatalf("restored summary cache: %+v %v", memory, err)
}
}
}
})
+22
View File
@@ -355,6 +355,28 @@ func rowExists(tx *sql.Tx, table string, id int64) bool {
}
func insertArchiveRows(tx *sql.Tx, table string, raw json.RawMessage) error {
// json_populate_recordset inserts NULL for absent columns, bypassing SQL
// defaults. Preserve compatibility with v3 archives predating side memory.
if (table == "side_question_sessions" || table == "side_question_requests") && len(raw) > 0 {
var rows []map[string]json.RawMessage
if err := json.Unmarshal(raw, &rows); err != nil {
return err
}
field := "memory"
if table == "side_question_requests" {
field = "context_info"
}
for _, row := range rows {
if len(row[field]) == 0 || string(row[field]) == "null" {
row[field] = json.RawMessage(`{}`)
}
}
var err error
raw, err = json.Marshal(rows)
if err != nil {
return err
}
}
allowed := map[string]bool{
"exploration_nodes": true, "exploration_edges": true, "exploration_anchors": true,
"task_constraints": true, "activity": true, "task_asset_links": true, "findings": true,