first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
+556
View File
@@ -0,0 +1,556 @@
package agent
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/mount"
)
// 协作镜像名(构建方式见各自目录的 RUN.md)。
const (
coopImagePigo = "pigo-coop" // pigo 协作镜像(支持 openai + anthropic 协议)
coopImage = "pi-coop" // pi 协作镜像(支持 openai + anthropic 协议)
coopImageClaude = "claude-coop" // Claude Code 协作镜像(仅支持 anthropic 协议)
)
// 协作引擎到镜像 / 容器名前缀 / 展示标签 / 运行时目录的映射。
// 三种引擎共享同一套环境变量契约(MODEL/BASE_URL/API_KEY/PROTOCOL/TASK 等),
// supervisor 各自适配,控制层只需选择镜像并做协议约束 + baseURL 规范化。
// runtimeDir 是 local 模式下 supervisor.sh / prompts / extensions 所在的相对目录。
var coopEngines = map[string]struct {
image, namePrefix, label, runtimeDir string
}{
"pigo": {coopImagePigo, "pigo-coop-", "pigo", "pigo/coop"},
"pi": {coopImage, "pi-coop-", "pi", "pi-coop"},
"claude": {coopImageClaude, "claude-coop-", "claude code", "claude code"},
}
// codePattern 从任务描述中提取题目编号(如 "c-06" / "a-05" / "e1-01")。
var codePattern = regexp.MustCompile(`[A-Za-z0-9]{1,8}-\d{1,4}`)
// runCoop 异步启动 pi 协作任务:
// 校验参数与镜像后立即返回,容器创建 / 运行 / 等待 / 清理全部放到后台 goroutine。
// 任务结束后由 CoopManager 通知 Agent,主 agent 会自动收到一条
// "【协作任务完成通知】"消息并汇报结果,无需在前端长时间等待。
func (t *Toolset) runCoop(ctx context.Context, sessionID string, params map[string]any) ToolResult {
if t.coop == nil {
return ToolResult{Success: false, Output: "协作任务管理器未初始化"}
}
task, _ := params["task"].(string)
task = strings.TrimSpace(task)
if task == "" {
return ToolResult{Success: false, Output: "task 不能为空"}
}
// 完成协议:约束协作 agent 主动收尾,避免"已提交成功却没写 DONE 被强杀"、
// "解不出却空耗到超时"两类问题。supervisor 只在黑板根目录出现 DONE 标记时正常退出。
completionProtocol := `
【完成协议(务必严格遵守,决定协作能否正常收尾)】
- 找到 flag 并提交成功(平台响应 correct=true)后:用 blackboard 工具 action=done,在黑板根目录创建 DONE 标记,内容写入完成摘要(含 flag 值、提交响应、解题路径)。supervisor 检测到 DONE 即正常结束(exit 0);不创建 DONE 会空耗到超时被强杀,协作被视为失败。
- 若经充分尝试后确认本轮无法解出(目标不可达 / 无漏洞 / 试错过多):同样用 action=done 创建 DONE,内容开头写明「未解出」与已尝试内容,让调度方及时关闭靶机并切换下一题,不要空耗到超时。
- 已通关题目不要重复提交:平台对已通关题目的后续提交统一返回 correct:false(而非 duplicate),属正常现象、不影响已得分数,不要误判为失败。`
task += completionProtocol
// 从任务描述解析题目编号(如 "c-06"),供完成通知携带,便于控制层关靶机/切题
challengeCode := ""
if m := codePattern.FindString(task); m != "" {
challengeCode = m
}
roundMax := intParam(params, "round_max")
if roundMax <= 0 {
// 单 agent 默认 1 轮:一次运行完成全部工作,未完成则失败并重新下发
roundMax = 1
}
if roundMax > 30 {
roundMax = 30
}
timeoutSec := intParam(params, "timeout")
if timeoutSec <= 0 {
// 默认 1800s600s/900s 对需要写脚本+多步探测的渗透/解题任务偏紧,
// 实测多因单轮超时(exit 143)导致协作失败。
timeoutSec = 1800
}
if t.apiCfg == nil {
return ToolResult{Success: false, Output: "缺少 LLM API 配置,无法注入模型配置"}
}
// 解析协作引擎:engine=pi(默认)/ pigo / claude。
// 未显式指定时使用设置页配置的默认引擎(apiCfg.Engine())。
// 三种镜像共享同一套环境变量契约(MODEL/BASE_URL/API_KEY/PROTOCOL/TASK 等),
// supervisor 各自适配,控制层只需选择镜像并做协议约束 + baseURL 规范化。
engine, _ := params["engine"].(string)
engine = strings.TrimSpace(strings.ToLower(engine))
if engine == "" {
engine = t.apiCfg.Engine()
}
eng, ok := coopEngines[engine]
if !ok {
return ToolResult{Success: false, Output: "不支持的 engine \"" + engine + "\",可选值:pigo | pi(默认)| claude"}
}
model := t.apiCfg.Model()
apiKey := t.apiCfg.APIKey()
provider := t.apiCfg.Provider()
// coopBaseURL 根据 engine 规范化 baseURL
// pigo 的 anthropicCompatDriver 只追加 /messages,需补 /v1
// pi/claude 用官方 SDK(自带 /v1/messages),需剥离 /v1 避免双重路径。
baseURL := coopBaseURL(provider, t.apiCfg.BaseURL(), engine)
if model == "" || apiKey == "" || baseURL == "" {
return ToolResult{Success: false, Output: "LLM API 配置不完整(model / base_url / api_key 缺一不可),请先在设置页配置"}
}
// Claude Code 仅支持 Anthropic 协议端点(ANTHROPIC_BASE_URL),
// 若配置为 openai 协议则直接拒绝,避免容器启动后才报错。
if engine == "claude" && provider != ProviderAnthropic {
return ToolResult{Success: false, Output: "claude-coop 仅支持 Anthropic 协议端点,当前 provider 为 " + provider +
"。请改用 engine=pi 或 engine=pigo,或将 API 配置切换为 anthropic 协议(如 DeepSeek 的 /anthropic 端点)。"}
}
env := []string{
"MODEL=" + model,
"BASE_URL=" + baseURL,
"API_KEY=" + apiKey,
"PROTOCOL=" + provider,
"TASK=" + task,
fmt.Sprintf("ROUND_MAX=%d", roundMax),
fmt.Sprintf("TIMEOUT=%d", timeoutSec),
}
// 生成任务 ID(也用作容器名与默认黑板子目录)
taskID := newID()
// 黑板目录:用户显式指定 blackboard 时使用指定路径;
// 未指定时为该任务分配独立的会话工作区子目录(data/workspaces/<sessionID>/blackboard/<taskID>),
// 保证同一会话发起的多个 coop 任务互相隔离、互不污染。
blackboardDir := ""
if dirParam, _ := params["blackboard"].(string); strings.TrimSpace(dirParam) != "" {
dirParam = strings.TrimSpace(dirParam)
abs, err := filepath.Abs(dirParam)
if err != nil {
return ToolResult{Success: false, Output: "blackboard 路径无效: " + err.Error()}
}
// 安全约束:blackboard 会被 worker 进程/容器读写,必须限制在项目根目录内,
// 否则 LLM 可通过指定任意主机目录(如 ~/.ssh)让 worker 读写敏感文件。
if !pathWithin(abs, t.Workspace) {
return ToolResult{Success: false, Output: "blackboard 路径超出项目根目录范围,已拒绝: " + abs}
}
if err := os.MkdirAll(abs, 0o755); err != nil {
return ToolResult{Success: false, Output: "创建 blackboard 目录失败: " + err.Error()}
}
blackboardDir = abs
} else {
dir := filepath.Join(t.workspaceFor(sessionID), "blackboard", taskID)
if err := os.MkdirAll(dir, 0o755); err != nil {
return ToolResult{Success: false, Output: "创建 blackboard 目录失败: " + err.Error()}
}
blackboardDir = dir
}
record := &CoopTask{
ID: taskID,
SessionID: sessionID,
Status: "running",
Blackboard: blackboardDir,
RoundMax: roundMax,
CreatedAt: time.Now(),
ChallengeCode: challengeCode,
}
t.coop.Register(record)
mode := t.apiCfg.CoopMode()
log.Printf("[coop] 协作任务启动 task=%s session=%s mode=%s engine=%s challenge=%s round_max=%d timeout=%ds blackboard=%s",
record.ID, sessionID, mode, engine, challengeCode, roundMax, timeoutSec, blackboardDir)
overallSec := timeoutSec*roundMax + 300
if mode == CoopModeLocal {
// 本地子进程模式:无需 Docker,直接 exec supervisor.sh
supervisor, promptsDir, extensionsDir, err := findCoopRuntime(engine)
if err != nil {
t.coop.Complete(t.failedTask(record, err))
return ToolResult{Success: false, Output: err.Error()}
}
go t.runCoopLocalAsync(record, env, blackboardDir, supervisor, promptsDir, extensionsDir, roundMax, timeoutSec, eng.label)
} else {
// Docker 容器模式:现有逻辑
var mounts []mount.Mount
cli, err := t.dockerClient()
if err != nil {
t.coop.Complete(t.failedTask(record, err))
return ToolResult{Success: false, Output: err.Error()}
}
defer cli.Close()
// 判断目标 daemon 系统类型:远程 Linux daemon(如 WSL)的 bind 挂载
// 只认容器侧路径,Windows 路径需转换为 /mnt/<盘符>/... 形式。
info, infoErr := cli.Info(ctx)
linuxDaemon := infoErr == nil && info.OSType == "linux"
// 检查镜像是否存在(同步快速失败,避免后台任务因镜像缺失白跑)
checkCtx, cancel := context.WithTimeout(ctx, t.Timeout)
defer cancel()
if _, err := cli.ImageInspect(checkCtx, eng.image); err != nil {
t.coop.Complete(t.failedTask(record, fmt.Errorf("未找到镜像 %s", eng.image)))
return ToolResult{Success: false, Output: "未找到镜像 " + eng.image + "。请先在仓库根构建:\n" +
coopBuildHint(engine) + "\n(详见对应目录的 RUN.md"}
}
// 容器内以非 root 用户(uid=1000 agent)运行,主机目录必须对任何用户可写,
// 否则 supervisor 初始化(mkdir/写 task.md/复制 AGENTS.md)会失败。
_ = os.Chmod(blackboardDir, 0o777)
source := blackboardDir
if linuxDaemon {
source = wslBindPath(blackboardDir)
}
mounts = append(mounts, mount.Mount{
Type: mount.TypeBind,
Source: source,
Target: "/blackboard",
})
// 容器创建 / 启动 / 等待 / 清理放到后台,并使用独立上下文,
// 避免阻塞当前 SSE 流(此前同步等待最长可达 timeout×round_max+300 秒)。
go t.runCoopAsync(record, env, mounts, roundMax, timeoutSec, eng.image, eng.namePrefix)
}
return ToolResult{Success: true, Output: fmt.Sprintf(
"协作任务已在后台启动,任务 ID:%s。\n运行方式:%s 协作单 Agent(%s 模式),最多 %d 轮,整体上限约 %d 分钟。\n"+
"你无需在此等待,可以继续处理其他请求;任务完成后系统会自动通知你并汇报结果。",
record.ID, eng.label, mode, roundMax, overallSec/60)}
}
// runCoopAsync 在后台完成协作容器的创建、启动、等待、日志收集与清理。
// 结束(成功 / 失败 / 超时)后通过 coop.Complete 通知 Agent 唤醒主 agent。
// image / namePrefix 由 runCoop 根据 engine 选择(pi-coop 或 claude-coop)。
func (t *Toolset) runCoopAsync(record *CoopTask, env []string, mounts []mount.Mount, roundMax, timeoutSec int, image, namePrefix string) {
// 使用独立后台上下文,避免随 SSE 请求断开而中断协作任务
ctx := context.Background()
cli, err := t.dockerClient()
if err != nil {
t.coop.Complete(t.failedTask(record, err))
return
}
defer cli.Close()
name := namePrefix + record.ID[:8]
created, err := cli.ContainerCreate(ctx, &container.Config{
Image: image,
Env: env,
}, &container.HostConfig{
Mounts: mounts,
}, nil, nil, name)
if err != nil {
t.coop.Complete(t.failedTask(record, fmt.Errorf("创建容器失败: %w", err)))
return
}
record.ContainerID = created.ID
// 运行结束后无论如何清理容器(等价 docker run --rm
cleanup := func() {
cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_ = cli.ContainerRemove(cleanupCtx, created.ID, container.RemoveOptions{Force: true})
}
if err := cli.ContainerStart(ctx, created.ID, container.StartOptions{}); err != nil {
cleanup()
t.coop.Complete(t.failedTask(record, fmt.Errorf("启动容器失败: %w", err)))
return
}
// 等待容器退出;整体超时上限 = 轮次 × 单轮超时 + 缓冲
overall := time.Duration(timeoutSec*roundMax+300) * time.Second
waitCtx, waitCancel := context.WithTimeout(ctx, overall)
defer waitCancel()
waitCh, errCh := cli.ContainerWait(waitCtx, created.ID, container.WaitConditionNotRunning)
exitCode := -1
select {
case res := <-waitCh:
exitCode = int(res.StatusCode)
case err := <-errCh:
cleanup()
t.coop.Complete(t.failedTask(record, fmt.Errorf("等待容器退出失败: %w", err)))
return
case <-waitCtx.Done():
cleanup()
t.coop.Complete(t.failedTask(record, fmt.Errorf(
"协作运行超时(整体上限 %d 秒,约 %.0f 分钟),已强制清理容器。可增大 round_max / timeout 或缩小任务规模后重试。",
int(overall.Seconds()), overall.Minutes())))
return
}
// 读取容器日志(supervisor 输出 + DONE 总结)
logCtx, logCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer logCancel()
result := ""
if logs, err := cli.ContainerLogs(logCtx, created.ID, container.LogsOptions{
ShowStdout: true,
ShowStderr: true,
}); err == nil {
if raw, readErr := io.ReadAll(logs); readErr == nil {
result = demuxDockerLogs(raw)
_ = logs.Close()
}
}
result = strings.TrimSpace(result)
switch exitCode {
case 0:
result += "\n[协作完成,退出码 0]"
case 1:
result += "\n[达到最大轮次仍未完成,退出码 1;可增大 round_max 后重试]"
default:
result += fmt.Sprintf("\n[容器退出码 %d,请检查上方日志]", exitCode)
}
if len(mounts) > 0 {
result += "\n黑板产物已保留在: " + mounts[0].Source
}
cleanup()
record.ExitCode = exitCode
record.Output = t.truncate(result)
// 解析 supervisor 生成的结构化结果 result.json(如有):
// 完成通知与汇报轮优先使用 Result 字段,容器 stdout 仅作兜底。
status := "unknown"
if data, rerr := os.ReadFile(filepath.Join(record.Blackboard, "result.json")); rerr == nil {
var res CoopResult
if json.Unmarshal(data, &res) == nil {
record.Result = &res
status = res.Status
}
}
log.Printf("[coop] 协作任务结束 task=%s session=%s exit_code=%d status=%s", record.ID, record.SessionID, exitCode, status)
t.coop.Complete(record)
}
// failedTask 生成一个失败任务记录,供 runCoopAsync 失败路径统一上报。
func (t *Toolset) failedTask(record *CoopTask, err error) *CoopTask {
record.Error = err.Error()
record.Output = "协作任务失败: " + err.Error()
return record
}
// coopBuildHint 返回指定 engine 对应镜像的构建命令提示。
func coopBuildHint(engine string) string {
switch engine {
case "pigo":
// pigo-coop 构建上下文是 pigo/ 目录,且需先交叉编译 pigo 二进制
return "cd pigo && GOOS=linux CGO_ENABLED=0 go build -trimpath -ldflags=\"-s -w\" -o coop/tmp/pigo-linux-amd64 ./cmd/pigo && docker build -f coop/Dockerfile -t pigo-coop ."
case "claude":
return "docker build -f \"claude code/Dockerfile\" -t claude-coop \"claude code/\""
default:
return "docker build -f pi-coop/Dockerfile -t pi-coop pi-coop/"
}
}
// coopBaseURL 把当前 Agent 配置的 Base URL 规范化为协作容器期望的"基础地址"。
// 不同 engine 的模型接入层对 baseURL 的路径处理不同,需按 engine 区分:
// - pigoanthropicCompatDriver 只追加 /messages(不含 /v1),因此 anthropic
// 端点需补 /v1(最终 /v1/messages);openai 端点需保留 /v1。
// - piprovider 使用官方 SDK@anthropic-ai/sdk / openai),SDK 自行追加完整
// 路径(anthropic: /v1/messagesopenai: /chat/completions),不能再补 /v1
// 否则产生 /v1/v1/messages 双重路径导致 404。
// - claude:同 pi,用 @anthropic-ai/sdk,不能补 /v1。
//
// 对用户误填的完整路径后缀(/v1/messages、/chat/completions 等)统一剥离,
// 再按 engine + provider 决定是否补 /v1。
func coopBaseURL(provider, baseURL, engine string) string {
base := strings.TrimRight(strings.TrimSpace(baseURL), "/")
// 先剥离用户误填的完整路径后缀,统一回退到"基础地址"
switch {
case strings.HasSuffix(base, "/v1/chat/completions"):
base = strings.TrimSuffix(base, "/chat/completions")
case strings.HasSuffix(base, "/chat/completions"):
base = strings.TrimSuffix(base, "/chat/completions")
case strings.HasSuffix(base, "/v1/messages"):
base = strings.TrimSuffix(base, "/v1/messages")
case strings.HasSuffix(base, "/messages"):
base = strings.TrimSuffix(base, "/messages")
}
if provider != ProviderAnthropic {
return base
}
// Anthropic 协议端点处理
switch engine {
case "pigo":
// pigo 的 anthropicCompatDriver 只追加 /messages,需补 /v1。
// 若用户已填 /v1 结尾则保持,否则补上。
if strings.HasSuffix(base, "/v1") {
return base
}
return base + "/v1"
default:
// pi / claude 用 @anthropic-ai/sdkSDK 自带 /v1/messages
// 不能补 /v1;若用户已填 /v1 则剥离(SDK 会补回完整的 /v1/messages)。
if strings.HasSuffix(base, "/v1") {
return strings.TrimSuffix(base, "/v1")
}
return base
}
}
// wslBindPath 把 Windows 绝对路径转换为 WSL 挂载路径(E:\path → /mnt/e/path),
// 供运行在 WSL 内的 Linux Docker daemon 做 bind 挂载。
func wslBindPath(path string) string {
if len(path) < 2 || path[1] != ':' {
return path
}
drive := strings.ToLower(path[:1])
rest := strings.ReplaceAll(path[2:], "\\", "/")
return "/mnt/" + drive + rest
}
// findCoopRuntime 查找指定 engine 的 supervisor.sh / prompts / extensions 目录。
// 查找顺序:环境变量 COOP_DIR > 相对于可执行文件 > 相对于工作目录。
// 返回的路径均为绝对路径,供本地子进程模式(runCoopLocalAsync)使用。
func findCoopRuntime(engine string) (supervisor, prompts, extensions string, err error) {
eng, ok := coopEngines[engine]
if !ok {
return "", "", "", fmt.Errorf("不支持的 engine: %s", engine)
}
dirName := eng.runtimeDir
// 候选基目录列表:COOP_DIR 环境变量 > 可执行文件同级/上级 > 当前工作目录
var candidates []string
if envDir := os.Getenv("COOP_DIR"); envDir != "" {
candidates = append(candidates, filepath.Join(envDir, dirName))
}
if exe, exeErr := os.Executable(); exeErr == nil {
exeDir := filepath.Dir(exe)
candidates = append(candidates, filepath.Join(exeDir, dirName))
candidates = append(candidates, filepath.Join(exeDir, "..", dirName))
}
if wd, wdErr := os.Getwd(); wdErr == nil {
candidates = append(candidates, filepath.Join(wd, dirName))
}
for _, base := range candidates {
sp := filepath.Join(base, "supervisor.sh")
if st, statErr := os.Stat(sp); statErr == nil && !st.IsDir() {
pp := filepath.Join(base, "prompts")
ep := filepath.Join(base, "extensions")
// prompts / extensions 可选:缺失时传空串,supervisor 用内置默认
return sp, pp, ep, nil
}
}
return "", "", "", fmt.Errorf(
"本地模式未找到 %s 的 supervisor.sh,已查找目录: %v\n"+
"请确保 %s 目录存在且包含 supervisor.sh,或设置 COOP_DIR 环境变量指向包含该目录的父目录",
engine, candidates, dirName)
}
// runCoopLocalAsync 在后台以本地子进程方式运行 supervisor.sh 完成 worker 任务。
// 与 runCoopAsyncDocker 模式)对应:无需 Docker daemon,直接 exec supervisor.sh
// 通过 BLACKBOARD/PROMPTS/EXTENSIONS 环境变量指向本地路径。
// 结束(成功 / 失败 / 超时)后通过 coop.Complete 通知 Agent。
func (t *Toolset) runCoopLocalAsync(record *CoopTask, env []string, blackboardDir, supervisor, promptsDir, extensionsDir string, roundMax, timeoutSec int, label string) {
// 使用独立后台上下文,避免随 SSE 请求断开而中断协作任务
ctx := context.Background()
overall := time.Duration(timeoutSec*roundMax+300) * time.Second
runCtx, runCancel := context.WithTimeout(ctx, overall)
defer runCancel()
// Windows 上 bash 通常是 WSL bash,不认反斜杠路径(E:\foo → E:foo 被吞)。
// 需把传给 bash 的路径转为 /mnt/<盘符>/... 格式;Go 侧文件操作仍用原始路径。
toBashPath := func(p string) string {
if len(p) >= 2 && p[1] == ':' {
return wslBindPath(p)
}
return p
}
bashSupervisor := toBashPath(supervisor)
bashBlackboard := toBashPath(blackboardDir)
bashPrompts := toBashPath(promptsDir)
bashExtensions := toBashPath(extensionsDir)
// 构建子进程环境:继承父进程环境(PATH 等)+ 注入协作环境变量
procEnv := os.Environ()
procEnv = append(procEnv, env...)
procEnv = append(procEnv, "BLACKBOARD="+bashBlackboard)
if bashPrompts != "" {
procEnv = append(procEnv, "PROMPTS="+bashPrompts)
}
if bashExtensions != "" {
procEnv = append(procEnv, "EXTENSIONS="+bashExtensions)
}
cmd := exec.CommandContext(runCtx, "bash", bashSupervisor)
cmd.Env = procEnv
// stdout+stderr 合并捕获(supervisor 的日志输出)
var buf bytes.Buffer
cmd.Stdout = &buf
cmd.Stderr = &buf
log.Printf("[coop] 本地协作进程启动 task=%s pid=pending blackboard=%s supervisor=%s",
record.ID, bashBlackboard, bashSupervisor)
if err := cmd.Start(); err != nil {
t.coop.Complete(t.failedTask(record, fmt.Errorf("启动 supervisor 失败: %w", err)))
return
}
record.ProcessID = cmd.Process.Pid
log.Printf("[coop] 本地协作进程已启动 task=%s pid=%d", record.ID, record.ProcessID)
// 等待进程退出(exec.CommandContext 在 runCtx 超时时自动发送 SIGKILL
waitErr := cmd.Wait()
exitCode := 0
if waitErr != nil {
if exitErr, ok := waitErr.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
} else {
exitCode = -1
}
}
// 判断是否因超时被杀
timedOut := runCtx.Err() == context.DeadlineExceeded
result := strings.TrimSpace(buf.String())
switch {
case timedOut:
result += fmt.Sprintf("\n[协作运行超时(整体上限 %d 秒,约 %.0f 分钟),已强制终止进程]",
int(overall.Seconds()), overall.Minutes())
exitCode = 124
case exitCode == 0:
result += "\n[协作完成,退出码 0]"
case exitCode == 1:
result += "\n[达到最大轮次仍未完成,退出码 1;可增大 round_max 后重试]"
default:
result += fmt.Sprintf("\n[进程退出码 %d,请检查上方日志]", exitCode)
}
result += "\n黑板产物已保留在: " + blackboardDir
record.ExitCode = exitCode
record.Output = t.truncate(result)
// 解析 supervisor 生成的结构化结果 result.json(与 docker 模式一致)
status := "unknown"
if data, rerr := os.ReadFile(filepath.Join(blackboardDir, "result.json")); rerr == nil {
var res CoopResult
if json.Unmarshal(data, &res) == nil {
record.Result = &res
status = res.Status
}
}
log.Printf("[coop] 本地协作任务结束 task=%s pid=%d exit_code=%d status=%s",
record.ID, record.ProcessID, exitCode, status)
t.coop.Complete(record)
}