Files
Agent-System-Backend/internal/store/recovery_test.go
T
weichenandCursor 44aa764b56
ci / vet-and-test (pull_request) Successful in 43s
Recover interrupted runs and commit terminal state once
Why: A hard kill left runs in pending, running, or waiting_hitl, a busy send stored a message before the 409, finishRun could mark a run completed without an assistant message, and a full SSE buffer dropped events without closing the stream.
Evidence: GOWORK=off go vet ./... and GOWORK=off go test -count=1 ./... passed, including store, runmgr, bus, and server recovery tests.
Impact: Migration v14 adds runs.instance_id and messages.request_id. Startup reconciliation and shutdown share one seal. Turn and workflow sends claim a slot before writing. Duplicate request ids return the existing message. SettleRun updates the run and appends the assistant message in one transaction. A full subscriber buffer closes the channel so SSE can replay from the cursor.
Refs: Dev feature/pi-durable-borrow
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 14:37:49 +00:00

135 lines
4.0 KiB
Go

package store
import (
"errors"
"testing"
"time"
core "agent-system-core"
)
func seedConversation(t *testing.T, st *Store) core.Conversation {
t.Helper()
p, err := st.CreateProject(OwnerDefault, "p")
if err != nil {
t.Fatal(err)
}
c, err := st.CreateConversation(OwnerDefault, p.ID, "c", "")
if err != nil {
t.Fatal(err)
}
return c
}
func seedRun(t *testing.T, st *Store, conv core.Conversation, status core.RunStatus) core.Run {
t.Helper()
r := core.Run{
ConversationID: conv.ID, ProjectID: conv.ProjectID, Owner: conv.Owner,
Kind: core.RunConversation, Status: status, Phase: core.PhaseThinking,
}
if err := st.CreateRun(&r); err != nil {
t.Fatal(err)
}
return r
}
func TestMessageRequestIDDedup(t *testing.T) {
st := openTestStore(t)
conv := seedConversation(t, st)
first := core.Message{ConversationID: conv.ID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "hi"}}}
if err := st.AppendMessageWithRequest(&first, "req-1"); err != nil {
t.Fatal(err)
}
second := core.Message{ConversationID: conv.ID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "hi again"}}}
err := st.AppendMessageWithRequest(&second, "req-1")
var dup *DuplicateRequestError
if !errors.As(err, &dup) || dup.Existing.ID != first.ID {
t.Fatalf("duplicate request: %v %#v", err, dup)
}
blankA := core.Message{ConversationID: conv.ID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "a"}}}
blankB := core.Message{ConversationID: conv.ID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "b"}}}
if err := st.AppendMessageWithRequest(&blankA, ""); err != nil {
t.Fatal(err)
}
if err := st.AppendMessageWithRequest(&blankB, ""); err != nil {
t.Fatal(err)
}
msgs, err := st.ListMessages(conv.ID)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 3 {
t.Fatalf("messages: %d", len(msgs))
}
}
func TestCreateRunRecordsInstance(t *testing.T) {
st := openTestStore(t)
st.SetInstanceID("host:8787")
conv := seedConversation(t, st)
run := seedRun(t, st, conv, core.RunPending)
var got string
if err := st.db.QueryRow(st.q(`SELECT instance_id FROM runs WHERE id=?`), run.ID).Scan(&got); err != nil {
t.Fatal(err)
}
if got != "host:8787" {
t.Fatalf("instance_id %q", got)
}
}
func TestRecoverableRunsRespectInstance(t *testing.T) {
st := openTestStore(t)
conv := seedConversation(t, st)
st.SetInstanceID("inst-a")
own := seedRun(t, st, conv, core.RunPending)
st.SetInstanceID("inst-b")
other := seedRun(t, st, conv, core.RunRunning)
st.SetInstanceID("")
legacy := seedRun(t, st, conv, core.RunWaitingHITL)
done := seedRun(t, st, conv, core.RunCompleted)
all := st.Dialect() != "postgres"
got, err := st.ListRecoverableRuns("inst-a", all)
if err != nil {
t.Fatal(err)
}
ids := map[string]bool{}
for _, r := range got {
ids[r.ID] = true
}
if !ids[own.ID] || !ids[legacy.ID] || ids[done.ID] {
t.Fatalf("recoverable ids: %v", ids)
}
if st.Dialect() == "postgres" && ids[other.ID] {
t.Fatal("postgres reconcile must leave another instance's run alone")
}
if st.Dialect() != "postgres" && !ids[other.ID] {
t.Fatal("sqlite reconcile scans every open run")
}
}
func TestSettleRunRollsBackWhenMessageInsertFails(t *testing.T) {
st := openTestStore(t)
conv := seedConversation(t, st)
run := seedRun(t, st, conv, core.RunRunning)
existing := core.Message{ID: "msg_fixed", ConversationID: conv.ID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "user"}}}
if err := st.AppendMessage(&existing); err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
run.Status = core.RunCompleted
run.Phase = core.PhaseDone
run.EndedAt = &now
dup := core.Message{ID: "msg_fixed", ConversationID: conv.ID, RunID: run.ID, Role: core.RoleAssistant, Parts: []core.Part{{Type: core.PartText, Text: "assistant"}}}
if _, err := st.SettleRun(&run, &dup); err == nil {
t.Fatal("duplicate message id must fail the settle")
}
got, err := st.GetRunByID(run.ID)
if err != nil {
t.Fatal(err)
}
if got.Status != core.RunRunning {
t.Fatalf("rolled-back status: %s", got.Status)
}
}