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 {
		// 默认 1800s：600s/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 区分：
//   - pigo：anthropicCompatDriver 只追加 /messages（不含 /v1），因此 anthropic
//     端点需补 /v1（最终 /v1/messages）；openai 端点需保留 /v1。
//   - pi：provider 使用官方 SDK（@anthropic-ai/sdk / openai），SDK 自行追加完整
//     路径（anthropic: /v1/messages；openai: /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/sdk，SDK 自带 /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 任务。
// 与 runCoopAsync（Docker 模式）对应：无需 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)
}
