package runmgr import ( "context" "encoding/json" "fmt" "log" "os" "path/filepath" "time" core "agent-system-core" ) // subagent_transcript.go — 子代理独立转录与中断续跑(N2 WP5)。 // 每个子 run 一个目录:///subagents// // 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) { if err := m.writeSubagentRequestChecked(context.Background(), run, agentID, name, task, hint, detach, nil); err != nil { log.Printf("[runmgr] subagent request %s: %v", run.ID, err) } } // writeSubagentRequestChecked keeps transcript file effects behind an optional // live account check. Failures stop later file writes; this auxiliary record // cannot be committed atomically with the account database. func (m *Manager) writeSubagentRequestChecked(ctx context.Context, run core.Run, agentID, name, task, hint string, detach bool, check func(context.Context) error) error { dir := m.subagentDir(run.Owner, run.ConversationID, run.ID) if dir == "" { return nil } if err := checkFileEffect(ctx, check); err != nil { return err } if err := os.MkdirAll(dir, 0o755); err != nil { return err } body, _ := json.MarshalIndent(subagentRequest{ AgentID: agentID, Name: name, Task: task, Hint: hint, Detach: detach, CreatedAt: time.Now().UTC().Format(time.RFC3339), }, "", " ") if err := checkFileEffect(ctx, check); err != nil { return err } if err := os.WriteFile(filepath.Join(dir, "request.json"), body, 0o644); err != nil { return err } // 转录文件先建(append 语义清晰可见)。 if err := checkFileEffect(ctx, check); err != nil { return err } f, err := os.OpenFile(filepath.Join(dir, "transcript.jsonl"), os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return err } return f.Close() } // writeSubagentTranscript 把本次收集到的 parts 追加进转录并落 done 标记。 // status 是终态(completed/failed/cancelled/interrupted)。 func (m *Manager) writeSubagentTranscript(run core.Run, parts []core.Part, status string) { if err := m.writeSubagentTranscriptChecked(context.Background(), run, parts, status, nil); err != nil { log.Printf("[runmgr] subagent transcript %s: %v", run.ID, err) } } func (m *Manager) writeSubagentTranscriptChecked(ctx context.Context, run core.Run, parts []core.Part, status string, check func(context.Context) error) error { dir := m.subagentDir(run.Owner, run.ConversationID, run.ID) if dir == "" { return nil } if err := checkFileEffect(ctx, check); err != nil { return err } if err := os.MkdirAll(dir, 0o755); err != nil { return err } if err := checkFileEffect(ctx, check); err != nil { return err } f, err := os.OpenFile(filepath.Join(dir, "transcript.jsonl"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { return err } enc := json.NewEncoder(f) for i, p := range parts { if err := checkFileEffect(ctx, check); err != nil { _ = f.Close() return err } if err := enc.Encode(map[string]any{"index": i, "type": string(p.Type), "text": p.Text, "at": time.Now().UTC().Format(time.RFC3339)}); err != nil { _ = f.Close() return err } } if err := f.Close(); err != nil { return err } done, _ := json.Marshal(map[string]string{"status": status, "endedAt": time.Now().UTC().Format(time.RFC3339)}) if err := checkFileEffect(ctx, check); err != nil { return err } if err := os.WriteFile(filepath.Join(dir, "done.json"), done, 0o644); err != nil { return err } return nil } // 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() { m.ResumeInterruptedChildrenChecked(context.Background(), nil) } // ResumeInterruptedChildrenChecked rechecks each run owner before redispatch // and before writing the resumed marker. A disable between redispatch and the // marker can still leave a spawned run without this file; DB/file atomicity is // unavailable, so callers must keep the run admission guard as well. func (m *Manager) ResumeInterruptedChildrenChecked(ctx context.Context, check func(context.Context, string) error) { // Legacy request files cannot reconstruct a canonical task's attempt, inputs or // authority. Preserve its interrupted state rather than silently create a new task. records, err := m.st.TaskCards() if err != nil { log.Printf("[runmgr] resume task bindings: %v (fail-closed)", err) return } bound := make(map[string]bool, len(records)) for _, record := range records { if id := record.Card.Task.RunID; id != "" { bound[id] = true } if id := record.Card.SourceRunID; id != "" { bound[id] = true } } runs, err := m.st.ListInterruptedSubagentRuns() if err != nil { log.Printf("[runmgr] resume scan: %v", err) return } for _, run := range runs { if err := ctx.Err(); err != nil { log.Printf("[runmgr] resume cancelled: %v", err) return } if bound[run.ID] { continue } // 恢复隔离(协作存储 U3):未知外部副作用的 run 不自动续跑——hold // 未解除即跳过;判定出错同样跳过(fail-closed:宁可人工核对,不盲重派)。 if held, herr := m.st.HasOpenRecoveryHold(string(run.ID), string(run.ConversationID)); herr != nil || held { if herr != nil { log.Printf("[runmgr] resume skip %s: recovery hold 判定失败(fail-closed): %v", run.ID, herr) } else { log.Printf("[runmgr] resume skip %s: recovery hold 未解除(人工核对后显式释放才会续跑)", run.ID) } continue } 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") + ",可读取后继续。)" if err := checkOwnerFileEffect(ctx, run.Owner, check); err != nil { log.Printf("[runmgr] resume skip %s: account check: %v", run.ID, err) continue } 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 := checkOwnerFileEffect(ctx, run.Owner, check); err != nil { log.Printf("[runmgr] resumed marker skip %s: account check: %v", run.ID, err) continue } 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 checkOwnerFileEffect(ctx context.Context, owner string, check func(context.Context, string) error) error { if check == nil { return checkFileEffect(ctx, nil) } return checkFileEffect(ctx, func(ctx context.Context) error { return check(ctx, owner) }) } func fileExists(path string) bool { _, err := os.Stat(path) return err == nil }