Files
Agent-Squad/adapters/sqlite/runtime.go
weichen 90ac98ea65
ci / vet-and-test (pull_request) Successful in 23s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Converge run persistence on SQLite through the Store port
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
2026-10-07 10:29:47 +00:00

303 lines
8.6 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 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)