Files
Agent-System-Backend/internal/runmgr/subagent_transcript.go
T
weichen f1302ed5b3 Enable canonical versioned Go module consumption
Why: Allow consumers to download an immutable module revision without nested source checkouts or local path replacements.

Evidence: SELF package tests and the prior canonical-path rejection are recorded in the Dev dependency-workspace task card; remote consumption is replayed against the committed candidate.

Impact: Change the module identity and its Go imports while preserving API behavior. Existing historical submodule revisions remain buildable.

Refs: Versioned dependencies U1/U3; feature/dependency-workspace
2026-10-13 15:47:16 +00:00

185 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package runmgr
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"time"
core "git-ssh.u1680832.nyat.app/weichen/Agent-System-Core.git"
)
// 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 {
// 恢复隔离(协作存储 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") + ",可读取后继续。)"
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
}