agent-exec transport 9b5caca2968f510a4f81402ca7dfdb4c/f6d16260d971
This commit is contained in:
@@ -147,6 +147,28 @@ func TestReconcileInterruptedSealsOpenRuns(t *testing.T) {
|
|||||||
if failed != 3 {
|
if failed != 3 {
|
||||||
t.Fatalf("run.failed events: %d", failed)
|
t.Fatalf("run.failed events: %d", failed)
|
||||||
}
|
}
|
||||||
|
msgs, err := st.ListMessages(conv.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
asstByRun := map[string]int{}
|
||||||
|
for _, m := range msgs {
|
||||||
|
if m.Role == core.RoleAssistant {
|
||||||
|
asstByRun[m.RunID]++
|
||||||
|
if len(m.Parts) != 1 || m.Parts[0].Type != core.PartError || m.Parts[0].Err == nil || m.Parts[0].Err.Code != "run.interrupted" || m.Parts[0].Err.Message != "进程重启或停机,运行已中断" {
|
||||||
|
t.Fatalf("unexpected assistant message parts for run %s: %+v", m.RunID, m.Parts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, id := range []string{pending.ID, running.ID, waiting.ID} {
|
||||||
|
if asstByRun[id] != 1 {
|
||||||
|
t.Fatalf("run %s: expected 1 assistant message, got %d", id, asstByRun[id])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if asstByRun[done.ID] != 0 {
|
||||||
|
t.Fatalf("terminal run %s: expected 0 assistant messages, got %d", done.ID, asstByRun[done.ID])
|
||||||
|
}
|
||||||
|
|
||||||
if err := mgr.ReconcileInterrupted(); err != nil {
|
if err := mgr.ReconcileInterrupted(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -157,6 +179,103 @@ func TestReconcileInterruptedSealsOpenRuns(t *testing.T) {
|
|||||||
if len(again) != len(evs) {
|
if len(again) != len(evs) {
|
||||||
t.Fatalf("second reconcile emitted more events: %d -> %d", len(evs), len(again))
|
t.Fatalf("second reconcile emitted more events: %d -> %d", len(evs), len(again))
|
||||||
}
|
}
|
||||||
|
msgsAgain, err := st.ListMessages(conv.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(msgsAgain) != len(msgs) {
|
||||||
|
t.Fatalf("second reconcile added duplicate messages: %d -> %d", len(msgs), len(msgsAgain))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileInterruptedAssistantNotice(t *testing.T) {
|
||||||
|
st := openRecoveryStore(t)
|
||||||
|
st.SetInstanceID("this")
|
||||||
|
mgr := newRecoveryManager(t, st)
|
||||||
|
mgr.SetInstanceID("this")
|
||||||
|
|
||||||
|
// 1. Conversation with an open run that gets sealed
|
||||||
|
conv1 := recoveryConversation(t, st)
|
||||||
|
userMsg1 := core.Message{ConversationID: conv1.ID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "hello"}}}
|
||||||
|
if err := st.AppendMessage(&userMsg1); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sealedRun := recoveryRun(t, st, conv1, core.RunRunning)
|
||||||
|
|
||||||
|
// 2. Conversation with an already terminal run
|
||||||
|
conv2 := recoveryConversation(t, st)
|
||||||
|
userMsg2 := core.Message{ConversationID: conv2.ID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "hi"}}}
|
||||||
|
if err := st.AppendMessage(&userMsg2); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
terminalRun := recoveryRun(t, st, conv2, core.RunCompleted)
|
||||||
|
|
||||||
|
// 3. Conversation with an open run that already has an assistant message
|
||||||
|
conv3 := recoveryConversation(t, st)
|
||||||
|
userMsg3 := core.Message{ConversationID: conv3.ID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "test"}}}
|
||||||
|
if err := st.AppendMessage(&userMsg3); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
openRunWithMsg := recoveryRun(t, st, conv3, core.RunRunning)
|
||||||
|
asstMsg3 := core.Message{ConversationID: conv3.ID, RunID: openRunWithMsg.ID, Role: core.RoleAssistant, Parts: []core.Part{{Type: core.PartText, Text: "already answered"}}}
|
||||||
|
if err := st.AppendMessage(&asstMsg3); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mgr.ReconcileInterrupted(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// conv1: history contains exactly one assistant message with run.interrupted
|
||||||
|
msgs1, err := st.ListMessages(conv1.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var asst1 []core.Message
|
||||||
|
for _, m := range msgs1 {
|
||||||
|
if m.Role == core.RoleAssistant {
|
||||||
|
asst1 = append(asst1, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(asst1) != 1 {
|
||||||
|
t.Fatalf("conv1: expected exactly 1 assistant message, got %d", len(asst1))
|
||||||
|
}
|
||||||
|
if asst1[0].RunID != sealedRun.ID {
|
||||||
|
t.Fatalf("conv1: expected assistant message for run %s, got %s", sealedRun.ID, asst1[0].RunID)
|
||||||
|
}
|
||||||
|
if len(asst1[0].Parts) != 1 || asst1[0].Parts[0].Type != core.PartError || asst1[0].Parts[0].Err == nil || asst1[0].Parts[0].Err.Code != "run.interrupted" || asst1[0].Parts[0].Err.Message != "进程重启或停机,运行已中断" {
|
||||||
|
t.Fatalf("conv1: unexpected parts: %+v", asst1[0].Parts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// conv2: already-terminal run must have NO assistant message written
|
||||||
|
kept, err := st.GetRunByID(terminalRun.ID)
|
||||||
|
if err != nil || kept.Status != core.RunCompleted {
|
||||||
|
t.Fatalf("terminal run changed: %+v %v", kept, err)
|
||||||
|
}
|
||||||
|
msgs2, err := st.ListMessages(conv2.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, m := range msgs2 {
|
||||||
|
if m.Role == core.RoleAssistant {
|
||||||
|
t.Fatalf("conv2: unexpected assistant message for already-terminal run: %+v", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// conv3: run had an assistant message already, so no duplicate was created
|
||||||
|
msgs3, err := st.ListMessages(conv3.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var asst3 []core.Message
|
||||||
|
for _, m := range msgs3 {
|
||||||
|
if m.Role == core.RoleAssistant {
|
||||||
|
asst3 = append(asst3, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(asst3) != 1 || asst3[0].ID != asstMsg3.ID {
|
||||||
|
t.Fatalf("conv3: expected only original assistant message, got %+v", asst3)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReconcileLeavesOtherInstanceOnPostgres(t *testing.T) {
|
func TestReconcileLeavesOtherInstanceOnPostgres(t *testing.T) {
|
||||||
|
|||||||
@@ -805,12 +805,20 @@ func (s *Store) searchKnowledgeFTSCandidates(pred knowledgePred, q core.Retrieva
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
out := []core.RetrievalHit{}
|
var rawHits []core.RetrievalHit
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
h, err := scanKnowledgeHit(rows)
|
h, err := scanKnowledgeHit(rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
rawHits = append(rawHits, h)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = rows.Close()
|
||||||
|
out := []core.RetrievalHit{}
|
||||||
|
for _, h := range rawHits {
|
||||||
if !pred(h.Doc) {
|
if !pred(h.Doc) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -819,7 +827,7 @@ func (s *Store) searchKnowledgeFTSCandidates(pred knowledgePred, q core.Retrieva
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) searchKnowledgeLikeShared(requester string, q core.RetrievalQuery, pred knowledgePred) ([]core.RetrievalHit, error) {
|
func (s *Store) searchKnowledgeLikeShared(requester string, q core.RetrievalQuery, pred knowledgePred) ([]core.RetrievalHit, error) {
|
||||||
@@ -842,7 +850,7 @@ func (s *Store) searchKnowledgeLikeSharedCandidates(requester string, q core.Ret
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
out := []core.RetrievalHit{}
|
var rawDocs []core.KnowledgeDoc
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var d core.KnowledgeDoc
|
var d core.KnowledgeDoc
|
||||||
var own, ca string
|
var own, ca string
|
||||||
@@ -851,6 +859,14 @@ func (s *Store) searchKnowledgeLikeSharedCandidates(requester string, q core.Ret
|
|||||||
}
|
}
|
||||||
d.Owner = own
|
d.Owner = own
|
||||||
d.CreatedAt, _ = parseTime(ca)
|
d.CreatedAt, _ = parseTime(ca)
|
||||||
|
rawDocs = append(rawDocs, d)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = rows.Close()
|
||||||
|
out := []core.RetrievalHit{}
|
||||||
|
for _, d := range rawDocs {
|
||||||
if !pred(d) {
|
if !pred(d) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -859,7 +875,7 @@ func (s *Store) searchKnowledgeLikeSharedCandidates(requester string, q core.Ret
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) searchKnowledgeLike(owner string, q core.RetrievalQuery, topK int) ([]core.RetrievalHit, error) {
|
func (s *Store) searchKnowledgeLike(owner string, q core.RetrievalQuery, topK int) ([]core.RetrievalHit, error) {
|
||||||
|
|||||||
@@ -210,9 +210,14 @@ func (s *Store) CreateRun(r *core.Run) error {
|
|||||||
if r.ID == "" {
|
if r.ID == "" {
|
||||||
r.ID = newID("run")
|
r.ID = newID("run")
|
||||||
}
|
}
|
||||||
r.CreatedAt = r.CreatedAt.UTC()
|
if r.CreatedAt.IsZero() {
|
||||||
|
r.CreatedAt = time.Now().UTC()
|
||||||
|
} else {
|
||||||
|
r.CreatedAt = r.CreatedAt.UTC()
|
||||||
|
}
|
||||||
|
ca := r.CreatedAt.Format(time.RFC3339Nano)
|
||||||
_, err := s.db.Exec(s.q(`INSERT INTO runs(id,parent_run_id,conversation_id,project_id,owner,kind,profile_id,status,phase,agent_name,created_at,instance_id) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`),
|
_, err := s.db.Exec(s.q(`INSERT INTO runs(id,parent_run_id,conversation_id,project_id,owner,kind,profile_id,status,phase,agent_name,created_at,instance_id) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`),
|
||||||
r.ID, r.ParentRunID, r.ConversationID, r.ProjectID, r.Owner, string(r.Kind), r.ProfileID, string(r.Status), string(r.Phase), r.AgentName, nowISO(), s.instanceID)
|
r.ID, r.ParentRunID, r.ConversationID, r.ProjectID, r.Owner, string(r.Kind), r.ProfileID, string(r.Status), string(r.Phase), r.AgentName, ca, s.instanceID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+98
-30
@@ -86,15 +86,70 @@ func (s *Store) ListRecoverableRuns(instanceID string, all bool) ([]core.Run, er
|
|||||||
|
|
||||||
// SealInterrupted 把仍在进行的 run 收成 interrupted。
|
// SealInterrupted 把仍在进行的 run 收成 interrupted。
|
||||||
// 条件更新保证并发对账时只有一个赢家;已经是终态的行不受影响。
|
// 条件更新保证并发对账时只有一个赢家;已经是终态的行不受影响。
|
||||||
|
// 赢家并在同一事务中为该 run 写入一条助手中断错误消息(若尚无助手消息)。
|
||||||
func (s *Store) SealInterrupted(id string) (bool, error) {
|
func (s *Store) SealInterrupted(id string) (bool, error) {
|
||||||
errPart := marshalJSON(&core.ErrorPart{Code: "run.interrupted", Message: "进程重启或停机,运行已中断"})
|
tx, err := s.db.Begin()
|
||||||
res, err := s.db.Exec(s.q(`UPDATE runs SET status=?, phase=?, error=?, ended_at=? WHERE id=? AND status IN ('pending','running','waiting_hitl')`),
|
if err != nil {
|
||||||
string(core.RunInterrupted), string(core.PhaseDone), errPart, nowISO(), id)
|
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 {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
n, err := res.RowsAffected()
|
n, err := res.RowsAffected()
|
||||||
return n == 1, err
|
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 卡片。
|
// ActiveHITLCardsByRun 返回某个 run 上尚未关闭的 HITL 卡片。
|
||||||
@@ -256,32 +311,7 @@ func (s *Store) settleRunOnce(r *core.Run, msg *core.Message, fence *fenceRef, a
|
|||||||
return false, false, err
|
return false, false, err
|
||||||
}
|
}
|
||||||
if msg != nil {
|
if msg != nil {
|
||||||
if msg.CreatedAt.IsZero() {
|
if err := s.insertSettledMessageTx(tx, msg); err != nil {
|
||||||
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 false, false, err
|
|
||||||
}
|
|
||||||
if err := s.appendProjectMessageObservationsTx(tx, msg); err != nil {
|
|
||||||
return false, false, 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 false, false, 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 false, false, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, err := tx.Exec(s.q(`UPDATE conversations SET updated_at=? WHERE id=?`), nowISO(), msg.ConversationID); err != nil {
|
|
||||||
return false, false, err
|
return false, false, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -291,6 +321,44 @@ func (s *Store) settleRunOnce(r *core.Run, msg *core.Message, fence *fenceRef, a
|
|||||||
return n == 1, false, nil
|
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
|
// ListInterruptedSubagentRuns 列出被收成 interrupted 的子代理 run
|
||||||
// (N2 WP5 重启续跑的候选集;幂等过滤由转录目录的 done/resumed 标记完成)。
|
// (N2 WP5 重启续跑的候选集;幂等过滤由转录目录的 done/resumed 标记完成)。
|
||||||
func (s *Store) ListInterruptedSubagentRuns() ([]core.Run, error) {
|
func (s *Store) ListInterruptedSubagentRuns() ([]core.Run, error) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package store
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
core "agent-system-core"
|
core "agent-system-core"
|
||||||
)
|
)
|
||||||
@@ -42,7 +43,12 @@ func (s *Store) CreateRunAccountChecked(r *core.Run, allowImplicitDefault bool)
|
|||||||
if r.ID == "" {
|
if r.ID == "" {
|
||||||
r.ID = newID("run")
|
r.ID = newID("run")
|
||||||
}
|
}
|
||||||
r.CreatedAt = r.CreatedAt.UTC()
|
if r.CreatedAt.IsZero() {
|
||||||
|
r.CreatedAt = time.Now().UTC()
|
||||||
|
} else {
|
||||||
|
r.CreatedAt = r.CreatedAt.UTC()
|
||||||
|
}
|
||||||
|
ca := r.CreatedAt.Format(time.RFC3339Nano)
|
||||||
return s.withEffectiveAccountWrite(string(r.Owner), allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
return s.withEffectiveAccountWrite(string(r.Owner), allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||||||
var owner string
|
var owner string
|
||||||
if err := tx.QueryRow(s.q(`SELECT owner FROM conversations WHERE id=?`), r.ConversationID).Scan(&owner); errors.Is(err, sql.ErrNoRows) {
|
if err := tx.QueryRow(s.q(`SELECT owner FROM conversations WHERE id=?`), r.ConversationID).Scan(&owner); errors.Is(err, sql.ErrNoRows) {
|
||||||
@@ -54,7 +60,7 @@ func (s *Store) CreateRunAccountChecked(r *core.Run, allowImplicitDefault bool)
|
|||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
_, err := tx.Exec(s.q(`INSERT INTO runs(id,parent_run_id,conversation_id,project_id,owner,kind,profile_id,status,phase,agent_name,created_at,instance_id) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`),
|
_, err := tx.Exec(s.q(`INSERT INTO runs(id,parent_run_id,conversation_id,project_id,owner,kind,profile_id,status,phase,agent_name,created_at,instance_id) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`),
|
||||||
r.ID, r.ParentRunID, r.ConversationID, r.ProjectID, r.Owner, string(r.Kind), r.ProfileID, string(r.Status), string(r.Phase), r.AgentName, nowISO(), s.instanceID)
|
r.ID, r.ParentRunID, r.ConversationID, r.ProjectID, r.Owner, string(r.Kind), r.ProfileID, string(r.Status), string(r.Phase), r.AgentName, ca, s.instanceID)
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
core "agent-system-core"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSearchDeadlockRegression(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Seed account for allow/pred callback to query
|
||||||
|
if err := st.CreateAccount("alice", "", "", false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed knowledge doc and vector
|
||||||
|
docVec := core.KnowledgeDoc{
|
||||||
|
ID: "doc_vec_deadlock",
|
||||||
|
Owner: "alice",
|
||||||
|
ProjectID: "proj_deadlock",
|
||||||
|
Source: "src_vec",
|
||||||
|
Title: "vector doc",
|
||||||
|
Chunk: "vector chunk content",
|
||||||
|
}
|
||||||
|
docFTS := core.KnowledgeDoc{
|
||||||
|
ID: "doc_fts_deadlock",
|
||||||
|
Owner: "alice",
|
||||||
|
ProjectID: "proj_deadlock",
|
||||||
|
Source: "src_fts",
|
||||||
|
Title: "Database Optimization Guide",
|
||||||
|
Chunk: "Performance tuning and indexing for relational databases",
|
||||||
|
}
|
||||||
|
docLike := core.KnowledgeDoc{
|
||||||
|
ID: "doc_like_deadlock",
|
||||||
|
Owner: "alice",
|
||||||
|
ProjectID: "proj_deadlock",
|
||||||
|
Source: "src_like",
|
||||||
|
Title: "微服务架构治理",
|
||||||
|
Chunk: "分布式系统与全链路追踪方案",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := st.AddKnowledge(docVec, docFTS, docLike); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.UpsertKnowledgeVector(ctx, "alice", docVec.ID, "ref_vec", []float32{1.0, 0.0}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
runBounded := func(name string, fn func()) {
|
||||||
|
t.Helper()
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
fn()
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatalf("deadlock detected in %s: did not finish within 10s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Vector scan: allow callback queries the same Store
|
||||||
|
runBounded("searchKnowledgeVectorsSharedAuthorizedScan", func() {
|
||||||
|
hits, err := st.SearchKnowledgeVectorsSharedAuthorized(ctx, "bob", "", []float32{1.0, 0.0}, 5, core.RetrievalQuery{}, func(d core.KnowledgeDoc) bool {
|
||||||
|
// Query the store inside the callback: on a single-connection SQLite store,
|
||||||
|
// this would deadlock if rows was not closed before invoking the callback.
|
||||||
|
_, qerr := st.GetAccountByOwner(d.Owner)
|
||||||
|
if qerr != nil {
|
||||||
|
t.Errorf("GetAccountByOwner failed in callback: %v", qerr)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("SearchKnowledgeVectorsSharedAuthorized failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(hits) == 0 {
|
||||||
|
t.Errorf("expected at least 1 hit, got 0")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. Content FTS scan: pred callback queries the same Store
|
||||||
|
runBounded("searchKnowledgeFTSCandidates", func() {
|
||||||
|
hits, err := st.searchKnowledgeFTSCandidates(func(d core.KnowledgeDoc) bool {
|
||||||
|
_, qerr := st.GetAccountByOwner(d.Owner)
|
||||||
|
if qerr != nil {
|
||||||
|
t.Errorf("GetAccountByOwner failed in callback: %v", qerr)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}, core.RetrievalQuery{Text: "database optimization", TopK: 5}, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("searchKnowledgeFTSCandidates failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(hits) == 0 {
|
||||||
|
t.Errorf("expected at least 1 hit, got 0")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. Content LIKE scan: pred callback queries the same Store
|
||||||
|
runBounded("searchKnowledgeLikeSharedCandidates", func() {
|
||||||
|
hits, err := st.searchKnowledgeLikeSharedCandidates("bob", core.RetrievalQuery{Text: "微服务", TopK: 5}, func(d core.KnowledgeDoc) bool {
|
||||||
|
_, qerr := st.GetAccountByOwner(d.Owner)
|
||||||
|
if qerr != nil {
|
||||||
|
t.Errorf("GetAccountByOwner failed in callback: %v", qerr)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("searchKnowledgeLikeSharedCandidates failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(hits) == 0 {
|
||||||
|
t.Errorf("expected at least 1 hit, got 0")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunCreatedAtNonZeroRecentAndMatchesReadBack(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
|
||||||
|
if err := st.CreateAccount("alice", "", "", false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
proj, err := st.CreateProject("alice", "test_proj")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
conv, err := st.CreateConversation("alice", proj.ID, "test_conv", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. CreateRun with zero CreatedAt
|
||||||
|
before1 := time.Now().UTC().Add(-time.Second)
|
||||||
|
r1 := core.Run{
|
||||||
|
ConversationID: conv.ID,
|
||||||
|
ProjectID: conv.ProjectID,
|
||||||
|
Owner: conv.Owner,
|
||||||
|
Kind: core.RunConversation,
|
||||||
|
Status: core.RunRunning,
|
||||||
|
Phase: core.PhaseThinking,
|
||||||
|
}
|
||||||
|
if err := st.CreateRun(&r1); err != nil {
|
||||||
|
t.Fatalf("CreateRun failed: %v", err)
|
||||||
|
}
|
||||||
|
if r1.CreatedAt.IsZero() {
|
||||||
|
t.Fatalf("CreateRun: returned run has zero CreatedAt")
|
||||||
|
}
|
||||||
|
if r1.CreatedAt.Before(before1) || r1.CreatedAt.After(time.Now().UTC().Add(time.Second)) {
|
||||||
|
t.Fatalf("CreateRun: returned run CreatedAt %v not recent (before=%v)", r1.CreatedAt, before1)
|
||||||
|
}
|
||||||
|
readBack1, err := st.GetRunByID(r1.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetRunByID failed: %v", err)
|
||||||
|
}
|
||||||
|
if !readBack1.CreatedAt.Equal(r1.CreatedAt) {
|
||||||
|
t.Fatalf("CreateRun: in-memory CreatedAt %v != stored CreatedAt %v", r1.CreatedAt, readBack1.CreatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. CreateRunAccountChecked with zero CreatedAt
|
||||||
|
before2 := time.Now().UTC().Add(-time.Second)
|
||||||
|
r2 := core.Run{
|
||||||
|
ConversationID: conv.ID,
|
||||||
|
ProjectID: conv.ProjectID,
|
||||||
|
Owner: conv.Owner,
|
||||||
|
Kind: core.RunConversation,
|
||||||
|
Status: core.RunRunning,
|
||||||
|
Phase: core.PhaseThinking,
|
||||||
|
}
|
||||||
|
if err := st.CreateRunAccountChecked(&r2, false); err != nil {
|
||||||
|
t.Fatalf("CreateRunAccountChecked failed: %v", err)
|
||||||
|
}
|
||||||
|
if r2.CreatedAt.IsZero() {
|
||||||
|
t.Fatalf("CreateRunAccountChecked: returned run has zero CreatedAt")
|
||||||
|
}
|
||||||
|
if r2.CreatedAt.Before(before2) || r2.CreatedAt.After(time.Now().UTC().Add(time.Second)) {
|
||||||
|
t.Fatalf("CreateRunAccountChecked: returned run CreatedAt %v not recent (before=%v)", r2.CreatedAt, before2)
|
||||||
|
}
|
||||||
|
readBack2, err := st.GetRunByID(r2.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetRunByID failed: %v", err)
|
||||||
|
}
|
||||||
|
if !readBack2.CreatedAt.Equal(r2.CreatedAt) {
|
||||||
|
t.Fatalf("CreateRunAccountChecked: in-memory CreatedAt %v != stored CreatedAt %v", r2.CreatedAt, readBack2.CreatedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,7 +79,11 @@ func (s *Store) searchKnowledgePGSharedCandidates(requester string, q core.Retri
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
out := []core.RetrievalHit{}
|
type rawPGCandidate struct {
|
||||||
|
doc core.KnowledgeDoc
|
||||||
|
rank float64
|
||||||
|
}
|
||||||
|
var rawCands []rawPGCandidate
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var d core.KnowledgeDoc
|
var d core.KnowledgeDoc
|
||||||
var own, ca string
|
var own, ca string
|
||||||
@@ -89,17 +93,25 @@ func (s *Store) searchKnowledgePGSharedCandidates(requester string, q core.Retri
|
|||||||
}
|
}
|
||||||
d.Owner = own
|
d.Owner = own
|
||||||
d.CreatedAt, _ = parseTime(ca)
|
d.CreatedAt, _ = parseTime(ca)
|
||||||
if !pred(d) {
|
rawCands = append(rawCands, rawPGCandidate{doc: d, rank: rank})
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = rows.Close()
|
||||||
|
out := []core.RetrievalHit{}
|
||||||
|
for _, c := range rawCands {
|
||||||
|
if !pred(c.doc) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
score := rank
|
score := c.rank
|
||||||
if score == 0 {
|
if score == 0 {
|
||||||
score = 0.5
|
score = 0.5
|
||||||
}
|
}
|
||||||
out = append(out, core.RetrievalHit{Doc: d, Score: score, Kind: "fulltext"})
|
out = append(out, core.RetrievalHit{Doc: c.doc, Score: score, Kind: "fulltext"})
|
||||||
if len(out) >= topK {
|
if len(out) >= topK {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -279,11 +279,11 @@ func (s *Store) searchKnowledgeVectorsSharedAuthorizedScan(ctx context.Context,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
type cand struct {
|
type rawVectorCandidate struct {
|
||||||
hit core.RetrievalHit
|
doc core.KnowledgeDoc
|
||||||
sim float64
|
blob []byte
|
||||||
}
|
}
|
||||||
var cands []cand
|
var rawCands []rawVectorCandidate
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var d core.KnowledgeDoc
|
var d core.KnowledgeDoc
|
||||||
var own, ca string
|
var own, ca string
|
||||||
@@ -293,6 +293,20 @@ func (s *Store) searchKnowledgeVectorsSharedAuthorizedScan(ctx context.Context,
|
|||||||
}
|
}
|
||||||
d.Owner = own
|
d.Owner = own
|
||||||
d.CreatedAt, _ = parseTime(ca)
|
d.CreatedAt, _ = parseTime(ca)
|
||||||
|
rawCands = append(rawCands, rawVectorCandidate{doc: d, blob: blob})
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = rows.Close()
|
||||||
|
|
||||||
|
type cand struct {
|
||||||
|
hit core.RetrievalHit
|
||||||
|
sim float64
|
||||||
|
}
|
||||||
|
var cands []cand
|
||||||
|
for _, raw := range rawCands {
|
||||||
|
d := raw.doc
|
||||||
if d.ProjectID == "" || !allow(d) {
|
if d.ProjectID == "" || !allow(d) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -302,12 +316,9 @@ func (s *Store) searchKnowledgeVectorsSharedAuthorizedScan(ctx context.Context,
|
|||||||
if len(filter.Sources) > 0 && !containsStr(filter.Sources, d.Source) {
|
if len(filter.Sources) > 0 && !containsStr(filter.Sources, d.Source) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
sim := cosine(vec, decodeVector(blob))
|
sim := cosine(vec, decodeVector(raw.blob))
|
||||||
cands = append(cands, cand{hit: core.RetrievalHit{Doc: d, Score: sim, Kind: "vector"}, sim: sim})
|
cands = append(cands, cand{hit: core.RetrievalHit{Doc: d, Score: sim, Kind: "vector"}, sim: sim})
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
sort.SliceStable(cands, func(i, j int) bool { return cands[i].sim > cands[j].sim })
|
sort.SliceStable(cands, func(i, j int) bool { return cands[i].sim > cands[j].sim })
|
||||||
out := []core.RetrievalHit{}
|
out := []core.RetrievalHit{}
|
||||||
for _, c := range cands {
|
for _, c := range cands {
|
||||||
|
|||||||
Reference in New Issue
Block a user