Files
Agent-System-Backend/internal/store/cards.go
T
weichen 5cd67be982
ci / vet-and-test (pull_request) Successful in 41s
Close request atomicity: locked message append, card versions, atomic rate limit, recovery holds (U3 slice 1)
Why: the approved plan U3 (KTD3/KTD7/KTD10 plus minimal recovery isolation) requires concurrent submits to not silently overwrite, unknown-side-effect runs to never auto-resume, and the shared rate limiter to degrade conservatively instead of failing open.

Evidence: full backend suite green twice-relevant paths against real PostgreSQL 18.6 and Redis (isolated instance) and on SQLite; new tests - card_version_test (concurrent single winner), recovery_hold_test + recovery_holds_test (skip/release/fail-closed CRUD), ratelimit atomic-window and degraded-fallback tests.

Impact: AppendMessageWithRequest now allocates seq under the conversation advisory lock in one transaction; activity_cards gain version (v19) with SaveCardVersioned/GetCardVersion/CardVersion and cardsvc user mutations (status/pin/action/resolve-by-ref) return 409-mapped conflicts while system mirrors keep blind writes; recovery_holds (v20) with admin list/release endpoints and fail-closed resume skip; Redis limiter uses one Lua INCR+PEXPIRE script and falls back to a per-instance limit with a Degraded counter.

Refs: proposals/2026-10-11-collaboration-storage-and-distribution.md U3 KTD3/KTD7/KTD10; R3/R6/R7/R8.
2026-10-11 04:37:50 +00:00

172 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
core "agent-system-core"
)
// 收件箱任务卡持久化:payload 存 core.ActivityCard 整卡 JSON(卡片是可变状态对象,
// 服务端权威);status/pinned/ref 冗余列支撑过滤、排序与 HITL 投影联动。
// SaveCard 插入或更新整卡(upsert)。
func (s *Store) SaveCard(owner string, c *core.ActivityCard, ref string) error {
payload, err := json.Marshal(c)
if err != nil {
return fmt.Errorf("save card %s: %w", c.ID, err)
}
_, err = s.db.Exec(s.q(`INSERT INTO activity_cards (id, project_id, conversation_id, ref, owner, payload, status, pinned, created_at, updated_at, version)
VALUES (?,?,?,?,?,?,?,?,?,?,1)
ON CONFLICT(id) DO UPDATE SET payload=excluded.payload, status=excluded.status, pinned=excluded.pinned, ref=CASE WHEN excluded.ref='' THEN activity_cards.ref ELSE excluded.ref END, updated_at=excluded.updated_at, version=activity_cards.version+1`),
c.ID, c.ProjectID, c.ConversationID, ref, owner, string(payload), string(c.Status), boolInt(c.Pinned), c.CreatedAt.Format(time.RFC3339Nano), c.UpdatedAt.Format(time.RFC3339Nano))
if err != nil {
return fmt.Errorf("save card %s: %w", c.ID, err)
}
return nil
}
// SaveCardVersioned 乐观并发保存(U3,KTD7):行存在且 expected 与当前
// version 一致才应用并递增,否则 ErrConflict(调用方回 409 让客户端重读
// 重试);行不存在时按新卡插入 version=1(expected==0 的建卡语义)。
// 现产品没有卡片删除路径,expected>0 而行缺失不可达;若未来引入删除,
// 此处须补「插入回滚为冲突」的语义。
func (s *Store) SaveCardVersioned(owner string, c *core.ActivityCard, ref string, expected int) error {
payload, err := json.Marshal(c)
if err != nil {
return fmt.Errorf("save card %s: %w", c.ID, err)
}
res, err := s.db.Exec(s.q(`INSERT INTO activity_cards (id, project_id, conversation_id, ref, owner, payload, status, pinned, created_at, updated_at, version)
VALUES (?,?,?,?,?,?,?,?,?,?,1)
ON CONFLICT(id) DO UPDATE SET payload=excluded.payload, status=excluded.status, pinned=excluded.pinned, ref=CASE WHEN excluded.ref='' THEN activity_cards.ref ELSE excluded.ref END, updated_at=excluded.updated_at, version=activity_cards.version+1
WHERE activity_cards.version=?`),
c.ID, c.ProjectID, c.ConversationID, ref, owner, string(payload), string(c.Status), boolInt(c.Pinned), c.CreatedAt.Format(time.RFC3339Nano), c.UpdatedAt.Format(time.RFC3339Nano), expected)
if err != nil {
return fmt.Errorf("save card %s: %w", c.ID, err)
}
if n, _ := res.RowsAffected(); n == 0 {
// DO UPDATE 的 WHERE 未命中:行存在但版本不匹配 → 明确冲突(客户端重读)。
return fmt.Errorf("save card %s: %w", c.ID, ErrConflict)
}
return nil
}
// CardVersion 返回当前版本号(乐观并发的读侧)。
func (s *Store) CardVersion(owner, id string) (int, error) {
var v int
err := s.db.QueryRow(s.q(`SELECT version FROM activity_cards WHERE id=? AND owner=?`), id, owner).Scan(&v)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, ErrNotFound
}
return 0, err
}
return v, nil
}
// GetCardVersion 读卡与当前版本(乐观并发的读侧;U3 KTD7)。
func (s *Store) GetCardVersion(owner, id string) (core.ActivityCard, int, error) {
row := s.db.QueryRow(s.q(`SELECT payload, version FROM activity_cards WHERE id=? AND owner=?`), id, owner)
var payload string
var ver int
if err := row.Scan(&payload, &ver); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return core.ActivityCard{}, 0, ErrNotFound
}
return core.ActivityCard{}, 0, err
}
var c core.ActivityCard
if err := json.Unmarshal([]byte(payload), &c); err != nil {
return core.ActivityCard{}, 0, fmt.Errorf("card %s payload: %w", id, err)
}
return c, ver, nil
}
func (s *Store) GetCard(owner, id string) (core.ActivityCard, error) {
row := s.db.QueryRow(s.q(`SELECT payload FROM activity_cards WHERE id=? AND owner=?`), id, owner)
var payload string
if err := row.Scan(&payload); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return core.ActivityCard{}, ErrNotFound
}
return core.ActivityCard{}, err
}
var c core.ActivityCard
if err := json.Unmarshal([]byte(payload), &c); err != nil {
return core.ActivityCard{}, fmt.Errorf("card %s payload: %w", id, err)
}
return c, nil
}
func (s *Store) ListCards(owner, conversationID string) ([]core.ActivityCard, error) {
rows, err := s.db.Query(s.q(`SELECT payload FROM activity_cards WHERE conversation_id=? AND owner=? ORDER BY updated_at DESC`), conversationID, owner)
if err != nil {
return nil, fmt.Errorf("list cards: %w", err)
}
defer rows.Close()
var out []core.ActivityCard
for rows.Next() {
var payload string
if err := rows.Scan(&payload); err != nil {
return nil, err
}
var c core.ActivityCard
if err := json.Unmarshal([]byte(payload), &c); err != nil {
return nil, fmt.Errorf("card payload: %w", err)
}
out = append(out, c)
}
return out, rows.Err()
}
// ListProjectCards 项目收件箱聚合(D49 拍板口径):项目级卡(conversation_id
// 为空,任意状态)∪ 该项目全部会话的未决卡(action_required);done 卡不聚合
// (留在原会话,防噪音)。
func (s *Store) ListProjectCards(owner, projectID string) ([]core.ActivityCard, error) {
rows, err := s.db.Query(s.q(`SELECT payload FROM activity_cards WHERE project_id=? AND owner=? AND (conversation_id='' OR status=?) ORDER BY updated_at DESC`), projectID, owner, string(core.CardActionRequired))
if err != nil {
return nil, fmt.Errorf("list project cards: %w", err)
}
defer rows.Close()
var out []core.ActivityCard
for rows.Next() {
var payload string
if err := rows.Scan(&payload); err != nil {
return nil, err
}
var c core.ActivityCard
if err := json.Unmarshal([]byte(payload), &c); err != nil {
return nil, fmt.Errorf("card payload: %w", err)
}
out = append(out, c)
}
return out, rows.Err()
}
// CardRef 取卡的 ref 列(命令确认卡回道判定:"cmd:<id>" 前缀)。
func (s *Store) CardRef(owner, id string) (string, error) {
row := s.db.QueryRow(s.q(`SELECT ref FROM activity_cards WHERE id=? AND owner=?`), id, owner)
var ref string
if err := row.Scan(&ref); err != nil {
return "", err
}
return ref, nil
}
// CardIDByRef 按引用取卡 ID(HITL 投影联动:"hitl:<id>";最新一条)。
func (s *Store) CardIDByRef(owner, ref string) (string, error) {
row := s.db.QueryRow(s.q(`SELECT id FROM activity_cards WHERE ref=? AND owner=? ORDER BY updated_at DESC LIMIT 1`), ref, owner)
var id string
err := row.Scan(&id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
return "", err
}
return id, nil
}