Why: Q1 WP5 - platform's SQLite becomes an implementation of the squad Store port so run state and orchestration state live in one database, and platform's in-process execution goes through the Executor port instead of bypassing it (X6). Evidence: all five modules go vet clean and go test ok, including engine-on-SQLite (state in DB, no run-level JSON files, second engine recovers from the same DB), SQLiteStore round trip, and the full squad compatibility net. Impact: runState/hitlGate exported so the port is implementable; Options.Store wires into engineDeps; leader persistence and checkpoints route through the port; PrepareWorkspace no longer writes run-level JSON (st.save owns it); platform RunInProcess takes squad.Executor (callers updated); serve mounts the engine on platform's RunStore - single orchestration.db for both. Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md Q1 WP5
303 lines
8.6 KiB
Go
303 lines
8.6 KiB
Go
package platform
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
"agent-squad/squad"
|
||
)
|
||
|
||
// CLICommands are the external tools a daemon probes, in order.
|
||
var CLICommands = []string{
|
||
"claude", "codex", "opencode", "codearts", "deveco", "openclaw", "hermes", "pi",
|
||
"omp", "cursor-agent", "copilot", "kimi", "reasonix", "dsh", "kiro-cli", "codebuddy",
|
||
"agy", "qodercli", "qodercln", "traecli", "grok", "qwen", "qwenpaw", "dim", "mcode",
|
||
}
|
||
|
||
// ProtocolFor maps a command onto a protocol family.
|
||
func ProtocolFor(command string) string {
|
||
switch command {
|
||
case "claude":
|
||
return "claude"
|
||
case "codex":
|
||
return "codex"
|
||
case "pi":
|
||
return "pi"
|
||
case "opencode":
|
||
return "opencode"
|
||
default:
|
||
return "custom"
|
||
}
|
||
}
|
||
|
||
// ProbeCLIs returns commands present on PATH. An empty list is allowed:
|
||
// the in-process runtime still runs.
|
||
func ProbeCLIs() []string {
|
||
var found []string
|
||
for _, name := range CLICommands {
|
||
if env := os.Getenv("AGENT_SQUAD_" + strings.ToUpper(strings.ReplaceAll(name, "-", "_")) + "_PATH"); env != "" {
|
||
if _, err := os.Stat(env); err == nil {
|
||
found = append(found, env)
|
||
continue
|
||
}
|
||
}
|
||
if path, err := exec.LookPath(name); err == nil {
|
||
found = append(found, path)
|
||
}
|
||
}
|
||
return found
|
||
}
|
||
|
||
// RegisterProbed records every discovered CLI plus the in-process runtime.
|
||
func (s *Store) RegisterProbed(workspaceID string) ([]Runtime, error) {
|
||
inproc, err := s.RegisterRuntime(Runtime{
|
||
WorkspaceID: workspaceID, Name: "agent-native", Command: "in-process",
|
||
Protocol: "agent-native", MaxConcurrency: 2,
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := []Runtime{inproc}
|
||
for _, path := range ProbeCLIs() {
|
||
base := filepath.Base(path)
|
||
rt, err := s.RegisterRuntime(Runtime{
|
||
WorkspaceID: workspaceID, Name: base, Command: path,
|
||
Protocol: ProtocolFor(base), MaxConcurrency: 1,
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, rt)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// PrepareWorkdir creates workdir, output, and logs, and writes the brief and skills.
|
||
func (s *Store) PrepareWorkdir(root string, task Task) (string, error) {
|
||
base := filepath.Join(root, task.WorkspaceID, task.IssueID)
|
||
for _, sub := range []string{"workdir", "output", "logs"} {
|
||
if err := os.MkdirAll(filepath.Join(base, sub), 0o755); err != nil {
|
||
return "", err
|
||
}
|
||
}
|
||
brief := task.IssueID
|
||
if task.SquadID != "" {
|
||
text, err := s.LeaderBrief(task.SquadID)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
brief = text
|
||
}
|
||
work := filepath.Join(base, "workdir")
|
||
for _, name := range []string{"AGENTS.md", "CLAUDE.md"} {
|
||
if err := os.WriteFile(filepath.Join(work, name), []byte(brief), 0o644); err != nil {
|
||
return "", err
|
||
}
|
||
}
|
||
if err := s.materializeSkills(work, task.AgentID); err != nil {
|
||
return "", err
|
||
}
|
||
return work, nil
|
||
}
|
||
|
||
func (s *Store) materializeSkills(work, agentID string) error {
|
||
rows, err := s.db.Query(`SELECT skill.id, skill.name FROM skill JOIN agent_skill ON agent_skill.skill_id=skill.id WHERE agent_skill.agent_id=? AND agent_skill.enabled=1`, agentID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
type skillRef struct{ id, name string }
|
||
var skills []skillRef
|
||
for rows.Next() {
|
||
var item skillRef
|
||
if err := rows.Scan(&item.id, &item.name); err != nil {
|
||
rows.Close()
|
||
return err
|
||
}
|
||
skills = append(skills, item)
|
||
}
|
||
err = rows.Err()
|
||
rows.Close()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for _, item := range skills {
|
||
dir := filepath.Join(work, ".agents", "skills", item.name)
|
||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||
return err
|
||
}
|
||
files, err := s.db.Query(`SELECT path, body FROM skill_file WHERE skill_id=?`, item.id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for files.Next() {
|
||
var path, body string
|
||
if err := files.Scan(&path, &body); err != nil {
|
||
files.Close()
|
||
return err
|
||
}
|
||
target := filepath.Join(dir, path)
|
||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||
files.Close()
|
||
return err
|
||
}
|
||
if err := os.WriteFile(target, []byte(body), 0o644); err != nil {
|
||
files.Close()
|
||
return err
|
||
}
|
||
}
|
||
if err := files.Err(); err != nil {
|
||
files.Close()
|
||
return err
|
||
}
|
||
files.Close()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ExecutionEnv is the environment for one task. custom_env cannot override
|
||
// the task token, PATH, or HOME.
|
||
func ExecutionEnv(task Task, custom string) []string {
|
||
env := os.Environ()
|
||
reserved := map[string]bool{"AGENT_SQUAD_TOKEN": true, "PATH": true, "HOME": true}
|
||
for _, line := range strings.Split(custom, "\n") {
|
||
if line == "" || !strings.Contains(line, "=") {
|
||
continue
|
||
}
|
||
key := strings.SplitN(line, "=", 2)[0]
|
||
if reserved[key] {
|
||
continue
|
||
}
|
||
env = append(env, line)
|
||
}
|
||
env = append(env, "AGENT_SQUAD_TOKEN="+task.Token, "AGENT_SQUAD_TASK="+task.ID, "AGENT_SQUAD_ISSUE="+task.IssueID)
|
||
return env
|
||
}
|
||
|
||
// RunCommand executes an external CLI in the workdir and records the result.
|
||
func (s *Store) RunCommand(ctx context.Context, task Task, command, workdir string) error {
|
||
if err := s.MarkRunning(task.ID); err != nil {
|
||
return err
|
||
}
|
||
cmd := exec.CommandContext(ctx, command)
|
||
cmd.Dir = workdir
|
||
agent, _ := s.GetAgent(task.AgentID)
|
||
cmd.Env = ExecutionEnv(task, agent.CustomEnv)
|
||
out, err := cmd.CombinedOutput()
|
||
_ = s.AddTaskMessage(task.ID, "log", string(out))
|
||
if err != nil {
|
||
return s.FinishTask(task.ID, "failed", "timeout")
|
||
}
|
||
return s.FinishTask(task.ID, "completed", "")
|
||
}
|
||
|
||
// RunInProcess executes the task through the squad.Executor port(Q1 WP5 修 X6:
|
||
// 不再绕过执行端口直驱 agent-native runtime)。调用方经 adapters/native 等
|
||
// 适配器构造 Executor;nil 即 fail-closed。
|
||
func (s *Store) RunInProcess(ctx context.Context, task Task, workdir string, exec squad.Executor) (string, error) {
|
||
if exec == nil {
|
||
return "", fmt.Errorf("RunInProcess requires an executor (no bypass)")
|
||
}
|
||
if err := s.MarkRunning(task.ID); err != nil {
|
||
return "", err
|
||
}
|
||
issue, _ := s.GetIssue(task.IssueID)
|
||
res, err := exec.Execute(ctx, squad.ExecRequest{
|
||
WorkDir: workdir,
|
||
Prompt: issue.Title + "\n" + issue.Body,
|
||
Targets: []string{"output.md"},
|
||
OnEvent: func(ev squad.ProcessEvent) {
|
||
_ = s.AddTaskMessage(task.ID, ev.Type, ev.Text)
|
||
},
|
||
})
|
||
if err != nil {
|
||
_ = s.FinishTask(task.ID, "failed", "config")
|
||
return "", err
|
||
}
|
||
if res == nil || !res.Success {
|
||
reason := "model"
|
||
if res != nil && res.ErrorReason != "" {
|
||
reason = res.ErrorReason
|
||
}
|
||
_ = s.FinishTask(task.ID, "failed", reason)
|
||
if res != nil {
|
||
return res.Logs, fmt.Errorf("executor failed: %s", res.ErrorReason)
|
||
}
|
||
return "", fmt.Errorf("executor failed")
|
||
}
|
||
if len(res.Patches) > 0 {
|
||
_, _ = squad.MaterializeTargets(workdir, []string{"output.md"}, res.Patches)
|
||
}
|
||
_ = s.AddUsage(task.ID, exec.Name(), res.PromptTokens, res.CompletionTokens, 0, 0)
|
||
if err := s.FinishTask(task.ID, "completed", ""); err != nil {
|
||
return res.Logs, err
|
||
}
|
||
if data, rerr := os.ReadFile(filepath.Join(workdir, "output.md")); rerr == nil {
|
||
return string(data), nil
|
||
}
|
||
if err := os.WriteFile(filepath.Join(workdir, "output.md"), []byte(res.Logs), 0o644); err != nil {
|
||
return res.Logs, err
|
||
}
|
||
return res.Logs, nil
|
||
}
|
||
|
||
// AgentCLI applies a task-token command: comment, status, sub-issue, or activity.
|
||
func (s *Store) AgentCLI(token string, args []string) error {
|
||
task, err := s.TaskByToken(token)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if len(args) == 0 {
|
||
return fmt.Errorf("agent cli: missing command")
|
||
}
|
||
switch args[0] {
|
||
case "comment":
|
||
if len(args) < 2 {
|
||
return fmt.Errorf("comment requires a body")
|
||
}
|
||
_, err = s.AddComment(Comment{IssueID: task.IssueID, AuthorKind: "agent", AuthorID: task.AgentID, Body: args[1]})
|
||
return err
|
||
case "status":
|
||
if len(args) < 2 {
|
||
return fmt.Errorf("status requires a value")
|
||
}
|
||
goal := args[1] == "in_review"
|
||
return s.TouchStatus(task.IssueID, task.AgentID, goal)
|
||
case "sub-issue":
|
||
if len(args) < 2 {
|
||
return fmt.Errorf("sub-issue requires a title")
|
||
}
|
||
_, err = s.CreateIssue(Issue{WorkspaceID: task.WorkspaceID, Title: args[1], Body: args[1], ParentID: task.IssueID, Status: "todo"})
|
||
return err
|
||
case "activity":
|
||
if len(args) < 2 {
|
||
return fmt.Errorf("activity requires an action")
|
||
}
|
||
detail := ""
|
||
if len(args) > 2 {
|
||
detail = args[2]
|
||
}
|
||
return s.RecordActivity(task.SquadID, task.IssueID, task.AgentID, args[1], detail)
|
||
default:
|
||
return fmt.Errorf("unknown agent command %s", args[0])
|
||
}
|
||
}
|
||
|
||
// CheckoutWorktree documents the branch name used for parallel local_directory work.
|
||
func WorktreeBranch(agentName, issueID string) string {
|
||
safe := strings.Map(func(r rune) rune {
|
||
if r == '/' || r == ' ' {
|
||
return '-'
|
||
}
|
||
return r
|
||
}, agentName)
|
||
return "agent/" + safe + "/" + issueID
|
||
}
|
||
|
||
// Ensure the JSON import stays referenced for providers that pass raw tool args.
|
||
var _ = json.RawMessage(nil)
|