Files
2026-08-14 23:41:57 +08:00

557 lines
22 KiB
Go
Raw Permalink 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 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)
}