Resume gracefully-interrupted subagents and take the RunState repair pins (N3 WP1) #91

Merged
weichen merged 2 commits from feature/n3-runstate-wp1 into main 2026-10-07 15:08:58 +00:00
5 changed files with 99 additions and 8 deletions
+69
View File
@@ -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)
}
}
+19 -1
View File
@@ -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)
+9 -5
View File
@@ -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 {