Resume gracefully-interrupted subagents and take the RunState repair pins (N3 WP1) #91
Vendored
+1
-1
Submodule deps/agent-native updated: 74aa60bf2e...cb21ec4e2a
Vendored
+1
-1
Submodule deps/agent-squad updated: 6306451552...931e5ae410
@@ -138,3 +138,72 @@ func TestSpawnBackgroundRecordsDetach(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// N3 WP1:done.json 语义分流——优雅停机写的 interrupted 不挡续跑(一次且仅一次);
|
||||
// completed 终态挡续跑。
|
||||
func TestResumeAfterGracefulInterruptMarker(t *testing.T) {
|
||||
runner := &gateRunner{parts: []core.Part{{Type: core.PartText, Text: "续跑结果"}}}
|
||||
c, st := newSpawnEnv(t, runner)
|
||||
c.mgr.runner = noopRunner{}
|
||||
ws := t.TempDir()
|
||||
c.mgr.SetWorkspaceRoot(ws)
|
||||
parentRun := c.run
|
||||
if err := st.CreateRun(&parentRun); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
child := core.Run{
|
||||
ID: store.NewID("run"), ParentRunID: c.run.ID, ConversationID: c.run.ConversationID,
|
||||
ProjectID: c.run.ProjectID, Owner: c.run.Owner, Kind: core.RunSubagent, ProfileID: c.run.ProfileID,
|
||||
Status: core.RunRunning, CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := st.CreateRun(&child); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.mgr.writeSubagentRequest(child, "agent.sub.explore", "检索", "原始任务", "原始提示", true)
|
||||
// 模拟优雅停机:partial 转录 + done.json{status:"interrupted"}。
|
||||
c.mgr.writeSubagentTranscript(child, []core.Part{{Type: core.PartText, Text: "半截现场"}}, "interrupted")
|
||||
if _, err := st.SealInterrupted(child.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, _ := st.SubagentRunIDs(c.run.ConversationID)
|
||||
c.mgr.ResumeInterruptedChildren()
|
||||
ids, _ := st.SubagentRunIDs(c.run.ConversationID)
|
||||
if len(ids) != len(before)+1 {
|
||||
t.Fatalf("优雅停机现场应被续跑一次:before=%v after=%v", before, ids)
|
||||
}
|
||||
c.mgr.ResumeInterruptedChildren()
|
||||
ids2, _ := st.SubagentRunIDs(c.run.ConversationID)
|
||||
if len(ids2) != len(ids) {
|
||||
t.Fatalf("重复续跑:%v → %v", ids, ids2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeSkipsCompletedMarker(t *testing.T) {
|
||||
c, st := newSpawnEnv(t, &gateRunner{})
|
||||
c.mgr.runner = noopRunner{}
|
||||
ws := t.TempDir()
|
||||
c.mgr.SetWorkspaceRoot(ws)
|
||||
parentRun := c.run
|
||||
if err := st.CreateRun(&parentRun); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
child := core.Run{
|
||||
ID: store.NewID("run"), ParentRunID: c.run.ID, ConversationID: c.run.ConversationID,
|
||||
ProjectID: c.run.ProjectID, Owner: c.run.Owner, Kind: core.RunSubagent, ProfileID: c.run.ProfileID,
|
||||
Status: core.RunRunning, CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := st.CreateRun(&child); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.mgr.writeSubagentRequest(child, "agent.sub.explore", "检索", "原始任务", "", true)
|
||||
c.mgr.writeSubagentTranscript(child, []core.Part{{Type: core.PartText, Text: "已完成"}}, "completed")
|
||||
if _, err := st.SealInterrupted(child.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, _ := st.SubagentRunIDs(c.run.ConversationID)
|
||||
c.mgr.ResumeInterruptedChildren()
|
||||
ids, _ := st.SubagentRunIDs(c.run.ConversationID)
|
||||
if len(ids) != len(before) {
|
||||
t.Fatalf("completed 终态不得续跑:before=%v after=%v", before, ids)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,22 @@ func (m *Manager) writeSubagentTranscript(run core.Run, parts []core.Part, statu
|
||||
}
|
||||
}
|
||||
|
||||
// doneStatus 读 done.json 的 status(无文件或不可读按 "interrupted" 处理——
|
||||
// 缺标记本身就是中断现场)。
|
||||
func doneStatus(dir string) string {
|
||||
body, err := os.ReadFile(filepath.Join(dir, "done.json"))
|
||||
if err != nil {
|
||||
return "interrupted"
|
||||
}
|
||||
var done struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if json.Unmarshal(body, &done) != nil || done.Status == "" {
|
||||
return "interrupted"
|
||||
}
|
||||
return done.Status
|
||||
}
|
||||
|
||||
// readSubagentRequest 读 request.json(无文件返回 nil, nil)。
|
||||
func readSubagentRequest(dir string) (*subagentRequest, error) {
|
||||
body, err := os.ReadFile(filepath.Join(dir, "request.json"))
|
||||
@@ -116,7 +132,9 @@ func (m *Manager) ResumeInterruptedChildren() {
|
||||
if dir == "" {
|
||||
continue
|
||||
}
|
||||
if fileExists(filepath.Join(dir, "done.json")) || fileExists(filepath.Join(dir, "resumed.json")) {
|
||||
// done.json 语义分流(N3 WP1):终态(completed/failed/cancelled)挡续跑;
|
||||
// interrupted 不挡——优雅停机与崩溃都是可续跑现场。
|
||||
if doneStatus(dir) != "interrupted" || fileExists(filepath.Join(dir, "resumed.json")) {
|
||||
continue
|
||||
}
|
||||
req, err := readSubagentRequest(dir)
|
||||
|
||||
@@ -784,7 +784,8 @@ func TestLLMRunnerForgedToolMarkerTriggersGuard(t *testing.T) {
|
||||
if calls != 2 {
|
||||
t.Fatalf("stream calls = %d, want 2", calls)
|
||||
}
|
||||
var placeholder bool
|
||||
// N6(N3 WP1):伪造正文不得作为 assistant 的话术回放;更正以 user
|
||||
// 消息出现(不再改写 assistant)。
|
||||
for _, m := range replay {
|
||||
if m.Role != agentllm.RoleAssistant {
|
||||
continue
|
||||
@@ -792,12 +793,15 @@ func TestLLMRunnerForgedToolMarkerTriggersGuard(t *testing.T) {
|
||||
if strings.Contains(m.Content, "run_python") {
|
||||
t.Fatalf("forged body was replayed: %q", m.Content)
|
||||
}
|
||||
if strings.Contains(m.Content, "已作废") {
|
||||
placeholder = true
|
||||
}
|
||||
var correction bool
|
||||
for _, m := range replay {
|
||||
if m.Role == agentllm.RoleUser && strings.Contains(m.Content, "已作废") {
|
||||
correction = true
|
||||
}
|
||||
}
|
||||
if !placeholder {
|
||||
t.Fatalf("correction replay missing placeholder, got %#v", replay)
|
||||
if !correction {
|
||||
t.Fatalf("correction replay missing user note, got %#v", replay)
|
||||
}
|
||||
texts := textParts(rc.assistant)
|
||||
if len(texts) != 2 || !texts[0].Process || texts[1].Process {
|
||||
|
||||
Reference in New Issue
Block a user