Files
Zezae Oh 505b7a9d99 fix(agent): recover interrupted runs to failed on gateway startup (#1272)
A run records its terminal run.status (completed/failed/cancelled) only
when the agent loop returns. If the gateway process is killed mid-run
(restart, crash, or SIGKILL after a slow graceful shutdown), the terminal
event is never emitted, so the run's last run.status item stays "started"
forever — it shows as perpetually running in the timeline and is never
counted as failed.

Mirror the cron scheduler's startup reset (recomputeStaleJobs flips stale
'running' jobs to 'interrupted'): add RunTimelineStore.RecoverInterruptedRuns,
invoked once on gateway startup. It finds runs that have a started run.status
but no terminal sibling and appends a terminal failed run.status item, marked
interrupted in metadata so it stays distinguishable from a genuine agent
failure. Running only at startup means nothing from before is still
executing, so there is no false-positive risk — unlike a periodic sweep
(see the deliberately-disabled tracing stale-trace loop).

- store: RecoverInterruptedRuns on the interface + PG and SQLite impls
- cmd/gateway_managed: invoke once after the timeline recorder is built
- tests: SQLite store test — orphan recovered, completed run untouched,
  idempotent on re-run
2026-06-24 14:03:00 +07:00

238 lines
7.9 KiB
Go

//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// SQLiteRunTimelineStore implements store.RunTimelineStore backed by SQLite.
type SQLiteRunTimelineStore struct {
db *sql.DB
}
func NewSQLiteRunTimelineStore(db *sql.DB) *SQLiteRunTimelineStore {
return &SQLiteRunTimelineStore{db: db}
}
func (s *SQLiteRunTimelineStore) AppendRunTimelineItem(ctx context.Context, item *store.RunTimelineItem) error {
if item.ID == uuid.Nil {
item.ID = store.GenNewID()
}
if item.CreatedAt.IsZero() {
item.CreatedAt = time.Now()
}
tenantID := tenantIDForInsert(ctx)
item.TenantID = tenantID
metadata := item.Metadata
if len(metadata) == 0 {
metadata = []byte(`{}`)
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO run_timeline_items
(id, tenant_id, run_id, session_key, agent_id, user_id, channel, chat_id, seq,
item_type, status, title, preview, content, tool_name, tool_call_id, trace_id, span_id,
metadata, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (tenant_id, run_id, seq) DO UPDATE SET
session_key = excluded.session_key,
agent_id = excluded.agent_id,
user_id = excluded.user_id,
channel = excluded.channel,
chat_id = excluded.chat_id,
item_type = excluded.item_type,
status = excluded.status,
title = excluded.title,
preview = excluded.preview,
content = '',
tool_name = excluded.tool_name,
tool_call_id = excluded.tool_call_id,
trace_id = excluded.trace_id,
span_id = excluded.span_id,
metadata = excluded.metadata,
created_at = excluded.created_at`,
item.ID, tenantID, item.RunID, item.SessionKey, nilUUID(item.AgentID), nilStr(item.UserID),
nilStr(item.Channel), nilStr(item.ChatID), item.Seq, item.ItemType, nilStr(item.Status),
nilStr(item.Title), nilStr(item.Preview), "", nilStr(item.ToolName), nilStr(item.ToolCallID),
nilUUID(item.TraceID), nilUUID(item.SpanID), string(metadata), item.CreatedAt,
)
if err == nil {
item.Content = ""
}
return err
}
func (s *SQLiteRunTimelineStore) ListRunTimelineItems(ctx context.Context, opts store.RunTimelineListOpts) ([]store.RunTimelineItem, error) {
where, args := buildRunTimelineWhere(ctx, opts)
limit := opts.Limit
if limit <= 0 || limit > 500 {
limit = 200
}
q := `SELECT id, tenant_id, run_id, session_key, agent_id, user_id, channel, chat_id, seq,
item_type, status, title, preview, COALESCE(content, '') AS content, tool_name, tool_call_id,
trace_id, span_id, COALESCE(metadata, '{}') AS metadata, created_at
FROM run_timeline_items` + where +
runTimelineOrderBy(opts) +
fmt.Sprintf(" LIMIT %d OFFSET %d", limit, opts.Offset)
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanRunTimelineRows(rows)
}
func runTimelineOrderBy(opts store.RunTimelineListOpts) string {
if opts.RunID != "" {
return " ORDER BY seq ASC, created_at ASC"
}
return " ORDER BY created_at ASC, seq ASC"
}
func buildRunTimelineWhere(ctx context.Context, opts store.RunTimelineListOpts) (string, []any) {
var conditions []string
var args []any
if !store.IsCrossTenant(ctx) {
tenantID := store.TenantIDFromContext(ctx)
if tenantID == uuid.Nil {
return " WHERE 1=0", nil
}
conditions = append(conditions, "tenant_id = ?")
args = append(args, tenantID)
}
if opts.RunID != "" {
conditions = append(conditions, "run_id = ?")
args = append(args, opts.RunID)
}
if opts.SessionKey != "" {
conditions = append(conditions, "session_key = ?")
args = append(args, opts.SessionKey)
}
if len(conditions) == 0 {
return " WHERE 1=0", nil
}
return " WHERE " + strings.Join(conditions, " AND "), args
}
func scanRunTimelineRows(rows *sql.Rows) ([]store.RunTimelineItem, error) {
var items []store.RunTimelineItem
for rows.Next() {
var item store.RunTimelineItem
var agentID, traceID, spanID *uuid.UUID
var userID, channel, chatID, status, title, preview, toolName, toolCallID sql.NullString
var metadata string
var createdAt sqliteTime
if err := rows.Scan(&item.ID, &item.TenantID, &item.RunID, &item.SessionKey, &agentID,
&userID, &channel, &chatID, &item.Seq, &item.ItemType, &status, &title, &preview,
&item.Content, &toolName, &toolCallID, &traceID, &spanID, &metadata, &createdAt); err != nil {
return nil, err
}
item.AgentID = agentID
item.TraceID = traceID
item.SpanID = spanID
item.UserID = userID.String
item.Channel = channel.String
item.ChatID = chatID.String
item.Status = status.String
item.Title = title.String
item.Preview = preview.String
item.ToolName = toolName.String
item.ToolCallID = toolCallID.String
item.Metadata = []byte(metadata)
item.CreatedAt = createdAt.Time
items = append(items, item)
}
return items, rows.Err()
}
const interruptedRunPreview = "interrupted: gateway stopped while this run was in progress"
// interruptedRunMetadata marks a backfilled terminal status so it is
// distinguishable from a genuine agent failure in the timeline.
var interruptedRunMetadata = []byte(`{"event_type":"run.failed","interrupted":true,"reason":"server_restart"}`)
// RecoverInterruptedRuns appends a terminal failed run.status item to every run
// that has a "started" run.status but no terminal sibling — i.e. runs killed
// mid-execution by a previous gateway stop, which would otherwise stay
// "running" forever. Cross-tenant (startup reconciliation); see the interface doc.
func (s *SQLiteRunTimelineStore) RecoverInterruptedRuns(ctx context.Context) (int64, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT st.tenant_id, st.run_id, st.session_key, st.agent_id, st.user_id, st.channel, st.chat_id, agg.max_seq
FROM (
SELECT run_id, MAX(seq) AS max_seq,
MAX(item_type = 'run.status' AND status = 'started') AS has_start,
MAX(item_type = 'run.status' AND status IN ('completed', 'failed', 'cancelled')) AS has_term
FROM run_timeline_items
GROUP BY run_id
) agg
JOIN run_timeline_items st
ON st.run_id = agg.run_id AND st.item_type = 'run.status' AND st.status = 'started'
WHERE agg.has_start = 1 AND IFNULL(agg.has_term, 0) = 0`)
if err != nil {
return 0, fmt.Errorf("list interrupted runs: %w", err)
}
orphans, err := scanInterruptedRuns(rows)
if err != nil {
return 0, err
}
var recovered int64
for i := range orphans {
item := &orphans[i]
if err := s.AppendRunTimelineItem(store.WithTenantID(ctx, item.TenantID), item); err != nil {
return recovered, fmt.Errorf("append interrupted terminal for run %s: %w", item.RunID, err)
}
recovered++
}
return recovered, nil
}
// scanInterruptedRuns reads orphaned-run rows and pre-builds the terminal failed
// item to append for each. Rows are fully drained and closed before returning so
// the caller can issue inserts on the same connection without cursor contention.
func scanInterruptedRuns(rows *sql.Rows) ([]store.RunTimelineItem, error) {
defer rows.Close()
var items []store.RunTimelineItem
for rows.Next() {
var (
tenantID uuid.UUID
runID, sessionKey string
agentID uuid.NullUUID
userID, channel, chatID sql.NullString
maxSeq int
)
if err := rows.Scan(&tenantID, &runID, &sessionKey, &agentID, &userID, &channel, &chatID, &maxSeq); err != nil {
return nil, fmt.Errorf("scan interrupted run: %w", err)
}
item := store.RunTimelineItem{
TenantID: tenantID,
RunID: runID,
SessionKey: sessionKey,
UserID: userID.String,
Channel: channel.String,
ChatID: chatID.String,
Seq: maxSeq + 1,
ItemType: store.RunTimelineItemTypeRunStatus,
Status: store.RunTimelineStatusFailed,
Title: "Run failed",
Preview: interruptedRunPreview,
Metadata: interruptedRunMetadata,
}
if agentID.Valid {
id := agentID.UUID
item.AgentID = &id
}
items = append(items, item)
}
return items, rows.Err()
}