Why: The failed extra A01 had no accepted findings but claimed saved discoveries and future progress. Evidence: The zero-findings regression failed before the fix; full RunMgr tests now pass. Impact: Use truthful conclusion, remaining and next-step text while retaining strict source validation. Refs: Independent extra A01 assessment; approved R08
187 lines
6.5 KiB
Go
187 lines
6.5 KiB
Go
package runmgr
|
|
|
|
import (
|
|
"agent-system-backend/internal/cardsvc"
|
|
core "agent-system-core"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestFailedTaskDoesNotPromiseAutomaticContinuation(t *testing.T) {
|
|
c := checkpointEnvironment(t)
|
|
if err := c.mgr.mutateTask(c.task, func(s *core.TaskSnapshot) error {
|
|
s.NextStep = "输出最终结构化整理结果"
|
|
return nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := c.mgr.finishTaskProjection(c.task, core.AgentResultPayload{}, errors.New("budget_exceeded: run token budget exhausted")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
card, err := c.mgr.st.GetCard(c.run.Owner, c.task.cardID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if card.Task.Phase.ID != "failed" || card.Task.Phase.Label != "执行已停止" || strings.Contains(card.Task.NextStep, "输出最终") || strings.Contains(card.Task.Conclusion, "budget_exceeded") {
|
|
t.Fatalf("stopped task retained a process promise: %+v", card.Task)
|
|
}
|
|
if !strings.Contains(card.Task.Conclusion, "尚无可用") || strings.Contains(card.Task.NextStep, "已有发现") || card.Task.Remaining == "待任务进展后更新" {
|
|
t.Fatalf("zero accepted findings were presented as retained results: %+v", card.Task)
|
|
}
|
|
}
|
|
|
|
func TestTaskConclusionSkipsProcessText(t *testing.T) {
|
|
c, _ := newCardSpawnEnv(t, &gateRunner{parts: []core.Part{{Type: core.PartText, Process: true, Text: "我先检查知识库。"}, {Type: core.PartText, Text: "日期仍待裁定,预算审批尚未完成。"}}})
|
|
c.mgr.runner = noopRunner{}
|
|
if _, err := c.mgr.spawnBackground(c.run, "agent.sub.general", "整理", "核对资料", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
waitUntil(t, func() bool {
|
|
records, err := c.mgr.st.TaskCards()
|
|
return err == nil && len(records) == 1 && records[0].Card.Task.Terminal()
|
|
})
|
|
records, err := c.mgr.st.TaskCards()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
conclusion := records[0].Card.Task.Conclusion
|
|
if strings.Contains(conclusion, "检查知识库") || conclusion != "日期仍待裁定,预算审批尚未完成。" {
|
|
t.Fatalf("process text became the terminal conclusion: %q", conclusion)
|
|
}
|
|
}
|
|
|
|
func TestTaskBoundInterruptedChildDoesNotRespawn(t *testing.T) {
|
|
c, _ := newCardSpawnEnv(t, noopRunner{})
|
|
st := c.mgr.st
|
|
c.mgr.runner = noopRunner{}
|
|
c.mgr.SetWorkspaceRoot(t.TempDir())
|
|
parent := c.run
|
|
if err := st.CreateRun(&parent); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
child := core.Run{ID: "run_bound_interrupted", ParentRunID: parent.ID, Owner: parent.Owner, ProjectID: parent.ProjectID, ConversationID: parent.ConversationID, Kind: core.RunSubagent, ProfileID: parent.ProfileID, Status: core.RunRunning}
|
|
if err := st.CreateRun(&child); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, _, err := c.mgr.registerBackgroundTask(parent, bgSpawnOpts{name: "整理"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = c.mgr.startTaskProjection(b, child.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c.mgr.writeSubagentRequest(child, "agent.sub.explore", "整理", "旧请求", "旧提示", true)
|
|
c.mgr.writeSubagentTranscript(child, nil, "interrupted")
|
|
if _, err = st.SealInterrupted(child.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c.mgr.ResumeInterruptedChildren()
|
|
ids, err := st.SubagentRunIDs(parent.ConversationID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(ids) != 1 {
|
|
t.Fatalf("canonical task was automatically dispatched as a new attempt: %v", ids)
|
|
}
|
|
dir := c.mgr.subagentDir(child.Owner, child.ConversationID, child.ID)
|
|
if _, err = os.Stat(filepath.Join(dir, "resumed.json")); !os.IsNotExist(err) {
|
|
t.Fatalf("bound task acquired legacy resume marker: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTaskQueueKeepsIdentityWhenPromoted(t *testing.T) {
|
|
runner := &holdRunner{release: make(chan struct{})}
|
|
c, _ := newCardSpawnEnv(t, runner)
|
|
c.mgr.runner = noopRunner{}
|
|
for i := 0; i < 4; i++ {
|
|
if _, e := c.mgr.spawnBackgroundOpts(c.run, bgSpawnOpts{name: "整理", task: "读材料", queue: true}); e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
}
|
|
cards, e := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
|
|
if e != nil || len(cards) != 4 {
|
|
t.Fatal(cards, e)
|
|
}
|
|
var queued core.ActivityCard
|
|
for _, card := range cards {
|
|
if card.Task == nil {
|
|
t.Fatal("background work has no canonical task")
|
|
}
|
|
if card.Task.ExecutionState == "queued" {
|
|
queued = card
|
|
}
|
|
}
|
|
if queued.ID == "" || queued.Task.RunID != "" {
|
|
t.Fatal("queue did not precede execution")
|
|
}
|
|
close(runner.release)
|
|
waitUntil(t, func() bool {
|
|
got, e := c.mgr.st.GetCard(c.run.Owner, queued.ID)
|
|
return e == nil && got.Task != nil && got.Task.ExecutionState == "completed"
|
|
})
|
|
got, _ := c.mgr.st.GetCard(c.run.Owner, queued.ID)
|
|
if got.Task.TaskID != queued.Task.TaskID || got.Task.AttemptID != queued.Task.AttemptID || got.Task.RunID == "" {
|
|
t.Fatal("promotion replaced identity")
|
|
}
|
|
}
|
|
func TestTaskReconcileOwnedQueueAndForeignLease(t *testing.T) {
|
|
st := openRecoveryStore(t)
|
|
m := newRecoveryManager(t, st)
|
|
m.SetInstanceID("A")
|
|
m.SetCards(cardsvc.New(st, nil))
|
|
conv := recoveryConversation(t, st)
|
|
parent := recoveryRun(t, st, conv, core.RunRunning)
|
|
_, own, e := m.registerBackgroundTask(parent, bgSpawnOpts{name: "自己的队列"})
|
|
if e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
other := newRecoveryManager(t, st)
|
|
other.SetInstanceID("B")
|
|
other.SetCards(cardsvc.New(st, nil))
|
|
_, foreign, e := other.registerBackgroundTask(parent, bgSpawnOpts{name: "其他实例"})
|
|
if e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
if e = m.ReconcileInterrupted(); e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
got, _ := st.GetCard(conv.Owner, own.ID)
|
|
if got.Task.ExecutionState != "interrupted" {
|
|
t.Fatal("orphan queue retained promise", got.Task)
|
|
}
|
|
if !strings.Contains(got.Task.NextStep, "重新提出请求") {
|
|
t.Fatal("interrupted queue promised automatic continuation", got.Task.NextStep)
|
|
}
|
|
got, _ = st.GetCard(conv.Owner, foreign.ID)
|
|
if st.Dialect() == "postgres" && got.Task.ExecutionState != "queued" {
|
|
t.Fatal("foreign lease was sealed")
|
|
}
|
|
}
|
|
|
|
func TestTaskProjectionStaleExecutionCannotOverwrite(t *testing.T) {
|
|
st := openRecoveryStore(t)
|
|
m := newRecoveryManager(t, st)
|
|
m.SetInstanceID("A")
|
|
m.SetCards(cardsvc.New(st, nil))
|
|
conv := recoveryConversation(t, st)
|
|
parent := recoveryRun(t, st, conv, core.RunRunning)
|
|
binding, card, e := m.registerBackgroundTask(parent, bgSpawnOpts{name: "整理"})
|
|
if e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
st.ReleaseExecution(taskLeaseKey(binding.taskID), binding.instanceID, binding.epoch)
|
|
if _, e = st.TryClaimExecution(taskLeaseKey(binding.taskID), "B", "", 60); e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
if e = m.startTaskProjection(binding, "old-run"); e == nil {
|
|
t.Fatal("old executor overwrote new lease")
|
|
}
|
|
got, _ := st.GetCard(conv.Owner, card.ID)
|
|
if got.Task.RunID != "" || got.Task.Revision != 1 {
|
|
t.Fatal("fenced write leaked", got.Task)
|
|
}
|
|
}
|