Files
goclaw/internal/store/run_timeline_store.go
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

73 lines
3.0 KiB
Go

package store
import (
"context"
"encoding/json"
"time"
"github.com/google/uuid"
)
const (
RunTimelineItemTypeActivity = "activity"
RunTimelineItemTypeAssistantMessage = "assistant.message"
RunTimelineItemTypeToolCall = "tool.call"
RunTimelineItemTypeToolResult = "tool.result"
RunTimelineItemTypeRunStatus = "run.status"
)
const (
RunTimelineStatusStarted = "started"
RunTimelineStatusRunning = "running"
RunTimelineStatusCompleted = "completed"
RunTimelineStatusFailed = "failed"
RunTimelineStatusCancelled = "cancelled"
)
// RunTimelineItem is a persisted, display-safe archive entry for one agent run.
type RunTimelineItem struct {
ID uuid.UUID `json:"id" db:"id"`
TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"`
RunID string `json:"run_id" db:"run_id"`
SessionKey string `json:"session_key" db:"session_key"`
AgentID *uuid.UUID `json:"agent_id,omitempty" db:"agent_id"`
UserID string `json:"user_id,omitempty" db:"user_id"`
Channel string `json:"channel,omitempty" db:"channel"`
ChatID string `json:"chat_id,omitempty" db:"chat_id"`
Seq int `json:"seq" db:"seq"`
ItemType string `json:"item_type" db:"item_type"`
Status string `json:"status,omitempty" db:"status"`
Title string `json:"title,omitempty" db:"title"`
Preview string `json:"preview,omitempty" db:"preview"`
Content string `json:"content,omitempty" db:"content"`
ToolName string `json:"tool_name,omitempty" db:"tool_name"`
ToolCallID string `json:"tool_call_id,omitempty" db:"tool_call_id"`
TraceID *uuid.UUID `json:"trace_id,omitempty" db:"trace_id"`
SpanID *uuid.UUID `json:"span_id,omitempty" db:"span_id"`
Metadata json.RawMessage `json:"metadata,omitempty" db:"metadata"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// RunTimelineListOpts scopes a timeline read. RunID is preferred; SessionKey is
// a fallback for session archive views that need the latest runs in a session.
type RunTimelineListOpts struct {
RunID string
SessionKey string
Limit int
Offset int
}
// RunTimelineStore appends and lists archived agent run timeline entries.
type RunTimelineStore interface {
AppendRunTimelineItem(ctx context.Context, item *RunTimelineItem) error
ListRunTimelineItems(ctx context.Context, opts RunTimelineListOpts) ([]RunTimelineItem, error)
// RecoverInterruptedRuns reconciles runs left mid-execution when the gateway
// stopped: a run with a "started" run.status item but no terminal
// (completed/failed/cancelled) run.status sibling. For each, it appends a
// terminal failed run.status item so the run is no longer reported as
// perpetually running. Intended to run once on startup (cross-tenant),
// mirroring the cron scheduler's stale-'running' reset. Returns the number
// of runs recovered.
RecoverInterruptedRuns(ctx context.Context) (int64, error)
}