Files
Agent-System-Backend/internal/store/recovery.go
T
agent-exec-transport 60f37adc04
governance / governance (pull_request) Successful in 20s
ci / vet-and-test (pull_request) Successful in 1m27s
agent-exec transport 9b5caca2968f510a4f81402ca7dfdb4c/f6d16260d971
2026-10-20 03:51:12 +00:00

381 lines
13 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"
"errors"
"fmt"
"time"
core "agent-system-core"
)
// DuplicateRequestError 带上已落库的那条消息。
type DuplicateRequestError struct {
Existing core.Message
}
func (e *DuplicateRequestError) Error() string { return ErrDuplicateRequest.Error() }
func (e *DuplicateRequestError) Unwrap() error { return ErrDuplicateRequest }
// MessageByRequest 按会话和 request_id 查找已落库消息。
func (s *Store) MessageByRequest(conversationID, requestID string) (core.Message, error) {
if requestID == "" {
return core.Message{}, ErrNotFound
}
var m core.Message
var parts, ca string
err := s.db.QueryRow(s.q(`SELECT id,conversation_id,run_id,role,parts,created_at FROM messages WHERE conversation_id=? AND request_id=?`),
conversationID, requestID).Scan(&m.ID, &m.ConversationID, &m.RunID, &m.Role, &parts, &ca)
if errors.Is(err, sql.ErrNoRows) {
return core.Message{}, ErrNotFound
}
if err != nil {
return core.Message{}, err
}
m.Role = core.Role(m.Role)
m.Parts, _ = unmarshalJSON[[]core.Part](parts)
m.CreatedAt, _ = parseTime(ca)
return m, nil
}
// SetMessageRunID 把用户消息挂到随后启动的 run 上,供同一 request_id 重试时带回。
func (s *Store) SetMessageRunID(messageID, runID string) error {
_, err := s.db.Exec(s.q(`UPDATE messages SET run_id=? WHERE id=?`), runID, messageID)
return err
}
// LatestOpenRun 返回会话里最新的非终态、非子代理 run。
// 幂等重试在用户消息还没写上 run_id 时,用它避免绑到更早的已完成 run。
func (s *Store) LatestOpenRun(conversationID string) (core.Run, error) {
r, err := scanRun(func(dst ...any) error {
return s.db.QueryRow(s.q(`SELECT `+runColumns+` FROM runs WHERE conversation_id=? AND kind<>? AND status IN ('pending','running','waiting_hitl') ORDER BY created_at DESC LIMIT 1`),
conversationID, string(core.RunSubagent)).Scan(dst...)
})
if errors.Is(err, sql.ErrNoRows) {
return r, ErrNotFound
}
return r, err
}
// ListRecoverableRuns 列出仍停在非终态的 run。
// all 为真时扫全部(SQLite 单进程)。否则只扫本实例,以及升级前 instance_id 为空的行。
func (s *Store) ListRecoverableRuns(instanceID string, all bool) ([]core.Run, error) {
q := `SELECT ` + runColumns + ` FROM runs WHERE status IN ('pending','running','waiting_hitl')`
args := []any{}
if !all {
q += ` AND (instance_id=? OR instance_id='')`
args = append(args, instanceID)
}
q += ` ORDER BY created_at`
rows, err := s.db.Query(s.q(q), args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []core.Run{}
for rows.Next() {
r, err := scanRun(rows.Scan)
if err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// SealInterrupted 把仍在进行的 run 收成 interrupted。
// 条件更新保证并发对账时只有一个赢家;已经是终态的行不受影响。
// 赢家并在同一事务中为该 run 写入一条助手中断错误消息(若尚无助手消息)。
func (s *Store) SealInterrupted(id string) (bool, error) {
tx, err := s.db.Begin()
if err != nil {
return false, err
}
defer tx.Rollback()
var convID string
err = tx.QueryRow(s.q(`SELECT conversation_id FROM runs WHERE id=?`), id).Scan(&convID)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
} else if err != nil {
return false, err
}
if convID != "" {
if err := s.dialect.lockConversation(tx, convID); err != nil {
return false, err
}
}
errPartObj := &core.ErrorPart{Code: "run.interrupted", Message: "进程重启或停机,运行已中断"}
errPartJSON := marshalJSON(errPartObj)
res, err := tx.Exec(s.q(`UPDATE runs SET status=?, phase=?, error=?, ended_at=? WHERE id=? AND status IN ('pending','running','waiting_hitl')`),
string(core.RunInterrupted), string(core.PhaseDone), errPartJSON, nowISO(), id)
if err != nil {
return false, err
}
n, err := res.RowsAffected()
if err != nil {
return false, err
}
if n != 1 {
return false, nil
}
var count int
if err := tx.QueryRow(s.q(`SELECT COUNT(*) FROM messages WHERE run_id=? AND role=?`), id, string(core.RoleAssistant)).Scan(&count); err != nil {
return false, err
}
if count == 0 && convID != "" {
msg := &core.Message{
ID: newID("msg"),
ConversationID: convID,
RunID: id,
Role: core.RoleAssistant,
Parts: []core.Part{{Type: core.PartError, Err: errPartObj}},
CreatedAt: time.Now().UTC(),
}
var variantGroup string
var variantIndex int
if err := tx.QueryRow(s.q(`SELECT variant_group, variant_index FROM regen_pending WHERE conversation_id=?`), convID).Scan(&variantGroup, &variantIndex); err == nil && variantGroup != "" {
msg.VariantGroup = variantGroup
msg.VariantIndex = variantIndex
}
if err := s.insertSettledMessageTx(tx, msg); err != nil {
return false, err
}
}
if err := tx.Commit(); err != nil {
return false, err
}
return true, nil
}
// ActiveHITLCardsByRun 返回某个 run 上尚未关闭的 HITL 卡片。
func (s *Store) ActiveHITLCardsByRun(runID string) ([]core.HITLCard, error) {
rows, err := s.db.Query(s.q(`SELECT payload FROM hitl_cards WHERE run_id=? AND resolved=0 ORDER BY created_at`), runID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []core.HITLCard{}
for rows.Next() {
var payload string
if err := rows.Scan(&payload); err != nil {
return nil, err
}
c, err := unmarshalJSON[core.HITLCard](payload)
if err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// SettleRun 在一个事务里更新 run,并可选地追加助手消息。
// updated 为假表示条件更新没有改到行(已被收成终态);消息仍会写入。
// 序号冲突时整段事务重试。其它失败回滚,run 保持原状态。
// SettleRunFenced(协作存储 U5/KTD4):终态结算带执行围栏——事务内校验
// (instance,epoch) 仍是该会话的当前执行权,被接管则拒写(fenced=true,run
// 不变)。无租约行(未启用执行所有权)恒放行,语义与 SettleRun 一致。
func (s *Store) SettleRunFenced(r *core.Run, msg *core.Message, instanceID string, epoch int64) (updated, fenced bool, err error) {
return s.settleRunRetried(r, msg, &fenceRef{instance: instanceID, epoch: epoch, conversation: string(r.ConversationID)}, nil)
}
// SettleRunFencedAccountChecked adds a transaction-time account check to the
// execution fence. A disable committing first prevents a later business
// message from reaching the authoritative message tree.
func (s *Store) SettleRunFencedAccountChecked(r *core.Run, msg *core.Message, instanceID string, epoch int64, allowImplicitLocal bool) (bool, bool, error) {
return s.settleRunRetried(r, msg, &fenceRef{instance: instanceID, epoch: epoch, conversation: string(r.ConversationID)},
&accountSettleCheck{allowImplicitLocal: allowImplicitLocal})
}
// fenceRef 执行围栏引用(nil = 不校验)。
type fenceRef struct {
instance, conversation string
epoch int64
}
type accountSettleCheck struct{ allowImplicitLocal bool }
func (s *Store) SettleRun(r *core.Run, msg *core.Message) (updated bool, err error) {
updated, _, err = s.settleRunRetried(r, msg, nil, nil)
return updated, err
}
func (s *Store) SettleRunAccountChecked(r *core.Run, msg *core.Message, allowImplicitLocal bool) (bool, error) {
updated, _, err := s.settleRunRetried(r, msg, nil, &accountSettleCheck{allowImplicitLocal: allowImplicitLocal})
return updated, err
}
func (s *Store) settleRunRetried(r *core.Run, msg *core.Message, fence *fenceRef, account *accountSettleCheck) (updated, fenced bool, err error) {
s.settleMu.Lock()
fault := s.settleFault
s.settleMu.Unlock()
if fault != nil {
return false, false, fault
}
if msg != nil && msg.ID == "" {
msg.ID = newID("msg")
}
var last error
for attempt := 0; attempt < 8; attempt++ {
updated, fenced, last = s.settleRunOnce(r, msg, fence, account)
if last == nil {
return updated, fenced, nil
}
if !isUniqueViolation(last) {
return false, false, last
}
time.Sleep(time.Duration(attempt) * time.Millisecond)
}
if last == nil {
last = fmt.Errorf("store: settle retries exhausted (run %s)", r.ID)
}
return false, false, last
}
func (s *Store) settleRunOnce(r *core.Run, msg *core.Message, fence *fenceRef, account *accountSettleCheck) (bool, bool, error) {
tx, err := s.db.Begin()
if err != nil {
return false, false, err
}
defer tx.Rollback()
if account != nil {
query := s.q(`SELECT enabled FROM users WHERE owner=?`)
if s.Dialect() == "postgres" {
query += ` FOR UPDATE`
}
var enabled bool
err := tx.QueryRow(query, r.Owner).Scan(&enabled)
if errors.Is(err, sql.ErrNoRows) {
if account.allowImplicitLocal && r.Owner == OwnerDefault {
enabled, err = true, nil
} else {
return false, false, ErrAccountForbidden
}
}
if err != nil {
return false, false, err
}
if !enabled {
// An already identified account-denial failure may persist a minimal
// error state; it contains no assistant content or business output.
allowedFailure := r.Status == core.RunFailed && r.Error != nil && r.Error.Code == "run.account_inactive"
if msg != nil {
for _, part := range msg.Parts {
if part.Type != core.PartError || part.Err == nil || part.Err.Code != "run.account_inactive" {
allowedFailure = false
break
}
}
}
if !allowedFailure {
return false, false, ErrAccountForbidden
}
}
}
if err := s.dialect.lockConversation(tx, string(r.ConversationID)); err != nil {
return false, false, err
}
// 执行围栏(KTD4):会话存在租约行且 (instance,epoch) 不匹配 → 拒写。
// 无行 = 该会话未启用执行所有权(本地档/旧路径),放行。
if fence != nil {
var n int
if err := tx.QueryRow(s.q(`SELECT COUNT(*) FROM conversation_executions WHERE conversation_id=?`), fence.conversation).Scan(&n); err != nil {
return false, false, err
}
if n > 0 {
var m int
if err := tx.QueryRow(s.q(`SELECT COUNT(*) FROM conversation_executions WHERE conversation_id=? AND instance_id=? AND epoch=?`), fence.conversation, fence.instance, fence.epoch).Scan(&m); err != nil {
return false, false, err
}
if m == 0 {
return false, true, nil // fenced:旧执行代次,run 不变
}
}
}
var ended any
if r.EndedAt != nil {
ended = nowISO()
}
res, err := tx.Exec(s.q(`UPDATE runs SET status=?, phase=?, usage=?, error=?, ended_at=? WHERE id=? AND status IN ('pending','running','waiting_hitl')`),
string(r.Status), string(r.Phase), marshalJSON(r.Usage), marshalJSON(r.Error), ended, r.ID)
if err != nil {
return false, false, err
}
n, err := res.RowsAffected()
if err != nil {
return false, false, err
}
if msg != nil {
if err := s.insertSettledMessageTx(tx, msg); err != nil {
return false, false, err
}
}
if err := tx.Commit(); err != nil {
return false, false, err
}
return n == 1, false, nil
}
func (s *Store) insertSettledMessageTx(tx *sql.Tx, msg *core.Message) error {
if msg == nil {
return nil
}
if msg.ID == "" {
msg.ID = newID("msg")
}
if msg.CreatedAt.IsZero() {
msg.CreatedAt = time.Now().UTC()
}
insert := `INSERT INTO messages(id,conversation_id,run_id,role,parts,seq,created_at,request_id)
SELECT ?,?,?,?,?,COALESCE(MAX(seq),0)+1,?,? FROM messages WHERE conversation_id=?`
if _, err := tx.Exec(s.q(insert), msg.ID, msg.ConversationID, msg.RunID, string(msg.Role), marshalJSON(msg.Parts), msg.CreatedAt.UTC().Format(time.RFC3339Nano), "", msg.ConversationID); err != nil {
return err
}
if err := s.appendProjectMessageObservationsTx(tx, msg); err != nil {
return err
}
if msg.VariantGroup != "" || msg.Excluded {
ex := 0
if msg.Excluded {
ex = 1
}
if _, err := tx.Exec(s.q(`UPDATE messages SET excluded=?, variant_group=?, variant_index=? WHERE id=?`), ex, msg.VariantGroup, msg.VariantIndex, msg.ID); err != nil {
return err
}
}
if msg.VariantGroup != "" {
if _, err := tx.Exec(s.q(`DELETE FROM regen_pending WHERE conversation_id=? AND variant_group=? AND variant_index=?`), msg.ConversationID, msg.VariantGroup, msg.VariantIndex); err != nil {
return err
}
}
if _, err := tx.Exec(s.q(`UPDATE conversations SET updated_at=? WHERE id=?`), nowISO(), msg.ConversationID); err != nil {
return err
}
return nil
}
// ListInterruptedSubagentRuns 列出被收成 interrupted 的子代理 run
// (N2 WP5 重启续跑的候选集;幂等过滤由转录目录的 done/resumed 标记完成)。
func (s *Store) ListInterruptedSubagentRuns() ([]core.Run, error) {
rows, err := s.db.Query(s.q(`SELECT `+runColumns+` FROM runs WHERE kind=? AND status=? ORDER BY created_at`),
string(core.RunSubagent), string(core.RunInterrupted))
if err != nil {
return nil, err
}
defer rows.Close()
out := []core.Run{}
for rows.Next() {
r, err := scanRun(rows.Scan)
if err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}