Why: InterruptRunning wrote done.json{status:interrupted} for every
child, and ResumeInterruptedChildren skipped anything with done.json —
graceful shutdowns never resumed (only SIGKILL-style deaths did). done
markers now split by status: terminal states block resume, interrupted
does not. Nested pins carry repairOrphanTail and the N6 fix.
Evidence: go test -count=1 ./... green; TestResumeAfterGracefulInterruptMarker
(resumed once and only once), TestResumeSkipsCompletedMarker (terminal
states still block).
Impact: after any restart, interrupted children with a stored request
re-spawn exactly once regardless of how the process stopped.
Refs: Agent-Native#30, Agent-Squad#34, N3 plan WP1
175 lines
6.1 KiB
Go
175 lines
6.1 KiB
Go
package runmgr
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
core "agent-system-core"
|
|
)
|
|
|
|
// subagent_transcript.go — 子代理独立转录与中断续跑(N2 WP5)。
|
|
// 每个子 run 一个目录:<workspaceRoot>/<owner>/<conv>/subagents/<runID>/
|
|
// request.json 派生请求(agentID/name/task/hint/detach),续跑的重建凭据
|
|
// transcript.jsonl 按 part 追加的转录(完成/中断时落定)
|
|
// done.json 终态标记(resume 幂等:有它不再续跑)
|
|
// resumed.json 已续跑标记(指向新 run)
|
|
|
|
// subagentDir 返回子 run 的转录目录(未配置 workspaceRoot 时为空,写盘静默跳过)。
|
|
func (m *Manager) subagentDir(owner, convID, runID string) string {
|
|
if m.workspaceRoot == "" {
|
|
return ""
|
|
}
|
|
return filepath.Join(m.workspaceRoot, filepath.Clean(owner), filepath.Clean(convID), "subagents", filepath.Clean(runID))
|
|
}
|
|
|
|
// subagentRequest 是 request.json 的内容。
|
|
type subagentRequest struct {
|
|
AgentID string `json:"agentId"`
|
|
Name string `json:"name"`
|
|
Task string `json:"task"`
|
|
Hint string `json:"hint,omitempty"`
|
|
Detach bool `json:"detach"`
|
|
CreatedAt string `json:"createdAt"`
|
|
}
|
|
|
|
// writeSubagentRequest 在派生时落 request.json 与空转录(失败只记日志,
|
|
// 不阻断派生——转录是辅助面)。
|
|
func (m *Manager) writeSubagentRequest(run core.Run, agentID, name, task, hint string, detach bool) {
|
|
dir := m.subagentDir(run.Owner, run.ConversationID, run.ID)
|
|
if dir == "" {
|
|
return
|
|
}
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
log.Printf("[runmgr] subagent dir %s: %v", run.ID, err)
|
|
return
|
|
}
|
|
body, _ := json.MarshalIndent(subagentRequest{
|
|
AgentID: agentID, Name: name, Task: task, Hint: hint, Detach: detach,
|
|
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
|
}, "", " ")
|
|
if err := os.WriteFile(filepath.Join(dir, "request.json"), body, 0o644); err != nil {
|
|
log.Printf("[runmgr] subagent request %s: %v", run.ID, err)
|
|
}
|
|
// 转录文件先建(append 语义清晰可见)。
|
|
if _, err := os.OpenFile(filepath.Join(dir, "transcript.jsonl"), os.O_CREATE|os.O_WRONLY, 0o644); err != nil {
|
|
log.Printf("[runmgr] subagent transcript %s: %v", run.ID, err)
|
|
}
|
|
}
|
|
|
|
// writeSubagentTranscript 把本次收集到的 parts 追加进转录并落 done 标记。
|
|
// status 是终态(completed/failed/cancelled/interrupted)。
|
|
func (m *Manager) writeSubagentTranscript(run core.Run, parts []core.Part, status string) {
|
|
dir := m.subagentDir(run.Owner, run.ConversationID, run.ID)
|
|
if dir == "" {
|
|
return
|
|
}
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return
|
|
}
|
|
f, err := os.OpenFile(filepath.Join(dir, "transcript.jsonl"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
|
if err != nil {
|
|
log.Printf("[runmgr] transcript open %s: %v", run.ID, err)
|
|
return
|
|
}
|
|
enc := json.NewEncoder(f)
|
|
for i, p := range parts {
|
|
_ = enc.Encode(map[string]any{"index": i, "type": string(p.Type), "text": p.Text, "at": time.Now().UTC().Format(time.RFC3339)})
|
|
}
|
|
_ = f.Close()
|
|
done, _ := json.Marshal(map[string]string{"status": status, "endedAt": time.Now().UTC().Format(time.RFC3339)})
|
|
if err := os.WriteFile(filepath.Join(dir, "done.json"), done, 0o644); err != nil {
|
|
log.Printf("[runmgr] done marker %s: %v", run.ID, err)
|
|
}
|
|
}
|
|
|
|
// 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"))
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var req subagentRequest
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
return nil, fmt.Errorf("request.json: %w", err)
|
|
}
|
|
return &req, nil
|
|
}
|
|
|
|
// ResumeInterruptedChildren 重启后扫描被中断的子代理 run:有派生请求且
|
|
// 未完成未续跑的,用原请求重新派生(后台),并把前次转录路径写进 hint
|
|
// 供模型读取后继续(N2 WP5 的「可恢复」)。幂等:done/resumed 标记跳过。
|
|
func (m *Manager) ResumeInterruptedChildren() {
|
|
runs, err := m.st.ListInterruptedSubagentRuns()
|
|
if err != nil {
|
|
log.Printf("[runmgr] resume scan: %v", err)
|
|
return
|
|
}
|
|
for _, run := range runs {
|
|
dir := m.subagentDir(run.Owner, run.ConversationID, run.ID)
|
|
if dir == "" {
|
|
continue
|
|
}
|
|
// done.json 语义分流(N3 WP1):终态(completed/failed/cancelled)挡续跑;
|
|
// interrupted 不挡——优雅停机与崩溃都是可续跑现场。
|
|
if doneStatus(dir) != "interrupted" || fileExists(filepath.Join(dir, "resumed.json")) {
|
|
continue
|
|
}
|
|
req, err := readSubagentRequest(dir)
|
|
if err != nil {
|
|
log.Printf("[runmgr] resume read %s: %v", run.ID, err)
|
|
continue
|
|
}
|
|
if req == nil {
|
|
continue // WP5 之前的旧 run 没有重建凭据,保持 interrupted 终态
|
|
}
|
|
parent, err := m.st.GetRunByID(run.ParentRunID)
|
|
if err != nil {
|
|
log.Printf("[runmgr] resume parent %s: %v", run.ParentRunID, err)
|
|
continue
|
|
}
|
|
hint := req.Hint + "\n(本任务是前次中断后的自动续跑;前次转录在 " +
|
|
filepath.Join(dir, "transcript.jsonl") + ",可读取后继续。)"
|
|
newID, err := m.spawnBackground(parent, req.AgentID, req.Name, req.Task, hint)
|
|
if err != nil {
|
|
log.Printf("[runmgr] resume spawn %s: %v", run.ID, err)
|
|
continue
|
|
}
|
|
marker, _ := json.Marshal(map[string]string{
|
|
"newRun": newID,
|
|
"resumedAt": time.Now().UTC().Format(time.RFC3339),
|
|
})
|
|
if err := os.WriteFile(filepath.Join(dir, "resumed.json"), marker, 0o644); err != nil {
|
|
log.Printf("[runmgr] resumed marker %s: %v", run.ID, err)
|
|
}
|
|
log.Printf("[runmgr] resumed subagent %s as %s", run.ID, newID)
|
|
}
|
|
}
|
|
|
|
func fileExists(path string) bool {
|
|
_, err := os.Stat(path)
|
|
return err == nil
|
|
}
|