Compare commits
93
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d76111a16 | ||
|
|
8103b8da35 | ||
|
|
f3a67ced9c | ||
|
|
c462bc4ae5 | ||
|
|
cff355d7e6 | ||
|
|
8b1000eb09 | ||
|
|
ef17c6630a | ||
|
|
40ce140cad | ||
|
|
b72bbe48a1 | ||
|
|
bf7fe8429b | ||
|
|
6dc03c02a9 | ||
|
|
ff36b23191 | ||
|
|
d775b4b205 | ||
|
|
f38cf7c441 | ||
|
|
a9948da781 | ||
|
|
90077b6601 | ||
|
|
b3363e83b9 | ||
|
|
931e5ae410 | ||
|
|
ec2c6a3495 | ||
|
|
6306451552 | ||
|
|
088d7f8543 | ||
|
|
9d3faefb97 | ||
|
|
c70d7c96d9 | ||
|
|
92f4e925d2 | ||
|
|
1bd42ab980 | ||
|
|
41ea27668c | ||
|
|
b4f1d26f33 | ||
|
|
56941bf8ea | ||
|
|
fe8de0e057 | ||
|
|
029edc08e7 | ||
|
|
1d86344c85 | ||
|
|
551e11ec19 | ||
|
|
83e72b4eaa | ||
|
|
eba77e782f | ||
|
|
90ac98ea65 | ||
|
|
1c3995beaa | ||
|
|
626b2a3f34 | ||
|
|
b34d6ed187 | ||
|
|
96d1e0e49c | ||
|
|
3870489614 | ||
|
|
f90abafe79 | ||
|
|
bb02683d89 | ||
|
|
4e8d684125 | ||
|
|
5403b6e7a9 | ||
|
|
ebbebdf20b | ||
|
|
592a410288 | ||
|
|
7fe41e86a1 | ||
|
|
6b7a991750 | ||
|
|
cb398dadc6 | ||
|
|
7ecc04ee61 | ||
|
|
3bf100fd9b | ||
|
|
fc9620e05f | ||
|
|
ef14bda11f | ||
|
|
bf5b210bd1 | ||
|
|
a75d359ade | ||
|
|
1051dc5ae5 | ||
|
|
bf5b389d55 | ||
|
|
61985d457f | ||
|
|
5a57636cc0 | ||
|
|
59ca80cee2 | ||
|
|
dcd523ea05 | ||
|
|
d9a573e497 | ||
|
|
a3b49e7b1f | ||
|
|
55e9b37770 | ||
|
|
beea97e8cf | ||
|
|
41dfe7e3ef | ||
|
|
68b18ec2c5 | ||
|
|
4d534508a9 | ||
|
|
3abc5944bd | ||
|
|
9efce79b86 | ||
|
|
c5c5a24e2c | ||
|
|
02c0d6aae4 | ||
|
|
5e414ffecc | ||
|
|
f68e5bde7b | ||
|
|
a182d7662b | ||
|
|
20a9eb8078 | ||
|
|
31ee395895 | ||
|
|
1bb0c3a2ae | ||
|
|
9537321782 | ||
|
|
8dc2c54adf | ||
|
|
6c8810029d | ||
|
|
e928ea3c0f | ||
|
|
cda1fd7c70 | ||
|
|
73b8d09545 | ||
|
|
8e987bf535 | ||
|
|
6aa61dff9f | ||
|
|
590fd86f4c | ||
|
|
40f7db9c61 | ||
|
|
5cbac00b99 | ||
|
|
87baa13a04 | ||
|
|
d38f838dbc | ||
|
|
75d8c08103 | ||
|
|
9a402e468e |
+38
-9
@@ -6,19 +6,48 @@ on:
|
||||
branches: [main]
|
||||
jobs:
|
||||
vet-and-test:
|
||||
runs-on: nightly
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GOPROXY: https://goproxy.cn,direct
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
- run: go vet ./... && go test -count=1 ./...
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git init -q
|
||||
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||
git -c http.extraheader="AUTHORIZATION: token ${TOKEN}" fetch --depth=1 origin "${GITHUB_SHA}"
|
||||
git checkout -q --detach FETCH_HEAD
|
||||
if [ -z "${SUBMODULE_TOKEN}" ]; then
|
||||
echo "SUBMODULE_TOKEN is not configured" >&2
|
||||
exit 1
|
||||
fi
|
||||
git config --global url."https://weichen:${SUBMODULE_TOKEN}@git-home.u1680832.nyat.app:30319/".insteadOf "ssh://git@git-ssh.u1680832.nyat.app:31411/"
|
||||
git -c fetch.recurseSubmodules=false submodule update --init --recursive
|
||||
- name: Install Go 1.27.1
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o "$RUNNER_TEMP/go.tgz" https://mirrors.aliyun.com/golang/go1.27.1.linux-amd64.tar.gz
|
||||
tar -C /usr/local -xzf "$RUNNER_TEMP/go.tgz"
|
||||
ln -sfn /usr/local/go/bin/go /usr/local/bin/go
|
||||
ln -sfn /usr/local/go/bin/gofmt /usr/local/bin/gofmt
|
||||
- name: Vet, test, and race
|
||||
run: GOWORK=off go vet ./... && GOWORK=off go test -count=1 ./... && GOWORK=off go test -count=1 -race ./...
|
||||
docker-checkout-hostgateway:
|
||||
runs-on: ubuntu-latest
|
||||
options: --add-host gitea:host-gateway
|
||||
env:
|
||||
GITHUB_SERVER_URL: http://gitea:3000
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
- run: test -f go.mod && test -f Agent-Native/go.mod && echo "docker-mode checkout via host-gateway OK"
|
||||
- name: Checkout without GitHub actions
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git init -q
|
||||
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||
git -c http.extraheader="AUTHORIZATION: token ${TOKEN}" fetch --depth=1 origin "${GITHUB_SHA}"
|
||||
git checkout -q --detach FETCH_HEAD
|
||||
test -f go.mod && echo "docker-mode checkout via host-gateway OK"
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
agent-squad
|
||||
/agent-squad
|
||||
agent-squad-data/
|
||||
*.test
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
|
||||
`go.mod` 的 `replace` 都指向 `./deps/`。嵌套在 `deps/agent-native/deps/`、`deps/agent-intent/deps/` 里的同名副本不参与本仓编译,提交必须与本仓 gitlink 一致(`scripts/submodule-consistency.sh`)。
|
||||
|
||||
同一 run 的临界区在 unix 上额外持有 `runs/<id>/.lock` 的 flock,拿不到锁时返回 `ErrRunBusy`。非 unix 平台没有这把文件锁,只保留进程内互斥。
|
||||
|
||||
克隆:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -6,22 +6,22 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"agent-native/exec"
|
||||
"agent-squad/squad"
|
||||
)
|
||||
|
||||
// Catalog 把识别快照渲染为 squad 的能力目录数据视图(规划者提示词 + 计划校验用)。
|
||||
// 闸门未启用(nil Gate)时返回 nil。
|
||||
func (g *Gate) Catalog() *squad.IntentCatalog {
|
||||
if g == nil || g.snap == nil {
|
||||
snap := g.Snapshot()
|
||||
if g == nil || snap == nil {
|
||||
return nil
|
||||
}
|
||||
catalog := &squad.IntentCatalog{
|
||||
Revision: g.snap.Revision(),
|
||||
Markdown: g.snap.CatalogMarkdown(),
|
||||
Specs: make(map[string]squad.IntentSpecView, len(g.snap.SpecIDs())),
|
||||
Revision: snap.Revision(),
|
||||
Markdown: snap.CatalogMarkdown(),
|
||||
Specs: make(map[string]squad.IntentSpecView, len(snap.SpecIDs())),
|
||||
}
|
||||
for _, cs := range g.snap.Catalog() {
|
||||
for _, cs := range snap.Catalog() {
|
||||
view := squad.IntentSpecView{
|
||||
ID: cs.ID,
|
||||
Description: cs.Description,
|
||||
@@ -66,11 +66,11 @@ func (g *Gate) EchoRunner() *Runner {
|
||||
}
|
||||
|
||||
// Run 实现 squad.IntentRunner。未知意图/未知 handler fail-closed(不静默降级)。
|
||||
func (r *Runner) Run(ctx context.Context, step squad.IntentStep) (*exec.Result, error) {
|
||||
if r == nil || r.gate == nil || r.gate.snap == nil {
|
||||
func (r *Runner) Run(ctx context.Context, step squad.IntentStep) (*squad.ExecResult, error) {
|
||||
if r == nil || r.gate == nil || r.gate.Snapshot() == nil {
|
||||
return nil, fmt.Errorf("intent runner: gate not configured")
|
||||
}
|
||||
spec, ok := r.gate.snap.SpecByID(step.IntentID)
|
||||
spec, ok := r.gate.Snapshot().SpecByID(step.IntentID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("intent runner: snapshot 中不存在意图 %s", step.IntentID)
|
||||
}
|
||||
@@ -90,12 +90,12 @@ func (r *Runner) Run(ctx context.Context, step squad.IntentStep) (*exec.Result,
|
||||
return textResult(step, text), nil
|
||||
}
|
||||
|
||||
func textResult(step squad.IntentStep, text string) *exec.Result {
|
||||
func textResult(step squad.IntentStep, text string) *squad.ExecResult {
|
||||
patches := make(map[string][]byte, len(step.Targets))
|
||||
for _, t := range step.Targets {
|
||||
patches[t] = []byte(text)
|
||||
}
|
||||
return &exec.Result{Success: true, Patches: patches, Logs: "intent:" + step.IntentID}
|
||||
return &squad.ExecResult{Success: true, Patches: patches, Logs: "intent:" + step.IntentID}
|
||||
}
|
||||
|
||||
func echoText(step squad.IntentStep, handlerKey string, _ bool) string {
|
||||
@@ -120,3 +120,25 @@ func TestNilGateCatalogAndRunner(t *testing.T) {
|
||||
t.Fatal("nil gate runner must fail-closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestR2GateReloadSeesNewIntent(t *testing.T) {
|
||||
fsys := fstest.MapFS{"intents.json": &fstest.MapFile{Data: []byte(`{"intents":[{"id":"demo.old","domain":"demo","version":1,"matcher":{"includes_any":["旧"]}}]}`)}}
|
||||
g, err := New(context.Background(), Options{
|
||||
IntentOptions: intent.Options{},
|
||||
FS: fsys,
|
||||
Roots: []string{"."},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := g.Snapshot().SpecByID("demo.new"); ok {
|
||||
t.Fatal("new spec exists before reload")
|
||||
}
|
||||
fsys["intents.json"].Data = []byte(`{"intents":[{"id":"demo.old","domain":"demo","version":1,"matcher":{"includes_any":["旧"]}},{"id":"demo.new","domain":"demo","version":1,"matcher":{"includes_any":["新"]}}]}`)
|
||||
if _, err := g.Reload(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := g.Snapshot().SpecByID("demo.new"); !ok {
|
||||
t.Fatal("reload did not publish demo.new")
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,21 @@ type Options struct {
|
||||
Roots []string
|
||||
}
|
||||
|
||||
// NewFromOverlay 从 JSON overlay 构造闸门,并把 guard 域标成受保护域。
|
||||
// 命令行用它,这样命令本身不必 import agent-intent。
|
||||
// NFKC 开启:全角/兼容等价输入与词条同形折叠(与 backend intentgate 一致)。
|
||||
func NewFromOverlay(ctx context.Context, fsys fs.FS, roots ...string) (*Gate, error) {
|
||||
return New(ctx, Options{
|
||||
IntentOptions: intent.Options{
|
||||
GuardDomains: []string{"guard"},
|
||||
ProtectedDomains: []string{"guard"},
|
||||
NFKC: true,
|
||||
},
|
||||
FS: fsys,
|
||||
Roots: roots,
|
||||
})
|
||||
}
|
||||
|
||||
// New 构造闸门。没有任何意图来源时返回 (nil, nil)——引擎拿到 nil IntentGate,
|
||||
// 行为与原实现一致(T0 行为解耦)。
|
||||
func New(ctx context.Context, o Options) (*Gate, error) {
|
||||
@@ -55,32 +70,40 @@ func New(ctx context.Context, o Options) (*Gate, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Gate{rec: intent.NewRecognizer(o.IntentOptions, nil, nil), snap: reg.Snapshot()}, nil
|
||||
return &Gate{rec: intent.NewRecognizer(o.IntentOptions, nil, nil), reg: reg}, nil
|
||||
}
|
||||
|
||||
// Gate 实现 squad.IntentGate。
|
||||
// Gate 实现 squad.IntentGate。快照从 Registry 现读,Reload 后下一次 Decide 即生效。
|
||||
type Gate struct {
|
||||
rec intent.Recognizer
|
||||
snap *intent.Snapshot
|
||||
rec intent.Recognizer
|
||||
reg *intent.Registry
|
||||
}
|
||||
|
||||
// Snapshot 暴露当前识别快照(诊断/审计用;Reload 由宿主经重建闸门实现)。
|
||||
// Snapshot 暴露当前识别快照(诊断/审计用)。
|
||||
func (g *Gate) Snapshot() *intent.Snapshot {
|
||||
if g == nil {
|
||||
if g == nil || g.reg == nil {
|
||||
return nil
|
||||
}
|
||||
return g.snap
|
||||
return g.reg.Snapshot()
|
||||
}
|
||||
|
||||
// Reload 重读配置来源。失败时保留旧快照。
|
||||
func (g *Gate) Reload(ctx context.Context) (*intent.Snapshot, error) {
|
||||
if g == nil || g.reg == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return g.reg.Reload(ctx)
|
||||
}
|
||||
|
||||
// Decide 实现 squad.IntentGate。同输入 + 同快照结果确定;无副作用。
|
||||
func (g *Gate) Decide(ctx context.Context, goal squad.Goal, opts squad.RunOptions) (*squad.IntentDecision, error) {
|
||||
if g == nil || g.snap == nil {
|
||||
if g == nil || g.Snapshot() == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if opts.TaskKey != "" {
|
||||
return nil, nil // 显式 TaskKey 优先(引擎侧同样保证,双保险)
|
||||
}
|
||||
res, err := g.rec.Recognize(ctx, goal.Text, g.snap)
|
||||
res, err := g.rec.Recognize(ctx, goal.Text, g.Snapshot())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
module agent-squad/adapters/intent
|
||||
|
||||
go 1.27
|
||||
|
||||
require (
|
||||
agent-intent v0.0.0
|
||||
agent-squad v0.0.0
|
||||
)
|
||||
|
||||
require (
|
||||
error-code-system v0.0.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
logging-system v0.0.0 // indirect
|
||||
)
|
||||
|
||||
replace agent-intent => ../../deps/agent-intent
|
||||
|
||||
replace agent-squad => ../..
|
||||
|
||||
replace error-code-system => ../../deps/error-code-system
|
||||
|
||||
replace logging-system => ../../deps/logging-system
|
||||
@@ -0,0 +1,2 @@
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-native/exec"
|
||||
"agent-squad/squad"
|
||||
)
|
||||
|
||||
@@ -16,7 +15,7 @@ type integrationScriptBackend struct{ content string }
|
||||
|
||||
func (b *integrationScriptBackend) Name() string { return "script" }
|
||||
|
||||
func (b *integrationScriptBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
|
||||
func (b *integrationScriptBackend) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
|
||||
patches := map[string][]byte{}
|
||||
for _, t := range req.Targets {
|
||||
out := filepath.Join(req.WorkDir, t)
|
||||
@@ -26,7 +25,7 @@ func (b *integrationScriptBackend) Execute(ctx context.Context, req exec.Request
|
||||
}
|
||||
patches[t] = []byte(b.content)
|
||||
}
|
||||
return &exec.Result{Success: true, Patches: patches, Logs: "script"}, nil
|
||||
return &squad.ExecResult{Success: true, Patches: patches, Logs: "script"}, nil
|
||||
}
|
||||
|
||||
const intentReport = "# 竞品分析报告\n\n## 概述\n\n由意图定向的蓝图模板生成的交付物,长度满足最小质检要求。\n\n## 结论\n\n符合预期。\n"
|
||||
@@ -0,0 +1,21 @@
|
||||
module agent-squad/adapters/native
|
||||
|
||||
go 1.27
|
||||
|
||||
require (
|
||||
agent-native v0.0.0
|
||||
agent-squad v0.0.0
|
||||
)
|
||||
|
||||
require (
|
||||
error-code-system v0.0.0 // indirect
|
||||
logging-system v0.0.0 // indirect
|
||||
)
|
||||
|
||||
replace agent-native => ../../deps/agent-native
|
||||
|
||||
replace agent-squad => ../..
|
||||
|
||||
replace error-code-system => ../../deps/error-code-system
|
||||
|
||||
replace logging-system => ../../deps/logging-system
|
||||
@@ -0,0 +1,205 @@
|
||||
// Package native 把 agent-native 接成小队的 Executor 与 ModelClient。
|
||||
package native
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
nativexec "agent-native/adapters/native"
|
||||
"agent-native/exec"
|
||||
"agent-native/runtime"
|
||||
"agent-native/runtime/checkpoint"
|
||||
"agent-native/runtime/hooks"
|
||||
"agent-native/runtime/llm"
|
||||
"agent-native/runtime/mcp"
|
||||
"agent-native/runtime/subagent"
|
||||
"agent-native/runtime/tools"
|
||||
"agent-squad/squad"
|
||||
)
|
||||
|
||||
// Config 是组合根交给 agent-native 后端的装配。零值只做模型执行。
|
||||
type Config struct {
|
||||
Client *llm.Client
|
||||
DefaultModel string
|
||||
MaxTurns int
|
||||
Timeout time.Duration
|
||||
Search tools.SearchProvider
|
||||
Exec *tools.ExecConfig
|
||||
Fetch *tools.FetchConfig
|
||||
MCP *mcp.Manager
|
||||
SkillsRoots []string
|
||||
Subagent subagent.Factory
|
||||
Compact *nativexec.CompactConfig
|
||||
HostTools []tools.Tool
|
||||
OnEvent func(ev squad.ProcessEvent)
|
||||
Stream llm.StreamFunc
|
||||
MaxToolCalls int
|
||||
BeforeTool func(call llm.ToolCall) error
|
||||
AfterTool func(call llm.ToolCall, res llm.Message)
|
||||
Stop func(last llm.Message, info runtime.StopInfo) runtime.StopDecision
|
||||
Reminders func(turn int) []string
|
||||
RepeatGuard bool
|
||||
Hooks *hooks.Bus
|
||||
Checkpoints *checkpoint.Log
|
||||
CheckpointAnchor func() string
|
||||
Trace func(name string, attrs map[string]string) func()
|
||||
CodeOrchestration bool
|
||||
Ask func(ctx context.Context, question string) (string, error)
|
||||
// PermissionAsk 把内核钩子的 ask 决策转交宿主裁决(nil = 内核保持
|
||||
// ask 按拒绝处理的旧行为)。经 nativexec.GateOptions 透传。
|
||||
PermissionAsk func(ctx context.Context, call llm.ToolCall) runtime.AskVerdict
|
||||
}
|
||||
|
||||
// Register 把 "agent-native" 工厂注册进解析器。
|
||||
func Register(r *squad.BackendResolver, client *llm.Client, defaultModel string, maxTurns int, timeout time.Duration) {
|
||||
RegisterFull(r, Config{Client: client, DefaultModel: defaultModel, MaxTurns: maxTurns, Timeout: timeout})
|
||||
}
|
||||
|
||||
// RegisterFull 注册带工具装配的 "agent-native" 工厂。
|
||||
func RegisterFull(r *squad.BackendResolver, cfg Config) {
|
||||
r.Register("agent-native", func(role squad.RoleSpec) (squad.Executor, error) {
|
||||
opts, err := buildOptions(cfg, role)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
next := &bridge{next: nativexec.New(opts)}
|
||||
if cfg.OnEvent == nil {
|
||||
return next, nil
|
||||
}
|
||||
return &eventTap{next: next, on: cfg.OnEvent}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func buildOptions(cfg Config, role squad.RoleSpec) (nativexec.Options, error) {
|
||||
model := role.Model
|
||||
if model == "" {
|
||||
model = cfg.DefaultModel
|
||||
}
|
||||
if model == "" {
|
||||
return nativexec.Options{}, fmt.Errorf("agent-native backend requires a model (role=%s)", role.Role)
|
||||
}
|
||||
opts := nativexec.Options{
|
||||
ModelOptions: nativexec.ModelOptions{
|
||||
Model: model, MaxTurns: cfg.MaxTurns, Timeout: cfg.Timeout, Stream: cfg.Stream,
|
||||
},
|
||||
ToolOptions: nativexec.ToolOptions{
|
||||
Search: cfg.Search, Fetch: cfg.Fetch, MCP: cfg.MCP,
|
||||
SkillsRoots: cfg.SkillsRoots, Subagent: cfg.Subagent, HostTools: cfg.HostTools,
|
||||
Checkpoints: cfg.Checkpoints, CheckpointAnchor: cfg.CheckpointAnchor,
|
||||
CodeOrchestration: cfg.CodeOrchestration,
|
||||
},
|
||||
ObserveOptions: nativexec.ObserveOptions{Trace: cfg.Trace},
|
||||
SessionOptions: nativexec.SessionOptions{Compact: cfg.Compact, MaxToolCalls: cfg.MaxToolCalls},
|
||||
GateOptions: nativexec.GateOptions{
|
||||
BeforeTool: cfg.BeforeTool, AfterTool: cfg.AfterTool, Stop: cfg.Stop,
|
||||
Reminders: cfg.Reminders, RepeatGuard: cfg.RepeatGuard, Hooks: cfg.Hooks,
|
||||
Ask: cfg.Ask, PermissionAsk: cfg.PermissionAsk,
|
||||
},
|
||||
}
|
||||
if cfg.Client != nil {
|
||||
opts.APIBase, opts.APIKey = cfg.Client.BaseURL, cfg.Client.APIKey
|
||||
}
|
||||
if cfg.Exec != nil && (role.BashPermission == squad.BashPermissionAllow || role.BashPermission == squad.BashPermissionAsk) {
|
||||
opts.Exec = cfg.Exec
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
type bridge struct{ next exec.Backend }
|
||||
|
||||
func (b *bridge) Name() string { return b.next.Name() }
|
||||
|
||||
func (b *bridge) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
|
||||
res, err := b.next.Execute(ctx, toNative(req))
|
||||
if res == nil {
|
||||
return nil, err
|
||||
}
|
||||
return fromNative(res), err
|
||||
}
|
||||
|
||||
func toNative(req squad.ExecRequest) exec.Request {
|
||||
out := exec.Request{
|
||||
WorkDir: req.WorkDir, Prompt: req.Prompt, Instructions: req.Instructions,
|
||||
Targets: req.Targets, Timeout: req.Timeout, ToolProfile: req.ToolProfile,
|
||||
Model: req.Model, ToolAllowlist: req.ToolAllowlist, Skills: req.Skills,
|
||||
SessionScope: req.SessionScope, ExecutionID: req.ExecutionID, Env: req.Env,
|
||||
}
|
||||
if req.Budget != nil {
|
||||
out.Budget = &exec.Budget{
|
||||
MaxPromptTokens: req.Budget.MaxPromptTokens, MaxCompletionTokens: req.Budget.MaxCompletionTokens,
|
||||
MaxToolCalls: req.Budget.MaxToolCalls, Deadline: req.Budget.Deadline,
|
||||
}
|
||||
}
|
||||
if req.OnEvent != nil {
|
||||
prev := req.OnEvent
|
||||
out.OnEvent = func(ev exec.ProcessEvent) {
|
||||
prev(squad.ProcessEvent{
|
||||
Type: ev.Type, Text: ev.Text, Tool: ev.Tool, Detail: ev.Detail,
|
||||
PromptTokens: ev.PromptTokens, CompletionTokens: ev.CompletionTokens,
|
||||
LastPromptTokens: ev.LastPromptTokens, LastCompletionTokens: ev.LastCompletionTokens,
|
||||
CacheReadTokens: ev.CacheReadTokens, CacheWriteTokens: ev.CacheWriteTokens,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func fromNative(res *exec.Result) *squad.ExecResult {
|
||||
return &squad.ExecResult{
|
||||
Success: res.Success, Patches: res.Patches, Logs: res.Logs, ErrorReason: res.ErrorReason,
|
||||
Retryable: res.Retryable, PromptTokens: res.PromptTokens, CompletionTokens: res.CompletionTokens,
|
||||
}
|
||||
}
|
||||
|
||||
type eventTap struct {
|
||||
next squad.Executor
|
||||
on func(squad.ProcessEvent)
|
||||
}
|
||||
|
||||
func (b *eventTap) Name() string { return b.next.Name() }
|
||||
|
||||
func (b *eventTap) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
|
||||
prev := req.OnEvent
|
||||
req.OnEvent = func(ev squad.ProcessEvent) {
|
||||
b.on(ev)
|
||||
if prev != nil {
|
||||
prev(ev)
|
||||
}
|
||||
}
|
||||
return b.next.Execute(ctx, req)
|
||||
}
|
||||
|
||||
// LLM 把 llm.Client 适配成 ModelClient。
|
||||
type LLM struct{ Inner *llm.Client }
|
||||
|
||||
func (c LLM) Complete(ctx context.Context, turn squad.ModelTurn) (string, error) {
|
||||
if c.Inner == nil {
|
||||
return "", fmt.Errorf("llm planner: no model client")
|
||||
}
|
||||
msgs := []llm.Message{
|
||||
{Role: llm.RoleSystem, Content: turn.System},
|
||||
{Role: llm.RoleUser, Content: turn.User},
|
||||
}
|
||||
opts := llm.StreamOptions{MaxTokens: turn.MaxTokens, APIBase: turn.APIBase, APIKey: turn.APIKey}
|
||||
var sb strings.Builder
|
||||
for ev := range c.Inner.Stream(ctx, turn.Model, msgs, nil, opts) {
|
||||
if ev.Kind == llm.KindError {
|
||||
return "", fmt.Errorf("%s", ev.ErrorMessage)
|
||||
}
|
||||
if ev.Kind == llm.KindTextDelta {
|
||||
sb.WriteString(ev.Text)
|
||||
}
|
||||
if ev.Kind == llm.KindMessageEnd {
|
||||
if ev.Message.IsError {
|
||||
return "", fmt.Errorf("assistant error: %s", ev.Message.Content)
|
||||
}
|
||||
if sb.Len() == 0 {
|
||||
sb.WriteString(ev.Message.Content)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package native
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-native/runtime/llm"
|
||||
"agent-native/runtime/tools"
|
||||
"agent-squad/squad"
|
||||
)
|
||||
|
||||
func TestBuildOptionsBashPermissionGating(t *testing.T) {
|
||||
base := Config{
|
||||
Client: &llm.Client{BaseURL: "http://x", APIKey: "k"},
|
||||
DefaultModel: "m1", MaxTurns: 5, Timeout: time.Minute,
|
||||
Exec: &tools.ExecConfig{Bash: "/bin/bash"},
|
||||
SkillsRoots: []string{"/skills"},
|
||||
}
|
||||
deny, err := buildOptions(base, squad.RoleSpec{Role: "r", BashPermission: squad.BashPermissionDeny})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if deny.Exec != nil {
|
||||
t.Fatal("deny role must not get exec tools even when config provides them")
|
||||
}
|
||||
ask, _ := buildOptions(base, squad.RoleSpec{Role: "r", BashPermission: squad.BashPermissionAsk})
|
||||
if ask.Exec == nil {
|
||||
t.Fatal("ask role gets exec tools")
|
||||
}
|
||||
allow, _ := buildOptions(base, squad.RoleSpec{Role: "r", BashPermission: squad.BashPermissionAllow})
|
||||
if allow.Exec == nil {
|
||||
t.Fatal("allow role gets exec tools")
|
||||
}
|
||||
empty, _ := buildOptions(base, squad.RoleSpec{Role: "r"})
|
||||
if empty.Exec != nil {
|
||||
t.Fatal("unset bash_permission defaults to deny")
|
||||
}
|
||||
none, _ := buildOptions(Config{Client: base.Client, DefaultModel: "m"}, squad.RoleSpec{Role: "r", BashPermission: squad.BashPermissionAllow})
|
||||
if none.Exec != nil {
|
||||
t.Fatal("no Exec config = no exec tools")
|
||||
}
|
||||
roleModel, _ := buildOptions(base, squad.RoleSpec{Role: "r", Model: "big"})
|
||||
if roleModel.Model != "big" {
|
||||
t.Fatalf("role model must win, got %s", roleModel.Model)
|
||||
}
|
||||
if _, err := buildOptions(Config{Client: base.Client}, squad.RoleSpec{Role: "r"}); err == nil {
|
||||
t.Fatal("missing model must fail-closed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Autopilot is a runbook fired by cron, webhook, or API.
|
||||
type Autopilot struct {
|
||||
ID, WorkspaceID, Title, Runbook, AssigneeKind, AssigneeID, Status, ExecutionMode string
|
||||
}
|
||||
|
||||
func (s *Store) CreateAutopilot(a Autopilot) (Autopilot, error) {
|
||||
if a.ID == "" {
|
||||
a.ID = newID()
|
||||
}
|
||||
if a.Status == "" {
|
||||
a.Status = "active"
|
||||
}
|
||||
if a.ExecutionMode != "create_issue" && a.ExecutionMode != "run_only" {
|
||||
return Autopilot{}, fmt.Errorf("execution mode must be create_issue or run_only")
|
||||
}
|
||||
_, err := s.db.Exec(`INSERT INTO autopilot(id,workspace_id,title,runbook,assignee_kind,assignee_id,status,execution_mode,created_at) VALUES(?,?,?,?,?,?,?,?,?)`,
|
||||
a.ID, a.WorkspaceID, a.Title, a.Runbook, a.AssigneeKind, a.AssigneeID, a.Status, a.ExecutionMode, now())
|
||||
return a, err
|
||||
}
|
||||
|
||||
// AddSchedule stores a five-field cron expression and an IANA timezone.
|
||||
func (s *Store) AddSchedule(autopilotID, cronExpr, timezone string) error {
|
||||
if _, err := time.LoadLocation(timezone); err != nil {
|
||||
return fmt.Errorf("timezone: %w", err)
|
||||
}
|
||||
if len(strings.Fields(cronExpr)) != 5 {
|
||||
return fmt.Errorf("cron must have 5 fields")
|
||||
}
|
||||
_, err := s.db.Exec(`INSERT INTO trigger_(id,autopilot_id,kind,cron,timezone) VALUES(?,?,?,?,?)`,
|
||||
newID(), autopilotID, "schedule", cronExpr, timezone)
|
||||
return err
|
||||
}
|
||||
|
||||
// AddWebhook returns the token clients post to.
|
||||
func (s *Store) AddWebhook(autopilotID, secret string) (string, error) {
|
||||
token := newID()
|
||||
_, err := s.db.Exec(`INSERT INTO trigger_(id,autopilot_id,kind,token,secret) VALUES(?,?,?,?,?)`,
|
||||
newID(), autopilotID, "webhook", token, secret)
|
||||
return token, err
|
||||
}
|
||||
|
||||
// FireSchedules runs every active schedule that matches at.
|
||||
func (s *Store) FireSchedules(at time.Time) (int, error) {
|
||||
rows, err := s.db.Query(`SELECT autopilot.id, trigger_.cron, trigger_.timezone FROM autopilot JOIN trigger_ ON trigger_.autopilot_id=autopilot.id WHERE autopilot.status='active' AND trigger_.kind='schedule'`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
type due struct{ id, cron, tz string }
|
||||
var list []due
|
||||
for rows.Next() {
|
||||
var item due
|
||||
if err := rows.Scan(&item.id, &item.cron, &item.tz); err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
list = append(list, item)
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n := 0
|
||||
for _, item := range list {
|
||||
if !cronDue(item.cron, item.tz, at) {
|
||||
continue
|
||||
}
|
||||
if err := s.fire(item.id, "schedule"); err != nil {
|
||||
return n, err
|
||||
}
|
||||
n++
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// AcceptWebhook validates, dedupes, and fires. Body must be a JSON object or array up to 256KiB.
|
||||
func (s *Store) AcceptWebhook(token string, body []byte, headers map[string]string) (string, error) {
|
||||
if len(body) > 256*1024 {
|
||||
return "", fmt.Errorf("webhook body exceeds 256KiB")
|
||||
}
|
||||
var probe any
|
||||
if err := json.Unmarshal(body, &probe); err != nil {
|
||||
return "", fmt.Errorf("webhook body must be JSON")
|
||||
}
|
||||
switch probe.(type) {
|
||||
case map[string]any, []any:
|
||||
default:
|
||||
return "", fmt.Errorf("webhook body must be a JSON object or array")
|
||||
}
|
||||
var autopilotID, secret string
|
||||
err := s.db.QueryRow(`SELECT autopilot_id, IFNULL(secret,'') FROM trigger_ WHERE kind='webhook' AND token=?`, token).Scan(&autopilotID, &secret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if secret != "" {
|
||||
sig := headers["X-Hub-Signature-256"]
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write(body)
|
||||
want := "sha256=" + hex.EncodeToString(mac.Sum(nil))
|
||||
if !hmac.Equal([]byte(sig), []byte(want)) {
|
||||
return "", fmt.Errorf("webhook signature mismatch")
|
||||
}
|
||||
}
|
||||
key := headers["Idempotency-Key"]
|
||||
if key == "" {
|
||||
key = headers["X-GitHub-Delivery"]
|
||||
}
|
||||
if key != "" {
|
||||
if _, err := s.db.Exec(`INSERT INTO webhook_delivery(id,autopilot_id,dedupe_key,created_at) VALUES(?,?,?,?)`, newID(), autopilotID, key, now()); err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE") {
|
||||
return "duplicate", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if err := s.fire(autopilotID, eventName(headers, probe)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return eventName(headers, probe), nil
|
||||
}
|
||||
|
||||
func eventName(headers map[string]string, probe any) string {
|
||||
if v := headers["X-GitHub-Event"]; v != "" {
|
||||
return v
|
||||
}
|
||||
if obj, ok := probe.(map[string]any); ok {
|
||||
if v, ok := obj["action"].(string); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
if v, ok := obj["event"].(string); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return "webhook"
|
||||
}
|
||||
|
||||
func (s *Store) fire(autopilotID, detail string) error {
|
||||
var a Autopilot
|
||||
err := s.db.QueryRow(`SELECT id,workspace_id,title,runbook,assignee_kind,assignee_id,status,execution_mode FROM autopilot WHERE id=?`, autopilotID).
|
||||
Scan(&a.ID, &a.WorkspaceID, &a.Title, &a.Runbook, &a.AssigneeKind, &a.AssigneeID, &a.Status, &a.ExecutionMode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if a.Status != "active" {
|
||||
return nil
|
||||
}
|
||||
status := "completed"
|
||||
if a.ExecutionMode == "create_issue" {
|
||||
issue, err := s.CreateIssue(Issue{WorkspaceID: a.WorkspaceID, Title: a.Title, Body: a.Runbook, Status: "todo", AssigneeKind: a.AssigneeKind, AssigneeID: a.AssigneeID})
|
||||
if err != nil {
|
||||
status = "failed"
|
||||
detail = err.Error()
|
||||
} else if err := s.AssignIssue(issue.ID, a.AssigneeKind, a.AssigneeID, true); err != nil {
|
||||
status = "failed"
|
||||
detail = err.Error()
|
||||
}
|
||||
} else if a.AssigneeKind == "agent" {
|
||||
issue := Issue{WorkspaceID: a.WorkspaceID}
|
||||
t, err := s.enqueue(issue, a.AssigneeID, false, "")
|
||||
if err != nil {
|
||||
status = "failed"
|
||||
} else {
|
||||
_, _ = s.db.Exec(`UPDATE task SET issue_id='' WHERE id=?`, t.ID)
|
||||
}
|
||||
}
|
||||
if _, err := s.db.Exec(`INSERT INTO autopilot_run(id,autopilot_id,status,detail,created_at) VALUES(?,?,?,?,?)`, newID(), autopilotID, status, detail, now()); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.maybePause(autopilotID)
|
||||
}
|
||||
|
||||
func (s *Store) maybePause(autopilotID string) error {
|
||||
since := time.Now().UTC().Add(-7 * 24 * time.Hour).Format(time.RFC3339Nano)
|
||||
var total, failed int
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM autopilot_run WHERE autopilot_id=? AND created_at>=?`, autopilotID, since).Scan(&total)
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM autopilot_run WHERE autopilot_id=? AND created_at>=? AND status='failed'`, autopilotID, since).Scan(&failed)
|
||||
if total >= 50 && failed*10 >= total*9 {
|
||||
_, err := s.db.Exec(`UPDATE autopilot SET status='paused' WHERE id=?`, autopilotID)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cronDue(expr, timezone string, at time.Time) bool {
|
||||
loc, err := time.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
at = at.In(loc)
|
||||
fields := strings.Fields(expr)
|
||||
if len(fields) != 5 {
|
||||
return false
|
||||
}
|
||||
values := []int{at.Minute(), at.Hour(), at.Day(), int(at.Month()), int(at.Weekday())}
|
||||
for i, field := range fields {
|
||||
if !cronField(field, values[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func cronField(field string, value int) bool {
|
||||
if field == "*" {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(field, "*/") {
|
||||
var step int
|
||||
if _, err := fmt.Sscan(field[2:], &step); err != nil || step <= 0 {
|
||||
return false
|
||||
}
|
||||
return value%step == 0
|
||||
}
|
||||
var n int
|
||||
if _, err := fmt.Sscan(field, &n); err != nil {
|
||||
return false
|
||||
}
|
||||
return n == value
|
||||
}
|
||||
|
||||
// ChatSession is private to its owner and may point at a project.
|
||||
type ChatSession struct {
|
||||
ID, WorkspaceID, OwnerID, ProjectID string
|
||||
}
|
||||
|
||||
func (s *Store) CreateChat(c ChatSession) (ChatSession, error) {
|
||||
if c.ID == "" {
|
||||
c.ID = newID()
|
||||
}
|
||||
_, err := s.db.Exec(`INSERT INTO chat_session(id,workspace_id,owner_id,project_id,created_at) VALUES(?,?,?,?,?)`,
|
||||
c.ID, c.WorkspaceID, c.OwnerID, c.ProjectID, now())
|
||||
return c, err
|
||||
}
|
||||
|
||||
func (s *Store) AddChatMessage(sessionID, role, body string) error {
|
||||
_, err := s.db.Exec(`INSERT INTO chat_message(id,session_id,role,body,created_at) VALUES(?,?,?,?,?)`, newID(), sessionID, role, body, now())
|
||||
return err
|
||||
}
|
||||
|
||||
// Project groups resources.
|
||||
type Project struct {
|
||||
ID, WorkspaceID, Name string
|
||||
}
|
||||
|
||||
func (s *Store) CreateProject(p Project) (Project, error) {
|
||||
if p.ID == "" {
|
||||
p.ID = newID()
|
||||
}
|
||||
_, err := s.db.Exec(`INSERT INTO project(id,workspace_id,name,created_at) VALUES(?,?,?,?)`, p.ID, p.WorkspaceID, p.Name, now())
|
||||
return p, err
|
||||
}
|
||||
|
||||
// AddResource records a github_repo or local_directory. local_directory mode is in_place or worktree.
|
||||
func (s *Store) AddResource(projectID, kind, locator, mode string) error {
|
||||
switch kind {
|
||||
case "github_repo":
|
||||
mode = ""
|
||||
case "local_directory":
|
||||
if mode != "in_place" && mode != "worktree" {
|
||||
return fmt.Errorf("local_directory mode must be in_place or worktree")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unknown resource kind %s", kind)
|
||||
}
|
||||
_, err := s.db.Exec(`INSERT INTO project_resource(id,project_id,kind,locator,mode) VALUES(?,?,?,?,?)`, newID(), projectID, kind, locator, mode)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// Store is the orchestration database. The goal engine keeps its file workspace.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func Open(path string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := db.Exec(`PRAGMA foreign_keys = ON`); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
s := &Store{db: db}
|
||||
if err := s.migrate(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
_, err := s.db.Exec(schema)
|
||||
return err
|
||||
}
|
||||
|
||||
func newID() string {
|
||||
var b [16]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
func now() string { return time.Now().UTC().Format(time.RFC3339Nano) }
|
||||
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS workspace (
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL, created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS member (
|
||||
id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL, created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS runtime (
|
||||
id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, name TEXT NOT NULL,
|
||||
command TEXT NOT NULL, protocol TEXT NOT NULL, max_concurrency INTEGER NOT NULL,
|
||||
last_heartbeat TEXT, created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS agent (
|
||||
id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, name TEXT NOT NULL,
|
||||
runtime_id TEXT, custom_env TEXT, custom_args TEXT, mcp_config TEXT,
|
||||
max_concurrency INTEGER NOT NULL, archived INTEGER NOT NULL, created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS squad (
|
||||
id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, name TEXT NOT NULL,
|
||||
leader_agent_id TEXT NOT NULL, instructions TEXT NOT NULL, created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS squad_member (
|
||||
squad_id TEXT NOT NULL, member_kind TEXT NOT NULL, member_id TEXT NOT NULL,
|
||||
role_description TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS issue (
|
||||
id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, title TEXT NOT NULL,
|
||||
body TEXT NOT NULL, status TEXT NOT NULL, parent_id TEXT, stage TEXT,
|
||||
assignee_kind TEXT, assignee_id TEXT, created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS comment (
|
||||
id TEXT PRIMARY KEY, issue_id TEXT NOT NULL, author_kind TEXT NOT NULL,
|
||||
author_id TEXT NOT NULL, body TEXT NOT NULL, parent_id TEXT, created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS task (
|
||||
id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, issue_id TEXT, agent_id TEXT NOT NULL,
|
||||
runtime_id TEXT, status TEXT NOT NULL, reason_code TEXT, attempts INTEGER NOT NULL,
|
||||
lease_until TEXT, fire_at TEXT, is_leader INTEGER NOT NULL, squad_id TEXT,
|
||||
token TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS task_message (
|
||||
id TEXT PRIMARY KEY, task_id TEXT NOT NULL, kind TEXT NOT NULL, body TEXT NOT NULL, created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS skill (
|
||||
id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, name TEXT NOT NULL,
|
||||
description TEXT NOT NULL, source TEXT NOT NULL, created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS skill_file (
|
||||
id TEXT PRIMARY KEY, skill_id TEXT NOT NULL, path TEXT NOT NULL, body TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS agent_skill (
|
||||
agent_id TEXT NOT NULL, skill_id TEXT NOT NULL, enabled INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS autopilot (
|
||||
id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, title TEXT NOT NULL,
|
||||
runbook TEXT NOT NULL, assignee_kind TEXT NOT NULL, assignee_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL, execution_mode TEXT NOT NULL, created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS trigger_ (
|
||||
id TEXT PRIMARY KEY, autopilot_id TEXT NOT NULL, kind TEXT NOT NULL,
|
||||
cron TEXT, timezone TEXT, token TEXT, secret TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS autopilot_run (
|
||||
id TEXT PRIMARY KEY, autopilot_id TEXT NOT NULL, status TEXT NOT NULL,
|
||||
detail TEXT, created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS webhook_delivery (
|
||||
id TEXT PRIMARY KEY, autopilot_id TEXT NOT NULL, dedupe_key TEXT NOT NULL, created_at TEXT NOT NULL,
|
||||
UNIQUE(autopilot_id, dedupe_key)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS chat_session (
|
||||
id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, owner_id TEXT NOT NULL,
|
||||
project_id TEXT, created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS chat_message (
|
||||
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL, body TEXT NOT NULL, created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS project (
|
||||
id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS project_resource (
|
||||
id TEXT PRIMARY KEY, project_id TEXT NOT NULL, kind TEXT NOT NULL,
|
||||
locator TEXT NOT NULL, mode TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS wakeup (
|
||||
id TEXT PRIMARY KEY, task_id TEXT NOT NULL, kind TEXT NOT NULL, fire_at TEXT, created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS usage_ (
|
||||
id TEXT PRIMARY KEY, task_id TEXT NOT NULL, model TEXT NOT NULL,
|
||||
input_tokens INTEGER NOT NULL, output_tokens INTEGER NOT NULL,
|
||||
cache_read INTEGER NOT NULL, cache_write INTEGER NOT NULL, created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS activity (
|
||||
id TEXT PRIMARY KEY, squad_id TEXT NOT NULL, issue_id TEXT NOT NULL,
|
||||
actor_id TEXT NOT NULL, action TEXT NOT NULL, detail TEXT NOT NULL, created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS agent_skill_pk ON agent_skill(agent_id, skill_id);
|
||||
`
|
||||
|
||||
// Workspace is a tenant boundary.
|
||||
type Workspace struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
func (s *Store) CreateWorkspace(name string) (Workspace, error) {
|
||||
w := Workspace{ID: newID(), Name: name}
|
||||
_, err := s.db.Exec(`INSERT INTO workspace(id,name,created_at) VALUES(?,?,?)`, w.ID, w.Name, now())
|
||||
return w, err
|
||||
}
|
||||
|
||||
// Member is a human identity. Agents live in their own table.
|
||||
type Member struct {
|
||||
ID, WorkspaceID, Name, Kind string
|
||||
}
|
||||
|
||||
func (s *Store) CreateMember(workspaceID, name string) (Member, error) {
|
||||
m := Member{ID: newID(), WorkspaceID: workspaceID, Name: name, Kind: "human"}
|
||||
_, err := s.db.Exec(`INSERT INTO member(id,workspace_id,name,kind,created_at) VALUES(?,?,?,?,?)`,
|
||||
m.ID, m.WorkspaceID, m.Name, m.Kind, now())
|
||||
return m, err
|
||||
}
|
||||
|
||||
// Agent is a durable executor identity.
|
||||
type Agent struct {
|
||||
ID, WorkspaceID, Name, RuntimeID, CustomEnv, CustomArgs, MCPConfig string
|
||||
MaxConcurrency int
|
||||
Archived bool
|
||||
}
|
||||
|
||||
func (s *Store) CreateAgent(a Agent) (Agent, error) {
|
||||
if a.ID == "" {
|
||||
a.ID = newID()
|
||||
}
|
||||
if a.MaxConcurrency <= 0 {
|
||||
a.MaxConcurrency = 1
|
||||
}
|
||||
_, err := s.db.Exec(`INSERT INTO agent(id,workspace_id,name,runtime_id,custom_env,custom_args,mcp_config,max_concurrency,archived,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,0,?)`,
|
||||
a.ID, a.WorkspaceID, a.Name, a.RuntimeID, a.CustomEnv, a.CustomArgs, a.MCPConfig, a.MaxConcurrency, now())
|
||||
return a, err
|
||||
}
|
||||
|
||||
func (s *Store) GetAgent(id string) (Agent, error) {
|
||||
var a Agent
|
||||
var archived int
|
||||
err := s.db.QueryRow(`SELECT id,workspace_id,name,IFNULL(runtime_id,''),IFNULL(custom_env,''),IFNULL(custom_args,''),IFNULL(mcp_config,''),max_concurrency,archived FROM agent WHERE id=?`, id).
|
||||
Scan(&a.ID, &a.WorkspaceID, &a.Name, &a.RuntimeID, &a.CustomEnv, &a.CustomArgs, &a.MCPConfig, &a.MaxConcurrency, &archived)
|
||||
a.Archived = archived == 1
|
||||
return a, err
|
||||
}
|
||||
|
||||
// ArchiveAgent cancels unfinished tasks for that agent.
|
||||
func (s *Store) ArchiveAgent(id string) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec(`UPDATE agent SET archived=1 WHERE id=?`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`UPDATE task SET status='cancelled', reason_code='archived', updated_at=? WHERE agent_id=? AND status IN ('deferred','queued','dispatched','waiting_local_directory','running')`, now(), id); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// CopyAgent copies identity only. Env, MCP, and runtime config are not copied.
|
||||
func (s *Store) CopyAgent(id string) (Agent, error) {
|
||||
src, err := s.GetAgent(id)
|
||||
if err != nil {
|
||||
return Agent{}, err
|
||||
}
|
||||
return s.CreateAgent(Agent{
|
||||
WorkspaceID: src.WorkspaceID, Name: src.Name + " copy", MaxConcurrency: src.MaxConcurrency,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) UpdateAgent(a Agent) error {
|
||||
_, err := s.db.Exec(`UPDATE agent SET name=?, runtime_id=?, custom_env=?, custom_args=?, mcp_config=?, max_concurrency=? WHERE id=?`,
|
||||
a.Name, a.RuntimeID, a.CustomEnv, a.CustomArgs, a.MCPConfig, a.MaxConcurrency, a.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
// Runtime is one registered executor, including the in-process agent-native runtime.
|
||||
type Runtime struct {
|
||||
ID, WorkspaceID, Name, Command, Protocol string
|
||||
MaxConcurrency int
|
||||
LastHeartbeat string
|
||||
}
|
||||
|
||||
func (s *Store) RegisterRuntime(rt Runtime) (Runtime, error) {
|
||||
if rt.ID == "" {
|
||||
rt.ID = newID()
|
||||
}
|
||||
if rt.MaxConcurrency <= 0 {
|
||||
rt.MaxConcurrency = 1
|
||||
}
|
||||
if rt.Protocol == "" {
|
||||
rt.Protocol = "custom"
|
||||
}
|
||||
_, err := s.db.Exec(`INSERT INTO runtime(id,workspace_id,name,command,protocol,max_concurrency,last_heartbeat,created_at) VALUES(?,?,?,?,?,?,?,?)`,
|
||||
rt.ID, rt.WorkspaceID, rt.Name, rt.Command, rt.Protocol, rt.MaxConcurrency, now(), now())
|
||||
return rt, err
|
||||
}
|
||||
|
||||
func (s *Store) Heartbeat(runtimeID string) error {
|
||||
_, err := s.db.Exec(`UPDATE runtime SET last_heartbeat=? WHERE id=?`, now(), runtimeID)
|
||||
return err
|
||||
}
|
||||
|
||||
func scanStrings(rows *sql.Rows) ([]string, error) {
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var v string
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func placeholder(n int) string {
|
||||
parts := make([]string, n)
|
||||
for i := range parts {
|
||||
parts[i] = "?"
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func fmtErr(msg string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s: %w", msg, err)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
module agent-squad/adapters/sqlite
|
||||
|
||||
go 1.27
|
||||
|
||||
require (
|
||||
agent-native v0.0.0
|
||||
agent-squad v0.0.0
|
||||
modernc.org/sqlite v1.37.0
|
||||
)
|
||||
|
||||
require (
|
||||
error-code-system v0.0.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
logging-system v0.0.0 // indirect
|
||||
modernc.org/libc v1.62.1 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.9.1 // indirect
|
||||
)
|
||||
|
||||
replace agent-native => ../../deps/agent-native
|
||||
|
||||
replace agent-squad => ../..
|
||||
|
||||
replace error-code-system => ../../deps/error-code-system
|
||||
|
||||
replace logging-system => ../../deps/logging-system
|
||||
@@ -0,0 +1,47 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
|
||||
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
|
||||
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
|
||||
modernc.org/cc/v4 v4.25.2 h1:T2oH7sZdGvTaie0BRNFbIYsabzCxUQg8nLqCdQ2i0ic=
|
||||
modernc.org/cc/v4 v4.25.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.25.1 h1:TFSzPrAGmDsdnhT9X2UrcPMI3N/mJ9/X9ykKXwLhDsU=
|
||||
modernc.org/ccgo/v4 v4.25.1/go.mod h1:njjuAYiPflywOOrm3B7kCB444ONP5pAVr8PIEoE0uDw=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/libc v1.62.1 h1:s0+fv5E3FymN8eJVmnk0llBe6rOxCu/DEU+XygRbS8s=
|
||||
modernc.org/libc v1.62.1/go.mod h1:iXhATfJQLjG3NWy56a6WVU73lWOcdYVxsvwCgoPljuo=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g=
|
||||
modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
|
||||
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,120 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func nowTime() time.Time { return time.Now() }
|
||||
|
||||
// Mount attaches the orchestration routes. The goal-engine /v1/runs routes stay separate.
|
||||
func Mount(mux *http.ServeMux, st *Store) {
|
||||
mux.HandleFunc("POST /v1/workspaces", func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct{ Name string }
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ws, err := st.CreateWorkspace(body.Name)
|
||||
write(w, ws, err)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/agents", func(w http.ResponseWriter, r *http.Request) {
|
||||
var a Agent
|
||||
if err := json.NewDecoder(r.Body).Decode(&a); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
created, err := st.CreateAgent(a)
|
||||
write(w, created, err)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/squads", func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Squad
|
||||
Members []SquadMember
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
created, err := st.CreateSquad(body.Squad, body.Members)
|
||||
write(w, created, err)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/issues", func(w http.ResponseWriter, r *http.Request) {
|
||||
var issue Issue
|
||||
if err := json.NewDecoder(r.Body).Decode(&issue); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
created, err := st.CreateIssue(issue)
|
||||
write(w, created, err)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/issues/{id}/assign", func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Kind string
|
||||
ID string
|
||||
Start bool
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
err := st.AssignIssue(r.PathValue("id"), body.Kind, body.ID, body.Start)
|
||||
write(w, map[string]bool{"ok": err == nil}, err)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/issues/{id}/comments", func(w http.ResponseWriter, r *http.Request) {
|
||||
var c Comment
|
||||
if err := json.NewDecoder(r.Body).Decode(&c); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
c.IssueID = r.PathValue("id")
|
||||
created, err := st.AddComment(c)
|
||||
write(w, created, err)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/tasks/claim", func(w http.ResponseWriter, r *http.Request) {
|
||||
task, ok, err := st.Claim(r.URL.Query().Get("runtime"), nowTime())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
write(w, map[string]any{"task": task, "ok": ok}, nil)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/webhooks/autopilots/{token}", func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 256*1024+1))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
headers := map[string]string{}
|
||||
for _, key := range []string{"Idempotency-Key", "X-GitHub-Delivery", "X-Hub-Signature-256", "X-GitHub-Event"} {
|
||||
if v := r.Header.Get(key); v != "" {
|
||||
headers[key] = v
|
||||
}
|
||||
}
|
||||
event, err := st.AcceptWebhook(r.PathValue("token"), body, headers)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
write(w, map[string]string{"event": event}, nil)
|
||||
})
|
||||
mux.HandleFunc("GET /v1/events/ws", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Upgrade") == "" {
|
||||
http.Error(w, "upgrade required", http.StatusUpgradeRequired)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte("{\"type\":\"ready\"}\n"))
|
||||
})
|
||||
}
|
||||
|
||||
func write(w http.ResponseWriter, v any, err error) {
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package platform
|
||||
|
||||
// 未核实项按保守语义实现,不假装和 multica 的内部表一致:
|
||||
//
|
||||
// - 权限只有 private 与 public_to 两个档,public_to 用一张允许列表,没有基于角色的工作区 ACL。
|
||||
// - 委派深度上限是 4。上游没有写成文的跳数。
|
||||
// - Access 的三层存储没有照搬。本库是编排状态;目标引擎仍用原来的文件工作区。
|
||||
// - 外部 CLI、扩展和包都以调用者权限运行,不是沙箱。
|
||||
// - 看板、Git 托管关联和 cloud runtime 不在这个库里。
|
||||
const ConservativeNotes = "private|public_to allow-list; delegation depth 4; file workspace kept for the goal engine"
|
||||
@@ -0,0 +1,258 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentsquad "agent-squad/squad"
|
||||
)
|
||||
|
||||
func TestOrchestrationFlow(t *testing.T) {
|
||||
s := openTest(t)
|
||||
ws, err := s.CreateWorkspace("lab")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
leader, err := s.CreateAgent(Agent{WorkspaceID: ws.ID, Name: "leader", MaxConcurrency: 2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
member, err := s.CreateAgent(Agent{WorkspaceID: ws.ID, Name: "writer", MaxConcurrency: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
human, err := s.CreateMember(ws.ID, "ada")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
copied, err := s.CopyAgent(leader.ID)
|
||||
if err != nil || copied.CustomEnv != "" || copied.RuntimeID != "" {
|
||||
t.Fatalf("copy leaked config: %+v %v", copied, err)
|
||||
}
|
||||
squad, err := s.CreateSquad(Squad{
|
||||
WorkspaceID: ws.ID, Name: "docs", LeaderAgentID: leader.ID, Instructions: "ship the note",
|
||||
}, []SquadMember{{Kind: "agent", ID: member.ID, RoleDescription: "writes"}, {Kind: "human", ID: human.ID, RoleDescription: "reviews"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
brief, err := s.LeaderBrief(squad.ID)
|
||||
if err != nil || !strings.Contains(brief, "Squad Operating Protocol") || !strings.Contains(brief, "mention://agent/") || !strings.Contains(brief, "ship the note") {
|
||||
t.Fatalf("brief: %s %v", brief, err)
|
||||
}
|
||||
issue, err := s.CreateIssue(Issue{WorkspaceID: ws.ID, Title: "write the note", Body: "three facts", Status: "todo"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.AssignIssue(issue.ID, "squad", squad.ID, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok, err := claimAny(t, s, ws.ID); err != nil || ok {
|
||||
t.Fatal("start=false must not enqueue")
|
||||
}
|
||||
if err := s.AssignIssue(issue.ID, "squad", squad.ID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rts, err := s.RegisterProbed(ws.ID)
|
||||
if err != nil || rts[0].Protocol != "agent-native" {
|
||||
t.Fatalf("runtimes %+v %v", rts, err)
|
||||
}
|
||||
leader.RuntimeID = rts[0].ID
|
||||
if err := s.UpdateAgent(leader); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
member.RuntimeID = rts[0].ID
|
||||
if err := s.UpdateAgent(member); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task, ok, err := s.Claim(rts[0].ID, time.Now())
|
||||
if err != nil || !ok || !task.IsLeader || task.AgentID != leader.ID {
|
||||
t.Fatalf("claim %+v %v %v", task, ok, err)
|
||||
}
|
||||
if err := s.TouchStatus(issue.ID, leader.ID, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.RecordActivity(squad.ID, issue.ID, leader.ID, "action", "delegate"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stages, err := s.PlanStages(issue.ID, []string{"draft"})
|
||||
if err != nil || len(stages) != 1 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.DelegateComment(issue.ID, leader.ID, member.ID, "please draft"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
memberTask, ok, err := s.Claim(rts[0].ID, time.Now())
|
||||
if err != nil || !ok || memberTask.AgentID != member.ID {
|
||||
t.Fatalf("member claim %+v %v %v", memberTask, ok, err)
|
||||
}
|
||||
if err := s.AgentCLI(memberTask.Token, []string{"comment", "draft ready"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.FinishTask(task.ID, "completed", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.CloseIssue(stages[0].ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
woke, ok, err := s.Claim(rts[0].ID, time.Now())
|
||||
if err != nil || !ok || woke.AgentID != leader.ID {
|
||||
t.Fatalf("child_done wake %+v %v %v", woke, ok, err)
|
||||
}
|
||||
if err := s.TouchStatus(issue.ID, leader.ID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := s.GetIssue(issue.ID)
|
||||
if err != nil || got.Status != "in_review" {
|
||||
t.Fatalf("status %+v %v", got, err)
|
||||
}
|
||||
work, err := s.PrepareWorkdir(t.TempDir(), task)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text, err := s.RunInProcess(context.Background(), woke, work, stubExecutor{result: &agentsquad.ExecResult{
|
||||
Success: true, Logs: "done",
|
||||
Patches: map[string][]byte{"output.md": []byte("done")},
|
||||
}})
|
||||
if err != nil || text != "done" {
|
||||
t.Fatalf("in-process %q %v", text, err)
|
||||
}
|
||||
|
||||
script := filepath.Join(t.TempDir(), "fake-cli")
|
||||
if err := os.WriteFile(script, []byte("#!/bin/sh\necho ok\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fakeTask, err := s.enqueue(issue, leader.ID, false, squad.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.RunCommand(context.Background(), fakeTask, script, work); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
finished, err := s.getTask(fakeTask.ID)
|
||||
if err != nil || finished.Status != "completed" {
|
||||
t.Fatalf("fake cli %+v %v", finished, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutopilotSkillAndQueueEdges(t *testing.T) {
|
||||
s := openTest(t)
|
||||
ws, _ := s.CreateWorkspace("lab")
|
||||
agent, _ := s.CreateAgent(Agent{WorkspaceID: ws.ID, Name: "bot", MaxConcurrency: 1})
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "review"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "review", "SKILL.md"), []byte("description: careful\nbody"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sk, err := s.ImportSkill(ws.ID, filepath.Join(dir, "review"), "fail")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.ImportSkill(ws.ID, filepath.Join(dir, "review"), "fail"); err == nil {
|
||||
t.Fatal("conflict fail")
|
||||
}
|
||||
if _, err := s.ImportSkill(ws.ID, filepath.Join(dir, "review"), "skip"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.BindSkill(agent.ID, sk.ID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ap, err := s.CreateAutopilot(Autopilot{
|
||||
WorkspaceID: ws.ID, Title: "daily", Runbook: "check", AssigneeKind: "agent", AssigneeID: agent.ID, ExecutionMode: "create_issue",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.AddSchedule(ap.ID, "* * * * *", "UTC"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n, err := s.FireSchedules(time.Now())
|
||||
if err != nil || n != 1 {
|
||||
t.Fatalf("cron %d %v", n, err)
|
||||
}
|
||||
token, err := s.AddWebhook(ap.ID, "secret")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := []byte(`{"event":"push"}`)
|
||||
mac := sign(body, "secret")
|
||||
if _, err := s.AcceptWebhook(token, body, map[string]string{"X-Hub-Signature-256": mac, "Idempotency-Key": "k1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := s.AcceptWebhook(token, body, map[string]string{"X-Hub-Signature-256": mac, "Idempotency-Key": "k1"})
|
||||
if err != nil || got != "duplicate" {
|
||||
t.Fatalf("dedupe %q %v", got, err)
|
||||
}
|
||||
issue, _ := s.CreateIssue(Issue{WorkspaceID: ws.ID, Title: "later", Status: "todo"})
|
||||
if _, err := s.DeferTask(issue, agent.ID, time.Now().Add(-time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
moved, err := s.PromoteDeferred(time.Now())
|
||||
if err != nil || moved < 1 {
|
||||
t.Fatalf("promote %d %v", moved, err)
|
||||
}
|
||||
if _, err := s.AddComment(Comment{IssueID: issue.ID, AuthorKind: "human", AuthorID: "h", Body: "/note secret"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chat, err := s.CreateChat(ChatSession{WorkspaceID: ws.ID, OwnerID: "h"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.AddChatMessage(chat.ID, "user", "hi"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
proj, err := s.CreateProject(Project{WorkspaceID: ws.ID, Name: "repo"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.AddResource(proj.ID, "local_directory", "/tmp/src", "worktree"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.ArchiveAgent(agent.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func openTest(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s, err := Open(filepath.Join(t.TempDir(), "orch.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
func TestR2JournalModeWAL(t *testing.T) {
|
||||
s := openTest(t)
|
||||
var mode string
|
||||
if err := s.db.QueryRow(`PRAGMA journal_mode`).Scan(&mode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mode != "wal" {
|
||||
t.Fatalf("journal_mode %q", mode)
|
||||
}
|
||||
}
|
||||
|
||||
func claimAny(t *testing.T, s *Store, workspaceID string) (Task, bool, error) {
|
||||
t.Helper()
|
||||
rt, err := s.RegisterRuntime(Runtime{WorkspaceID: workspaceID, Name: "tmp", Command: "in-process", Protocol: "agent-native"})
|
||||
if err != nil {
|
||||
return Task{}, false, err
|
||||
}
|
||||
return s.Claim(rt.ID, time.Now())
|
||||
}
|
||||
|
||||
func sign(body []byte, secret string) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write(body)
|
||||
return "sha256=" + hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Issue is the durable work record. A task is one execution of it.
|
||||
type Issue struct {
|
||||
ID, WorkspaceID, Title, Body, Status, ParentID, Stage, AssigneeKind, AssigneeID string
|
||||
}
|
||||
|
||||
func (s *Store) CreateIssue(issue Issue) (Issue, error) {
|
||||
if issue.ID == "" {
|
||||
issue.ID = newID()
|
||||
}
|
||||
if issue.Status == "" {
|
||||
issue.Status = "todo"
|
||||
}
|
||||
_, err := s.db.Exec(`INSERT INTO issue(id,workspace_id,title,body,status,parent_id,stage,assignee_kind,assignee_id,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)`,
|
||||
issue.ID, issue.WorkspaceID, issue.Title, issue.Body, issue.Status, issue.ParentID, issue.Stage, issue.AssigneeKind, issue.AssigneeID, now())
|
||||
return issue, err
|
||||
}
|
||||
|
||||
func (s *Store) GetIssue(id string) (Issue, error) {
|
||||
var issue Issue
|
||||
err := s.db.QueryRow(`SELECT id,workspace_id,title,body,status,IFNULL(parent_id,''),IFNULL(stage,''),IFNULL(assignee_kind,''),IFNULL(assignee_id,'') FROM issue WHERE id=?`, id).
|
||||
Scan(&issue.ID, &issue.WorkspaceID, &issue.Title, &issue.Body, &issue.Status, &issue.ParentID, &issue.Stage, &issue.AssigneeKind, &issue.AssigneeID)
|
||||
return issue, err
|
||||
}
|
||||
|
||||
// AssignIssue records the owner. Backlog, or start=false, does not enqueue.
|
||||
// A squad assignee enqueues only the leader.
|
||||
func (s *Store) AssignIssue(issueID, kind, id string, start bool) error {
|
||||
issue, err := s.GetIssue(issueID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.Exec(`UPDATE issue SET assignee_kind=?, assignee_id=? WHERE id=?`, kind, id, issueID); err != nil {
|
||||
return err
|
||||
}
|
||||
issue.AssigneeKind, issue.AssigneeID = kind, id
|
||||
if !start || issue.Status == "backlog" {
|
||||
return nil
|
||||
}
|
||||
return s.enqueueAssignee(issue)
|
||||
}
|
||||
|
||||
func (s *Store) enqueueAssignee(issue Issue) error {
|
||||
switch issue.AssigneeKind {
|
||||
case "agent":
|
||||
_, err := s.enqueue(issue, issue.AssigneeID, false, "")
|
||||
return err
|
||||
case "squad":
|
||||
var leader, squadID string
|
||||
if err := s.db.QueryRow(`SELECT id, leader_agent_id FROM squad WHERE id=?`, issue.AssigneeID).Scan(&squadID, &leader); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.enqueue(issue, leader, true, squadID)
|
||||
return err
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Comment is one note on an issue.
|
||||
type Comment struct {
|
||||
ID, IssueID, AuthorKind, AuthorID, Body, ParentID string
|
||||
}
|
||||
|
||||
var mentionRE = regexp.MustCompile(`mention://(agent|member)/([0-9a-fA-F]+)`)
|
||||
|
||||
// AddComment stores the comment and routes mentions. /note and a bare @all do not enqueue.
|
||||
func (s *Store) AddComment(c Comment) (Comment, error) {
|
||||
if c.ID == "" {
|
||||
c.ID = newID()
|
||||
}
|
||||
if _, err := s.db.Exec(`INSERT INTO comment(id,issue_id,author_kind,author_id,body,parent_id,created_at) VALUES(?,?,?,?,?,?,?)`,
|
||||
c.ID, c.IssueID, c.AuthorKind, c.AuthorID, c.Body, c.ParentID, now()); err != nil {
|
||||
return Comment{}, err
|
||||
}
|
||||
if err := s.routeComment(c); err != nil {
|
||||
return Comment{}, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (s *Store) routeComment(c Comment) error {
|
||||
trimmed := strings.TrimSpace(c.Body)
|
||||
if strings.HasPrefix(trimmed, "/note") {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(trimmed, "@all") && !mentionRE.MatchString(c.Body) {
|
||||
return nil
|
||||
}
|
||||
issue, err := s.GetIssue(c.IssueID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if issue.Status == "done" || issue.Status == "cancelled" || issue.Status == "backlog" {
|
||||
return nil
|
||||
}
|
||||
matches := mentionRE.FindAllStringSubmatch(c.Body, -1)
|
||||
for _, m := range matches {
|
||||
if m[1] != "agent" {
|
||||
continue
|
||||
}
|
||||
if _, err := s.enqueue(issue, m[2], false, issueSquad(issue)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if c.ParentID != "" && len(matches) == 0 {
|
||||
var kind, author string
|
||||
err := s.db.QueryRow(`SELECT author_kind, author_id FROM comment WHERE id=?`, c.ParentID).Scan(&kind, &author)
|
||||
if err == nil && kind == "agent" {
|
||||
_, err = s.enqueue(issue, author, false, issueSquad(issue))
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func issueSquad(issue Issue) string {
|
||||
if issue.AssigneeKind == "squad" {
|
||||
return issue.AssigneeID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Task is one execution attempt.
|
||||
type Task struct {
|
||||
ID, WorkspaceID, IssueID, AgentID, RuntimeID, Status, ReasonCode, LeaseUntil, FireAt, SquadID, Token string
|
||||
Attempts int
|
||||
IsLeader bool
|
||||
}
|
||||
|
||||
func (s *Store) enqueue(issue Issue, agentID string, leader bool, squadID string) (Task, error) {
|
||||
var existing string
|
||||
err := s.db.QueryRow(`SELECT id FROM task WHERE issue_id=? AND agent_id=? AND status IN ('queued','dispatched')`, issue.ID, agentID).Scan(&existing)
|
||||
if err == nil && existing != "" {
|
||||
return s.getTask(existing)
|
||||
}
|
||||
leaderFlag := 0
|
||||
if leader {
|
||||
leaderFlag = 1
|
||||
}
|
||||
t := Task{
|
||||
ID: newID(), WorkspaceID: issue.WorkspaceID, IssueID: issue.ID, AgentID: agentID,
|
||||
Status: "queued", SquadID: squadID, Token: newID(), IsLeader: leader,
|
||||
}
|
||||
_, err = s.db.Exec(`INSERT INTO task(id,workspace_id,issue_id,agent_id,runtime_id,status,reason_code,attempts,lease_until,fire_at,is_leader,squad_id,token,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,0,?,?,?,?,?,?,?)`,
|
||||
t.ID, t.WorkspaceID, t.IssueID, t.AgentID, "", t.Status, "", "", "", leaderFlag, t.SquadID, t.Token, now(), now())
|
||||
return t, err
|
||||
}
|
||||
|
||||
// DeferTask parks a task until fireAt.
|
||||
func (s *Store) DeferTask(issue Issue, agentID string, fireAt time.Time) (Task, error) {
|
||||
t := Task{ID: newID(), WorkspaceID: issue.WorkspaceID, IssueID: issue.ID, AgentID: agentID, Status: "deferred", Token: newID(), FireAt: fireAt.UTC().Format(time.RFC3339Nano)}
|
||||
_, err := s.db.Exec(`INSERT INTO task(id,workspace_id,issue_id,agent_id,runtime_id,status,reason_code,attempts,lease_until,fire_at,is_leader,squad_id,token,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
t.ID, t.WorkspaceID, t.IssueID, t.AgentID, "", "deferred", "", 0, "", t.FireAt, 0, "", t.Token, now(), now())
|
||||
if err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
_, err = s.db.Exec(`INSERT INTO wakeup(id,task_id,kind,fire_at,created_at) VALUES(?,?,?,?,?)`, newID(), t.ID, "deferred", t.FireAt, now())
|
||||
return t, err
|
||||
}
|
||||
|
||||
func (s *Store) getTask(id string) (Task, error) {
|
||||
var t Task
|
||||
var leader int
|
||||
err := s.db.QueryRow(`SELECT id,workspace_id,IFNULL(issue_id,''),agent_id,IFNULL(runtime_id,''),status,IFNULL(reason_code,''),attempts,IFNULL(lease_until,''),IFNULL(fire_at,''),is_leader,IFNULL(squad_id,''),token FROM task WHERE id=?`, id).
|
||||
Scan(&t.ID, &t.WorkspaceID, &t.IssueID, &t.AgentID, &t.RuntimeID, &t.Status, &t.ReasonCode, &t.Attempts, &t.LeaseUntil, &t.FireAt, &leader, &t.SquadID, &t.Token)
|
||||
t.IsLeader = leader == 1
|
||||
return t, err
|
||||
}
|
||||
|
||||
// PromoteDeferred moves due deferred tasks to queued.
|
||||
func (s *Store) PromoteDeferred(at time.Time) (int, error) {
|
||||
res, err := s.db.Exec(`UPDATE task SET status='queued', updated_at=? WHERE status='deferred' AND fire_at<=?`, now(), at.UTC().Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
// Claim takes one queued task whose agent and runtime are under their caps.
|
||||
// The task becomes dispatched with a five-minute lease.
|
||||
func (s *Store) Claim(runtimeID string, at time.Time) (Task, bool, error) {
|
||||
rt, err := s.getRuntime(runtimeID)
|
||||
if err != nil {
|
||||
return Task{}, false, err
|
||||
}
|
||||
rows, err := s.db.Query(`SELECT id, agent_id FROM task WHERE status='queued' AND workspace_id=? ORDER BY created_at`, rt.WorkspaceID)
|
||||
if err != nil {
|
||||
return Task{}, false, err
|
||||
}
|
||||
type candidate struct{ id, agentID string }
|
||||
var candidates []candidate
|
||||
for rows.Next() {
|
||||
var id, agentID string
|
||||
if err := rows.Scan(&id, &agentID); err != nil {
|
||||
rows.Close()
|
||||
return Task{}, false, err
|
||||
}
|
||||
candidates = append(candidates, candidate{id, agentID})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return Task{}, false, err
|
||||
}
|
||||
rows.Close()
|
||||
for _, cand := range candidates {
|
||||
agent, err := s.GetAgent(cand.agentID)
|
||||
if err != nil || agent.Archived {
|
||||
continue
|
||||
}
|
||||
if agent.RuntimeID != "" && agent.RuntimeID != runtimeID {
|
||||
continue
|
||||
}
|
||||
cap := agent.MaxConcurrency
|
||||
if rt.MaxConcurrency < cap {
|
||||
cap = rt.MaxConcurrency
|
||||
}
|
||||
var running int
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM task WHERE agent_id=? AND status IN ('dispatched','running')`, cand.agentID).Scan(&running)
|
||||
if running >= cap {
|
||||
continue
|
||||
}
|
||||
lease := at.UTC().Add(5 * time.Minute).Format(time.RFC3339Nano)
|
||||
res, err := s.db.Exec(`UPDATE task SET status='dispatched', runtime_id=?, lease_until=?, updated_at=?
|
||||
WHERE id=? AND status='queued'
|
||||
AND (SELECT COUNT(*) FROM task WHERE agent_id=? AND status IN ('dispatched','running')) < ?`,
|
||||
runtimeID, lease, now(), cand.id, cand.agentID, cap)
|
||||
if err != nil {
|
||||
return Task{}, false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 1 {
|
||||
t, err := s.getTask(cand.id)
|
||||
return t, true, err
|
||||
}
|
||||
}
|
||||
return Task{}, false, nil
|
||||
}
|
||||
|
||||
func (s *Store) getRuntime(id string) (Runtime, error) {
|
||||
var rt Runtime
|
||||
err := s.db.QueryRow(`SELECT id,workspace_id,name,command,protocol,max_concurrency,IFNULL(last_heartbeat,'') FROM runtime WHERE id=?`, id).
|
||||
Scan(&rt.ID, &rt.WorkspaceID, &rt.Name, &rt.Command, &rt.Protocol, &rt.MaxConcurrency, &rt.LastHeartbeat)
|
||||
return rt, err
|
||||
}
|
||||
|
||||
// MarkRunning starts the lease clock of an already claimed task.
|
||||
func (s *Store) MarkRunning(taskID string) error {
|
||||
_, err := s.db.Exec(`UPDATE task SET status='running', updated_at=? WHERE id=?`, now(), taskID)
|
||||
return err
|
||||
}
|
||||
|
||||
// TransientReason reports whether a failure may be retried.
|
||||
func TransientReason(code string) bool {
|
||||
switch code {
|
||||
case "runtime_offline", "daemon_restart", "timeout", "skill_bundle", "tool_network":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func retryLimit(code string) int {
|
||||
if code == "tool_network" {
|
||||
return 3
|
||||
}
|
||||
if TransientReason(code) {
|
||||
return 2
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// FinishTask closes a task. Transient failures requeue until the attempt cap.
|
||||
// Autopilot run_only tasks are not retried. A failed issue task rolls
|
||||
// in_progress back to todo when nothing else is still active.
|
||||
func (s *Store) FinishTask(taskID, status, reason string) error {
|
||||
t, err := s.getTask(taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status == "failed" && TransientReason(reason) && t.IssueID != "" && t.Attempts+1 < retryLimit(reason) {
|
||||
_, err = s.db.Exec(`UPDATE task SET status='queued', reason_code=?, attempts=attempts+1, runtime_id='', lease_until='', updated_at=? WHERE id=?`,
|
||||
reason, now(), taskID)
|
||||
return err
|
||||
}
|
||||
if status != "completed" && status != "failed" && status != "cancelled" {
|
||||
return fmt.Errorf("unsupported terminal status %s", status)
|
||||
}
|
||||
if _, err := s.db.Exec(`UPDATE task SET status=?, reason_code=?, updated_at=? WHERE id=?`, status, reason, now(), taskID); err != nil {
|
||||
return err
|
||||
}
|
||||
if status == "failed" && t.IssueID != "" {
|
||||
var active int
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM task WHERE issue_id=? AND status IN ('deferred','queued','dispatched','waiting_local_directory','running')`, t.IssueID).Scan(&active)
|
||||
if active == 0 {
|
||||
_, _ = s.db.Exec(`UPDATE issue SET status='todo' WHERE id=? AND status='in_progress'`, t.IssueID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecoverOrphans fails dispatched tasks whose lease expired and requeues
|
||||
// running tasks whose runtime heartbeat is older than three minutes.
|
||||
func (s *Store) RecoverOrphans(at time.Time) (int, error) {
|
||||
n := 0
|
||||
rows, err := s.db.Query(`SELECT id FROM task WHERE status='dispatched' AND lease_until < ?`, at.UTC().Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
ids, err := scanStrings(rows)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, id := range ids {
|
||||
if err := s.FinishTask(id, "failed", "timeout"); err != nil {
|
||||
return n, err
|
||||
}
|
||||
n++
|
||||
}
|
||||
cutoff := at.UTC().Add(-3 * time.Minute).Format(time.RFC3339Nano)
|
||||
rows, err = s.db.Query(`SELECT task.id FROM task JOIN runtime ON runtime.id=task.runtime_id WHERE task.status='running' AND runtime.last_heartbeat < ?`, cutoff)
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
ids, err = scanStrings(rows)
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
for _, id := range ids {
|
||||
if err := s.FinishTask(id, "failed", "runtime_offline"); err != nil {
|
||||
return n, err
|
||||
}
|
||||
n++
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// AddTaskMessage records a streamed progress line.
|
||||
func (s *Store) AddTaskMessage(taskID, kind, body string) error {
|
||||
_, err := s.db.Exec(`INSERT INTO task_message(id,task_id,kind,body,created_at) VALUES(?,?,?,?,?)`, newID(), taskID, kind, body, now())
|
||||
return err
|
||||
}
|
||||
|
||||
// AddUsage records token counts for one model.
|
||||
func (s *Store) AddUsage(taskID, model string, input, output, cacheRead, cacheWrite int) error {
|
||||
_, err := s.db.Exec(`INSERT INTO usage_(id,task_id,model,input_tokens,output_tokens,cache_read,cache_write,created_at) VALUES(?,?,?,?,?,?,?,?)`,
|
||||
newID(), taskID, model, input, output, cacheRead, cacheWrite, now())
|
||||
return err
|
||||
}
|
||||
|
||||
// TaskByToken resolves the agent-side credential.
|
||||
func (s *Store) TaskByToken(token string) (Task, error) {
|
||||
var id string
|
||||
err := s.db.QueryRow(`SELECT id FROM task WHERE token=?`, token).Scan(&id)
|
||||
if err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
return s.getTask(id)
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"agent-squad/squad"
|
||||
)
|
||||
|
||||
// SQLiteStore 把 squad 的 Store 端口落到本库(Q1 WP5):run/gate/checkpoint/leader
|
||||
// 与平台其余表同一个 SQLite 文件——运行状态与平台状态不再各记一份。
|
||||
// 表名带 squad_run_ 前缀,与平台既有 23 张表并存不冲突。
|
||||
type SQLiteStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// OpenRunStore 在本 Store 的底层连接上提供 squad.Store 端口(schema 随 migrate 建)。
|
||||
func (s *Store) RunStore() *SQLiteStore {
|
||||
return &SQLiteStore{db: s.db}
|
||||
}
|
||||
|
||||
const runSchema = `
|
||||
CREATE TABLE IF NOT EXISTS squad_run_state (
|
||||
ws_dir TEXT PRIMARY KEY,
|
||||
meta TEXT NOT NULL, workflow TEXT NOT NULL, squad TEXT NOT NULL, bb TEXT NOT NULL,
|
||||
template TEXT, approved TEXT, repair_budget_remaining INTEGER NOT NULL DEFAULT 0,
|
||||
repair_budget_granted INTEGER NOT NULL DEFAULT 0,
|
||||
clarifications TEXT, steering TEXT, usage TEXT, updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS squad_run_gate (
|
||||
ws_dir TEXT PRIMARY KEY, gate TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS squad_run_checkpoint (
|
||||
ws_dir TEXT NOT NULL, superstep INTEGER NOT NULL, at TEXT NOT NULL,
|
||||
graph_hash TEXT NOT NULL, steps TEXT NOT NULL,
|
||||
PRIMARY KEY (ws_dir, superstep)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS squad_run_leader (
|
||||
ws_dir TEXT PRIMARY KEY, data TEXT NOT NULL
|
||||
);
|
||||
`
|
||||
|
||||
func (s *SQLiteStore) migrate() error {
|
||||
_, err := s.db.Exec(runSchema)
|
||||
return err
|
||||
}
|
||||
|
||||
// OpenSQLiteStore 独立打开一个 squad.Store(不经 platform.Store 的场景,如测试)。
|
||||
func OpenSQLiteStore(path string) (*SQLiteStore, error) {
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &SQLiteStore{db: db}
|
||||
if err := s.migrate(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func j(v any) string {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) SaveRun(st *squad.RunState) error {
|
||||
if err := s.migrate(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.db.Exec(`INSERT INTO squad_run_state
|
||||
(ws_dir, meta, workflow, squad, bb, template, approved, repair_budget_remaining, repair_budget_granted, clarifications, steering, usage, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(ws_dir) DO UPDATE SET
|
||||
meta=excluded.meta, workflow=excluded.workflow, squad=excluded.squad, bb=excluded.bb,
|
||||
template=excluded.template, approved=excluded.approved,
|
||||
repair_budget_remaining=excluded.repair_budget_remaining,
|
||||
repair_budget_granted=excluded.repair_budget_granted,
|
||||
clarifications=excluded.clarifications, steering=excluded.steering,
|
||||
usage=excluded.usage, updated_at=excluded.updated_at`,
|
||||
st.Meta.WorkDir, j(st.Meta), j(st.Plan.Workflow), j(st.Plan.Squad), j(st.BB),
|
||||
st.Template, j(st.Approved), st.RepairBudgetRemaining, st.RepairBudgetGranted,
|
||||
j(st.Clarifications), j(st.Steering), j(st.Usage), now())
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) LoadRun(wsDir string) (*squad.RunState, error) {
|
||||
if err := s.migrate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st := &squad.RunState{}
|
||||
var meta, wf, sq, bb, template, approved, clar, steering, usage string
|
||||
row := s.db.QueryRow(`SELECT meta, workflow, squad, bb, template, approved,
|
||||
repair_budget_remaining, repair_budget_granted, clarifications, steering, usage
|
||||
FROM squad_run_state WHERE ws_dir = ?`, wsDir)
|
||||
var granted int
|
||||
err := row.Scan(&meta, &wf, &sq, &bb, &template, &approved,
|
||||
&st.RepairBudgetRemaining, &granted, &clar, &steering, &usage)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("load run %s: %w", wsDir, os.ErrNotExist)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
st.RepairBudgetGranted = granted
|
||||
for pair, dst := range map[string]any{
|
||||
meta: &st.Meta, wf: &st.Plan.Workflow, sq: &st.Plan.Squad, bb: &st.BB,
|
||||
approved: &st.Approved, clar: &st.Clarifications, steering: &st.Steering, usage: &st.Usage,
|
||||
} {
|
||||
if err := json.Unmarshal([]byte(pair), dst); err != nil {
|
||||
return nil, fmt.Errorf("load run %s: parse: %w", wsDir, err)
|
||||
}
|
||||
}
|
||||
st.Template = template
|
||||
if st.Approved == nil {
|
||||
st.Approved = map[string]bool{}
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) SaveGate(wsDir string, gate *squad.HitlGate) error {
|
||||
if err := s.migrate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if gate == nil {
|
||||
_, err := s.db.Exec(`DELETE FROM squad_run_gate WHERE ws_dir = ?`, wsDir)
|
||||
return err
|
||||
}
|
||||
_, err := s.db.Exec(`INSERT INTO squad_run_gate (ws_dir, gate) VALUES (?,?)
|
||||
ON CONFLICT(ws_dir) DO UPDATE SET gate=excluded.gate`, wsDir, j(gate))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) LoadGate(wsDir string) (*squad.HitlGate, error) {
|
||||
if err := s.migrate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw string
|
||||
err := s.db.QueryRow(`SELECT gate FROM squad_run_gate WHERE ws_dir = ?`, wsDir).Scan(&raw)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
gate := &squad.HitlGate{}
|
||||
if err := json.Unmarshal([]byte(raw), gate); err != nil {
|
||||
return nil, fmt.Errorf("parse gate: %w", err)
|
||||
}
|
||||
return gate, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) SaveCheckpoint(wsDir string, cp squad.Checkpoint) error {
|
||||
if err := s.migrate(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.db.Exec(`INSERT INTO squad_run_checkpoint (ws_dir, superstep, at, graph_hash, steps)
|
||||
VALUES (?,?,?,?,?)
|
||||
ON CONFLICT(ws_dir, superstep) DO UPDATE SET at=excluded.at, graph_hash=excluded.graph_hash, steps=excluded.steps`,
|
||||
wsDir, cp.Superstep, cp.At.UTC().Format("2006-01-02T15:04:05Z07:00"), cp.GraphHash, j(cp.StepStates))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) LoadLatestCheckpoint(wsDir string) (*squad.Checkpoint, error) {
|
||||
if err := s.migrate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cp squad.Checkpoint
|
||||
var at, steps string
|
||||
err := s.db.QueryRow(`SELECT superstep, at, graph_hash, steps FROM squad_run_checkpoint
|
||||
WHERE ws_dir = ? ORDER BY superstep DESC LIMIT 1`, wsDir).
|
||||
Scan(&cp.Superstep, &at, &cp.GraphHash, &steps)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
t, _ := parseTime(at)
|
||||
cp.At = t
|
||||
if err := json.Unmarshal([]byte(steps), &cp.StepStates); err != nil {
|
||||
return nil, fmt.Errorf("parse checkpoint steps: %w", err)
|
||||
}
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) SaveLeader(wsDir string, data []byte) error {
|
||||
if err := s.migrate(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.db.Exec(`INSERT INTO squad_run_leader (ws_dir, data) VALUES (?,?)
|
||||
ON CONFLICT(ws_dir) DO UPDATE SET data=excluded.data`, wsDir, string(data))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) LoadLeader(wsDir string) ([]byte, error) {
|
||||
if err := s.migrate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw string
|
||||
err := s.db.QueryRow(`SELECT data FROM squad_run_leader WHERE ws_dir = ?`, wsDir).Scan(&raw)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return []byte(raw), nil
|
||||
}
|
||||
|
||||
// Close 只在独立打开(OpenSQLiteStore)时调用;经 platform.Store 打开时由平台统一关闭。
|
||||
func (s *SQLiteStore) Close() error { return s.db.Close() }
|
||||
|
||||
var _ squad.Store = (*SQLiteStore)(nil)
|
||||
|
||||
// parseTime 解析检查点时间(RFC3339Nano 与 RFC3339 两态)。
|
||||
func parseTime(s string) (time.Time, error) {
|
||||
for _, layout := range []string{time.RFC3339Nano, time.RFC3339} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("bad time %q", s)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-squad/squad"
|
||||
)
|
||||
|
||||
func newTestRunState(wsDir string) *squad.RunState {
|
||||
return &squad.RunState{
|
||||
Meta: squad.RunMetadata{
|
||||
ID: "run_test", Status: "executing", WorkDir: wsDir,
|
||||
CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
||||
Metadata: map[string]string{"mode": ""},
|
||||
},
|
||||
Plan: squad.SquadPlan{
|
||||
Workflow: squad.WorkflowPlan{
|
||||
WorkflowID: "wf1", Status: "in_progress",
|
||||
Steps: []squad.StepSpec{
|
||||
{ID: "s1", Role: "researcher", Status: "completed", Attempts: 1, AttemptID: "run_test/s1/1"},
|
||||
{ID: "s2", Role: "writer", Status: "pending"},
|
||||
},
|
||||
},
|
||||
Squad: squad.SquadConfig{SquadName: "demo"},
|
||||
},
|
||||
BB: squad.BlackboardState{Facts: []squad.FactItem{{ID: "f1", Claim: "x"}}},
|
||||
Template: "tpl",
|
||||
Approved: map[string]bool{"step:s1": true},
|
||||
RepairBudgetRemaining: 1,
|
||||
RepairBudgetGranted: 0,
|
||||
Clarifications: []string{"c1"},
|
||||
Steering: []string{"st1"},
|
||||
Usage: map[string]*squad.Usage{"writer": {PromptTokens: 10, CompletionTokens: 5, Steps: 1}},
|
||||
}
|
||||
}
|
||||
|
||||
// TestSQLiteStoreRoundTrip:run/gate/checkpoint/leader 全链路(Q1 WP5 端口实现)。
|
||||
func TestSQLiteStoreRoundTrip(t *testing.T) {
|
||||
st, err := OpenSQLiteStore(filepath.Join(t.TempDir(), "runs.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
ws := filepath.Join(t.TempDir(), "runs", "run_test")
|
||||
orig := newTestRunState(ws)
|
||||
if err := st.SaveRun(orig); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := st.LoadRun(ws)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Meta.ID != "run_test" || got.Plan.Workflow.WorkflowID != "wf1" || got.Template != "tpl" {
|
||||
t.Fatalf("loaded = %+v", got.Meta)
|
||||
}
|
||||
if !got.Approved["step:s1"] || got.RepairBudgetRemaining != 1 || len(got.Clarifications) != 1 || len(got.Steering) != 1 {
|
||||
t.Fatalf("scalars = %+v", got)
|
||||
}
|
||||
if got.Usage["writer"].PromptTokens != 10 {
|
||||
t.Fatalf("usage = %+v", got.Usage)
|
||||
}
|
||||
if _, err := st.LoadRun(filepath.Join(t.TempDir(), "nope")); err == nil {
|
||||
t.Fatal("missing run should error")
|
||||
}
|
||||
|
||||
// gate:写→读→删。
|
||||
gate := &squad.HitlGate{PrevStatus: squad.RunExecuting}
|
||||
if err := st.SaveGate(ws, gate); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g2, err := st.LoadGate(ws); err != nil || g2 == nil {
|
||||
t.Fatalf("load gate: %v %+v", err, g2)
|
||||
}
|
||||
if err := st.SaveGate(ws, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g2, err := st.LoadGate(ws); err != nil || g2 != nil {
|
||||
t.Fatalf("gate after delete: %v %+v", err, g2)
|
||||
}
|
||||
|
||||
// checkpoint:两层,latest 取最大 superstep。
|
||||
if err := st.SaveCheckpoint(ws, squad.Checkpoint{Superstep: 1, At: time.Now(), GraphHash: "h1", StepStates: []squad.StepSnapshot{{ID: "s1", Status: "completed", AttemptID: "run_test/s1/1"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SaveCheckpoint(ws, squad.Checkpoint{Superstep: 2, At: time.Now(), GraphHash: "h2"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
latest, err := st.LoadLatestCheckpoint(ws)
|
||||
if err != nil || latest == nil {
|
||||
t.Fatalf("latest: %v", err)
|
||||
}
|
||||
if latest.Superstep != 2 || latest.GraphHash != "h2" {
|
||||
t.Fatalf("latest = %+v", latest)
|
||||
}
|
||||
if l2, err := st.LoadLatestCheckpoint(filepath.Join(t.TempDir(), "nope")); err != nil || l2 != nil {
|
||||
t.Fatalf("latest for unknown: %v %+v", err, l2)
|
||||
}
|
||||
|
||||
// leader bytes。
|
||||
if err := st.SaveLeader(ws, []byte(`{"turns":2}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := st.LoadLeader(ws)
|
||||
if err != nil || string(data) != `{"turns":2}` {
|
||||
t.Fatalf("leader = %q %v", data, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEngineRunsOnSQLiteStore:引擎注入 SQLiteStore 后运行状态进 SQLite、
|
||||
// 不再写 run 级 JSON 文件;第二引擎可从中恢复(验收第三条的实证形态)。
|
||||
func TestEngineRunsOnSQLiteStore(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "runs.db")
|
||||
runStore, err := OpenSQLiteStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer runStore.Close()
|
||||
dir := t.TempDir()
|
||||
reg := squad.NewRoleRegistry()
|
||||
for _, spec := range []squad.RoleSpec{{Role: "researcher", Backend: "script"}, {Role: "writer", Backend: "script"}, {Role: "reviewer", Backend: "script"}} {
|
||||
if err := reg.Register(spec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
backends := squad.NewBackendResolver()
|
||||
backends.RegisterInstance("script", stubExecutor{result: &squad.ExecResult{Success: false, Logs: "ok", Patches: map[string][]byte{"output.md": []byte(goodReportLite), "notes.md": []byte(goodReportLite), "fields.json": []byte("{}")}}})
|
||||
blueprints := squad.NewBlueprintRegistry()
|
||||
if err := blueprints.Register(squad.Blueprint{
|
||||
Key: "demo", Title: "入库演示", Match: []string{"入库演示"},
|
||||
Steps: []squad.StepSpec{
|
||||
{ID: "s1", Role: "researcher", Title: "调研", Targets: []string{"notes.md", "fields.json"}},
|
||||
{ID: "s2", Role: "writer", Title: "成文", Targets: []string{"output.md"}},
|
||||
{ID: "s3", Role: "reviewer", Title: "终审", Targets: []string{"output.md"}},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eng, err := squad.NewEngine(squad.Options{
|
||||
DataDir: dir, Registry: reg, Blueprints: blueprints,
|
||||
Planner: squad.NewRulePlanner(),
|
||||
Backends: backends, Sink: squad.NopSink{},
|
||||
Store: runStore,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := eng.Run(context.Background(), squad.Goal{Text: "做一个三步入库演示", HitlMode: squad.HitlAuto}, squad.RunOptions{})
|
||||
if err != nil || res.Status != squad.RunCompleted {
|
||||
t.Fatalf("run: %v %+v", err, res)
|
||||
}
|
||||
// run 级 JSON 不在盘上(状态全进了 SQLite)。
|
||||
for _, f := range []string{"workspace.json", "workflow.json", "squad.json", "blackboard.json", "runstate.json"} {
|
||||
if _, err := os.Stat(filepath.Join(res.WorkDir, f)); !os.IsNotExist(err) {
|
||||
t.Fatalf("%s should not be on disk with SQLite store (err=%v)", f, err)
|
||||
}
|
||||
}
|
||||
// 状态可经端口读回。
|
||||
st, err := runStore.LoadRun(res.WorkDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Plan.Workflow.Status != "completed" {
|
||||
t.Fatalf("workflow status = %s", st.Plan.Workflow.Status)
|
||||
}
|
||||
latest, err := runStore.LoadLatestCheckpoint(res.WorkDir)
|
||||
if err != nil || latest == nil {
|
||||
t.Fatalf("latest checkpoint: %v", err)
|
||||
}
|
||||
// 第二引擎(同库)恢复可用。
|
||||
eng2, err := squad.NewEngine(squad.Options{
|
||||
DataDir: dir, Registry: reg, Blueprints: blueprints,
|
||||
Planner: squad.NewRulePlanner(),
|
||||
Backends: backends, Sink: squad.NopSink{},
|
||||
Store: runStore,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := eng2.Inspect(res.RunID); err != nil {
|
||||
t.Fatalf("inspect from second engine: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
const goodReportLite = "# 测试交付物\n\n## 概述\n\n这是由脚本后端生成的合格交付物,长度超过五十个字符以满足最小质检要求。\n"
|
||||
@@ -0,0 +1,302 @@
|
||||
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)
|
||||
@@ -0,0 +1,176 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Skill is one imported SKILL.md plus attached files.
|
||||
type Skill struct {
|
||||
ID, WorkspaceID, Name, Description, Source string
|
||||
}
|
||||
|
||||
// ImportSkill loads a skill from a directory or a zip file.
|
||||
// conflict is fail, overwrite, rename, or skip.
|
||||
func (s *Store) ImportSkill(workspaceID, src, conflict string) (Skill, error) {
|
||||
name, desc, files, err := readSkillSource(src)
|
||||
if err != nil {
|
||||
return Skill{}, err
|
||||
}
|
||||
return s.saveSkill(workspaceID, name, desc, src, files, conflict)
|
||||
}
|
||||
|
||||
func (s *Store) saveSkill(workspaceID, name, desc, source string, files map[string]string, conflict string) (Skill, error) {
|
||||
var existing string
|
||||
_ = s.db.QueryRow(`SELECT id FROM skill WHERE workspace_id=? AND name=?`, workspaceID, name).Scan(&existing)
|
||||
if existing != "" {
|
||||
switch conflict {
|
||||
case "skip":
|
||||
return s.getSkill(existing)
|
||||
case "overwrite":
|
||||
if _, err := s.db.Exec(`DELETE FROM skill_file WHERE skill_id=?`, existing); err != nil {
|
||||
return Skill{}, err
|
||||
}
|
||||
if _, err := s.db.Exec(`UPDATE skill SET description=?, source=? WHERE id=?`, desc, source, existing); err != nil {
|
||||
return Skill{}, err
|
||||
}
|
||||
return s.writeSkillFiles(Skill{ID: existing, WorkspaceID: workspaceID, Name: name, Description: desc, Source: source}, files)
|
||||
case "rename":
|
||||
name = name + "-" + newID()[:4]
|
||||
case "fail", "":
|
||||
return Skill{}, fmt.Errorf("skill %q already exists", name)
|
||||
default:
|
||||
return Skill{}, fmt.Errorf("unknown conflict policy %q", conflict)
|
||||
}
|
||||
}
|
||||
sk := Skill{ID: newID(), WorkspaceID: workspaceID, Name: name, Description: desc, Source: source}
|
||||
if _, err := s.db.Exec(`INSERT INTO skill(id,workspace_id,name,description,source,created_at) VALUES(?,?,?,?,?,?)`,
|
||||
sk.ID, sk.WorkspaceID, sk.Name, sk.Description, sk.Source, now()); err != nil {
|
||||
return Skill{}, err
|
||||
}
|
||||
return s.writeSkillFiles(sk, files)
|
||||
}
|
||||
|
||||
func (s *Store) writeSkillFiles(sk Skill, files map[string]string) (Skill, error) {
|
||||
for path, body := range files {
|
||||
if _, err := s.db.Exec(`INSERT INTO skill_file(id,skill_id,path,body) VALUES(?,?,?,?)`, newID(), sk.ID, path, body); err != nil {
|
||||
return Skill{}, err
|
||||
}
|
||||
}
|
||||
return sk, nil
|
||||
}
|
||||
|
||||
func (s *Store) getSkill(id string) (Skill, error) {
|
||||
var sk Skill
|
||||
err := s.db.QueryRow(`SELECT id,workspace_id,name,description,source FROM skill WHERE id=?`, id).
|
||||
Scan(&sk.ID, &sk.WorkspaceID, &sk.Name, &sk.Description, &sk.Source)
|
||||
return sk, err
|
||||
}
|
||||
|
||||
// RefreshSkill re-reads the original source path.
|
||||
func (s *Store) RefreshSkill(id string) (Skill, error) {
|
||||
sk, err := s.getSkill(id)
|
||||
if err != nil {
|
||||
return Skill{}, err
|
||||
}
|
||||
return s.ImportSkill(sk.WorkspaceID, sk.Source, "overwrite")
|
||||
}
|
||||
|
||||
// BindSkill attaches a skill to an agent. enabled can be toggled later.
|
||||
func (s *Store) BindSkill(agentID, skillID string, enabled bool) error {
|
||||
flag := 0
|
||||
if enabled {
|
||||
flag = 1
|
||||
}
|
||||
_, err := s.db.Exec(`INSERT INTO agent_skill(agent_id,skill_id,enabled) VALUES(?,?,?)
|
||||
ON CONFLICT(agent_id, skill_id) DO UPDATE SET enabled=excluded.enabled`, agentID, skillID, flag)
|
||||
if err != nil {
|
||||
_, err = s.db.Exec(`DELETE FROM agent_skill WHERE agent_id=? AND skill_id=?`, agentID, skillID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.Exec(`INSERT INTO agent_skill(agent_id,skill_id,enabled) VALUES(?,?,?)`, agentID, skillID, flag)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func readSkillSource(src string) (string, string, map[string]string, error) {
|
||||
info, err := os.Stat(src)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
files := map[string]string{}
|
||||
if info.IsDir() {
|
||||
err = filepath.Walk(src, func(path string, fi os.FileInfo, err error) error {
|
||||
if err != nil || fi.IsDir() {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(src, path)
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files[rel] = string(raw)
|
||||
return nil
|
||||
})
|
||||
} else if strings.HasSuffix(src, ".zip") {
|
||||
zr, err := zip.OpenReader(src)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
defer zr.Close()
|
||||
for _, f := range zr.File {
|
||||
if f.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
raw, err := io.ReadAll(rc)
|
||||
_ = rc.Close()
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
files[f.Name] = string(raw)
|
||||
}
|
||||
} else {
|
||||
return "", "", nil, fmt.Errorf("skill source must be a directory or zip")
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
body := ""
|
||||
for path, content := range files {
|
||||
if filepath.Base(path) == "SKILL.md" {
|
||||
body = content
|
||||
break
|
||||
}
|
||||
}
|
||||
if body == "" {
|
||||
return "", "", nil, fmt.Errorf("SKILL.md not found in %s", src)
|
||||
}
|
||||
name := skillName(src, body)
|
||||
return name, skillDescription(body), files, nil
|
||||
}
|
||||
|
||||
func skillName(src, body string) string {
|
||||
if info, err := os.Stat(src); err == nil && info.IsDir() {
|
||||
return filepath.Base(src)
|
||||
}
|
||||
return strings.TrimSuffix(filepath.Base(src), ".zip")
|
||||
}
|
||||
|
||||
func skillDescription(body string) string {
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "description:") {
|
||||
return strings.TrimSpace(strings.TrimPrefix(line, "description:"))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Squad is a leader plus agent and human members.
|
||||
type Squad struct {
|
||||
ID, WorkspaceID, Name, LeaderAgentID, Instructions string
|
||||
}
|
||||
|
||||
// SquadMember is one roster row. Kind is agent or human.
|
||||
type SquadMember struct {
|
||||
Kind, ID, Name, RoleDescription string
|
||||
}
|
||||
|
||||
func (s *Store) CreateSquad(squad Squad, members []SquadMember) (Squad, error) {
|
||||
if squad.ID == "" {
|
||||
squad.ID = newID()
|
||||
}
|
||||
if _, err := s.db.Exec(`INSERT INTO squad(id,workspace_id,name,leader_agent_id,instructions,created_at) VALUES(?,?,?,?,?,?)`,
|
||||
squad.ID, squad.WorkspaceID, squad.Name, squad.LeaderAgentID, squad.Instructions, now()); err != nil {
|
||||
return Squad{}, err
|
||||
}
|
||||
for _, m := range members {
|
||||
if _, err := s.db.Exec(`INSERT INTO squad_member(squad_id,member_kind,member_id,role_description) VALUES(?,?,?,?)`,
|
||||
squad.ID, m.Kind, m.ID, m.RoleDescription); err != nil {
|
||||
return Squad{}, err
|
||||
}
|
||||
}
|
||||
return squad, nil
|
||||
}
|
||||
|
||||
// LeaderBrief is the fixed protocol, the roster with mention links, and the instructions.
|
||||
func (s *Store) LeaderBrief(squadID string) (string, error) {
|
||||
var name, instructions, leader string
|
||||
if err := s.db.QueryRow(`SELECT name, instructions, leader_agent_id FROM squad WHERE id=?`, squadID).Scan(&name, &instructions, &leader); err != nil {
|
||||
return "", err
|
||||
}
|
||||
leaderAgent, err := s.GetAgent(leader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("## Squad Operating Protocol\n")
|
||||
b.WriteString("Delegate with one short comment that mentions the owner. Record squad activity every turn as action, no_action, or failed. Stop after dispatch. Do not implement the task yourself. Do not both mention a member and create a todo sub-issue for the same work.\n\n")
|
||||
b.WriteString("## Squad Roster\n")
|
||||
fmt.Fprintf(&b, "- [@%s](mention://agent/%s) leader of %s\n", leaderAgent.Name, leaderAgent.ID, name)
|
||||
rows, err := s.db.Query(`SELECT member_kind, member_id, role_description FROM squad_member WHERE squad_id=?`, squadID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
type rosterRow struct{ kind, id, role string }
|
||||
var roster []rosterRow
|
||||
for rows.Next() {
|
||||
var row rosterRow
|
||||
if err := rows.Scan(&row.kind, &row.id, &row.role); err != nil {
|
||||
rows.Close()
|
||||
return "", err
|
||||
}
|
||||
roster = append(roster, row)
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, row := range roster {
|
||||
memberName := row.id
|
||||
if row.kind == "agent" {
|
||||
if a, err := s.GetAgent(row.id); err == nil {
|
||||
memberName = a.Name
|
||||
}
|
||||
} else {
|
||||
_ = s.db.QueryRow(`SELECT name FROM member WHERE id=?`, row.id).Scan(&memberName)
|
||||
}
|
||||
scheme := "member"
|
||||
if row.kind == "agent" {
|
||||
scheme = "agent"
|
||||
}
|
||||
fmt.Fprintf(&b, "- [@%s](mention://%s/%s) %s\n", memberName, scheme, row.id, row.role)
|
||||
}
|
||||
b.WriteString("\n## Squad Instructions\n")
|
||||
b.WriteString(instructions)
|
||||
b.WriteString("\n")
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// RecordActivity appends one squad activity row. action is action, no_action, or failed.
|
||||
func (s *Store) RecordActivity(squadID, issueID, actorID, action, detail string) error {
|
||||
switch action {
|
||||
case "action", "no_action", "failed":
|
||||
default:
|
||||
return fmt.Errorf("activity action must be action, no_action, or failed")
|
||||
}
|
||||
_, err := s.db.Exec(`INSERT INTO activity(id,squad_id,issue_id,actor_id,action,detail,created_at) VALUES(?,?,?,?,?,?,?)`,
|
||||
newID(), squadID, issueID, actorID, action, detail, now())
|
||||
return err
|
||||
}
|
||||
|
||||
// TouchStatus lets the squad leader move an issue. The first turn sets in_progress.
|
||||
// goalMet sets in_review. Other actors are refused.
|
||||
func (s *Store) TouchStatus(issueID, actorAgentID string, goalMet bool) error {
|
||||
issue, err := s.GetIssue(issueID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if issue.AssigneeKind != "squad" {
|
||||
return fmt.Errorf("status changes require a squad assignee")
|
||||
}
|
||||
var leader string
|
||||
if err := s.db.QueryRow(`SELECT leader_agent_id FROM squad WHERE id=?`, issue.AssigneeID).Scan(&leader); err != nil {
|
||||
return err
|
||||
}
|
||||
if actorAgentID != leader {
|
||||
return fmt.Errorf("only the squad leader may change issue status")
|
||||
}
|
||||
next := issue.Status
|
||||
if goalMet {
|
||||
next = "in_review"
|
||||
} else if issue.Status == "todo" || issue.Status == "" {
|
||||
next = "in_progress"
|
||||
}
|
||||
_, err = s.db.Exec(`UPDATE issue SET status=? WHERE id=?`, next, issueID)
|
||||
return err
|
||||
}
|
||||
|
||||
// CloseIssue marks a sub-issue done and wakes the leader when every
|
||||
// sibling in the same stage is closed. Delegation depth is capped at 4.
|
||||
func (s *Store) CloseIssue(issueID string) error {
|
||||
issue, err := s.GetIssue(issueID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if depth, err := s.issueDepth(issue); err != nil {
|
||||
return err
|
||||
} else if depth > 4 {
|
||||
return fmt.Errorf("delegation depth cap is 4")
|
||||
}
|
||||
if _, err := s.db.Exec(`UPDATE issue SET status='done' WHERE id=?`, issueID); err != nil {
|
||||
return err
|
||||
}
|
||||
if issue.ParentID == "" || issue.Stage == "" {
|
||||
return nil
|
||||
}
|
||||
var open int
|
||||
if err := s.db.QueryRow(`SELECT COUNT(*) FROM issue WHERE parent_id=? AND stage=? AND status NOT IN ('done','cancelled')`, issue.ParentID, issue.Stage).Scan(&open); err != nil {
|
||||
return err
|
||||
}
|
||||
if open > 0 {
|
||||
return nil
|
||||
}
|
||||
parent, err := s.GetIssue(issue.ParentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if parent.AssigneeKind != "squad" {
|
||||
return nil
|
||||
}
|
||||
var leader string
|
||||
if err := s.db.QueryRow(`SELECT leader_agent_id FROM squad WHERE id=?`, parent.AssigneeID).Scan(&leader); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.enqueue(parent, leader, true, parent.AssigneeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.Exec(`INSERT INTO wakeup(id,task_id,kind,fire_at,created_at) VALUES(?,?,?,?,?)`,
|
||||
newID(), parent.ID, "child_done", "", now())
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) issueDepth(issue Issue) (int, error) {
|
||||
depth := 0
|
||||
cur := issue
|
||||
for cur.ParentID != "" && depth < 8 {
|
||||
depth++
|
||||
next, err := s.GetIssue(cur.ParentID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
cur = next
|
||||
}
|
||||
return depth, nil
|
||||
}
|
||||
|
||||
// PlanStages creates one sub-issue per stage title under parent.
|
||||
func (s *Store) PlanStages(parentID string, stages []string) ([]Issue, error) {
|
||||
parent, err := s.GetIssue(parentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []Issue
|
||||
for i, title := range stages {
|
||||
child, err := s.CreateIssue(Issue{
|
||||
WorkspaceID: parent.WorkspaceID, Title: title, Body: title,
|
||||
Status: "todo", ParentID: parent.ID, Stage: fmt.Sprintf("%d", i+1),
|
||||
AssigneeKind: parent.AssigneeKind, AssigneeID: parent.AssigneeID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, child)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DelegateComment posts a mention comment. It does not write a JSON file.
|
||||
func (s *Store) DelegateComment(issueID, authorAgentID, mentionedAgentID, body string) (Comment, error) {
|
||||
text := body + " [@" + mentionedAgentID + "](mention://agent/" + mentionedAgentID + ")"
|
||||
return s.AddComment(Comment{IssueID: issueID, AuthorKind: "agent", AuthorID: authorAgentID, Body: text})
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"agent-squad/squad"
|
||||
)
|
||||
|
||||
// stubExecutor 是测试用的 squad.Executor(X6 后调用方经端口注入)。
|
||||
type stubExecutor struct {
|
||||
result *squad.ExecResult
|
||||
err error
|
||||
}
|
||||
|
||||
func (s stubExecutor) Name() string { return "stub" }
|
||||
|
||||
func (s stubExecutor) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
|
||||
if s.result == nil {
|
||||
return nil, s.err
|
||||
}
|
||||
// 每次调用返回拷贝:调用方会改写 Success 字段,共享指针会跨步骤串味。
|
||||
cp := *s.result
|
||||
if s.result.Patches != nil {
|
||||
cp.Patches = map[string][]byte{}
|
||||
for k, v := range s.result.Patches {
|
||||
cp.Patches[k] = v
|
||||
}
|
||||
}
|
||||
return &cp, s.err
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -14,11 +15,12 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agent-intent/intent"
|
||||
"agent-native/runtime/llm"
|
||||
"agent-squad/server"
|
||||
squadintent "agent-squad/adapters/intent"
|
||||
nativeadp "agent-squad/adapters/native"
|
||||
platform "agent-squad/adapters/sqlite"
|
||||
"agent-squad/cmd/agent-squad/server"
|
||||
"agent-squad/squad"
|
||||
"agent-squad/squadintent"
|
||||
"agent-squad/store"
|
||||
|
||||
"logging-system/config"
|
||||
@@ -44,6 +46,8 @@ func Main(args []string) int {
|
||||
return cmdRuns(args[1:])
|
||||
case "demo":
|
||||
return cmdDemo(args[1:])
|
||||
case "agent", "skill", "squad", "issue", "autopilot", "runtime", "daemon", "chat", "project":
|
||||
return cmdPlatform(args)
|
||||
case "help", "-h", "--help":
|
||||
usage()
|
||||
return 0
|
||||
@@ -58,12 +62,13 @@ func usage() {
|
||||
fmt.Print(`agent-squad — 通用智能体小队编排底座
|
||||
|
||||
用法:
|
||||
agent-squad serve [--addr :8090] [--data DIR] [--planner rule|llm] [--model M]
|
||||
agent-squad serve [--addr 127.0.0.1:8090] [--data DIR] [--token TOK] [--planner rule|llm] [--model M]
|
||||
agent-squad role list|add|rm|recommend|derive ...
|
||||
agent-squad blueprint list|add|rm ...
|
||||
agent-squad run "目标文本" [--task-key K] [--backend B] [--materials DIR] [--hitl-mode interactive|auto|disabled]
|
||||
agent-squad runs list|inspect|pending|respond|blackboard ...
|
||||
agent-squad demo [--keep]
|
||||
agent-squad agent|skill|squad|issue|autopilot|runtime|daemon|chat|project ...
|
||||
|
||||
环境变量:
|
||||
AGENT_SQUAD_DATA 数据目录(默认 ./agent-squad-data;role/blueprint 子命令也读它)
|
||||
@@ -81,6 +86,7 @@ type app struct {
|
||||
files *store.FileEventSink
|
||||
st *store.FileStore
|
||||
log *logsys.Logger
|
||||
plat *platform.Store
|
||||
}
|
||||
|
||||
func dataDir() string {
|
||||
@@ -117,12 +123,12 @@ func assemble(dir, plannerName, modelOverride, intentRoot string) (*app, error)
|
||||
fmt.Fprintln(os.Stderr, "提示:llm 规划器需要 AGENT_SQUAD_LLM_BASE_URL/API_KEY/MODEL,回退 rule 规划器")
|
||||
} else {
|
||||
client := &llm.Client{BaseURL: baseURL, APIKey: apiKey}
|
||||
planner = squad.NewLLMPlanner(client, model)
|
||||
planner = squad.NewLLMPlanner(nativeadp.LLM{Inner: client}, model)
|
||||
}
|
||||
}
|
||||
// 真实智能体执行一律经 agent-native 自研内核(角色 backend=agent-native)。
|
||||
if baseURL != "" && apiKey != "" && model != "" {
|
||||
backends.RegisterAgentNative(&llm.Client{BaseURL: baseURL, APIKey: apiKey}, model, 12, 10*time.Minute)
|
||||
nativeadp.Register(backends, &llm.Client{BaseURL: baseURL, APIKey: apiKey}, model, 12, 10*time.Minute)
|
||||
}
|
||||
|
||||
// 事件双层:FileEventSink 落盘(seq+脱敏,重启可审计)→ FanoutSink(SSE 实时/重放)。
|
||||
@@ -132,14 +138,7 @@ func assemble(dir, plannerName, modelOverride, intentRoot string) (*app, error)
|
||||
var catalog *squad.IntentCatalog
|
||||
var runner squad.IntentRunner
|
||||
if intentRoot != "" {
|
||||
g, err := squadintent.New(context.Background(), squadintent.Options{
|
||||
IntentOptions: intent.Options{
|
||||
GuardDomains: []string{"guard"},
|
||||
ProtectedDomains: []string{"guard"},
|
||||
},
|
||||
FS: os.DirFS(intentRoot),
|
||||
Roots: []string{"."},
|
||||
})
|
||||
g, err := squadintent.NewFromOverlay(context.Background(), os.DirFS(intentRoot), ".")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("意图配置加载失败(fail-closed):%w", err)
|
||||
}
|
||||
@@ -161,12 +160,20 @@ func assemble(dir, plannerName, modelOverride, intentRoot string) (*app, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 单库收编(Q1 WP5):引擎运行状态与平台编排状态进同一个 SQLite。
|
||||
// 库打不开即失败(与 serve 现状一致),不允许静默退回文件态。
|
||||
plat, err := platform.Open(filepath.Join(dir, "orchestration.db"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("编排库打开失败: %w", err)
|
||||
}
|
||||
engine, err := squad.NewEngine(squad.Options{
|
||||
DataDir: dir, Registry: reg, Blueprints: bps, Planner: planner,
|
||||
Backends: backends, Sink: fileSink, IntentGate: gate,
|
||||
IntentCatalog: catalog, IntentRunner: runner,
|
||||
Store: plat.RunStore(),
|
||||
})
|
||||
if err != nil {
|
||||
_ = plat.Close()
|
||||
return nil, err
|
||||
}
|
||||
logCfg, err := config.Load("")
|
||||
@@ -180,7 +187,7 @@ func assemble(dir, plannerName, modelOverride, intentRoot string) (*app, error)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &app{engine: engine, sink: fanout, files: fileSink, st: st, log: logger}, nil
|
||||
return &app{engine: engine, sink: fanout, files: fileSink, st: st, log: logger, plat: plat}, nil
|
||||
}
|
||||
|
||||
func (a *app) close() {
|
||||
@@ -202,7 +209,9 @@ func first(vals ...string) string {
|
||||
|
||||
func cmdServe(args []string) int {
|
||||
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
|
||||
addr := fs.String("addr", ":8090", "监听地址")
|
||||
addr := fs.String("addr", "127.0.0.1:8090", "监听地址")
|
||||
token := fs.String("token", os.Getenv("AGENT_SQUAD_TOKEN"), "Bearer token;也可用 AGENT_SQUAD_TOKEN")
|
||||
insecure := fs.Bool("insecure", false, "允许在没有 token 时监听非回环地址")
|
||||
data := fs.String("data", dataDir(), "数据目录")
|
||||
planner := fs.String("planner", "rule", "规划器:rule|llm")
|
||||
model := fs.String("model", "", "默认模型(llm 规划器/后端)")
|
||||
@@ -216,8 +225,17 @@ func cmdServe(args []string) int {
|
||||
return 1
|
||||
}
|
||||
defer a.close()
|
||||
if err := listenGuard(*addr, *token, *insecure); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 2
|
||||
}
|
||||
srv := server.New(a.engine, a.sink, a.st)
|
||||
srv.SetToken(*token)
|
||||
srv.SetLogger(a.log)
|
||||
if a.plat != nil {
|
||||
srv.SetPlatform(a.plat)
|
||||
defer a.plat.Close()
|
||||
}
|
||||
stopSweep := srv.StartWakeupSweeper(15 * time.Second)
|
||||
defer stopSweep()
|
||||
fmt.Printf("agent-squad 服务启动 http://localhost%s (data=%s, roles=%d, blueprints=%d, wakeup-sweep=15s)\n",
|
||||
@@ -225,6 +243,22 @@ func cmdServe(args []string) int {
|
||||
return httpListen(*addr, srv.Handler())
|
||||
}
|
||||
|
||||
func listenGuard(addr, token string, insecure bool) error {
|
||||
if token == "" && !insecure && !loopbackAddr(addr) {
|
||||
return fmt.Errorf("拒绝在没有 token 时监听非回环地址;传入 --token 或 --insecure")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loopbackAddr(addr string) bool {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
host = addr
|
||||
}
|
||||
host = strings.Trim(host, "[]")
|
||||
return host == "127.0.0.1" || host == "::1" || strings.EqualFold(host, "localhost")
|
||||
}
|
||||
|
||||
func httpListen(addr string, h http.Handler) int {
|
||||
if err := http.ListenAndServe(addr, h); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "服务退出:", err)
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"agent-native/exec"
|
||||
"agent-squad/squad"
|
||||
)
|
||||
|
||||
@@ -21,7 +20,7 @@ type demoBackend struct {
|
||||
|
||||
func (b *demoBackend) Name() string { return "demo" }
|
||||
|
||||
func (b *demoBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
|
||||
func (b *demoBackend) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
|
||||
key := filepath.Base(filepath.Clean(req.WorkDir))
|
||||
files, ok := b.script[key]
|
||||
if !ok {
|
||||
@@ -46,7 +45,7 @@ func (b *demoBackend) Execute(ctx context.Context, req exec.Request) (*exec.Resu
|
||||
}
|
||||
patches[t] = []byte(content)
|
||||
}
|
||||
return &exec.Result{Success: true, Patches: patches, Logs: "demo backend replay"}, nil
|
||||
return &squad.ExecResult{Success: true, Patches: patches, Logs: "demo backend replay"}, nil
|
||||
}
|
||||
|
||||
// cmdDemo 离线全链路演示:注册角色 → 规划 → 执行 → HITL 阻塞缺口挂起 →
|
||||
@@ -0,0 +1,28 @@
|
||||
package cli
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestListenGuard(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
addr string
|
||||
token string
|
||||
insecure bool
|
||||
ok bool
|
||||
}{
|
||||
{name: "loopback without token", addr: "127.0.0.1:8090", ok: true},
|
||||
{name: "public without token", addr: "0.0.0.0:8090", ok: false},
|
||||
{name: "public with insecure", addr: "0.0.0.0:8090", insecure: true, ok: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := listenGuard(tc.addr, tc.token, tc.insecure)
|
||||
if tc.ok && err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tc.ok && err == nil {
|
||||
t.Fatal("expected refusal")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
platform "agent-squad/adapters/sqlite"
|
||||
"agent-squad/squad"
|
||||
)
|
||||
|
||||
func cmdPlatform(args []string) int {
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "用法: agent-squad", args[0], "create|import|assign|claim|fire ...")
|
||||
return 2
|
||||
}
|
||||
fs := flag.NewFlagSet(args[0], flag.ContinueOnError)
|
||||
data := fs.String("data", dataDir(), "数据目录")
|
||||
workspace := fs.String("workspace", "", "workspace id")
|
||||
name := fs.String("name", "", "名称")
|
||||
if err := fs.Parse(args[2:]); err != nil {
|
||||
return 2
|
||||
}
|
||||
st, err := platform.Open(filepath.Join(*data, "orchestration.db"))
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
defer st.Close()
|
||||
switch args[0] + " " + args[1] {
|
||||
case "agent create":
|
||||
a, err := st.CreateAgent(platform.Agent{WorkspaceID: *workspace, Name: *name})
|
||||
return printPlatform(a, err)
|
||||
case "skill import":
|
||||
if fs.NArg() < 1 {
|
||||
fmt.Fprintln(os.Stderr, "skill import 需要目录或 zip 路径")
|
||||
return 2
|
||||
}
|
||||
sk, err := st.ImportSkill(*workspace, fs.Arg(0), "rename")
|
||||
return printPlatform(sk, err)
|
||||
case "squad create":
|
||||
if fs.NArg() < 1 {
|
||||
fmt.Fprintln(os.Stderr, "squad create 需要 leader agent id")
|
||||
return 2
|
||||
}
|
||||
sq, err := st.CreateSquad(platform.Squad{WorkspaceID: *workspace, Name: *name, LeaderAgentID: fs.Arg(0)}, nil)
|
||||
return printPlatform(sq, err)
|
||||
case "issue create":
|
||||
issue, err := st.CreateIssue(platform.Issue{WorkspaceID: *workspace, Title: *name, Status: "todo"})
|
||||
return printPlatform(issue, err)
|
||||
case "issue assign":
|
||||
if fs.NArg() < 3 {
|
||||
fmt.Fprintln(os.Stderr, "issue assign <issue> <agent|squad> <id>")
|
||||
return 2
|
||||
}
|
||||
err := st.AssignIssue(fs.Arg(0), fs.Arg(1), fs.Arg(2), true)
|
||||
return printPlatform(map[string]bool{"ok": err == nil}, err)
|
||||
case "autopilot create":
|
||||
if fs.NArg() < 2 {
|
||||
fmt.Fprintln(os.Stderr, "autopilot create <agent-id> <create_issue|run_only>")
|
||||
return 2
|
||||
}
|
||||
a, err := st.CreateAutopilot(platform.Autopilot{
|
||||
WorkspaceID: *workspace, Title: *name, Runbook: *name,
|
||||
AssigneeKind: "agent", AssigneeID: fs.Arg(0), ExecutionMode: fs.Arg(1),
|
||||
})
|
||||
return printPlatform(a, err)
|
||||
case "runtime probe":
|
||||
rts, err := st.RegisterProbed(*workspace)
|
||||
return printPlatform(rts, err)
|
||||
case "daemon claim":
|
||||
if fs.NArg() < 1 {
|
||||
fmt.Fprintln(os.Stderr, "daemon claim <runtime-id>")
|
||||
return 2
|
||||
}
|
||||
task, ok, err := st.Claim(fs.Arg(0), time.Now())
|
||||
return printPlatform(map[string]any{"ok": ok, "task": task}, err)
|
||||
case "chat create":
|
||||
c, err := st.CreateChat(platform.ChatSession{WorkspaceID: *workspace, OwnerID: *name})
|
||||
return printPlatform(c, err)
|
||||
case "project create":
|
||||
p, err := st.CreateProject(platform.Project{WorkspaceID: *workspace, Name: *name})
|
||||
return printPlatform(p, err)
|
||||
case "daemon run-native":
|
||||
if fs.NArg() < 2 {
|
||||
fmt.Fprintln(os.Stderr, "daemon run-native <task-id> <workdir>")
|
||||
return 2
|
||||
}
|
||||
task, err := st.TaskByToken(fs.Arg(0))
|
||||
if err != nil {
|
||||
var idErr error
|
||||
task, idErr = taskByID(st, fs.Arg(0))
|
||||
if idErr != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
text, err := st.RunInProcess(context.Background(), task, fs.Arg(1), scriptedExecutor{})
|
||||
return printPlatform(map[string]string{"text": text}, err)
|
||||
default:
|
||||
fmt.Fprintln(os.Stderr, "未知编排命令", args[0], args[1])
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
func taskByID(st *platform.Store, id string) (platform.Task, error) {
|
||||
return st.TaskByToken(id)
|
||||
}
|
||||
|
||||
// scriptedExecutor 是 daemon run-native 冒烟路径的脚本执行器(X6 后经端口注入)。
|
||||
type scriptedExecutor struct{}
|
||||
|
||||
func (scriptedExecutor) Name() string { return "scripted" }
|
||||
|
||||
func (scriptedExecutor) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
|
||||
return &squad.ExecResult{
|
||||
Success: true, Logs: "ok",
|
||||
Patches: map[string][]byte{"output.md": []byte("ok")},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func printPlatform(v any, err error) int {
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(v)
|
||||
return 0
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-native/exec"
|
||||
"agent-squad/squad"
|
||||
"agent-squad/store"
|
||||
)
|
||||
@@ -21,7 +20,7 @@ type replayBackend struct{ script map[string]map[string]string }
|
||||
|
||||
func (b *replayBackend) Name() string { return "replay" }
|
||||
|
||||
func (b *replayBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
|
||||
func (b *replayBackend) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
|
||||
key := filepath.Base(filepath.Clean(req.WorkDir))
|
||||
files := b.script[key]
|
||||
patches := map[string][]byte{}
|
||||
@@ -37,7 +36,7 @@ func (b *replayBackend) Execute(ctx context.Context, req exec.Request) (*exec.Re
|
||||
}
|
||||
patches[t] = []byte(content)
|
||||
}
|
||||
return &exec.Result{Success: true, Patches: patches, Logs: "replay"}, nil
|
||||
return &squad.ExecResult{Success: true, Patches: patches, Logs: "replay"}, nil
|
||||
}
|
||||
|
||||
const deliverable = "# 契约验收交付物\n\n## 概述\n\n本文件由回放后端生成,用于验证 agent-squad 全链路契约:注册、规划、执行、HITL、质检与审计。\n\n## 结论\n\n全链路行为符合契约。\n"
|
||||
@@ -0,0 +1,45 @@
|
||||
module agent-squad/cmd/agent-squad
|
||||
|
||||
go 1.27
|
||||
|
||||
require (
|
||||
agent-native v0.0.0
|
||||
agent-squad v0.0.0
|
||||
agent-squad/adapters/intent v0.0.0
|
||||
agent-squad/adapters/native v0.0.0
|
||||
agent-squad/adapters/sqlite v0.0.0
|
||||
error-code-system v0.0.0
|
||||
logging-system v0.0.0
|
||||
)
|
||||
|
||||
require (
|
||||
agent-intent v0.0.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
modernc.org/libc v1.62.1 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.9.1 // indirect
|
||||
modernc.org/sqlite v1.37.0 // indirect
|
||||
)
|
||||
|
||||
replace agent-intent => ../../deps/agent-intent
|
||||
|
||||
replace agent-native => ../../deps/agent-native
|
||||
|
||||
replace agent-squad => ../..
|
||||
|
||||
replace agent-squad/adapters/intent => ../../adapters/intent
|
||||
|
||||
replace agent-squad/adapters/native => ../../adapters/native
|
||||
|
||||
replace agent-squad/adapters/sqlite => ../../adapters/sqlite
|
||||
|
||||
replace error-code-system => ../../deps/error-code-system
|
||||
|
||||
replace logging-system => ../../deps/logging-system
|
||||
@@ -0,0 +1,49 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
modernc.org/cc/v4 v4.25.2 h1:T2oH7sZdGvTaie0BRNFbIYsabzCxUQg8nLqCdQ2i0ic=
|
||||
modernc.org/cc/v4 v4.25.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.25.1 h1:TFSzPrAGmDsdnhT9X2UrcPMI3N/mJ9/X9ykKXwLhDsU=
|
||||
modernc.org/ccgo/v4 v4.25.1/go.mod h1:njjuAYiPflywOOrm3B7kCB444ONP5pAVr8PIEoE0uDw=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/libc v1.62.1 h1:s0+fv5E3FymN8eJVmnk0llBe6rOxCu/DEU+XygRbS8s=
|
||||
modernc.org/libc v1.62.1/go.mod h1:iXhATfJQLjG3NWy56a6WVU73lWOcdYVxsvwCgoPljuo=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g=
|
||||
modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
|
||||
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -4,7 +4,7 @@ package main
|
||||
import (
|
||||
"os"
|
||||
|
||||
"agent-squad/cli"
|
||||
"agent-squad/cmd/agent-squad/cli"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -0,0 +1,74 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-squad/squad"
|
||||
"agent-squad/store"
|
||||
)
|
||||
|
||||
func TestMissingTokenIsUnauthorized(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, err := store.New(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg := squad.NewRoleRegistry()
|
||||
engine, err := squad.NewEngine(squad.Options{
|
||||
DataDir: dir, Registry: reg, Planner: &squad.MockPlanner{}, Sink: squad.NopSink{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := New(engine, squad.NewFanoutSink(8), st)
|
||||
srv.SetToken("secret")
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
resp, err := http.Get(ts.URL + "/v1/roles")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("status %d", resp.StatusCode)
|
||||
}
|
||||
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/roles", nil)
|
||||
req.Header.Set("Authorization", "Bearer secret")
|
||||
resp, err = http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("authed status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathTraversalRejected(t *testing.T) {
|
||||
ts, _ := newTestServer(t)
|
||||
resp, err := http.Get(ts.URL + "/v1/runs/a..b")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOversizedBodyIs413(t *testing.T) {
|
||||
ts, _ := newTestServer(t)
|
||||
body := strings.NewReader(`{"role":"x","name":"` + strings.Repeat("a", 2<<20) + `"}`)
|
||||
resp, err := http.Post(ts.URL+"/v1/roles", "application/json", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,18 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
platform "agent-squad/adapters/sqlite"
|
||||
"agent-squad/squad"
|
||||
"agent-squad/store"
|
||||
|
||||
@@ -32,8 +36,13 @@ type Server struct {
|
||||
mu sync.Mutex
|
||||
latest map[string]*squad.RunResult // 最近一次推进结果(供轮询)
|
||||
log *logsys.Logger
|
||||
plat *platform.Store
|
||||
token string
|
||||
}
|
||||
|
||||
// SetToken 要求除健康检查外的请求带 Bearer token。空串表示不校验。
|
||||
func (s *Server) SetToken(token string) { s.token = token }
|
||||
|
||||
// SetLogger 设置组合根日志器。nil 表示不写日志。
|
||||
func (s *Server) SetLogger(log *logsys.Logger) {
|
||||
if s == nil {
|
||||
@@ -42,6 +51,14 @@ func (s *Server) SetLogger(log *logsys.Logger) {
|
||||
s.log = log
|
||||
}
|
||||
|
||||
// SetPlatform attaches the orchestration database. Nil leaves the goal engine only.
|
||||
func (s *Server) SetPlatform(st *platform.Store) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.plat = st
|
||||
}
|
||||
|
||||
// New 组装服务。
|
||||
func New(engine *squad.SquadEngine, sink *squad.FanoutSink, st *store.FileStore) *Server {
|
||||
return &Server{engine: engine, sink: sink, st: st, latest: map[string]*squad.RunResult{}}
|
||||
@@ -74,7 +91,57 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("POST /v1/wakeups/sweep", s.handleSweep)
|
||||
mux.HandleFunc("GET /v1/runs/{id}/blackboard", s.handleBlackboard)
|
||||
mux.HandleFunc("GET /v1/runs/{id}/events", s.handleEvents)
|
||||
return httpx.Trace(mux)
|
||||
if s.plat != nil {
|
||||
platform.Mount(mux, s.plat)
|
||||
}
|
||||
var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
limit := int64(1 << 20)
|
||||
if r.Method == http.MethodPost && r.URL.Path == "/v1/runs" {
|
||||
limit = 32 << 20
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, limit)
|
||||
mux.ServeHTTP(w, r)
|
||||
})
|
||||
if s.token != "" {
|
||||
inner := h
|
||||
h = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v1/health" {
|
||||
inner.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
const prefix = "Bearer "
|
||||
header := r.Header.Get("Authorization")
|
||||
got := strings.TrimPrefix(header, prefix)
|
||||
if len(header) != len(prefix)+len(s.token) || subtle.ConstantTimeCompare([]byte(got), []byte(s.token)) != 1 {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
inner.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
return httpx.Trace(h)
|
||||
}
|
||||
|
||||
func (s *Server) decode(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||
err := json.NewDecoder(r.Body).Decode(dst)
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) || strings.Contains(err.Error(), "request body too large") {
|
||||
w.WriteHeader(http.StatusRequestEntityTooLarge)
|
||||
return false
|
||||
}
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, "请求 JSON 无法解析"))
|
||||
return false
|
||||
}
|
||||
|
||||
func rejectBadID(w http.ResponseWriter, id string) bool {
|
||||
if squad.ValidID(id) {
|
||||
return false
|
||||
}
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return true
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
@@ -119,8 +186,7 @@ func (s *Server) handleListRoles(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePutRole(w http.ResponseWriter, r *http.Request) {
|
||||
var spec squad.RoleSpec
|
||||
if err := json.NewDecoder(r.Body).Decode(&spec); err != nil {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, "请求 JSON 无法解析"))
|
||||
if !s.decode(w, r, &spec) {
|
||||
return
|
||||
}
|
||||
if key := r.PathValue("key"); key != "" && spec.Role == "" {
|
||||
@@ -135,6 +201,9 @@ func (s *Server) handlePutRole(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleDeleteRole(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.PathValue("key")
|
||||
if rejectBadID(w, key) {
|
||||
return
|
||||
}
|
||||
if !s.engine.Registry().Remove(key) {
|
||||
s.writeErr(w, r, errcode.New(errcode.SquadRoleNotFound, fmt.Sprintf("role %s not found", key)))
|
||||
return
|
||||
@@ -145,8 +214,7 @@ func (s *Server) handleDeleteRole(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleDeriveRole(w http.ResponseWriter, r *http.Request) {
|
||||
var req squad.DeriveRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, "请求 JSON 无法解析"))
|
||||
if !s.decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
spec, err := s.engine.Registry().Derive(req)
|
||||
@@ -170,8 +238,7 @@ func (s *Server) handleListBlueprints(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePutBlueprint(w http.ResponseWriter, r *http.Request) {
|
||||
var bp squad.Blueprint
|
||||
if err := json.NewDecoder(r.Body).Decode(&bp); err != nil {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, "请求 JSON 无法解析"))
|
||||
if !s.decode(w, r, &bp) {
|
||||
return
|
||||
}
|
||||
if err := s.engine.Blueprints().Register(bp); err != nil {
|
||||
@@ -183,6 +250,9 @@ func (s *Server) handlePutBlueprint(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleDeleteBlueprint(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.PathValue("key")
|
||||
if rejectBadID(w, key) {
|
||||
return
|
||||
}
|
||||
if !s.engine.Blueprints().Remove(key) {
|
||||
s.writeErr(w, r, errcode.New(errcode.SquadBlueprintNotFound, fmt.Sprintf("blueprint %s not found", key)))
|
||||
return
|
||||
@@ -208,8 +278,7 @@ type createRunRequest struct {
|
||||
|
||||
func (s *Server) handleCreateRun(w http.ResponseWriter, r *http.Request) {
|
||||
var req createRunRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, "请求 JSON 无法解析"))
|
||||
if !s.decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
for name, b64 := range req.MaterialsB64 {
|
||||
@@ -263,7 +332,11 @@ func (s *Server) handleListRuns(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleInspectRun(w http.ResponseWriter, r *http.Request) {
|
||||
res, err := s.engine.Inspect(r.PathValue("id"))
|
||||
id := r.PathValue("id")
|
||||
if rejectBadID(w, id) {
|
||||
return
|
||||
}
|
||||
res, err := s.engine.Inspect(id)
|
||||
if err != nil {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SquadRunNotFound, "运行不存在"))
|
||||
return
|
||||
@@ -272,7 +345,11 @@ func (s *Server) handleInspectRun(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handlePending(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := s.engine.Pending(r.PathValue("id"))
|
||||
id := r.PathValue("id")
|
||||
if rejectBadID(w, id) {
|
||||
return
|
||||
}
|
||||
req, err := s.engine.Pending(id)
|
||||
if err != nil || req == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"pending": nil})
|
||||
return
|
||||
@@ -282,11 +359,14 @@ func (s *Server) handlePending(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleRespond(w http.ResponseWriter, r *http.Request) {
|
||||
var decision squad.RespondDecision
|
||||
if err := json.NewDecoder(r.Body).Decode(&decision); err != nil {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, "请求 JSON 无法解析"))
|
||||
if !s.decode(w, r, &decision) {
|
||||
return
|
||||
}
|
||||
res, err := s.engine.Respond(r.Context(), r.PathValue("id"), decision)
|
||||
id := r.PathValue("id")
|
||||
if rejectBadID(w, id) {
|
||||
return
|
||||
}
|
||||
res, err := s.engine.Respond(r.Context(), id, decision)
|
||||
if err != nil {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, err.Error()))
|
||||
return
|
||||
@@ -297,7 +377,11 @@ func (s *Server) handleRespond(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleResume(w http.ResponseWriter, r *http.Request) {
|
||||
res, err := s.engine.Resume(r.Context(), r.PathValue("id"))
|
||||
id := r.PathValue("id")
|
||||
if rejectBadID(w, id) {
|
||||
return
|
||||
}
|
||||
res, err := s.engine.Resume(r.Context(), id)
|
||||
if err != nil {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, err.Error()))
|
||||
return
|
||||
@@ -308,7 +392,11 @@ func (s *Server) handleResume(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleBlackboard(w http.ResponseWriter, r *http.Request) {
|
||||
bb, err := s.engine.Blackboard(r.PathValue("id"))
|
||||
id := r.PathValue("id")
|
||||
if rejectBadID(w, id) {
|
||||
return
|
||||
}
|
||||
bb, err := s.engine.Blackboard(id)
|
||||
if err != nil {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SquadRunNotFound, "运行不存在"))
|
||||
return
|
||||
@@ -320,11 +408,14 @@ func (s *Server) handleSteer(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, "请求 JSON 无法解析"))
|
||||
if !s.decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if err := s.engine.Steer(r.PathValue("id"), req.Text); err != nil {
|
||||
id := r.PathValue("id")
|
||||
if rejectBadID(w, id) {
|
||||
return
|
||||
}
|
||||
if err := s.engine.Steer(id, req.Text); err != nil {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, err.Error()))
|
||||
return
|
||||
}
|
||||
@@ -336,11 +427,14 @@ func (s *Server) handleSetField(w http.ResponseWriter, r *http.Request) {
|
||||
Field string `json:"field"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, "请求 JSON 无法解析"))
|
||||
if !s.decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if err := s.engine.SetBlackboardField(r.PathValue("id"), req.Field, req.Value); err != nil {
|
||||
id := r.PathValue("id")
|
||||
if rejectBadID(w, id) {
|
||||
return
|
||||
}
|
||||
if err := s.engine.SetBlackboardField(id, req.Field, req.Value); err != nil {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, err.Error()))
|
||||
return
|
||||
}
|
||||
@@ -368,6 +462,8 @@ func (s *Server) StartWakeupSweeper(interval time.Duration) (stop func()) {
|
||||
return
|
||||
case <-t.C:
|
||||
s.engine.SweepWakeups(context.Background())
|
||||
// Q6:与事件唤醒并列,过期挂起按 ExpireAction 合成决策(此前是死代码)。
|
||||
s.engine.SweepExpired(context.Background(), time.Now())
|
||||
}
|
||||
}
|
||||
}()
|
||||
@@ -376,6 +472,9 @@ func (s *Server) StartWakeupSweeper(interval time.Duration) (stop func()) {
|
||||
|
||||
// handleEvents SSE:cursor 之后重放缓冲再实时推送。
|
||||
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
|
||||
if rejectBadID(w, r.PathValue("id")) {
|
||||
return
|
||||
}
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
s.writeErr(w, r, errcode.New(errcode.SysInternal, "streaming unsupported"))
|
||||
@@ -402,6 +501,9 @@ func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if id := r.PathValue("id"); id != "" && ev.RunID != id {
|
||||
continue
|
||||
}
|
||||
data, _ := json.Marshal(ev)
|
||||
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Type, data)
|
||||
flusher.Flush()
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-native/exec"
|
||||
"agent-squad/squad"
|
||||
"agent-squad/store"
|
||||
|
||||
@@ -25,7 +24,7 @@ type scriptBackend struct{ script map[string]map[string]string }
|
||||
|
||||
func (b *scriptBackend) Name() string { return "script" }
|
||||
|
||||
func (b *scriptBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
|
||||
func (b *scriptBackend) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
|
||||
key := filepath.Base(filepath.Clean(req.WorkDir))
|
||||
files := b.script[key]
|
||||
patches := map[string][]byte{}
|
||||
@@ -39,7 +38,7 @@ func (b *scriptBackend) Execute(ctx context.Context, req exec.Request) (*exec.Re
|
||||
_ = os.WriteFile(out, []byte(content), 0o644)
|
||||
patches[t2] = []byte(content)
|
||||
}
|
||||
return &exec.Result{Success: true, Patches: patches}, nil
|
||||
return &squad.ExecResult{Success: true, Patches: patches}, nil
|
||||
}
|
||||
|
||||
const goodReport = "# API 测试交付物\n\n## 概述\n\n脚本后端生成的合格交付物正文,长度超过五十个字符以满足最小质检要求。\n"
|
||||
Vendored
+1
-1
Submodule deps/agent-intent updated: 3674dc4bfd...343b1e6d9a
Vendored
+1
-1
Submodule deps/agent-native updated: f9ab2ef698...1ac21c68d4
Vendored
+1
-1
Submodule deps/error-code-system updated: dbeed2f1b6...a3ee67bba2
Vendored
+1
-1
Submodule deps/logging-system updated: 2c5716fdcf...49b5d8be60
@@ -2,17 +2,6 @@ module agent-squad
|
||||
|
||||
go 1.27
|
||||
|
||||
require (
|
||||
agent-intent v0.0.0
|
||||
agent-native v0.0.0
|
||||
error-code-system v0.0.0
|
||||
logging-system v0.0.0
|
||||
)
|
||||
|
||||
replace agent-intent => ./deps/agent-intent
|
||||
|
||||
replace agent-native => ./deps/agent-native
|
||||
require error-code-system v0.0.0
|
||||
|
||||
replace error-code-system => ./deps/error-code-system
|
||||
|
||||
replace logging-system => ./deps/logging-system
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"key":"demo","title":"示例","steps":[{"id":"s1","role":"writer","title":"起草","parallel":false}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"role":"writer","name":"写作者","backend":"stub","tool_profile":"workspace"}
|
||||
@@ -0,0 +1,59 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"agent-squad/squad"
|
||||
)
|
||||
|
||||
func TestSamplesRoundTrip(t *testing.T) {
|
||||
var role squad.RoleSpec
|
||||
mustRoundTrip(t, "role.sample.json", &role)
|
||||
if role.Role != "writer" || role.Name != "写作者" {
|
||||
t.Fatalf("%+v", role)
|
||||
}
|
||||
var bp squad.Blueprint
|
||||
mustRoundTrip(t, "blueprint.sample.json", &bp)
|
||||
if bp.Key != "demo" || len(bp.Steps) != 1 {
|
||||
t.Fatalf("%+v", bp)
|
||||
}
|
||||
var plan squad.WorkflowPlan
|
||||
mustRoundTrip(t, "workflow-plan.sample.json", &plan)
|
||||
if plan.WorkflowID != "wf-1" || plan.Status != "pending" {
|
||||
t.Fatalf("%+v", plan)
|
||||
}
|
||||
}
|
||||
|
||||
func mustRoundTrip(t *testing.T, name string, dest any) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, dest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := json.Marshal(dest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again := reflectNew(dest)
|
||||
if err := json.Unmarshal(back, again); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func reflectNew(v any) any {
|
||||
switch v.(type) {
|
||||
case *squad.RoleSpec:
|
||||
return &squad.RoleSpec{}
|
||||
case *squad.Blueprint:
|
||||
return &squad.Blueprint{}
|
||||
case *squad.WorkflowPlan:
|
||||
return &squad.WorkflowPlan{}
|
||||
default:
|
||||
panic("unknown sample")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"workflow_id":"wf-1","title":"示例","status":"pending","current_step_index":0,"steps":[{"id":"s1","role":"writer","title":"起草","parallel":false}]}
|
||||
@@ -0,0 +1,56 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAtomicWriteLeavesNoTemp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "runstate.json")
|
||||
if err := AtomicWrite(path, []byte(`{"ok":true}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil || string(got) != `{"ok":true}` {
|
||||
t.Fatalf("content: %v %s", err, got)
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.Name() != "runstate.json" {
|
||||
t.Fatalf("temp file left behind: %s", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtomicWriteRenameFailureKeepsOriginal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "occupied")
|
||||
if err := os.Mkdir(path, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kept := filepath.Join(path, "keep.txt")
|
||||
if err := os.WriteFile(kept, []byte("stay"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := AtomicWrite(path, []byte("new")); err == nil {
|
||||
t.Fatal("rename onto a non-empty directory should fail")
|
||||
}
|
||||
got, err := os.ReadFile(kept)
|
||||
if err != nil || string(got) != "stay" {
|
||||
t.Fatalf("original changed: %v %s", err, got)
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.Name() != "occupied" {
|
||||
t.Fatalf("temp file left behind: %s", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// perStepRecordingBackend 按子工作区归因计数(steps 的 role→tasks/NN_role 布局)。
|
||||
type perStepRecordingBackend struct {
|
||||
mu sync.Mutex
|
||||
calls map[string]int
|
||||
}
|
||||
|
||||
func (b *perStepRecordingBackend) Name() string { return "recording" }
|
||||
|
||||
func (b *perStepRecordingBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
|
||||
b.mu.Lock()
|
||||
if b.calls == nil {
|
||||
b.calls = map[string]int{}
|
||||
}
|
||||
b.calls[filepath.Base(req.WorkDir)]++
|
||||
b.mu.Unlock()
|
||||
out := map[string][]byte{}
|
||||
for _, t := range req.Targets {
|
||||
out[t] = []byte(goodReport)
|
||||
}
|
||||
if _, err := MaterializeTargets(req.WorkDir, req.Targets, out); err != nil {
|
||||
return &ExecResult{Success: false, ErrorReason: err.Error()}, nil
|
||||
}
|
||||
return &ExecResult{Success: true, Logs: "ok"}, nil
|
||||
}
|
||||
|
||||
func (b *perStepRecordingBackend) countOf(sub string) int {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.calls[sub]
|
||||
}
|
||||
|
||||
func newAttemptTestEngine(t *testing.T, backend *perStepRecordingBackend, dir string) *SquadEngine {
|
||||
t.Helper()
|
||||
reg := NewRoleRegistry()
|
||||
for _, role := range []string{"researcher", "writer", "reviewer"} {
|
||||
if err := reg.Register(RoleSpec{Role: role, Backend: "recording"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
backends := NewBackendResolver()
|
||||
backends.RegisterInstance("recording", backend)
|
||||
eng, err := NewEngine(Options{
|
||||
DataDir: dir, Registry: reg, Planner: mockScriptPlanner(baseSteps()...),
|
||||
Backends: backends, Sink: NopSink{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return eng
|
||||
}
|
||||
|
||||
func flipToInterrupted(t *testing.T, workDir string, stepIdx int) {
|
||||
t.Helper()
|
||||
st, err := loadRunState(workDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st.Meta.Status = string(RunExecuting)
|
||||
st.Meta.CompletedAt = nil
|
||||
wf := st.Workflow()
|
||||
wf.Status = "running"
|
||||
wf.Steps[stepIdx].Status = "running"
|
||||
wf.Steps[stepIdx].Attempts = 1
|
||||
st.setWorkflow(wf)
|
||||
if err := st.save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAttemptIDFormat:attempt id 稳定落盘且形如 runID/stepID/N(Q1 WP3)。
|
||||
func TestAttemptIDFormat(t *testing.T) {
|
||||
backend := &perStepRecordingBackend{}
|
||||
dir := t.TempDir()
|
||||
eng := newAttemptTestEngine(t, backend, dir)
|
||||
res, err := eng.Run(context.Background(), Goal{Text: "格式"}, RunOptions{})
|
||||
if err != nil || res.Status != RunCompleted {
|
||||
t.Fatalf("run: %v %+v", err, res)
|
||||
}
|
||||
if res.Plan == nil {
|
||||
t.Fatal("no plan")
|
||||
}
|
||||
for _, s := range res.Plan.Steps {
|
||||
want := res.RunID + "/" + s.ID + "/1"
|
||||
if s.AttemptID != want {
|
||||
t.Fatalf("step %s attempt id = %q, want %q", s.ID, s.AttemptID, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestKilledAtSuperstepBoundarySideEffectsOnce:杀进程于 superstep 边界后恢复,
|
||||
// 已完成步骤不重新执行(执行次数=1);running 且产物齐全的步骤按 attempt 判定
|
||||
// 完成、不 rerun——Q1 验收第一条(WP3)。
|
||||
func TestKilledAtSuperstepBoundarySideEffectsOnce(t *testing.T) {
|
||||
backend := &perStepRecordingBackend{}
|
||||
dir := t.TempDir()
|
||||
eng := newAttemptTestEngine(t, backend, dir)
|
||||
res, err := eng.Run(context.Background(), Goal{Text: "先跑完再中断", HitlMode: HitlAuto}, RunOptions{})
|
||||
if err != nil || res.Status != RunCompleted {
|
||||
t.Fatalf("setup run: %v %+v", err, res)
|
||||
}
|
||||
before := map[string]int{}
|
||||
st, err := loadRunState(res.WorkDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, s := range st.Plan.Workflow.Steps {
|
||||
before[s.ID] = backend.countOf(filepath.Base(filepath.FromSlash(s.SubWorkspace)))
|
||||
}
|
||||
|
||||
// 第二步停在 running(产物其实在盘上——首次运行真实写出的)。
|
||||
flipToInterrupted(t, res.WorkDir, 1)
|
||||
|
||||
eng2 := newAttemptTestEngine(t, backend, dir)
|
||||
res, err = eng2.Resume(context.Background(), res.RunID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != RunCompleted {
|
||||
t.Fatalf("resume: %+v", res)
|
||||
}
|
||||
for _, s := range res.Plan.Steps {
|
||||
if s.Status != "completed" && s.Status != "skipped" {
|
||||
t.Fatalf("step %s not finished: %s", s.ID, s.Status)
|
||||
}
|
||||
got := backend.countOf(filepath.Base(filepath.FromSlash(s.SubWorkspace)))
|
||||
if got != before[s.ID] {
|
||||
t.Fatalf("step %s executed %d times after kill (before=%d)——已完成/running 步骤被重复执行", s.ID, got, before[s.ID])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestInterruptedMissingProductsRetries:running 崩溃且产物缺失时按 auto 一次重试(WP3)。
|
||||
func TestInterruptedMissingProductsRetries(t *testing.T) {
|
||||
backend := &perStepRecordingBackend{}
|
||||
dir := t.TempDir()
|
||||
eng := newAttemptTestEngine(t, backend, dir)
|
||||
res, err := eng.Run(context.Background(), Goal{Text: "先跑完再中断", HitlMode: HitlAuto}, RunOptions{})
|
||||
if err != nil || res.Status != RunCompleted {
|
||||
t.Fatalf("setup run: %v %+v", err, res)
|
||||
}
|
||||
// 第二步停 running 并删掉其交付物(副作用未发生的形态)。
|
||||
flipToInterrupted(t, res.WorkDir, 1)
|
||||
st, err := loadRunState(res.WorkDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sub := filepath.FromSlash(st.Plan.Workflow.Steps[1].SubWorkspace)
|
||||
if err := os.Remove(filepath.Join(res.WorkDir, sub, "output.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eng2 := newAttemptTestEngine(t, backend, dir)
|
||||
res, err = eng2.Resume(context.Background(), res.RunID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != RunCompleted {
|
||||
t.Fatalf("resume: %+v", res)
|
||||
}
|
||||
got := backend.countOf(filepath.Base(sub))
|
||||
if got != 2 {
|
||||
t.Fatalf("missing-product step executed %d times, want 2(auto 一次重试)", got)
|
||||
}
|
||||
}
|
||||
+11
-90
@@ -3,21 +3,13 @@ package squad
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"agent-native/adapters/native"
|
||||
"agent-native/exec"
|
||||
"agent-native/runtime/llm"
|
||||
"agent-native/runtime/mcp"
|
||||
"agent-native/runtime/subagent"
|
||||
"agent-native/runtime/tools"
|
||||
|
||||
"error-code-system/errcode"
|
||||
)
|
||||
|
||||
// BackendFactory 按角色构造后端:角色级 Model/ToolProfile 在工厂闭包层绑定
|
||||
// (agent-native exec.Request 无 Model 字段,绑定不侵入执行契约)。
|
||||
type BackendFactory func(role RoleSpec) (exec.Backend, error)
|
||||
// (agent-native ExecRequest 无 Model 字段,绑定不侵入执行契约)。
|
||||
type BackendFactory func(role RoleSpec) (Executor, error)
|
||||
|
||||
// BackendResolver 角色 → 智能体后端解析器。
|
||||
// 内置 "stub"(agent-native 离线桩);真实智能体经 RegisterAgentNative 注册
|
||||
@@ -27,18 +19,18 @@ type BackendFactory func(role RoleSpec) (exec.Backend, error)
|
||||
type BackendResolver struct {
|
||||
mu sync.RWMutex
|
||||
factories map[string]BackendFactory
|
||||
underlying map[string]exec.Backend // 无工厂直挂的后端实例
|
||||
wrapped map[string]exec.Backend // 角色(含模型绑定)→ 已包装实例缓存
|
||||
underlying map[string]Executor // 无工厂直挂的后端实例
|
||||
wrapped map[string]Executor // 角色(含模型绑定)→ 已包装实例缓存
|
||||
}
|
||||
|
||||
// NewBackendResolver 创建解析器并注册内置后端。
|
||||
func NewBackendResolver() *BackendResolver {
|
||||
r := &BackendResolver{
|
||||
factories: make(map[string]BackendFactory),
|
||||
underlying: make(map[string]exec.Backend),
|
||||
wrapped: make(map[string]exec.Backend),
|
||||
underlying: make(map[string]Executor),
|
||||
wrapped: make(map[string]Executor),
|
||||
}
|
||||
r.RegisterInstance("stub", &exec.StubBackend{})
|
||||
r.RegisterInstance("stub", &StubExecutor{})
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -50,83 +42,12 @@ func (r *BackendResolver) Register(name string, f BackendFactory) {
|
||||
}
|
||||
|
||||
// RegisterInstance 直接注册后端实例(忽略角色级绑定)。
|
||||
func (r *BackendResolver) RegisterInstance(name string, b exec.Backend) {
|
||||
func (r *BackendResolver) RegisterInstance(name string, b Executor) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.underlying[name] = b
|
||||
}
|
||||
|
||||
// AgentNativeConfig 声明 agent-native 后端的全量能力装配(组合根提供)。
|
||||
// 零值 = 仅基础模型执行:搜索/取回/执行/MCP/技能/委派工具一律不注册(fail-closed)。
|
||||
type AgentNativeConfig struct {
|
||||
Client *llm.Client
|
||||
DefaultModel string
|
||||
MaxTurns int
|
||||
Timeout time.Duration
|
||||
// 以下均为 agent-native Options 的直通注入,详见其注释。
|
||||
Search tools.SearchProvider
|
||||
Exec *tools.ExecConfig
|
||||
Fetch *tools.FetchConfig
|
||||
MCP *mcp.Manager
|
||||
SkillsRoots []string
|
||||
Subagent subagent.Factory
|
||||
Compact *native.CompactConfig
|
||||
}
|
||||
|
||||
// buildAgentNativeOptions 按角色把配置换算为 native.Options。
|
||||
// BashPermission 三态在此执行:deny = 不装配执行工具(无论组合根是否提供 Exec);
|
||||
// ask/allow = 装配(ask 的人工门由引擎 permission 挂起点先行把关)。
|
||||
func buildAgentNativeOptions(cfg AgentNativeConfig, role RoleSpec) (native.Options, error) {
|
||||
model := role.Model
|
||||
if model == "" {
|
||||
model = cfg.DefaultModel
|
||||
}
|
||||
if model == "" {
|
||||
return native.Options{}, fmt.Errorf("agent-native backend requires a model (role=%s)", role.Role)
|
||||
}
|
||||
opts := native.Options{
|
||||
Model: model,
|
||||
MaxTurns: cfg.MaxTurns,
|
||||
Timeout: cfg.Timeout,
|
||||
Search: cfg.Search,
|
||||
Fetch: cfg.Fetch,
|
||||
MCP: cfg.MCP,
|
||||
SkillsRoots: cfg.SkillsRoots,
|
||||
Subagent: cfg.Subagent,
|
||||
Compact: cfg.Compact,
|
||||
}
|
||||
if cfg.Client != nil {
|
||||
opts.APIBase, opts.APIKey = cfg.Client.BaseURL, cfg.Client.APIKey
|
||||
}
|
||||
// 显式 allow/ask 才装配(注册层默认 deny,但派生/程序构造的 RoleSpec
|
||||
// 未必经注册——工厂层兜底,空白串不放行)。
|
||||
if cfg.Exec != nil && (role.BashPermission == BashPermissionAllow || role.BashPermission == BashPermissionAsk) {
|
||||
opts.Exec = cfg.Exec
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
// RegisterAgentNative 注册 "agent-native" 后端工厂(基础签名,向后兼容):
|
||||
// 角色级 Model 覆盖 defaultModel,无附加能力装配。
|
||||
func (r *BackendResolver) RegisterAgentNative(client *llm.Client, defaultModel string, maxTurns int, timeout time.Duration) {
|
||||
r.RegisterAgentNativeFull(AgentNativeConfig{
|
||||
Client: client, DefaultModel: defaultModel, MaxTurns: maxTurns, Timeout: timeout,
|
||||
})
|
||||
}
|
||||
|
||||
// RegisterAgentNativeFull 注册带全量能力装配的 "agent-native" 工厂:
|
||||
// 搜索/取回/执行/MCP/技能/委派经配置注入;BashPermission=deny 的角色
|
||||
// 不装配执行工具。智能体执行全部委托 agent-native,本库不实现 Agent。
|
||||
func (r *BackendResolver) RegisterAgentNativeFull(cfg AgentNativeConfig) {
|
||||
r.Register("agent-native", func(role RoleSpec) (exec.Backend, error) {
|
||||
opts, err := buildAgentNativeOptions(cfg, role)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return native.New(opts), nil
|
||||
})
|
||||
}
|
||||
|
||||
// ListBackends 返回可用后端名(排序稳定)。
|
||||
func (r *BackendResolver) ListBackends() []string {
|
||||
r.mu.RLock()
|
||||
@@ -154,7 +75,7 @@ func (r *BackendResolver) ListBackends() []string {
|
||||
// Resolve 解析角色的执行后端:Role.Backend → Options.DefaultBackend → "stub"。
|
||||
// MaxConcurrentTasks > 1 时以 agent-native 并发限流包装(per-role 缓存,跨步骤共享;
|
||||
// 创建段持写锁防并行首解析竞态——否则并发步骤会各拿一份限流器)。
|
||||
func (r *BackendResolver) Resolve(role RoleSpec, defaultBackend string) (exec.Backend, string, error) {
|
||||
func (r *BackendResolver) Resolve(role RoleSpec, defaultBackend string) (Executor, string, error) {
|
||||
name := role.Backend
|
||||
if name == "" {
|
||||
name = defaultBackend
|
||||
@@ -182,7 +103,7 @@ func (r *BackendResolver) Resolve(role RoleSpec, defaultBackend string) (exec.Ba
|
||||
return b, name, nil
|
||||
}
|
||||
|
||||
var b exec.Backend
|
||||
var b Executor
|
||||
if hasUnder {
|
||||
b = under
|
||||
} else if hasFactory {
|
||||
@@ -197,7 +118,7 @@ func (r *BackendResolver) Resolve(role RoleSpec, defaultBackend string) (exec.Ba
|
||||
|
||||
// MaxConcurrentTasks>=1 即包装限流(1 = 对象级串行;0 = 不限)。
|
||||
if max := role.MaxConcurrentTasks; max >= 1 {
|
||||
b = exec.WrapWithConcurrencyLimit(b, max)
|
||||
b = WrapWithConcurrencyLimit(b, max)
|
||||
}
|
||||
r.wrapped[cacheKey] = b
|
||||
return b, name, nil
|
||||
|
||||
+1
-5
@@ -34,11 +34,7 @@ func saveBlackboard(wsDir string, bb *BlackboardState) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := filepath.Join(wsDir, ".blackboard.json.tmp")
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, filepath.Join(wsDir, "blackboard.json"))
|
||||
return AtomicWrite(filepath.Join(wsDir, "blackboard.json"), append(data, '\n'))
|
||||
}
|
||||
|
||||
// mergeStepArtifacts 合并步骤产出:fields.json(字符串键值)与
|
||||
|
||||
+6
-4
@@ -13,7 +13,7 @@ type BlueprintRegistry struct {
|
||||
mu sync.RWMutex
|
||||
bps map[string]Blueprint
|
||||
// OnChange 可选钩子:注册/删除后回调(store 层用于落盘)。
|
||||
OnChange func(bp Blueprint)
|
||||
OnChange func(bp Blueprint) error
|
||||
}
|
||||
|
||||
// NewBlueprintRegistry 创建空注册表。
|
||||
@@ -23,8 +23,8 @@ func NewBlueprintRegistry() *BlueprintRegistry {
|
||||
|
||||
// Register 注册或更新模板。
|
||||
func (r *BlueprintRegistry) Register(bp Blueprint) error {
|
||||
if strings.TrimSpace(bp.Key) == "" {
|
||||
return fmt.Errorf("blueprint key cannot be empty")
|
||||
if !ValidID(bp.Key) {
|
||||
return invalidID(bp.Key)
|
||||
}
|
||||
if len(bp.Steps) == 0 {
|
||||
return fmt.Errorf("blueprint %s has no steps", bp.Key)
|
||||
@@ -41,7 +41,9 @@ func (r *BlueprintRegistry) Register(bp Blueprint) error {
|
||||
r.bps[bp.Key] = bp
|
||||
r.mu.Unlock()
|
||||
if r.OnChange != nil {
|
||||
r.OnChange(bp)
|
||||
if err := r.OnChange(bp); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCheckpointFilesAtBoundaries:sync 默认档——每个 superstep 边界有快照,
|
||||
// latest 指向最后一层且图指纹与重编译一致(Q1 WP4)。
|
||||
func TestCheckpointFilesAtBoundaries(t *testing.T) {
|
||||
eng, _ := newTestEngine(t, mockScriptPlanner(baseSteps()...), map[string]map[string]string{
|
||||
"01_researcher": {"notes.md": goodReport, "fields.json": "{}"},
|
||||
"02_writer": {"output.md": goodReport},
|
||||
"03_reviewer": {"output.md": goodReport},
|
||||
}, NopSink{})
|
||||
res, err := eng.Run(context.Background(), Goal{Text: "快照"}, RunOptions{})
|
||||
if err != nil || res.Status != RunCompleted {
|
||||
t.Fatalf("run: %v %+v", err, res)
|
||||
}
|
||||
latest, err := FileStore{}.LoadLatestCheckpoint(res.WorkDir)
|
||||
if err != nil || latest == nil {
|
||||
t.Fatalf("latest checkpoint: %v", err)
|
||||
}
|
||||
if latest.Superstep < 3 {
|
||||
t.Fatalf("superstep = %d, want >= 3(三步各占一层)", latest.Superstep)
|
||||
}
|
||||
g, err := compileGraph(res.Plan.Steps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if latest.GraphHash != g.hash() {
|
||||
t.Fatalf("hash = %s, want %s", latest.GraphHash, g.hash())
|
||||
}
|
||||
if len(latest.StepStates) != 3 {
|
||||
t.Fatalf("step states = %d", len(latest.StepStates))
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(res.WorkDir, "checkpoints", "001.json")); err != nil {
|
||||
t.Fatalf("first boundary snapshot missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckpointAsyncAndOnExit:async 在退出前落完;on_exit 只有最终快照。
|
||||
func TestCheckpointAsyncAndOnExit(t *testing.T) {
|
||||
for _, mode := range []string{"async", "on_exit"} {
|
||||
eng, _ := newTestEngine(t, mockScriptPlanner(baseSteps()...), map[string]map[string]string{
|
||||
"01_researcher": {"notes.md": goodReport, "fields.json": "{}"},
|
||||
"02_writer": {"output.md": goodReport},
|
||||
"03_reviewer": {"output.md": goodReport},
|
||||
}, NopSink{})
|
||||
res, err := eng.Run(context.Background(), Goal{Text: "快照"}, RunOptions{CheckpointMode: mode})
|
||||
if err != nil || res.Status != RunCompleted {
|
||||
t.Fatalf("%s run: %v %+v", mode, err, res)
|
||||
}
|
||||
latest, err := FileStore{}.LoadLatestCheckpoint(res.WorkDir)
|
||||
if err != nil || latest == nil {
|
||||
t.Fatalf("%s latest: %v", mode, err)
|
||||
}
|
||||
if latest.Superstep < 3 {
|
||||
t.Fatalf("%s superstep = %d", mode, latest.Superstep)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGraphContinuityRejectsStructuralChange:崩溃期间图形态被改则拒绝恢复(WP1 裁定)。
|
||||
func TestGraphContinuityRejectsStructuralChange(t *testing.T) {
|
||||
eng, _ := newTestEngine(t, mockScriptPlanner(baseSteps()...), map[string]map[string]string{
|
||||
"01_researcher": {"notes.md": goodReport, "fields.json": "{}"},
|
||||
"02_writer": {"output.md": goodReport},
|
||||
"03_reviewer": {"output.md": goodReport},
|
||||
}, NopSink{})
|
||||
dir := eng.opts.DataDir
|
||||
res, err := eng.Run(context.Background(), Goal{Text: "先跑完再中断", HitlMode: HitlAuto}, RunOptions{})
|
||||
if err != nil || res.Status != RunCompleted {
|
||||
t.Fatalf("setup run: %v %+v", err, res)
|
||||
}
|
||||
// 翻回 running(仅状态变化,图结构不变——应先过连续性校验)。
|
||||
st, err := loadRunState(res.WorkDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st.Meta.Status = string(RunExecuting)
|
||||
st.Meta.CompletedAt = nil
|
||||
wf := st.Workflow()
|
||||
wf.Status = "running"
|
||||
wf.Steps[1].Status = "running"
|
||||
wf.Steps[1].Attempts = 1
|
||||
// 再改结构:把 s3 的 needs 改成另一个合法 id——编译能过但指纹必变。
|
||||
wf.Steps[2].Needs = []string{"s1"}
|
||||
st.setWorkflow(wf)
|
||||
if err := st.save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 用一个新的引擎在同一目录上恢复(图编译本身合法:needs 引用存在的 id)。
|
||||
reg := NewRoleRegistry()
|
||||
for _, role := range []string{"researcher", "writer", "reviewer"} {
|
||||
if err := reg.Register(RoleSpec{Role: role, Backend: "script"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
backends := NewBackendResolver()
|
||||
backends.RegisterInstance("script", &scriptBackend{script: map[string]map[string]string{}})
|
||||
eng2, err := NewEngine(Options{DataDir: dir, Registry: reg, Planner: eng.opts.Planner, Backends: backends, Sink: NopSink{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = eng2.Resume(context.Background(), res.RunID)
|
||||
if err == nil || !strings.Contains(err.Error(), "图形态不一致") && !strings.Contains(err.Error(), "graph continuity") {
|
||||
t.Fatalf("expected continuity rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func slashClean(p string) string {
|
||||
return filepath.ToSlash(filepath.Clean(p))
|
||||
}
|
||||
|
||||
// MatchDeliverableRel reports whether a runner rel_path refers to target
|
||||
// inside workDir. Accepts output.md, ./output.md, absolute paths under workDir,
|
||||
// and a duplicated step prefix (tasks/01_draft/output.md when workDir already
|
||||
// ends with tasks/01_draft). Paths that escape workDir never match.
|
||||
func MatchDeliverableRel(workDir, relPath, target string) bool {
|
||||
target = slashClean(strings.TrimSpace(target))
|
||||
if target == "" || target == "." || strings.HasPrefix(target, "../") || strings.Contains(target, "..") {
|
||||
return false
|
||||
}
|
||||
raw := strings.TrimSpace(relPath)
|
||||
if raw == "" {
|
||||
return false
|
||||
}
|
||||
abs := raw
|
||||
if !filepath.IsAbs(raw) {
|
||||
abs = filepath.Join(workDir, raw)
|
||||
}
|
||||
abs = filepath.Clean(abs)
|
||||
rel, err := filepath.Rel(filepath.Clean(workDir), abs)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
rel = slashClean(rel)
|
||||
if rel == ".." || strings.HasPrefix(rel, "../") {
|
||||
return false
|
||||
}
|
||||
if rel == target {
|
||||
return true
|
||||
}
|
||||
if filepath.Base(rel) != filepath.Base(target) {
|
||||
return false
|
||||
}
|
||||
prefix := slashClean(filepath.Dir(rel))
|
||||
wd := slashClean(workDir)
|
||||
return prefix != "." && (strings.HasSuffix(wd, "/"+prefix) || strings.HasSuffix(wd, prefix))
|
||||
}
|
||||
|
||||
// CollectTargetPatches maps incoming runner keys onto canonical target names
|
||||
// and also accepts a non-empty file already at workDir/target. Empty bodies
|
||||
// do not count — callers must not invent a deliverable.
|
||||
func CollectTargetPatches(workDir string, targets []string, incoming map[string][]byte) map[string][]byte {
|
||||
out := make(map[string][]byte, len(targets))
|
||||
for _, t := range targets {
|
||||
t = strings.TrimSpace(t)
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
for k, v := range incoming {
|
||||
if len(bytes.TrimSpace(v)) == 0 {
|
||||
continue
|
||||
}
|
||||
if MatchDeliverableRel(workDir, k, t) {
|
||||
out[t] = v
|
||||
break
|
||||
}
|
||||
}
|
||||
if _, ok := out[t]; ok {
|
||||
continue
|
||||
}
|
||||
p := filepath.Join(workDir, t)
|
||||
if b, err := os.ReadFile(p); err == nil && len(bytes.TrimSpace(b)) > 0 {
|
||||
out[t] = b
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MaterializeTargets writes collected non-empty target bodies to workDir/<target>.
|
||||
// Returns a missing-target error without writing a stub if any required file is absent.
|
||||
func MaterializeTargets(workDir string, targets []string, incoming map[string][]byte) (map[string][]byte, error) {
|
||||
collected := CollectTargetPatches(workDir, targets, incoming)
|
||||
for _, t := range targets {
|
||||
t = strings.TrimSpace(t)
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
body, ok := collected[t]
|
||||
if !ok || len(bytes.TrimSpace(body)) == 0 {
|
||||
return collected, fmt.Errorf("missing target deliverable %s", t)
|
||||
}
|
||||
dest := filepath.Join(workDir, filepath.FromSlash(t))
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return collected, err
|
||||
}
|
||||
if err := rejectSymlinkEscape(workDir, dest); err != nil {
|
||||
return collected, err
|
||||
}
|
||||
// Container-written files may be root-owned; make writable before overwrite.
|
||||
_ = os.Chmod(dest, 0o666)
|
||||
if err := os.WriteFile(dest, body, 0o644); err != nil {
|
||||
return collected, err
|
||||
}
|
||||
}
|
||||
return collected, nil
|
||||
}
|
||||
|
||||
func rejectSymlinkEscape(workDir, dest string) error {
|
||||
root, err := filepath.Abs(workDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
absDest, err := filepath.Abs(dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(root, absDest)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||
return fmt.Errorf("typed result path rejected")
|
||||
}
|
||||
resolvedRoot, err := filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
resolvedRoot = root
|
||||
}
|
||||
parent := filepath.Dir(absDest)
|
||||
resolvedParent, err := filepath.EvalSymlinks(parent)
|
||||
if err != nil {
|
||||
return fmt.Errorf("typed result path rejected")
|
||||
}
|
||||
relParent, err := filepath.Rel(resolvedRoot, resolvedParent)
|
||||
if err != nil || relParent == ".." || strings.HasPrefix(relParent, ".."+string(os.PathSeparator)) {
|
||||
return fmt.Errorf("typed result path rejected")
|
||||
}
|
||||
if info, err := os.Lstat(absDest); err == nil && info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("typed result path rejected")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+467
-72
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -34,6 +35,9 @@ type Options struct {
|
||||
HitlPolicy HitlPolicy
|
||||
// RepairBudget 质检失败自动修复次数(默认 1)。
|
||||
RepairBudget int
|
||||
// Store 运行状态持久化端口(Q1 WP4/WP5);nil 回落 FileStore。
|
||||
// 注入 SQLiteStore 后运行状态进 SQLite(装配期一次,跨 run 一致)。
|
||||
Store Store
|
||||
// IntentGate 可选意图闸门:Run 入口一次确定性裁决(nil = 不启用,
|
||||
// 行为与原实现一致)。见 intent.go;实现方为 squadintent 适配器。
|
||||
IntentGate IntentGate
|
||||
@@ -43,6 +47,9 @@ type Options struct {
|
||||
// IntentRunner 系统功能步执行器(squadintent 提供)。目录启用而 Runner 缺席时,
|
||||
// intent 步骤 fail-closed。
|
||||
IntentRunner IntentRunner
|
||||
// Router 可选分诊。direct = 单步 assistant,不进多角色;plan = leader 委派。
|
||||
// nil = 保持调用方传入的 RunOptions.Mode。
|
||||
Router Router
|
||||
}
|
||||
|
||||
// engineDeps 驱动器依赖(由引擎从 Options 派生)。
|
||||
@@ -56,6 +63,31 @@ type engineDeps struct {
|
||||
StepTimeout time.Duration
|
||||
HitlPolicy HitlPolicy
|
||||
IntentRunner IntentRunner
|
||||
// Store 运行状态持久化端口(Q1 WP4);nil 回落 FileStore(装配期注入替换实现)。
|
||||
Store Store
|
||||
}
|
||||
|
||||
// storeOr 返回注入的 Store,缺省 FileStore。
|
||||
func (d engineDeps) storeOr() Store {
|
||||
if d.Store != nil {
|
||||
return d.Store
|
||||
}
|
||||
return FileStore{}
|
||||
}
|
||||
|
||||
// loadRun 经端口加载运行状态,并让它携带同一端口(后续 save 自然走同一实现)。
|
||||
// runDir 是运行的工作区目录(所有 run 级 JSON 的根)。
|
||||
func (e *SquadEngine) runDir(runID string) string {
|
||||
return filepath.Join(e.opts.DataDir, "runs", runID)
|
||||
}
|
||||
|
||||
func (e *SquadEngine) loadRun(wsDir string) (*RunState, error) {
|
||||
st, err := e.deps.storeOr().LoadRun(wsDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st.store = e.deps.storeOr()
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// RunOptions 单次运行的可选项。
|
||||
@@ -71,6 +103,17 @@ type RunOptions struct {
|
||||
LeaderRole string `json:"leader_role,omitempty"`
|
||||
// MaxLeaderTurns leader 模式每运行的 leader 轮次上限(默认 8,防失控)。
|
||||
MaxLeaderTurns int `json:"max_leader_turns,omitempty"`
|
||||
// CheckpointMode superstep 边界快照档位(Q1 WP4):sync(默认)| async | on_exit。
|
||||
CheckpointMode string `json:"checkpoint_mode,omitempty"`
|
||||
// Topology 显式拓扑名(Q2;优先于 Blueprint.Topology;空 = standard 图)。
|
||||
// 未注册名 / primitive 单跑 / driver 未点亮均 fail-closed。
|
||||
Topology string `json:"topology,omitempty"`
|
||||
// Deadline 运行级截止时刻(Q2 long_running):superstep 边界检查,
|
||||
// 超期把未完步骤置 skipped 并降级收口。零值 = 不设限。
|
||||
Deadline time.Time `json:"deadline,omitempty"`
|
||||
// MaxReplans leader 模式的重规划上限(Q2;与 MaxLeaderTurns 独立)。
|
||||
// 0 = 默认 DefaultMaxReplans。
|
||||
MaxReplans int `json:"max_replans,omitempty"`
|
||||
}
|
||||
|
||||
// SquadEngine 小队引擎门面:Run / Respond / Resume / Inspect / ListRuns。
|
||||
@@ -127,7 +170,7 @@ func NewEngine(opts Options) (*SquadEngine, error) {
|
||||
Registry: opts.Registry, Backends: opts.Backends, Evaluator: opts.Evaluator,
|
||||
Checker: opts.Checker, Sink: opts.Sink, Workspaces: ws,
|
||||
StepTimeout: opts.StepTimeout, HitlPolicy: opts.HitlPolicy,
|
||||
IntentRunner: opts.IntentRunner,
|
||||
IntentRunner: opts.IntentRunner, Store: opts.Store,
|
||||
}
|
||||
return &SquadEngine{opts: opts, deps: deps, ws: ws, runLocks: map[string]*sync.Mutex{}}, nil
|
||||
}
|
||||
@@ -149,15 +192,42 @@ func (e *SquadEngine) runLock(runID string) *sync.Mutex {
|
||||
return l
|
||||
}
|
||||
|
||||
// ErrRunBusy 表示该 run 的文件锁正被占用,且在 ctx 截止前没有拿到。
|
||||
// 非 unix 平台没有 flock,只保留进程内互斥;跨进程互斥需要 unix。
|
||||
var ErrRunBusy = errors.New("run is busy")
|
||||
|
||||
// acquire 进入 run 临界区:先校验 id,再拿进程内锁,然后按 ctx 轮询文件锁。
|
||||
func (e *SquadEngine) acquire(ctx context.Context, runID string) (func(), error) {
|
||||
if !ValidID(runID) {
|
||||
return nil, invalidID(runID)
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
mu := e.runLock(runID)
|
||||
mu.Lock()
|
||||
release, err := lockRunFile(ctx, filepath.Join(e.opts.DataDir, "runs", runID))
|
||||
if err != nil {
|
||||
mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
return func() {
|
||||
release()
|
||||
mu.Unlock()
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Run 发起一次运行:规划(含澄清/确认挂起)→ 执行 → 收口。
|
||||
func (e *SquadEngine) Run(ctx context.Context, goal Goal, runOpts RunOptions) (*RunResult, error) {
|
||||
if strings.TrimSpace(goal.Text) == "" {
|
||||
return nil, fmt.Errorf("goal text is required")
|
||||
}
|
||||
runID := fmt.Sprintf("run_%s", time.Now().UTC().Format("20060102T150405.000000000"))
|
||||
lock := e.runLock(runID)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
release, err := e.acquire(ctx, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
|
||||
meta := RunMetadata{
|
||||
ID: runID, Goal: goal, Status: string(RunPlanning),
|
||||
@@ -175,6 +245,19 @@ func (e *SquadEngine) Run(ctx context.Context, goal Goal, runOpts RunOptions) (*
|
||||
meta.Metadata["max_leader_turns"] = fmt.Sprintf("%d", runOpts.MaxLeaderTurns)
|
||||
}
|
||||
}
|
||||
if runOpts.CheckpointMode != "" {
|
||||
meta.Metadata["checkpoint_mode"] = runOpts.CheckpointMode
|
||||
}
|
||||
if runOpts.Topology != "" {
|
||||
meta.Metadata["topology"] = runOpts.Topology
|
||||
}
|
||||
if !runOpts.Deadline.IsZero() {
|
||||
// RFC3339Nano:秒级 RFC3339 会截断亚秒精度,短截止会被立刻判超期。
|
||||
meta.Metadata["deadline"] = runOpts.Deadline.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
if runOpts.MaxReplans > 0 {
|
||||
meta.Metadata["max_replans"] = fmt.Sprintf("%d", runOpts.MaxReplans)
|
||||
}
|
||||
e.deps.Sink.Emit(Event{Type: EvRunStarted, RunID: runID, Text: goal.Text, At: time.Now()})
|
||||
|
||||
// 意图闸门(可选,入口一次裁决;显式 TaskKey 优先,不重复过闸)。
|
||||
@@ -208,22 +291,44 @@ func (e *SquadEngine) Run(ctx context.Context, goal Goal, runOpts RunOptions) (*
|
||||
}
|
||||
}
|
||||
|
||||
st := &runState{Meta: meta, Approved: map[string]bool{}, RepairBudgetRemaining: e.opts.RepairBudget}
|
||||
st := &RunState{Meta: meta, Approved: map[string]bool{}, RepairBudgetRemaining: e.opts.RepairBudget, store: e.deps.storeOr()}
|
||||
return e.planAndExecute(ctx, st, runOpts)
|
||||
}
|
||||
|
||||
// planAndExecute 规划 → 挂起或执行;st 可为已有运行状态(澄清续跑复用)。
|
||||
func (e *SquadEngine) planAndExecute(ctx context.Context, st *runState, runOpts RunOptions) (*RunResult, error) {
|
||||
func (e *SquadEngine) planAndExecute(ctx context.Context, st *RunState, runOpts RunOptions) (*RunResult, error) {
|
||||
drv := &flowDriver{opts: e.deps}
|
||||
meta := &st.Meta
|
||||
|
||||
bpMatch, _ := e.opts.Blueprints.Match(meta.Goal.Text, runOpts.TaskKey)
|
||||
planOut, err := e.opts.Planner.Plan(ctx, PlanInput{
|
||||
Goal: meta.Goal, Blueprint: bpPtr(bpMatch), Registry: e.opts.Registry, RunID: meta.ID,
|
||||
IntentCatalog: e.opts.IntentCatalog,
|
||||
})
|
||||
if err != nil {
|
||||
return e.failNew(meta, err)
|
||||
if e.opts.Router != nil && runOpts.Mode == "" && meta.Metadata["route"] == "" {
|
||||
dec, rerr := e.opts.Router.Route(ctx, meta.Goal)
|
||||
if rerr != nil {
|
||||
return e.failNew(meta, rerr)
|
||||
}
|
||||
applyRoute(meta, dec, runOpts.Mode)
|
||||
}
|
||||
|
||||
var planOut *PlanOutput
|
||||
var err error
|
||||
if meta.Metadata["route_direct"] == "1" {
|
||||
plan, perr := directAssistantPlan(e.opts.Registry, meta.Goal)
|
||||
if perr != nil {
|
||||
return e.failNew(meta, perr)
|
||||
}
|
||||
planOut = &PlanOutput{Plan: plan}
|
||||
} else {
|
||||
bpMatch, _ := e.opts.Blueprints.Match(meta.Goal.Text, runOpts.TaskKey)
|
||||
// 拓扑解析(Q2):RunOptions 优先,Blueprint 兜底。
|
||||
if meta.Metadata["topology"] == "" && bpMatch.Topology != "" {
|
||||
meta.Metadata["topology"] = bpMatch.Topology
|
||||
}
|
||||
planOut, err = e.opts.Planner.Plan(ctx, PlanInput{
|
||||
Goal: meta.Goal, Blueprint: bpPtr(bpMatch), Registry: e.opts.Registry, RunID: meta.ID,
|
||||
IntentCatalog: e.opts.IntentCatalog,
|
||||
})
|
||||
if err != nil {
|
||||
return e.failNew(meta, err)
|
||||
}
|
||||
}
|
||||
// 目录驱动编排的确定性校验:intent 步骤命中目录、必填槽位齐、枚举合法(fail-closed)。
|
||||
if err := validateIntentSteps(planOut.Plan, e.opts.IntentCatalog); err != nil {
|
||||
@@ -250,7 +355,7 @@ func (e *SquadEngine) planAndExecute(ctx context.Context, st *runState, runOpts
|
||||
Context: fmt.Sprintf("原因:%s\n问题:\n%s", planOut.Clarification.Reason, strings.Join(qs, "\n")),
|
||||
CreatedAt: time.Now(), Status: "pending",
|
||||
}
|
||||
gate := &hitlGate{Request: *req, PrevStatus: RunPlanning}
|
||||
gate := &HitlGate{Request: *req, PrevStatus: RunPlanning}
|
||||
// 澄清挂起也需要工作区承载 pending.json。
|
||||
if err := e.materializeWorkspace(meta, st, &SquadPlan{Workflow: WorkflowPlan{WorkflowID: NewPlanID(), Status: "pending", Steps: nil}}); err != nil {
|
||||
return nil, err
|
||||
@@ -288,8 +393,9 @@ func (e *SquadEngine) planAndExecute(ctx context.Context, st *runState, runOpts
|
||||
Context: fmt.Sprintf("确认原因:%s\n编制:\n%s\n步骤:\n%s",
|
||||
planOut.ConfirmReason, strings.Join(roles, "\n"), strings.Join(lines, "\n")),
|
||||
CreatedAt: time.Now(), Status: "pending",
|
||||
Reason: "plan",
|
||||
}
|
||||
gate := &hitlGate{Request: *req, Plan: planOut.Plan, PrevStatus: RunPlanning}
|
||||
gate := &HitlGate{Request: *req, Plan: planOut.Plan, PrevStatus: RunPlanning}
|
||||
if err := st.saveGate(gate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -302,6 +408,20 @@ func (e *SquadEngine) planAndExecute(ctx context.Context, st *runState, runOpts
|
||||
if err := e.materializeWorkspace(meta, st, planOut.Plan); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 拓扑校验与分发(Q2):未注册名 / primitive 单跑 / driver 未点亮 fail-closed;
|
||||
// leader 拓扑映射到既有 Mode 委派循环(不新造执行面)。
|
||||
if topo := meta.Metadata["topology"]; topo != "" {
|
||||
kind, terr := checkTopology(topo)
|
||||
if terr != nil {
|
||||
return e.failNew(meta, terr)
|
||||
}
|
||||
if kind == TopologyMode && topo == TopologyLeader {
|
||||
if meta.Metadata["mode"] == "" {
|
||||
meta.Metadata["mode"] = "leader"
|
||||
}
|
||||
}
|
||||
e.deps.Sink.Emit(Event{Type: EvPhase, RunID: meta.ID, Text: "拓扑 " + topo, At: time.Now()})
|
||||
}
|
||||
// leader-delegation 模式:忽略规划步骤,进 leader 委派循环。
|
||||
if meta.Metadata["mode"] == "leader" {
|
||||
e.initLeaderState(st)
|
||||
@@ -323,7 +443,7 @@ func (e *SquadEngine) planAndExecute(ctx context.Context, st *runState, runOpts
|
||||
}
|
||||
|
||||
// initLeaderState 按运行选项初始化 leader 循环状态。
|
||||
func (e *SquadEngine) initLeaderState(st *runState) {
|
||||
func (e *SquadEngine) initLeaderState(st *RunState) {
|
||||
leaderRole := st.Meta.Metadata["leader_role"]
|
||||
if leaderRole == "" {
|
||||
if _, ok := findAgent(st.Plan.Squad.Agents, "leader"); ok {
|
||||
@@ -336,7 +456,13 @@ func (e *SquadEngine) initLeaderState(st *runState) {
|
||||
maxTurns = n
|
||||
}
|
||||
}
|
||||
st.Leader = &LeaderState{LeaderRole: leaderRole, MaxTurns: maxTurns}
|
||||
maxReplans := DefaultMaxReplans
|
||||
if v := st.Meta.Metadata["max_replans"]; v != "" {
|
||||
if n := parseIntDefault(v, DefaultMaxReplans); n > 0 {
|
||||
maxReplans = n
|
||||
}
|
||||
}
|
||||
st.Leader = &LeaderState{LeaderRole: leaderRole, MaxTurns: maxTurns, MaxReplans: maxReplans}
|
||||
st.saveLeader()
|
||||
}
|
||||
|
||||
@@ -352,12 +478,12 @@ func parseIntDefault(s string, def int) int {
|
||||
}
|
||||
|
||||
// isLeaderRun 该运行是否处于 leader-delegation 模式。
|
||||
func (e *SquadEngine) isLeaderRun(st *runState) bool {
|
||||
func (e *SquadEngine) isLeaderRun(st *RunState) bool {
|
||||
return st.Meta.Metadata["mode"] == "leader"
|
||||
}
|
||||
|
||||
// resumeAfterHold 挂起解除后的统一续跑入口(按模式分发)。
|
||||
func (e *SquadEngine) resumeAfterHold(ctx context.Context, st *runState) (*RunResult, error) {
|
||||
func (e *SquadEngine) resumeAfterHold(ctx context.Context, st *RunState) (*RunResult, error) {
|
||||
if e.isLeaderRun(st) {
|
||||
out, gate, err := e.runLeaderLoop(ctx, st)
|
||||
if err != nil {
|
||||
@@ -378,16 +504,16 @@ func (e *SquadEngine) resumeAfterHold(ctx context.Context, st *runState) (*RunRe
|
||||
}
|
||||
|
||||
// materializeWorkspace 落盘工作区与子工作区(已存在则只更新计划并重建待执行子目录)。
|
||||
func (e *SquadEngine) materializeWorkspace(meta *RunMetadata, st *runState, plan *SquadPlan) error {
|
||||
func (e *SquadEngine) materializeWorkspace(meta *RunMetadata, st *RunState, plan *SquadPlan) error {
|
||||
if st.Meta.WorkDir == "" {
|
||||
st.Meta = *meta
|
||||
wsDir, err := e.ws.PrepareWorkspace(&st.Meta, plan)
|
||||
_, err := e.ws.PrepareWorkspace(&st.Meta, plan)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
st.Template = plan.Template
|
||||
bb, _ := loadBlackboard(wsDir)
|
||||
st.BB = *bb
|
||||
// 新运行的黑板在内存初始化;持久化由后续 st.save() 经 Store 端口完成。
|
||||
st.BB = BlackboardState{Fields: map[string]string{}}
|
||||
} else {
|
||||
// 复用既有工作区:磁盘黑板为权威(澄清续跑/计划更新场景)。
|
||||
if bb, err := loadBlackboard(st.Meta.WorkDir); err == nil {
|
||||
@@ -429,12 +555,17 @@ func (e *SquadEngine) failNew(meta *RunMetadata, err error) (*RunResult, error)
|
||||
|
||||
// Respond 应用人工决策并续跑。按请求 ID 幂等:重复回应同一请求返回当前状态。
|
||||
func (e *SquadEngine) Respond(ctx context.Context, runID string, decision RespondDecision) (*RunResult, error) {
|
||||
lock := e.runLock(runID)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
release, err := e.acquire(ctx, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
return e.respondLocked(ctx, runID, decision)
|
||||
}
|
||||
|
||||
func (e *SquadEngine) respondLocked(ctx context.Context, runID string, decision RespondDecision) (*RunResult, error) {
|
||||
wsDir := filepath.Join(e.opts.DataDir, "runs", runID)
|
||||
st, err := loadRunState(wsDir)
|
||||
st, err := e.loadRun(wsDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load run %s: %w", runID, err)
|
||||
}
|
||||
@@ -454,18 +585,85 @@ func (e *SquadEngine) Respond(ctx context.Context, runID string, decision Respon
|
||||
}
|
||||
|
||||
req := &gate.Request
|
||||
// 非 human actor 不得决议 choice 门(Q2 验收三的第二道防线:邮箱/代理
|
||||
// 消息即便伪造 Respond 调用也清不掉 permission/confirmation/quality 门)。
|
||||
if decision.Actor != "" && decision.Actor != ActorHuman && choiceHoldKind(req.Kind) &&
|
||||
strings.TrimSpace(decision.Choice) != "" {
|
||||
e.deps.Sink.Emit(Event{Type: EvPhase, RunID: runID,
|
||||
Text: fmt.Sprintf("拒绝非人工 actor(%s) 决议 %s 门(请求 %s)", decision.Actor, req.Kind, req.ID), At: time.Now()})
|
||||
return e.noteAndKeepHold(st, gate, RespondDecision{
|
||||
RequestID: decision.RequestID,
|
||||
Answer: fmt.Sprintf("[blocked] 非人工 actor(%s) 尝试决议本门,已拒绝;等待人工裁决。", decision.Actor),
|
||||
})
|
||||
}
|
||||
if strings.TrimSpace(decision.Choice) == "" && strings.TrimSpace(decision.Answer) != "" && choiceHoldKind(req.Kind) {
|
||||
return e.noteAndKeepHold(st, gate, decision)
|
||||
}
|
||||
req.Status = "answered"
|
||||
now := time.Now()
|
||||
req.AnsweredAt = &now
|
||||
if err := st.saveGate(gate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.deps.Sink.Emit(Event{Type: EvHumanResponded, RunID: runID, Text: firstNonEmpty(decision.Choice, decision.Answer),
|
||||
Data: marshalJSON(decision), At: time.Now()})
|
||||
_ = st.saveGate(nil)
|
||||
|
||||
res, err := e.applyDecision(ctx, st, gate, decision)
|
||||
if err != nil {
|
||||
req.Status = "pending"
|
||||
req.AnsweredAt = nil
|
||||
_ = st.saveGate(gate)
|
||||
return nil, err
|
||||
}
|
||||
if err := clearSameGate(st, req.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func choiceHoldKind(kind HumanRequestKind) bool {
|
||||
switch kind {
|
||||
case HitlConfirmation, HitlPermission, HitlQualityReview:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (e *SquadEngine) noteAndKeepHold(st *RunState, gate *HitlGate, decision RespondDecision) (*RunResult, error) {
|
||||
if err := st.addSteering(decision.Answer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gate.Request.Status = "pending"
|
||||
gate.Request.AnsweredAt = nil
|
||||
if err := st.saveGate(gate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return heldResult(st, gate), nil
|
||||
}
|
||||
|
||||
func clearSameGate(st *RunState, requestID string) error {
|
||||
current, err := loadGate(st.Meta.WorkDir)
|
||||
if err != nil || current == nil || current.Request.ID != requestID {
|
||||
return err
|
||||
}
|
||||
return st.saveGate(nil)
|
||||
}
|
||||
|
||||
func (e *SquadEngine) applyDecision(ctx context.Context, st *RunState, gate *HitlGate, decision RespondDecision) (*RunResult, error) {
|
||||
req := &gate.Request
|
||||
now := time.Now()
|
||||
drv := &flowDriver{opts: e.deps}
|
||||
switch req.Kind {
|
||||
case HitlClarification:
|
||||
return e.respondClarification(ctx, st, gate, decision)
|
||||
case HitlConfirmation:
|
||||
if req.Reason == "interrupted" {
|
||||
return e.respondInterrupted(ctx, st, req, decision)
|
||||
}
|
||||
if req.StepID != "" && gate.Plan == nil {
|
||||
return e.respondStepApproval(ctx, st, req, decision)
|
||||
}
|
||||
switch strings.ToLower(decision.Choice) {
|
||||
case "approve":
|
||||
if gate.Plan != nil {
|
||||
@@ -583,8 +781,17 @@ func (e *SquadEngine) Respond(ctx context.Context, runID string, decision Respon
|
||||
case "approve":
|
||||
return e.manualPass(st, decision)
|
||||
case "retry":
|
||||
// 追加一次修复预算并重跑交付步骤。
|
||||
// 追加一次修复预算并重跑交付步骤。累计追加有上限(Q26),
|
||||
// 上限内才加——无上限的人工 retry 会把运行变成永动机。
|
||||
term := e.terminationOf(st)
|
||||
if st.RepairBudgetGranted >= term.MaxRepairGranted {
|
||||
drv.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf("人工修复追加已达上限 %d,不再追加(Q26)", term.MaxRepairGranted)})
|
||||
return &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
|
||||
Error: fmt.Sprintf("repair budget grants exhausted (%d/%d)", st.RepairBudgetGranted, term.MaxRepairGranted),
|
||||
WorkDir: st.Meta.WorkDir}, nil
|
||||
}
|
||||
st.RepairBudgetRemaining++
|
||||
st.RepairBudgetGranted++
|
||||
st.Meta.Status = string(RunExecuting)
|
||||
_ = st.save()
|
||||
deliverStepID := lastCompletedStepID(st)
|
||||
@@ -601,8 +808,8 @@ func (e *SquadEngine) Respond(ctx context.Context, runID string, decision Respon
|
||||
wf.Status = "failed"
|
||||
st.setWorkflow(wf)
|
||||
_ = st.save()
|
||||
e.deps.Sink.Emit(Event{Type: EvRunFailed, RunID: runID, Text: "人工裁决质检失败", At: time.Now()})
|
||||
return &RunResult{RunID: runID, Status: RunFailed, Plan: planPtr(st),
|
||||
e.deps.Sink.Emit(Event{Type: EvRunFailed, RunID: st.Meta.ID, Text: "人工裁决质检失败", At: time.Now()})
|
||||
return &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
|
||||
Error: "quality review rejected by human", WorkDir: st.Meta.WorkDir}, nil
|
||||
}
|
||||
default:
|
||||
@@ -611,7 +818,7 @@ func (e *SquadEngine) Respond(ctx context.Context, runID string, decision Respon
|
||||
}
|
||||
|
||||
// respondClarification 澄清回应:答案并入目标后重新规划。
|
||||
func (e *SquadEngine) respondClarification(ctx context.Context, st *runState, gate *hitlGate, decision RespondDecision) (*RunResult, error) {
|
||||
func (e *SquadEngine) respondClarification(ctx context.Context, st *RunState, gate *HitlGate, decision RespondDecision) (*RunResult, error) {
|
||||
choice := strings.ToLower(decision.Choice)
|
||||
if choice == "abort" {
|
||||
return e.cancelRun(st, "人工中止(澄清阶段)"), nil
|
||||
@@ -646,7 +853,7 @@ func (e *SquadEngine) respondClarification(ctx context.Context, st *runState, ga
|
||||
}
|
||||
|
||||
// bootstrapMinimalSquad 注册通用最小编制(organizer/specialist/writer/leader)。
|
||||
func (e *SquadEngine) bootstrapMinimalSquad(st *runState) {
|
||||
func (e *SquadEngine) bootstrapMinimalSquad(st *RunState) {
|
||||
backend := st.Meta.Backend
|
||||
if backend == "" {
|
||||
backend = e.opts.DefaultBackend
|
||||
@@ -666,7 +873,7 @@ func (e *SquadEngine) bootstrapMinimalSquad(st *runState) {
|
||||
}
|
||||
|
||||
// manualPass 人工放行质检:标记 ManualPass 并收口。
|
||||
func (e *SquadEngine) manualPass(st *runState, decision RespondDecision) (*RunResult, error) {
|
||||
func (e *SquadEngine) manualPass(st *RunState, decision RespondDecision) (*RunResult, error) {
|
||||
report := e.deps.Checker.Check(CheckRequest{
|
||||
RunID: st.Meta.ID, Workspace: st.Meta.WorkDir, Deliverable: "output.md",
|
||||
Plan: planPtr(st), Squad: &st.Plan.Squad, Template: st.Template,
|
||||
@@ -695,7 +902,7 @@ func (e *SquadEngine) manualPass(st *runState, decision RespondDecision) (*RunRe
|
||||
Summary: summarize(st, *report, status) + "(质检人工放行)"}, nil
|
||||
}
|
||||
|
||||
func (e *SquadEngine) cancelRun(st *runState, reason string) *RunResult {
|
||||
func (e *SquadEngine) cancelRun(st *RunState, reason string) *RunResult {
|
||||
now := time.Now()
|
||||
st.Meta.Status = string(RunCancelled)
|
||||
st.Meta.CompletedAt = &now
|
||||
@@ -709,18 +916,143 @@ func (e *SquadEngine) cancelRun(st *runState, reason string) *RunResult {
|
||||
}
|
||||
|
||||
// failStepResume 权限拒绝后的失败步骤处理(关键失败/非关键降级)。
|
||||
func (d *flowDriver) failStepResume(ctx context.Context, st *runState, idx int) (*RunResult, *hitlGate, error) {
|
||||
func (d *flowDriver) failStepResume(ctx context.Context, st *RunState, idx int) (*RunResult, *HitlGate, error) {
|
||||
return d.failStep(ctx, st, idx)
|
||||
}
|
||||
|
||||
func (e *SquadEngine) respondStepApproval(ctx context.Context, st *RunState, req *HumanRequest, decision RespondDecision) (*RunResult, error) {
|
||||
switch strings.ToLower(decision.Choice) {
|
||||
case "approve":
|
||||
st.Approved["step:"+req.StepID] = true
|
||||
case "skip":
|
||||
if !markStep(st, req.StepID, "skipped", "") {
|
||||
return nil, fmt.Errorf("step %s not found", req.StepID)
|
||||
}
|
||||
default:
|
||||
return e.cancelRun(st, "人工中止(步骤审批)"), nil
|
||||
}
|
||||
st.Meta.Status = string(RunExecuting)
|
||||
if err := st.save(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.resumeAfterHold(ctx, st)
|
||||
}
|
||||
|
||||
func (e *SquadEngine) respondInterrupted(ctx context.Context, st *RunState, req *HumanRequest, decision RespondDecision) (*RunResult, error) {
|
||||
switch strings.ToLower(decision.Choice) {
|
||||
case "retry":
|
||||
if !markStep(st, req.StepID, "pending", "") {
|
||||
return nil, fmt.Errorf("step %s not found", req.StepID)
|
||||
}
|
||||
case "skip":
|
||||
if !markStep(st, req.StepID, "skipped", "") {
|
||||
return nil, fmt.Errorf("step %s not found", req.StepID)
|
||||
}
|
||||
default:
|
||||
return e.cancelRun(st, "人工中止(中断恢复)"), nil
|
||||
}
|
||||
st.Meta.Status = string(RunExecuting)
|
||||
if err := st.save(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.resumeAfterHold(ctx, st)
|
||||
}
|
||||
|
||||
func markStep(st *RunState, id, status, errText string) bool {
|
||||
for i := range st.Plan.Workflow.Steps {
|
||||
if st.Plan.Workflow.Steps[i].ID != id {
|
||||
continue
|
||||
}
|
||||
st.Plan.Workflow.Steps[i].Status = status
|
||||
st.Plan.Workflow.Steps[i].Error = errText
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// recoverInterrupted 处理崩溃时停在 running 的步骤。
|
||||
// interactive 挂起确认,不自动再执行;auto 允许一次重试;disabled 直接失败。
|
||||
func (e *SquadEngine) recoverInterrupted(ctx context.Context, st *RunState) (*RunResult, error) {
|
||||
for i := range st.Plan.Workflow.Steps {
|
||||
step := st.Plan.Workflow.Steps[i]
|
||||
if step.Status != "running" {
|
||||
continue
|
||||
}
|
||||
switch st.policy().mode() {
|
||||
case HitlDisabled:
|
||||
st.Plan.Workflow.Steps[i].Status = "failed"
|
||||
st.Plan.Workflow.Steps[i].Error = "interrupted"
|
||||
if err := st.save(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
drv := &flowDriver{opts: e.deps}
|
||||
out, _, err := drv.failStep(ctx, st, i)
|
||||
return out, err
|
||||
case HitlAuto:
|
||||
if step.Attempts >= 2 {
|
||||
st.Plan.Workflow.Steps[i].Status = "failed"
|
||||
st.Plan.Workflow.Steps[i].Error = "interrupted twice"
|
||||
if err := st.save(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
drv := &flowDriver{opts: e.deps}
|
||||
out, _, err := drv.failStep(ctx, st, i)
|
||||
return out, err
|
||||
}
|
||||
// Q1 WP3 产物校验:步骤在 running 崩溃,但其交付物已全部落盘——
|
||||
// 副作用实际发生过,直接判定完成而不是重新执行(副作用次数=1)。
|
||||
subDir := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(orDefault(step.SubWorkspace, fmt.Sprintf("tasks/%02d_%s", i+1, step.Role))))
|
||||
targets := step.Targets
|
||||
if len(targets) == 0 {
|
||||
targets = []string{"output.md"}
|
||||
}
|
||||
if missingTargets(subDir, targets) == nil {
|
||||
step.Status = "completed"
|
||||
step.Error = ""
|
||||
st.Plan.Workflow.Steps[i] = step
|
||||
st.mu.Lock()
|
||||
_ = mergeStepArtifacts(&st.BB, subDir)
|
||||
err := st.save()
|
||||
st.mu.Unlock()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
drv := &flowDriver{opts: e.deps}
|
||||
drv.emit(st, Event{Type: EvStepCompleted, StepID: step.ID, Role: step.Role,
|
||||
Text: fmt.Sprintf("中断恢复:产物齐全,按 attempt %s 判定完成", step.AttemptID)})
|
||||
continue
|
||||
}
|
||||
st.Plan.Workflow.Steps[i].Status = "pending"
|
||||
if err := st.save(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, nil
|
||||
default:
|
||||
drv := &flowDriver{opts: e.deps}
|
||||
out, _, err := drv.hold(st, HitlConfirmation, step.ID, step.Role,
|
||||
fmt.Sprintf("步骤「%s」在执行中中断。如何继续?", step.Title),
|
||||
[]HumanOption{
|
||||
{Value: "retry", Label: "重试"},
|
||||
{Value: "skip", Label: "跳过该步骤"},
|
||||
{Value: "abort", Label: "中止运行"},
|
||||
},
|
||||
"interrupted", "interrupted", nil)
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Resume 恢复运行:有挂起则返回挂起状态;未完成则继续推进(重启恢复入口)。
|
||||
func (e *SquadEngine) Resume(ctx context.Context, runID string) (*RunResult, error) {
|
||||
lock := e.runLock(runID)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
release, err := e.acquire(ctx, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
|
||||
wsDir := filepath.Join(e.opts.DataDir, "runs", runID)
|
||||
st, err := loadRunState(wsDir)
|
||||
st, err := e.loadRun(wsDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load run %s: %w", runID, err)
|
||||
}
|
||||
@@ -731,13 +1063,24 @@ func (e *SquadEngine) Resume(ctx context.Context, runID string) (*RunResult, err
|
||||
if RunStatus(st.Meta.Status).Terminal() {
|
||||
return e.Inspect(runID)
|
||||
}
|
||||
// 图连续性校验(Q1 WP4/WP1 裁定):崩溃期间的检查点图指纹与当前 Steps
|
||||
// 重编译不一致 = 图形态被改过(动态调整或外力),拒绝恢复并提示重新规划。
|
||||
if err := validateGraphContinuity(st); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out, err := e.recoverInterrupted(ctx, st); out != nil || err != nil {
|
||||
return out, err
|
||||
}
|
||||
return e.resumeAfterHold(ctx, st)
|
||||
}
|
||||
|
||||
// Inspect 查看运行当前状态(含挂起请求与黑板)。
|
||||
func (e *SquadEngine) Inspect(runID string) (*RunResult, error) {
|
||||
if !ValidID(runID) {
|
||||
return nil, invalidID(runID)
|
||||
}
|
||||
wsDir := filepath.Join(e.opts.DataDir, "runs", runID)
|
||||
st, err := loadRunState(wsDir)
|
||||
st, err := e.loadRun(wsDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load run %s: %w", runID, err)
|
||||
}
|
||||
@@ -762,6 +1105,9 @@ func (e *SquadEngine) Inspect(runID string) (*RunResult, error) {
|
||||
|
||||
// Blackboard 查看运行黑板。
|
||||
func (e *SquadEngine) Blackboard(runID string) (*BlackboardState, error) {
|
||||
if !ValidID(runID) {
|
||||
return nil, invalidID(runID)
|
||||
}
|
||||
wsDir := filepath.Join(e.opts.DataDir, "runs", runID)
|
||||
return loadBlackboard(wsDir)
|
||||
}
|
||||
@@ -781,7 +1127,7 @@ func (e *SquadEngine) ListRuns() ([]RunMetadata, error) {
|
||||
if !ent.IsDir() {
|
||||
continue
|
||||
}
|
||||
st, err := loadRunState(filepath.Join(runsDir, ent.Name()))
|
||||
st, err := e.loadRun(filepath.Join(runsDir, ent.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -793,6 +1139,9 @@ func (e *SquadEngine) ListRuns() ([]RunMetadata, error) {
|
||||
|
||||
// Pending 返回运行当前挂起请求(无则 nil)。
|
||||
func (e *SquadEngine) Pending(runID string) (*HumanRequest, error) {
|
||||
if !ValidID(runID) {
|
||||
return nil, invalidID(runID)
|
||||
}
|
||||
gate, err := loadGate(filepath.Join(e.opts.DataDir, "runs", runID))
|
||||
if err != nil || gate == nil {
|
||||
return nil, err
|
||||
@@ -806,11 +1155,13 @@ func (e *SquadEngine) Steer(runID, text string) error {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return fmt.Errorf("steering text is empty")
|
||||
}
|
||||
lock := e.runLock(runID)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
release, err := e.acquire(context.Background(), runID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
st, err := loadRunState(filepath.Join(e.opts.DataDir, "runs", runID))
|
||||
st, err := e.loadRun(filepath.Join(e.opts.DataDir, "runs", runID))
|
||||
if err != nil {
|
||||
return fmt.Errorf("load run %s: %w", runID, err)
|
||||
}
|
||||
@@ -828,11 +1179,13 @@ func (e *SquadEngine) SetBlackboardField(runID, key, value string) error {
|
||||
if strings.TrimSpace(key) == "" {
|
||||
return fmt.Errorf("field key is empty")
|
||||
}
|
||||
lock := e.runLock(runID)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
release, err := e.acquire(context.Background(), runID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
st, err := loadRunState(filepath.Join(e.opts.DataDir, "runs", runID))
|
||||
st, err := e.loadRun(filepath.Join(e.opts.DataDir, "runs", runID))
|
||||
if err != nil {
|
||||
return fmt.Errorf("load run %s: %w", runID, err)
|
||||
}
|
||||
@@ -862,35 +1215,77 @@ func (e *SquadEngine) SweepWakeups(ctx context.Context) int {
|
||||
if err != nil || gate == nil || gate.Request.Status != "pending" || gate.Request.WaitCondition == nil {
|
||||
continue
|
||||
}
|
||||
cond := gate.Request.WaitCondition
|
||||
met, fact := e.evaluateWaitCondition(meta.ID, cond)
|
||||
if !met {
|
||||
release, err := e.acquire(ctx, meta.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
gate.Request.WakeupFires++
|
||||
if gate.Request.WakeupFires >= MaxWakeupFires {
|
||||
// 护栏耗尽:自动中止,不无限自动续跑。
|
||||
_, _ = e.Respond(ctx, meta.ID, RespondDecision{RequestID: gate.Request.ID, Choice: "abort",
|
||||
Answer: "自动唤醒次数达上限,自动中止"})
|
||||
if e.fireWakeup(ctx, meta.ID) {
|
||||
woken++
|
||||
continue
|
||||
}
|
||||
_ = writeJSON(filepath.Join(e.opts.DataDir, "runs", meta.ID, "pending.json"), gate)
|
||||
decision := RespondDecision{RequestID: gate.Request.ID, Answer: "[自动唤醒] " + fact}
|
||||
switch gate.Request.Kind {
|
||||
case HitlConfirmation, HitlPermission, HitlQualityReview:
|
||||
decision.Choice = "approve" // 事实型条件满足视为放行依据
|
||||
default:
|
||||
decision.Choice = "resolved"
|
||||
}
|
||||
if _, err := e.Respond(ctx, meta.ID, decision); err == nil {
|
||||
woken++
|
||||
e.deps.Sink.Emit(Event{Type: "wakeup_fired", RunID: meta.ID, Text: fact, At: time.Now()})
|
||||
}
|
||||
release()
|
||||
}
|
||||
return woken
|
||||
}
|
||||
|
||||
// SweepExpired 把超过 HITL 时限的挂起请求合成 fail 或 cancel 决策。
|
||||
func (e *SquadEngine) SweepExpired(ctx context.Context, now time.Time) int {
|
||||
runs, err := e.ListRuns()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
n := 0
|
||||
for _, meta := range runs {
|
||||
if !RunStatus(meta.Status).IsHeld() {
|
||||
continue
|
||||
}
|
||||
gate, err := loadGate(filepath.Join(e.opts.DataDir, "runs", meta.ID))
|
||||
if err != nil || gate == nil || gate.Request.Status != "pending" {
|
||||
continue
|
||||
}
|
||||
decision, due := expiredDecision(gate.Request, e.deps.HitlPolicy, now)
|
||||
if !due {
|
||||
continue
|
||||
}
|
||||
if _, err := e.Respond(ctx, meta.ID, decision); err == nil {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// fireWakeup 在已持有 run 锁时处理一条挂起。只自动放行 gap 与 escalation。
|
||||
func (e *SquadEngine) fireWakeup(ctx context.Context, runID string) bool {
|
||||
gate, err := loadGate(filepath.Join(e.opts.DataDir, "runs", runID))
|
||||
if err != nil || gate == nil || gate.Request.Status != "pending" || gate.Request.WaitCondition == nil {
|
||||
return false
|
||||
}
|
||||
met, fact := e.evaluateWaitCondition(runID, gate.Request.WaitCondition)
|
||||
if !met {
|
||||
return false
|
||||
}
|
||||
gate.Request.WakeupFires++
|
||||
if err := writeJSON(filepath.Join(e.opts.DataDir, "runs", runID, "pending.json"), gate); err != nil {
|
||||
return false
|
||||
}
|
||||
if gate.Request.WakeupFires >= e.terminationOf(nil).MaxWakeupFires {
|
||||
_, err := e.respondLocked(ctx, runID, RespondDecision{RequestID: gate.Request.ID, Choice: "abort",
|
||||
Answer: "自动唤醒次数达上限,自动中止"})
|
||||
return err == nil
|
||||
}
|
||||
switch gate.Request.Kind {
|
||||
case HitlGap, HitlEscalation:
|
||||
decision := RespondDecision{RequestID: gate.Request.ID, Choice: "resolved", Answer: "[自动唤醒] " + fact}
|
||||
if _, err := e.respondLocked(ctx, runID, decision); err != nil {
|
||||
return false
|
||||
}
|
||||
e.deps.Sink.Emit(Event{Type: "wakeup_fired", RunID: runID, Text: fact, At: time.Now()})
|
||||
return true
|
||||
default:
|
||||
e.deps.Sink.Emit(Event{Type: "wakeup_held", RunID: runID, Text: fact, At: time.Now()})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// evaluateWaitCondition 评估等待条件;返回是否满足与事实描述。
|
||||
func (e *SquadEngine) evaluateWaitCondition(runID string, cond *WaitCondition) (bool, string) {
|
||||
switch cond.Type {
|
||||
@@ -977,7 +1372,7 @@ func appendAnswer(d RespondDecision) string {
|
||||
return "(" + d.Answer + ")"
|
||||
}
|
||||
|
||||
func lastCompletedStepID(st *runState) string {
|
||||
func lastCompletedStepID(st *RunState) string {
|
||||
id := ""
|
||||
for _, s := range st.Plan.Workflow.Steps {
|
||||
if s.Status == "completed" {
|
||||
|
||||
@@ -8,8 +8,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-native/exec"
|
||||
)
|
||||
|
||||
// scriptBackend 测试用脚本后端:按子目录名回放产出。
|
||||
@@ -17,7 +15,7 @@ type scriptBackend struct{ script map[string]map[string]string }
|
||||
|
||||
func (b *scriptBackend) Name() string { return "script" }
|
||||
|
||||
func (b *scriptBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
|
||||
func (b *scriptBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
|
||||
key := filepath.Base(filepath.Clean(req.WorkDir))
|
||||
files := b.script[key]
|
||||
patches := map[string][]byte{}
|
||||
@@ -33,7 +31,7 @@ func (b *scriptBackend) Execute(ctx context.Context, req exec.Request) (*exec.Re
|
||||
}
|
||||
patches[t] = []byte(content)
|
||||
}
|
||||
return &exec.Result{Success: true, Patches: patches}, nil
|
||||
return &ExecResult{Success: true, Patches: patches}, nil
|
||||
}
|
||||
|
||||
const goodReport = "# 测试交付物\n\n## 概述\n\n这是由脚本后端生成的合格交付物,长度超过五十个字符以满足最小质检要求。\n\n## 内容\n\n正文内容。\n"
|
||||
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-native/exec"
|
||||
)
|
||||
|
||||
// scriptedEvaluator 第一步后建议:追加一个补漏步骤 + 跳过冗余步骤。
|
||||
@@ -146,7 +144,7 @@ type mutableScriptBackend struct {
|
||||
|
||||
func (b *mutableScriptBackend) Name() string { return "mut" }
|
||||
|
||||
func (b *mutableScriptBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
|
||||
func (b *mutableScriptBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
|
||||
b.calls++
|
||||
if b.mutate != nil && b.calls == b.mutateAt {
|
||||
b.mutate(b.script)
|
||||
@@ -164,7 +162,7 @@ func (b *mutableScriptBackend) Execute(ctx context.Context, req exec.Request) (*
|
||||
_ = os.WriteFile(out, []byte(content), 0o644)
|
||||
patches[tg] = []byte(content)
|
||||
}
|
||||
return &exec.Result{Success: true, Patches: patches}, nil
|
||||
return &ExecResult{Success: true, Patches: patches}, nil
|
||||
}
|
||||
|
||||
// TestQualityRepairAutoRerun 验证:质检失败 → 自动重跑交付步骤 → 通过。
|
||||
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// graph 是一次执行图:由权威 Steps 现场编译(Steps 仍是唯一真相源,
|
||||
// 动态调整改 Steps 后下一轮自然生效)。只携带调度所需的派生信息:
|
||||
// 依赖、写集与可执行分层。
|
||||
type graph struct {
|
||||
needs map[string][]string // step id → 依赖 id(含相邻 Parallel 兼容映射产生的)
|
||||
order []string // 声明序
|
||||
writes map[string][]string // step id → 写集
|
||||
}
|
||||
|
||||
// compileGraph 编译步骤图为调度图。规则:
|
||||
// - 声明 Needs 的步骤:依赖即其 Needs(未知引用/环 fail-closed)。
|
||||
// - 未声明 Needs 的步骤:保持现状语义——相邻 Parallel=true 的步骤同层
|
||||
// (依赖 = 上一个「非本组」步骤),非 Parallel 步骤依赖前一个步骤。
|
||||
// - 混合图允许:Needs 提供剪枝,未声明处保持线性。
|
||||
func compileGraph(steps []StepSpec) (*graph, error) {
|
||||
g := &graph{
|
||||
needs: map[string][]string{},
|
||||
order: make([]string, 0, len(steps)),
|
||||
writes: map[string][]string{},
|
||||
}
|
||||
ids := map[string]bool{}
|
||||
for _, s := range steps {
|
||||
if s.ID == "" {
|
||||
return nil, fmt.Errorf("graph: 存在空 id 的步骤")
|
||||
}
|
||||
if ids[s.ID] {
|
||||
return nil, fmt.Errorf("graph: 步骤 id 重复:%s", s.ID)
|
||||
}
|
||||
ids[s.ID] = true
|
||||
}
|
||||
prevNonGroup := -1 // 上一个「非本 Parallel 组」的步骤索引
|
||||
for i, s := range steps {
|
||||
g.order = append(g.order, s.ID)
|
||||
g.writes[s.ID] = append([]string(nil), s.Writes...)
|
||||
needsDecl := len(s.Needs) > 0
|
||||
switch {
|
||||
case needsDecl:
|
||||
for _, n := range s.Needs {
|
||||
if !ids[n] {
|
||||
return nil, fmt.Errorf("graph: 步骤 %s 的 needs 引用未知步骤 %s", s.ID, n)
|
||||
}
|
||||
if n == s.ID {
|
||||
return nil, fmt.Errorf("graph: 步骤 %s 的 needs 引用自身", s.ID)
|
||||
}
|
||||
}
|
||||
g.needs[s.ID] = append([]string(nil), s.Needs...)
|
||||
prevNonGroup = i
|
||||
case s.Parallel:
|
||||
// 相邻 Parallel 组:依赖上一个组外步骤(没有则无依赖)。
|
||||
if prevNonGroup >= 0 {
|
||||
g.needs[s.ID] = []string{steps[prevNonGroup].ID}
|
||||
}
|
||||
// Parallel 步骤不推进 prevNonGroup:相邻同伴同层。
|
||||
default:
|
||||
// 非 Parallel:依赖紧邻的前一步(与旧线性扫描的执行序一致——
|
||||
// 整组 Parallel 完成后才轮到它,而不是只等组外前一步)。
|
||||
if i > 0 {
|
||||
g.needs[s.ID] = []string{steps[i-1].ID}
|
||||
}
|
||||
prevNonGroup = i
|
||||
}
|
||||
}
|
||||
if cycle := g.findCycle(); cycle != nil {
|
||||
return nil, fmt.Errorf("graph: needs 依赖成环:%s", strings.Join(cycle, " → "))
|
||||
}
|
||||
// 循环步与路由步校验(Q2):预算必须显式,引用必须存在,两者互斥
|
||||
//(同一步既循环又分支的语义未定义,fail-closed)。
|
||||
for _, s := range steps {
|
||||
if s.Loop != nil && len(s.Branches) > 0 {
|
||||
return nil, fmt.Errorf("graph: 步骤 %s 不得同时声明 loop 与 branches", s.ID)
|
||||
}
|
||||
if s.Loop != nil {
|
||||
if s.Loop.MaxIterations <= 0 {
|
||||
return nil, fmt.Errorf("graph: 步骤 %s 的 loop.max_iterations 必须 > 0", s.ID)
|
||||
}
|
||||
for _, b := range s.Loop.Body {
|
||||
if !ids[b] {
|
||||
return nil, fmt.Errorf("graph: 步骤 %s 的 loop.body 引用未知步骤 %s", s.ID, b)
|
||||
}
|
||||
if b == s.ID {
|
||||
return nil, fmt.Errorf("graph: 步骤 %s 的 loop.body 引用自身", s.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, br := range s.Branches {
|
||||
if br.When.Field == "" {
|
||||
return nil, fmt.Errorf("graph: 步骤 %s 的分支缺少 when.field", s.ID)
|
||||
}
|
||||
if br.When.Equals == "" && br.When.NotEquals == "" {
|
||||
return nil, fmt.Errorf("graph: 步骤 %s 的分支 when 需要 equals 或 not_equals", s.ID)
|
||||
}
|
||||
if len(br.Then) == 0 {
|
||||
return nil, fmt.Errorf("graph: 步骤 %s 的分支 then 为空", s.ID)
|
||||
}
|
||||
for _, t := range br.Then {
|
||||
if !ids[t] {
|
||||
return nil, fmt.Errorf("graph: 步骤 %s 的分支 then 引用未知步骤 %s", s.ID, t)
|
||||
}
|
||||
if t == s.ID {
|
||||
return nil, fmt.Errorf("graph: 步骤 %s 的分支 then 引用自身", s.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// findCycle 报告一条依赖环(没有则 nil)。
|
||||
func (g *graph) findCycle() []string {
|
||||
const (
|
||||
white = iota
|
||||
gray
|
||||
black
|
||||
)
|
||||
color := map[string]int{}
|
||||
var stack []string
|
||||
var visit func(id string) []string
|
||||
visit = func(id string) []string {
|
||||
if color[id] == gray {
|
||||
return append(stack, id)
|
||||
}
|
||||
if color[id] == black {
|
||||
return nil
|
||||
}
|
||||
color[id] = gray
|
||||
stack = append(stack, id)
|
||||
for _, n := range g.needs[id] {
|
||||
if c := visit(n); c != nil {
|
||||
return c
|
||||
}
|
||||
}
|
||||
stack = stack[:len(stack)-1]
|
||||
color[id] = black
|
||||
return nil
|
||||
}
|
||||
for _, id := range g.order {
|
||||
if c := visit(id); c != nil {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// layer 是一个 superstep 的可执行集合:seq 为有写集冲突需按声明序串行的
|
||||
// 步骤索引,par 为可并行的步骤索引。先执行 par 组(runParallel 语义),
|
||||
// 再逐个执行 seq(含动态调整),与现状「并行组先于后续顺序步骤」一致。
|
||||
type layer struct {
|
||||
par []int
|
||||
seq []int
|
||||
}
|
||||
|
||||
// stepPending 报告步骤是否仍待执行。蓝图登记的步骤不带 status(空串),
|
||||
// 旧线性扫描将其视为可执行——这里与之一致:空串等同 pending。
|
||||
func stepPending(status string) bool {
|
||||
return status == "" || status == "pending" || status == "running"
|
||||
}
|
||||
|
||||
// nextLayer 计算当前可执行层:状态为 pending/running 且全部依赖已「满足」。
|
||||
// 依赖满足 = completed/skipped/failed——failed 视为满足与现状「非关键失败
|
||||
// 降级继续」的线性扫描一致(关键失败时运行已终止,不会走到这里)。
|
||||
// 无可执行步骤时返回 (layer{}, false):可能全部完成,也可能调度卡死
|
||||
// (由调用方用 pendingCount 区分)。
|
||||
func (g *graph) nextLayer(steps []StepSpec) (layer, bool) {
|
||||
idxOf := map[string]int{}
|
||||
for i, s := range steps {
|
||||
idxOf[s.ID] = i
|
||||
}
|
||||
satisfied := func(id string) bool {
|
||||
st := steps[idxOf[id]].Status
|
||||
return st == "completed" || st == "skipped" || st == "failed"
|
||||
}
|
||||
var out layer
|
||||
for i, s := range steps {
|
||||
if !stepPending(s.Status) {
|
||||
continue
|
||||
}
|
||||
ready := true
|
||||
for _, n := range g.needs[s.ID] {
|
||||
if !satisfied(n) {
|
||||
ready = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ready {
|
||||
continue
|
||||
}
|
||||
if g.conflictsWithEarlier(out, i, steps) {
|
||||
out.seq = append(out.seq, i)
|
||||
} else {
|
||||
out.par = append(out.par, i)
|
||||
}
|
||||
}
|
||||
return out, len(out.par)+len(out.seq) > 0
|
||||
}
|
||||
|
||||
// conflictsWithEarlier 报告步骤 i 的写集与本层中更早的可执行步骤相交。
|
||||
func (g *graph) conflictsWithEarlier(l layer, i int, steps []StepSpec) bool {
|
||||
wi := g.writes[steps[i].ID]
|
||||
if len(wi) == 0 {
|
||||
return false
|
||||
}
|
||||
earlier := append(append([]int(nil), l.par...), l.seq...)
|
||||
for _, j := range earlier {
|
||||
for _, w := range g.writes[steps[j].ID] {
|
||||
for _, v := range wi {
|
||||
if w == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// pendingCount 统计仍未终结(pending/running/空串)的步骤数。
|
||||
func pendingCount(steps []StepSpec) int {
|
||||
n := 0
|
||||
for _, s := range steps {
|
||||
if stepPending(s.Status) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// hash 是图结构指纹:声明序、依赖与写集的稳定散列。
|
||||
// 恢复连续性校验用它比对崩溃前后的图形态(Q1 WP4;WP1 的图版本裁定落点)。
|
||||
func (g *graph) hash() string {
|
||||
h := fnv.New64a()
|
||||
for _, id := range g.order {
|
||||
h.Write([]byte(id))
|
||||
h.Write([]byte{0})
|
||||
for _, n := range g.needs[id] {
|
||||
h.Write([]byte(">"))
|
||||
h.Write([]byte(n))
|
||||
}
|
||||
h.Write([]byte{1})
|
||||
for _, w := range g.writes[id] {
|
||||
h.Write([]byte("w"))
|
||||
h.Write([]byte(w))
|
||||
}
|
||||
h.Write([]byte{2})
|
||||
}
|
||||
return fmt.Sprintf("%016x", h.Sum64())
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func stepsOf(specs ...StepSpec) []StepSpec {
|
||||
out := make([]StepSpec, len(specs))
|
||||
copy(out, specs)
|
||||
return out
|
||||
}
|
||||
|
||||
func TestCompileGraphLinearAndAdjacentParallel(t *testing.T) {
|
||||
steps := stepsOf(
|
||||
StepSpec{ID: "a", Status: "pending"},
|
||||
StepSpec{ID: "b", Status: "pending", Parallel: true},
|
||||
StepSpec{ID: "c", Status: "pending", Parallel: true},
|
||||
StepSpec{ID: "d", Status: "pending"},
|
||||
)
|
||||
g, err := compileGraph(steps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 线性链:b/c 同层依赖 a,d 依赖 c。
|
||||
if len(g.needs["a"]) != 0 {
|
||||
t.Fatalf("a needs = %v", g.needs["a"])
|
||||
}
|
||||
if len(g.needs["b"]) != 1 || g.needs["b"][0] != "a" || g.needs["c"][0] != "a" {
|
||||
t.Fatalf("parallel needs = %v %v", g.needs["b"], g.needs["c"])
|
||||
}
|
||||
if len(g.needs["d"]) != 1 || g.needs["d"][0] != "c" {
|
||||
t.Fatalf("d needs = %v", g.needs["d"])
|
||||
}
|
||||
// 首轮可执行:只有 a。
|
||||
layer, ok := g.nextLayer(steps)
|
||||
if !ok || len(layer.par) != 1 || layer.par[0] != 0 || len(layer.seq) != 0 {
|
||||
t.Fatalf("first layer = %+v", layer)
|
||||
}
|
||||
// a 完成后:b、c 同层并行。
|
||||
steps[0].Status = "completed"
|
||||
layer, ok = g.nextLayer(steps)
|
||||
if !ok || len(layer.par) != 2 || layer.par[0] != 1 || layer.par[1] != 2 {
|
||||
t.Fatalf("second layer = %+v", layer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileGraphNeedsPruneAndMixed(t *testing.T) {
|
||||
steps := stepsOf(
|
||||
StepSpec{ID: "a", Status: "pending"},
|
||||
StepSpec{ID: "b", Status: "pending"},
|
||||
StepSpec{ID: "c", Status: "pending", Needs: []string{"a"}},
|
||||
)
|
||||
g, err := compileGraph(steps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// a 完成后:b(线性依赖 a)与 c(needs a)同层。
|
||||
steps[0].Status = "completed"
|
||||
layer, ok := g.nextLayer(steps)
|
||||
if !ok || len(layer.par) != 2 {
|
||||
t.Fatalf("layer = %+v", layer)
|
||||
}
|
||||
// b 失败(非关键降级)后 c 仍按 needs 调度(与线性扫描的降级继续一致)。
|
||||
steps[1].Status = "failed"
|
||||
layer, ok = g.nextLayer(steps)
|
||||
if !ok || len(layer.par)+len(layer.seq) == 0 {
|
||||
t.Fatalf("after b failed: layer = %+v", layer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileGraphRejectsUnknownAndCycleAndDup(t *testing.T) {
|
||||
if _, err := compileGraph(stepsOf(
|
||||
StepSpec{ID: "a", Status: "pending", Needs: []string{"ghost"}},
|
||||
)); err == nil || !strings.Contains(err.Error(), "未知步骤") {
|
||||
t.Fatalf("unknown ref: %v", err)
|
||||
}
|
||||
if _, err := compileGraph(stepsOf(
|
||||
StepSpec{ID: "a", Status: "pending", Needs: []string{"b"}},
|
||||
StepSpec{ID: "b", Status: "pending", Needs: []string{"a"}},
|
||||
)); err == nil || !strings.Contains(err.Error(), "成环") {
|
||||
t.Fatalf("cycle: %v", err)
|
||||
}
|
||||
if _, err := compileGraph(stepsOf(
|
||||
StepSpec{ID: "a", Status: "pending"},
|
||||
StepSpec{ID: "a", Status: "pending"},
|
||||
)); err == nil || !strings.Contains(err.Error(), "重复") {
|
||||
t.Fatalf("dup: %v", err)
|
||||
}
|
||||
if _, err := compileGraph(stepsOf(
|
||||
StepSpec{ID: "a", Status: "pending", Needs: []string{"a"}},
|
||||
)); err == nil || !strings.Contains(err.Error(), "自身") {
|
||||
t.Fatalf("self ref: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSetConflictSerializes(t *testing.T) {
|
||||
steps := stepsOf(
|
||||
StepSpec{ID: "z", Status: "completed"},
|
||||
StepSpec{ID: "a", Status: "pending", Parallel: true, Writes: []string{"report.md"}},
|
||||
StepSpec{ID: "b", Status: "pending", Parallel: true, Writes: []string{"report.md"}},
|
||||
StepSpec{ID: "c", Status: "pending", Parallel: true, Writes: []string{"notes.md"}},
|
||||
StepSpec{ID: "d", Status: "pending", Parallel: true},
|
||||
)
|
||||
// 全部首轮可执行(无依赖):b 与 a 冲突 → b 串行;c、d 无冲突 → 并行。
|
||||
g, err := compileGraph(steps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
layer, ok := g.nextLayer(steps)
|
||||
if !ok {
|
||||
t.Fatal("no layer")
|
||||
}
|
||||
if len(layer.seq) != 1 || layer.seq[0] != 2 {
|
||||
t.Fatalf("seq = %v", layer.seq)
|
||||
}
|
||||
if len(layer.par) != 3 || layer.par[0] != 1 || layer.par[1] != 3 || layer.par[2] != 4 {
|
||||
t.Fatalf("par = %v", layer.par)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedDependencyStillSchedules(t *testing.T) {
|
||||
// 与线性扫描的「非关键失败降级继续」一致:依赖 failed 仍视为满足。
|
||||
steps := stepsOf(
|
||||
StepSpec{ID: "b", Status: "failed"},
|
||||
StepSpec{ID: "a", Status: "pending", Needs: []string{"b"}},
|
||||
)
|
||||
g, err := compileGraph(steps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
layer, ok := g.nextLayer(steps)
|
||||
if !ok || len(layer.par) != 1 || layer.par[0] != 1 {
|
||||
t.Fatalf("failed dependency should still schedule: %+v", layer)
|
||||
}
|
||||
if pendingCount(steps) != 1 {
|
||||
t.Fatalf("pending = %d", pendingCount(steps))
|
||||
}
|
||||
}
|
||||
+39
-4
@@ -75,8 +75,10 @@ type HumanRequest struct {
|
||||
Kind HumanRequestKind `json:"kind"`
|
||||
Severity string `json:"severity,omitempty"` // action_required(默认)/ attention
|
||||
Question string `json:"question"`
|
||||
Options []HumanOption `json:"options,omitempty"`
|
||||
Context string `json:"context,omitempty"` // 脱敏摘要(计划/缺口/质检报告)
|
||||
// Reason 区分同一 Kind 的来源:plan、step_approval、interrupted。空值保持旧行为。
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Options []HumanOption `json:"options,omitempty"`
|
||||
Context string `json:"context,omitempty"` // 脱敏摘要(计划/缺口/质检报告)
|
||||
// WaitCondition 非空时,条件满足可自动唤醒(人工回应仍然优先)。
|
||||
WaitCondition *WaitCondition `json:"wait_condition,omitempty"`
|
||||
// WakeupFires 已自动唤醒次数(护栏:达到 MaxWakeupFires 后过期)。
|
||||
@@ -103,8 +105,19 @@ type RespondDecision struct {
|
||||
Choice string `json:"choice,omitempty"`
|
||||
// EditPlan 确认计划时的修改指令(附加在 Answer 上传递给引擎)。
|
||||
EditPlan string `json:"edit_plan,omitempty"`
|
||||
// Actor 决议发起方(Q2):"" 或 human = 人工(默认,兼容既有调用方);
|
||||
// agent/system = 非人工。非人工 actor 的 choice 决议在
|
||||
// permission/confirmation/quality_review 门上一律被拒绝(验收三)。
|
||||
Actor string `json:"actor,omitempty"`
|
||||
}
|
||||
|
||||
// Actor 归属常量。
|
||||
const (
|
||||
ActorHuman = "human"
|
||||
ActorAgent = "agent"
|
||||
ActorSystem = "system"
|
||||
)
|
||||
|
||||
// newRequestID 生成请求 ID。
|
||||
func newRequestID() string {
|
||||
b := make([]byte, 8)
|
||||
@@ -112,8 +125,30 @@ func newRequestID() string {
|
||||
return "hr_" + hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// hitlGate 引擎内部:一次挂起的完整上下文(落盘 pending.json)。
|
||||
type hitlGate struct {
|
||||
// expiredDecision 在请求超过 TimeoutAfter 或 ExpiresAt 时合成过期决策。
|
||||
// ExpireAction 为 cancel 时选择 cancel,否则 fail。未到期返回 false。
|
||||
func expiredDecision(req HumanRequest, policy HitlPolicy, now time.Time) (RespondDecision, bool) {
|
||||
var deadline time.Time
|
||||
switch {
|
||||
case req.ExpiresAt != nil:
|
||||
deadline = *req.ExpiresAt
|
||||
case policy.TimeoutAfter > 0 && !req.CreatedAt.IsZero():
|
||||
deadline = req.CreatedAt.Add(policy.TimeoutAfter)
|
||||
default:
|
||||
return RespondDecision{}, false
|
||||
}
|
||||
if now.Before(deadline) {
|
||||
return RespondDecision{}, false
|
||||
}
|
||||
choice := "fail"
|
||||
if policy.ExpireAction == "cancel" {
|
||||
choice = "cancel"
|
||||
}
|
||||
return RespondDecision{RequestID: req.ID, Choice: choice, Answer: "expired"}, true
|
||||
}
|
||||
|
||||
// HitlGate 引擎内部:一次挂起的完整上下文(落盘 pending.json)。
|
||||
type HitlGate struct {
|
||||
Request HumanRequest `json:"request"`
|
||||
Plan *SquadPlan `json:"plan,omitempty"` // confirmation 时携带候选计划
|
||||
Steps []StepSpec `json:"steps,omitempty"` // adjustment:建议追加的步骤
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// idPattern 限制 run、角色、蓝图 key:首字符为字母或数字,其余为字母、数字、点、下划线、连字符,最长 128,且不得含 ".."。
|
||||
var idPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
|
||||
|
||||
// ValidID 报告 id 是否可安全拼进数据目录路径。
|
||||
func ValidID(id string) bool {
|
||||
return idPattern.MatchString(id) && !strings.Contains(id, "..")
|
||||
}
|
||||
|
||||
func invalidID(id string) error {
|
||||
return fmt.Errorf("invalid id %q", id)
|
||||
}
|
||||
+1
-3
@@ -3,8 +3,6 @@ package squad
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"agent-native/exec"
|
||||
)
|
||||
|
||||
// IntentRejection 守卫拦截:拒绝该次运行并给出引导("识别即拒绝"语义)。
|
||||
@@ -96,7 +94,7 @@ type IntentStep struct {
|
||||
|
||||
// IntentRunner 系统功能执行器(squadintent 提供实现:查 spec.Handler 并调宿主处理函数)。
|
||||
type IntentRunner interface {
|
||||
Run(ctx context.Context, step IntentStep) (*exec.Result, error)
|
||||
Run(ctx context.Context, step IntentStep) (*ExecResult, error)
|
||||
}
|
||||
|
||||
// DefaultIntentRole 系统功能步的默认角色 key(无后端语义,仅满足编制覆盖)。
|
||||
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-native/exec"
|
||||
)
|
||||
|
||||
type stubRunner struct {
|
||||
@@ -16,7 +14,7 @@ type stubRunner struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *stubRunner) Run(ctx context.Context, step IntentStep) (*exec.Result, error) {
|
||||
func (r *stubRunner) Run(ctx context.Context, step IntentStep) (*ExecResult, error) {
|
||||
r.calls = append(r.calls, step)
|
||||
if r.err != nil {
|
||||
return nil, r.err
|
||||
@@ -25,7 +23,7 @@ func (r *stubRunner) Run(ctx context.Context, step IntentStep) (*exec.Result, er
|
||||
for _, t := range step.Targets {
|
||||
patches[t] = []byte(r.text)
|
||||
}
|
||||
return &exec.Result{Success: true, Patches: patches, Logs: "stub-intent"}, nil
|
||||
return &ExecResult{Success: true, Patches: patches, Logs: "stub-intent"}, nil
|
||||
}
|
||||
|
||||
func testCatalog() *IntentCatalog {
|
||||
|
||||
+143
-27
@@ -8,32 +8,30 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agent-native/exec"
|
||||
)
|
||||
|
||||
// leaderExecRequest 构造 leader 轮次的执行请求:与标准步骤同等装配
|
||||
// (过程事件桥、ToolProfile/Skills/工具白名单、执行标识与预算)——
|
||||
// 修平此前 leader 轮丢弃 OnEvent 与 ToolProfile 的缺口。
|
||||
func leaderExecRequest(drv *flowDriver, st *runState, leader RoleSpec, workDir, prompt, instructions string, targets []string, timeout time.Duration) exec.Request {
|
||||
func leaderExecRequest(drv *flowDriver, st *RunState, leader RoleSpec, workDir, prompt, instructions string, targets []string, timeout time.Duration) ExecRequest {
|
||||
stepID := fmt.Sprintf("leader_turn_%d", st.Leader.Turns)
|
||||
var budget *exec.Budget
|
||||
var budget *ExecBudget
|
||||
if timeout > 0 {
|
||||
budget = &exec.Budget{Deadline: time.Now().Add(timeout)}
|
||||
budget = &ExecBudget{Deadline: time.Now().Add(timeout)}
|
||||
}
|
||||
return exec.Request{
|
||||
return ExecRequest{
|
||||
WorkDir: workDir,
|
||||
Prompt: prompt,
|
||||
Instructions: instructions,
|
||||
Targets: targets,
|
||||
Timeout: timeout,
|
||||
ToolProfile: orDefault(leader.ToolProfile, exec.ToolProfileWorkspace),
|
||||
ToolProfile: orDefault(leader.ToolProfile, ToolProfileWorkspace),
|
||||
ToolAllowlist: leader.Tools,
|
||||
Skills: leader.Skills,
|
||||
SessionScope: st.Meta.ID + "/" + stepID,
|
||||
ExecutionID: st.Meta.ID + "-" + stepID,
|
||||
Budget: budget,
|
||||
OnEvent: func(ev exec.ProcessEvent) {
|
||||
OnEvent: func(ev ProcessEvent) {
|
||||
drv.emit(st, Event{Type: EvDetail, StepID: stepID, Role: leader.Role, Text: ev.Text,
|
||||
Data: marshalJSON(map[string]string{"process_type": ev.Type, "tool": ev.Tool, "detail": ev.Detail})})
|
||||
},
|
||||
@@ -49,12 +47,34 @@ func leaderExecRequest(drv *flowDriver, st *runState, leader RoleSpec, workDir,
|
||||
// DefaultMaxLeaderTurns leader 模式每运行的 leader 轮次上限。
|
||||
const DefaultMaxLeaderTurns = 8
|
||||
|
||||
// DefaultMaxReplans leader 卡顿触发的重规划上限(Q2;与轮次上限独立)。
|
||||
const DefaultMaxReplans = 3
|
||||
|
||||
// DefaultStuckThreshold 连续无进展轮数达到该值触发一次重规划(Q2)。
|
||||
const DefaultStuckThreshold = 2
|
||||
|
||||
// LeaderState leader 循环状态(workspace leader.json 持久化,重启可恢复)。
|
||||
type LeaderState struct {
|
||||
Turns int `json:"turns"`
|
||||
MaxTurns int `json:"max_turns"`
|
||||
LeaderRole string `json:"leader_role"`
|
||||
Delegations []DelegationRecord `json:"delegations"`
|
||||
// 进度账本(Q2):每轮委派后记一条进展评估;卡顿检测与重规划的凭据。
|
||||
Progress []ProgressEntry `json:"progress,omitempty"`
|
||||
// StuckTurns 连续无进展轮数(有进展即清零)。
|
||||
StuckTurns int `json:"stuck_turns,omitempty"`
|
||||
// Replans 已触发的重规划次数;MaxReplans 触顶降级收口。
|
||||
Replans int `json:"replans,omitempty"`
|
||||
MaxReplans int `json:"max_replans,omitempty"`
|
||||
// ReplanNote 一次性重规划指令(并入下一轮 leader 任务书后清空)。
|
||||
ReplanNote string `json:"replan_note,omitempty"`
|
||||
}
|
||||
|
||||
// ProgressEntry 是进度账本的一条评估记录。
|
||||
type ProgressEntry struct {
|
||||
Turn int `json:"turn"`
|
||||
Signal string `json:"signal"` // progress | stuck
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// DelegationRecord 一次已执行的委派(历史注入下一轮 leader 上下文)。
|
||||
@@ -73,6 +93,8 @@ type delegation struct {
|
||||
Member string `json:"member,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Targets []string `json:"targets,omitempty"`
|
||||
Skills []string `json:"skills,omitempty"`
|
||||
MinScore int `json:"min_score,omitempty"`
|
||||
Briefing string `json:"briefing,omitempty"` // 给成员的补充指令
|
||||
Question string `json:"question,omitempty"` // escalate 时的问题
|
||||
Reason string `json:"reason,omitempty"`
|
||||
@@ -80,22 +102,24 @@ type delegation struct {
|
||||
|
||||
const leaderSubWorkspace = "tasks/00_leader"
|
||||
|
||||
func (st *runState) loadLeader() {
|
||||
func (st *RunState) loadLeader() {
|
||||
if st.Leader != nil {
|
||||
return
|
||||
}
|
||||
st.Leader = &LeaderState{MaxTurns: DefaultMaxLeaderTurns}
|
||||
data, err := os.ReadFile(filepath.Join(st.Meta.WorkDir, "leader.json"))
|
||||
if err == nil {
|
||||
data, err := st.storeOr().LoadLeader(st.Meta.WorkDir)
|
||||
if err == nil && data != nil {
|
||||
_ = json.Unmarshal(data, st.Leader)
|
||||
}
|
||||
}
|
||||
|
||||
func (st *runState) saveLeader() {
|
||||
func (st *RunState) saveLeader() {
|
||||
if st.Leader == nil {
|
||||
return
|
||||
}
|
||||
_ = writeJSON(filepath.Join(st.Meta.WorkDir, "leader.json"), st.Leader)
|
||||
if data, err := json.Marshal(st.Leader); err == nil {
|
||||
_ = st.storeOr().SaveLeader(st.Meta.WorkDir, data)
|
||||
}
|
||||
}
|
||||
|
||||
// leaderPrompt 组装 leader 的系统指令(协调契约 + 通用守则)。
|
||||
@@ -104,7 +128,7 @@ func leaderPrompt(members []RoleSpec, catalogMd string) string {
|
||||
b.WriteString(`你是本小队的 LEADER。你被激活的唯一职责是协调,不是自己实现。
|
||||
|
||||
每轮你必须做且只做一件事:评估当前进展,然后输出一份委派决策文件 delegation.json 到本子目录(用工具写入,不要输出到对话里):
|
||||
{"action":"delegate","member":"成员role","title":"给成员的子任务标题","targets":["output.md"],"briefing":"给成员的补充指令/验收标准","reason":"为什么派给这个人"}
|
||||
{"action":"delegate","member":"成员role","title":"给成员的子任务标题","targets":["output.md"],"skills":["可选,覆盖该成员本步技能"],"min_score":0,"briefing":"给成员的补充指令/验收标准","reason":"为什么派给这个人"}
|
||||
{"action":"escalate","question":"要向人提出的问题","reason":"为什么需要人拍板"}
|
||||
{"action":"close","reason":"目标已达成,收口"}
|
||||
{"action":"failed","reason":"无法达成的原因"}
|
||||
@@ -132,7 +156,7 @@ func leaderPrompt(members []RoleSpec, catalogMd string) string {
|
||||
}
|
||||
|
||||
// leaderTaskBrief 组装 leader 本轮的 TASK.md(目标 + 黑板 + 委派历史)。
|
||||
func leaderTaskBrief(st *runState) string {
|
||||
func leaderTaskBrief(st *RunState, mail []MailMessage) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("# Leader 轮次:评估并委派\n\n")
|
||||
fmt.Fprintf(&b, "## 运行目标\n%s\n\n", st.Meta.Goal.Text)
|
||||
@@ -169,13 +193,28 @@ func leaderTaskBrief(st *runState) string {
|
||||
} else {
|
||||
b.WriteString("(尚无委派,这是第一轮。)\n\n")
|
||||
}
|
||||
if len(st.Leader.Progress) > 0 {
|
||||
b.WriteString("## 进度账本\n")
|
||||
for _, p := range st.Leader.Progress {
|
||||
fmt.Fprintf(&b, "- 第%d轮 [%s] %s\n", p.Turn, p.Signal, p.Detail)
|
||||
}
|
||||
fmt.Fprintf(&b, "- 已重规划 %d/%d 次\n\n", st.Leader.Replans, st.Leader.MaxReplans)
|
||||
}
|
||||
if st.Leader.ReplanNote != "" {
|
||||
fmt.Fprintf(&b, "%s\n\n", st.Leader.ReplanNote)
|
||||
}
|
||||
|
||||
if section := formatMailSection(mail); section != "" {
|
||||
b.WriteString(strings.TrimPrefix(section, "\n\n"))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("## 本轮产出要求\n\n写 delegation.json(结构见你的指令)到本子目录。\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// runLeaderLoop leader-delegation 主循环:委派→执行→唤醒,直到
|
||||
// close/failed/上限/挂起(escalation/gap/权限等由 HITL 处理后重入本函数)。
|
||||
func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResult, *hitlGate, error) {
|
||||
func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *RunState) (*RunResult, *HitlGate, error) {
|
||||
st.loadLeader()
|
||||
drv := &flowDriver{opts: e.deps}
|
||||
leader, ok := findAgent(st.Plan.Squad.Agents, st.Leader.LeaderRole)
|
||||
@@ -204,7 +243,8 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
|
||||
}
|
||||
_ = snapshotContextInto(st.Meta.WorkDir, subDir)
|
||||
|
||||
for st.Leader.Turns < st.Leader.MaxTurns {
|
||||
term := e.terminationOf(st)
|
||||
for st.Leader.Turns < term.MaxLeaderTurns {
|
||||
// HITL:leader 角色权限升级(与标准模式同语义)。
|
||||
if leader.BashPermission == "ask" && !st.Approved["role:"+leader.Role] {
|
||||
if drv.policy(st).mode() != HitlInteractive {
|
||||
@@ -220,7 +260,7 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
|
||||
},
|
||||
CreatedAt: time.Now(), Status: "pending",
|
||||
}
|
||||
gate := &hitlGate{Request: *req, PrevStatus: RunExecuting}
|
||||
gate := &HitlGate{Request: *req, PrevStatus: RunExecuting}
|
||||
if err := st.saveGate(gate); err == nil {
|
||||
st.Meta.Status = string(RunWaitingHuman)
|
||||
_ = st.save()
|
||||
@@ -230,9 +270,20 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
|
||||
}
|
||||
|
||||
// 每轮重写 leader 上下文(黑板/委派历史已变化)。
|
||||
if err := os.WriteFile(filepath.Join(subDir, "TASK.md"), []byte(leaderTaskBrief(st)), 0o644); err != nil {
|
||||
// 邮箱消息与重规划指令是一次性的:并入本轮任务书后清空并落盘。
|
||||
st.mu.Lock()
|
||||
leaderMail := takeMailFor(st, st.Leader.LeaderRole)
|
||||
st.mu.Unlock()
|
||||
if err := os.WriteFile(filepath.Join(subDir, "TASK.md"), []byte(leaderTaskBrief(st, leaderMail)), 0o644); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(leaderMail) > 0 {
|
||||
_ = st.save()
|
||||
}
|
||||
if st.Leader.ReplanNote != "" {
|
||||
st.Leader.ReplanNote = ""
|
||||
st.saveLeader()
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(subDir, "AGENTS.md"), []byte(leaderPrompt(members, e.opts.IntentCatalog.intentCatalogMd())), 0o644); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -246,7 +297,7 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
|
||||
if err != nil {
|
||||
return e.leaderFail(st, err.Error()), nil, nil
|
||||
}
|
||||
prompt := readFileOr(filepath.Join(subDir, "TASK.md"), leaderTaskBrief(st))
|
||||
prompt := readFileOr(filepath.Join(subDir, "TASK.md"), leaderTaskBrief(st, nil))
|
||||
prompt = st.injectSteering(prompt)
|
||||
instructions := readFileOr(filepath.Join(subDir, "AGENTS.md"), leaderPrompt(members, e.opts.IntentCatalog.intentCatalogMd()))
|
||||
result, execErr := backend.Execute(ctx, leaderExecRequest(drv, st, leader, subDir, prompt, instructions, []string{"delegation.json"}, e.deps.StepTimeout))
|
||||
@@ -292,7 +343,7 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
|
||||
},
|
||||
CreatedAt: time.Now(), Status: "pending",
|
||||
}
|
||||
gate := &hitlGate{Request: *req, PrevStatus: RunExecuting}
|
||||
gate := &HitlGate{Request: *req, PrevStatus: RunExecuting}
|
||||
if err := st.saveGate(gate); err == nil {
|
||||
st.Meta.Status = string(RunWaitingHuman)
|
||||
_ = st.save()
|
||||
@@ -316,6 +367,12 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
|
||||
} else {
|
||||
step.Targets = []string{"output.md"}
|
||||
}
|
||||
if len(dec.Skills) > 0 {
|
||||
step.Skills = dec.Skills
|
||||
}
|
||||
if dec.MinScore > 0 {
|
||||
step.MinScore = dec.MinScore
|
||||
}
|
||||
st.Plan.Workflow.Steps = append(st.Plan.Workflow.Steps, step)
|
||||
_ = st.save()
|
||||
|
||||
@@ -329,6 +386,7 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
|
||||
memberBrief += "\n\n## Leader 补充指令\n" + dec.Briefing
|
||||
_ = os.WriteFile(filepath.Join(st.Meta.WorkDir, filepath.FromSlash(step.SubWorkspace), "TASK.md"), []byte(memberBrief), 0o644)
|
||||
}
|
||||
factsBefore := len(st.BB.Facts)
|
||||
out, gate, err := drv.runOneStep(ctx, st, idx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -346,10 +404,56 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
|
||||
return out, gate, nil // 挂起(gap/权限等),Respond 后重入循环
|
||||
}
|
||||
st.Leader.Delegations = append(st.Leader.Delegations, rec)
|
||||
// 进度账本(Q2):委派完成且有新事实、或不是与上轮同题重复 → 有进展。
|
||||
factsGrew := len(st.BB.Facts) > factsBefore
|
||||
sameAsPrev := false
|
||||
if n := len(st.Leader.Delegations); n >= 2 {
|
||||
prev := st.Leader.Delegations[n-2]
|
||||
sameAsPrev = prev.Member == rec.Member && prev.Title == rec.Title
|
||||
}
|
||||
if factsGrew || !sameAsPrev {
|
||||
st.Leader.StuckTurns = 0
|
||||
st.Leader.Progress = append(st.Leader.Progress, ProgressEntry{
|
||||
Turn: rec.Turn, Signal: "progress",
|
||||
Detail: fmt.Sprintf("%s:%s(新事实 %v)", rec.Member, rec.Title, factsGrew),
|
||||
})
|
||||
} else {
|
||||
st.Leader.StuckTurns++
|
||||
st.Leader.Progress = append(st.Leader.Progress, ProgressEntry{
|
||||
Turn: rec.Turn, Signal: "stuck",
|
||||
Detail: fmt.Sprintf("与上轮同题重复且无新事实(连续 %d 轮)", st.Leader.StuckTurns),
|
||||
})
|
||||
}
|
||||
// 卡顿 → 重规划;重规划触顶 → 降级收口(与轮次上限同款 Q23 语义)。
|
||||
if st.Leader.StuckTurns >= DefaultStuckThreshold {
|
||||
st.Leader.StuckTurns = 0
|
||||
st.Leader.Replans++
|
||||
if st.Leader.Replans >= st.Leader.MaxReplans {
|
||||
st.saveLeader()
|
||||
drv.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf(
|
||||
"重规划达上限 %d,按现有产出降级收口(Q2)", st.Leader.MaxReplans)})
|
||||
out2, gate2, err2 := drv.finalize(ctx, st)
|
||||
if err2 != nil {
|
||||
return nil, nil, err2
|
||||
}
|
||||
if gate2 != nil {
|
||||
return heldResult(st, gate2), gate2, nil
|
||||
}
|
||||
return out2, nil, nil
|
||||
}
|
||||
st.Leader.ReplanNote = fmt.Sprintf(
|
||||
"【重规划指令】连续 %d 轮无进展(同题重复且无新事实),已触发第 %d/%d 次重规划。"+
|
||||
"本轮必须改变策略:换成员、把任务拆小、补充缺失输入,或 escalate 问人;不得原样重发上一次委派。",
|
||||
DefaultStuckThreshold, st.Leader.Replans, st.Leader.MaxReplans)
|
||||
drv.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf(
|
||||
"卡顿检测:触发重规划 %d/%d", st.Leader.Replans, st.Leader.MaxReplans)})
|
||||
}
|
||||
st.saveLeader()
|
||||
// 成员交付快照进 leader inbox(leader 工具沙箱看不到成员目录,
|
||||
// 须物化到 leader 工作区才能自查交付——InputLayoutSnapshot 的对称应用)。
|
||||
snapshotDeliverablesToLeader(st, step)
|
||||
if err := snapshotDeliverablesToLeader(st, step); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// 成员产出后检查阻塞缺口(等待条件挂起在标准 gap 通道上)。
|
||||
if g2 := drv.checkBlockingGaps(st); g2 != nil {
|
||||
return heldResult(st, g2), g2, nil
|
||||
@@ -360,11 +464,21 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
|
||||
return e.leaderFail(st, fmt.Sprintf("leader 输出了未知 action %q", dec.Action)), nil, nil
|
||||
}
|
||||
}
|
||||
return e.leaderFail(st, fmt.Sprintf("leader 轮次达上限 %d(防失控护栏),运行停止;可提高 MaxLeaderTurns 或人工介入", st.Leader.MaxTurns)), nil, nil
|
||||
// Q23:达上限不再裸失败——按现有产出走 finalize 降级收口(质检/汇总路径与正常收口一致)。
|
||||
drv2 := &flowDriver{opts: e.deps}
|
||||
drv2.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf("leader 轮次达上限 %d,按现有产出降级收口(Q23)", term.MaxLeaderTurns)})
|
||||
out, gate2, err := drv2.finalize(ctx, st)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if gate2 != nil {
|
||||
return heldResult(st, gate2), gate2, nil
|
||||
}
|
||||
return out, nil, nil
|
||||
}
|
||||
|
||||
// leaderFail 统一的 leader 模式失败收口。
|
||||
func (e *SquadEngine) leaderFail(st *runState, reason string) *RunResult {
|
||||
func (e *SquadEngine) leaderFail(st *RunState, reason string) *RunResult {
|
||||
now := time.Now()
|
||||
st.Meta.Status = string(RunFailed)
|
||||
st.Meta.CompletedAt = &now
|
||||
@@ -378,7 +492,7 @@ func (e *SquadEngine) leaderFail(st *runState, reason string) *RunResult {
|
||||
}
|
||||
|
||||
// snapshotDeliverablesToLeader 把成员交付文件复制进 leader 工作区 inbox/<步骤id>/。
|
||||
func snapshotDeliverablesToLeader(st *runState, step StepSpec) {
|
||||
func snapshotDeliverablesToLeader(st *RunState, step StepSpec) error {
|
||||
srcDir := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(step.SubWorkspace))
|
||||
dstDir := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(leaderSubWorkspace), "inbox", step.ID)
|
||||
for _, t := range step.Targets {
|
||||
@@ -387,9 +501,11 @@ func snapshotDeliverablesToLeader(st *runState, step StepSpec) {
|
||||
continue
|
||||
}
|
||||
dst := filepath.Join(dstDir, filepath.Base(filepath.FromSlash(t)))
|
||||
_ = os.MkdirAll(filepath.Dir(dst), 0o755)
|
||||
_ = os.WriteFile(dst, data, 0o644)
|
||||
if err := AtomicWrite(dst, data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseDelegation 解析 leader 产出的 delegation.json。
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// leader_mailbox_conformance_test.go — Q2 WP2 验收:leader 进度账本/重规划
|
||||
// 上限、mailbox 投递、验收三「mailbox 消息不能把挂起的权限批准掉」。
|
||||
|
||||
// ---- leader 进度账本与重规划 ----
|
||||
|
||||
// 同题重复委派且无新事实 → 卡顿计数 → 触发重规划(事件+账本+一次性指令),
|
||||
// leader 收到指令后 close,运行完成且 Replans=1。
|
||||
func TestLeaderLedgerStuckTriggersReplan(t *testing.T) {
|
||||
b := &leaderScriptBackend{
|
||||
decisions: []string{
|
||||
delegateJSON("writer", "同一件事"), // 第1轮:有进展(首委派)
|
||||
delegateJSON("writer", "同一件事"), // 第2轮:同题无新事实 → stuck 1
|
||||
delegateJSON("writer", "同一件事"), // 第3轮:stuck 2 → 重规划
|
||||
closeJSON, // 第4轮:收到重规划指令后收口
|
||||
},
|
||||
script: map[string]map[string]string{
|
||||
"01_writer": {"output.md": goodReport},
|
||||
"02_writer": {"output.md": goodReport},
|
||||
"03_writer": {"output.md": goodReport},
|
||||
},
|
||||
prompts: map[string][]string{},
|
||||
}
|
||||
sink := &collectSink{}
|
||||
engine := newLeaderEngineWithSink(t, b, sink)
|
||||
res, err := engine.Run(context.Background(), Goal{Text: "写一份报告"}, RunOptions{Mode: "leader"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != RunCompleted {
|
||||
t.Fatalf("status = %s err = %v", res.Status, res.Error)
|
||||
}
|
||||
st, err := engine.loadRun(engine.runDir(res.RunID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st.loadLeader()
|
||||
if st.Leader.Replans != 1 {
|
||||
t.Fatalf("Replans = %d,期望 1", st.Leader.Replans)
|
||||
}
|
||||
var stuck, progress int
|
||||
for _, p := range st.Leader.Progress {
|
||||
switch p.Signal {
|
||||
case "stuck":
|
||||
stuck++
|
||||
case "progress":
|
||||
progress++
|
||||
}
|
||||
}
|
||||
if stuck != 2 || progress != 1 {
|
||||
t.Fatalf("账本 progress=%d stuck=%d,期望 1/2", progress, stuck)
|
||||
}
|
||||
if sink.countText("触发重规划 1/3") != 1 {
|
||||
t.Fatal("缺重规划事件")
|
||||
}
|
||||
// 重规划指令一次性并入下一轮 leader 任务书。
|
||||
var sawNote bool
|
||||
for _, p := range b.prompts["00_leader"] {
|
||||
if strings.Contains(p, "重规划指令") {
|
||||
sawNote = true
|
||||
}
|
||||
}
|
||||
if !sawNote {
|
||||
t.Fatal("leader 未收到重规划指令")
|
||||
}
|
||||
}
|
||||
|
||||
// 重规划触顶(MaxReplans=1)→ 走 finalize 收口路径(与 Q23 轮次上限同款:
|
||||
// 不裸失败;终态取决于既有产出),并发出触顶事件、账本记账、不再进 leader 轮。
|
||||
func TestLeaderReplanCapFinalizes(t *testing.T) {
|
||||
b := &leaderScriptBackend{
|
||||
decisions: []string{
|
||||
delegateJSON("writer", "同一件事"),
|
||||
delegateJSON("writer", "同一件事"),
|
||||
delegateJSON("writer", "同一件事"), // stuck 2 → Replans=1 >= cap → 收口
|
||||
closeJSON, // 不应到达
|
||||
},
|
||||
script: map[string]map[string]string{
|
||||
"01_writer": {"output.md": goodReport},
|
||||
"02_writer": {"output.md": goodReport},
|
||||
"03_writer": {"output.md": goodReport},
|
||||
},
|
||||
}
|
||||
sink := &collectSink{}
|
||||
engine := newLeaderEngineWithSink(t, b, sink)
|
||||
res, err := engine.Run(context.Background(), Goal{Text: "写一份报告"}, RunOptions{Mode: "leader", MaxReplans: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
switch res.Status {
|
||||
case RunCompleted, RunDegraded, RunQualityReview:
|
||||
default:
|
||||
t.Fatalf("重规划触顶应走 finalize 收口而非 %s(err=%v)", res.Status, res.Error)
|
||||
}
|
||||
if sink.countText("重规划达上限 1") != 1 {
|
||||
t.Fatal("缺触顶事件")
|
||||
}
|
||||
st, err := engine.loadRun(engine.runDir(res.RunID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st.loadLeader()
|
||||
if st.Leader.Replans != 1 || st.Leader.MaxReplans != 1 {
|
||||
t.Fatalf("Replans=%d MaxReplans=%d,期望 1/1", st.Leader.Replans, st.Leader.MaxReplans)
|
||||
}
|
||||
if b.turn > 3 {
|
||||
t.Fatalf("触顶后不应再进 leader 轮(turn=%d)", b.turn)
|
||||
}
|
||||
}
|
||||
|
||||
func newLeaderEngineWithSink(t *testing.T, backend Executor, sink ProgressSink) *SquadEngine {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
reg := NewRoleRegistry()
|
||||
for _, r := range []RoleSpec{
|
||||
{Role: "leader", Name: "主管", Backend: "leader-script", Skills: []string{"协调"}},
|
||||
{Role: "writer", Name: "撰写", Backend: "leader-script", Skills: []string{"撰写"}},
|
||||
} {
|
||||
if err := reg.Register(r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
backends := NewBackendResolver()
|
||||
backends.RegisterInstance("leader-script", backend)
|
||||
engine, err := NewEngine(Options{DataDir: dir, Registry: reg, Backends: backends, Sink: sink})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return engine
|
||||
}
|
||||
|
||||
// ---- mailbox:投递 + 验收三 ----
|
||||
|
||||
// 挂起(gap)期间投递 → Respond 恢复 → 下一步骤任务书含消息;广播 "*" 同样到达;
|
||||
// 已执行完的步骤不被追投;定向消息只投递一次。
|
||||
func TestMailboxDeliveredAfterHoldIntoNextStep(t *testing.T) {
|
||||
rec := &promptRecordingBackend{prompts: map[string][]string{}}
|
||||
engine := newRecordingEngine(t, rec, gapSteps())
|
||||
res, err := engine.Run(context.Background(), Goal{Text: "带缺口"}, RunOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != RunWaitingHuman || res.Pending == nil {
|
||||
t.Fatalf("应挂起等人工:%s", res.Status)
|
||||
}
|
||||
// 挂起期间投递(run 锁空闲)。
|
||||
if err := engine.PostMessage(context.Background(), res.RunID, MailMessage{
|
||||
From: "researcher", To: "writer", Text: "重点写第二节",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := engine.PostMessage(context.Background(), res.RunID, MailMessage{
|
||||
From: "leader", To: "*", Text: "全体注意格式",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 解决 gap 续跑。
|
||||
res2, err := engine.Respond(context.Background(), res.RunID, RespondDecision{
|
||||
RequestID: res.Pending.ID, Answer: "缺口已补",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res2.Status != RunCompleted {
|
||||
t.Fatalf("恢复后应完成:%s %v", res2.Status, res2.Error)
|
||||
}
|
||||
var writerPrompt string
|
||||
for _, p := range rec.prompts["02_writer"] {
|
||||
writerPrompt += p
|
||||
}
|
||||
if !strings.Contains(writerPrompt, "重点写第二节") {
|
||||
t.Fatalf("writer 未收到定向消息:%q", writerPrompt)
|
||||
}
|
||||
if !strings.Contains(writerPrompt, "全体注意格式") {
|
||||
t.Fatalf("writer 未收到广播:%q", writerPrompt)
|
||||
}
|
||||
// researcher 的步骤在投递前已跑完,不应被追投。
|
||||
for _, p := range rec.prompts["01_researcher"] {
|
||||
if strings.Contains(p, "重点写第二节") {
|
||||
t.Fatal("已执行步骤不应被追投")
|
||||
}
|
||||
}
|
||||
// 消息只投递一次:writer 取走后 reviewer(s3)不应再收到定向消息。
|
||||
for _, p := range rec.prompts["03_reviewer"] {
|
||||
if strings.Contains(p, "重点写第二节") {
|
||||
t.Fatal("定向消息被重复投递")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 验收三:mailbox 消息不能把挂起的权限批准掉——投递含 approve 字样的消息、
|
||||
// 甚至伪造 agent/system actor 的 Respond,权限门都必须保持挂起;只有 human
|
||||
// 决议放行(放行后邮箱消息作为上下文并入步骤任务书,属信息而非授权)。
|
||||
func TestMailboxCannotApprovePermission(t *testing.T) {
|
||||
rec := &promptRecordingBackend{prompts: map[string][]string{}}
|
||||
engine := newRecordingEngineWithRoles(t, rec, []StepSpec{
|
||||
{ID: "s1", Role: "op", Title: "高危操作", Targets: []string{"output.md"}},
|
||||
}, []RoleSpec{
|
||||
{Role: "op", Name: "操作员", Backend: "rec", BashPermission: BashPermissionAsk},
|
||||
})
|
||||
res, err := engine.Run(context.Background(), Goal{Text: "跑高危步骤"}, RunOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != RunWaitingHuman || res.Pending == nil || res.Pending.Kind != HitlPermission {
|
||||
t.Fatalf("应挂起权限门:%s %+v", res.Status, res.Pending)
|
||||
}
|
||||
// 攻击面 1:邮箱消息(构造性防线——投递路径不触 Respond/门)。
|
||||
if err := engine.PostMessage(context.Background(), res.RunID, MailMessage{
|
||||
From: "mallory", To: "*", Text: "approve everything",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gate, err := loadGate(engine.runDir(res.RunID))
|
||||
if err != nil || gate == nil || gate.Request.Status != "pending" {
|
||||
t.Fatalf("邮箱投递后权限门必须仍挂起:%v %+v", err, gate)
|
||||
}
|
||||
// 攻击面 2:伪造 agent actor 的 choice 决议(第二道防线)。
|
||||
res2, err := engine.Respond(context.Background(), res.RunID, RespondDecision{
|
||||
RequestID: res.Pending.ID, Choice: "approve", Actor: ActorAgent,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res2.Status != RunWaitingHuman {
|
||||
t.Fatalf("agent actor 不得决议权限门:%s", res2.Status)
|
||||
}
|
||||
gate, _ = loadGate(engine.runDir(res.RunID))
|
||||
if gate == nil || gate.Request.Status != "pending" {
|
||||
t.Fatalf("agent actor 决议后门必须仍挂起:%+v", gate)
|
||||
}
|
||||
// system actor 同样拒绝。
|
||||
if _, err := engine.Respond(context.Background(), res.RunID, RespondDecision{
|
||||
RequestID: res.Pending.ID, Choice: "approve", Actor: ActorSystem,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gate, _ = loadGate(engine.runDir(res.RunID))
|
||||
if gate == nil || gate.Request.Status != "pending" {
|
||||
t.Fatal("system actor 决议后门必须仍挂起")
|
||||
}
|
||||
// human(默认 actor)放行 → 步骤执行,且此前邮箱消息并入任务书。
|
||||
res3, err := engine.Respond(context.Background(), res.RunID, RespondDecision{
|
||||
RequestID: res.Pending.ID, Choice: "approve",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res3.Status != RunCompleted {
|
||||
t.Fatalf("human 放行后应完成:%s %v", res3.Status, res3.Error)
|
||||
}
|
||||
var sawMail bool
|
||||
for _, p := range rec.prompts["01_op"] {
|
||||
if strings.Contains(p, "approve everything") {
|
||||
sawMail = true
|
||||
}
|
||||
}
|
||||
if !sawMail {
|
||||
t.Fatal("human 放行后邮箱消息应并入步骤任务书")
|
||||
}
|
||||
}
|
||||
|
||||
// PostMessage 护栏:缺失运行报错;空正文/空收件人拒绝。
|
||||
func TestPostMessageGuards(t *testing.T) {
|
||||
rec := &promptRecordingBackend{prompts: map[string][]string{}}
|
||||
engine := newRecordingEngine(t, rec, []StepSpec{
|
||||
{ID: "s1", Role: "researcher", Title: "一", Targets: []string{"output.md"}},
|
||||
})
|
||||
if err := engine.PostMessage(context.Background(), "run_missing", MailMessage{To: "x", Text: "y"}); err == nil {
|
||||
t.Fatal("缺失运行必须报错")
|
||||
}
|
||||
res, err := engine.Run(context.Background(), Goal{Text: "x"}, RunOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != RunCompleted {
|
||||
t.Fatalf("status = %s", res.Status)
|
||||
}
|
||||
if err := engine.PostMessage(context.Background(), res.RunID, MailMessage{To: "researcher", Text: ""}); err == nil {
|
||||
t.Fatal("空正文必须拒绝")
|
||||
}
|
||||
if err := engine.PostMessage(context.Background(), res.RunID, MailMessage{To: "", Text: "x"}); err == nil {
|
||||
t.Fatal("空收件人必须拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 夹具 ----
|
||||
|
||||
// promptRecordingBackend 按目录记录 prompt;gaps.json 产出阻塞缺口触发挂起,
|
||||
// 其余目标写合格交付物。
|
||||
type promptRecordingBackend struct {
|
||||
prompts map[string][]string
|
||||
}
|
||||
|
||||
func (b *promptRecordingBackend) Name() string { return "rec" }
|
||||
|
||||
func (b *promptRecordingBackend) Execute(_ context.Context, req ExecRequest) (*ExecResult, error) {
|
||||
key := filepath.Base(filepath.Clean(req.WorkDir))
|
||||
b.prompts[key] = append(b.prompts[key], req.Prompt)
|
||||
patches := map[string][]byte{}
|
||||
for _, t := range req.Targets {
|
||||
content := goodReport
|
||||
switch t {
|
||||
case "gaps.json":
|
||||
content = `[{"id":"g1","item":"缺一个输入","owner":"human","blocking":true}]`
|
||||
case "fields.json":
|
||||
content = `{}`
|
||||
}
|
||||
out := filepath.Join(req.WorkDir, t)
|
||||
if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(out, []byte(content), 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
patches[t] = []byte(content)
|
||||
}
|
||||
return &ExecResult{Success: true, Patches: patches}, nil
|
||||
}
|
||||
|
||||
func newRecordingEngine(t *testing.T, be Executor, steps []StepSpec) *SquadEngine {
|
||||
t.Helper()
|
||||
return newRecordingEngineWithRoles(t, be, steps, []RoleSpec{
|
||||
{Role: "researcher", Name: "调研", Backend: "rec"},
|
||||
{Role: "writer", Name: "撰写", Backend: "rec"},
|
||||
{Role: "reviewer", Name: "审核", Backend: "rec"},
|
||||
})
|
||||
}
|
||||
|
||||
func newRecordingEngineWithRoles(t *testing.T, be Executor, steps []StepSpec, roles []RoleSpec) *SquadEngine {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
reg := NewRoleRegistry()
|
||||
for _, r := range roles {
|
||||
if err := reg.Register(r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
backends := NewBackendResolver()
|
||||
backends.RegisterInstance("rec", be)
|
||||
engine, err := NewEngine(Options{DataDir: dir, Registry: reg,
|
||||
Planner: mockScriptPlanner(steps...), Backends: backends, Sink: NopSink{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return engine
|
||||
}
|
||||
+17
-14
@@ -10,8 +10,6 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-native/exec"
|
||||
)
|
||||
|
||||
// leaderScriptBackend leader 模式脚本后端:00_leader 目录按轮次回放
|
||||
@@ -26,7 +24,7 @@ type leaderScriptBackend struct {
|
||||
|
||||
func (b *leaderScriptBackend) Name() string { return "leader-script" }
|
||||
|
||||
func (b *leaderScriptBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
|
||||
func (b *leaderScriptBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
|
||||
key := filepath.Base(filepath.Clean(req.WorkDir))
|
||||
if b.prompts != nil {
|
||||
b.mu.Lock()
|
||||
@@ -43,7 +41,7 @@ func (b *leaderScriptBackend) Execute(ctx context.Context, req exec.Request) (*e
|
||||
}
|
||||
out := filepath.Join(req.WorkDir, "delegation.json")
|
||||
_ = os.WriteFile(out, []byte(b.decisions[idx]), 0o644)
|
||||
return &exec.Result{Success: true, Patches: map[string][]byte{"delegation.json": []byte(b.decisions[idx])}}, nil
|
||||
return &ExecResult{Success: true, Patches: map[string][]byte{"delegation.json": []byte(b.decisions[idx])}}, nil
|
||||
}
|
||||
files := b.script[key]
|
||||
patches := map[string][]byte{}
|
||||
@@ -57,7 +55,7 @@ func (b *leaderScriptBackend) Execute(ctx context.Context, req exec.Request) (*e
|
||||
_ = os.WriteFile(out, []byte(content), 0o644)
|
||||
patches[t] = []byte(content)
|
||||
}
|
||||
return &exec.Result{Success: true, Patches: patches,
|
||||
return &ExecResult{Success: true, Patches: patches,
|
||||
PromptTokens: 100, CompletionTokens: 50}, nil
|
||||
}
|
||||
|
||||
@@ -67,7 +65,7 @@ func delegateJSON(member, title string) string {
|
||||
|
||||
const closeJSON = `{"action":"close","reason":"所有子任务完成,交付物满足目标"}`
|
||||
|
||||
func newLeaderEngine(t *testing.T, backend exec.Backend, roles ...RoleSpec) *SquadEngine {
|
||||
func newLeaderEngine(t *testing.T, backend Executor, roles ...RoleSpec) *SquadEngine {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
reg := NewRoleRegistry()
|
||||
@@ -202,8 +200,13 @@ func TestLeaderModeTurnCapGuard(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != RunFailed || !strings.Contains(res.Error, "上限") {
|
||||
t.Fatalf("want turn-cap failure, got %s err=%s", res.Status, res.Error)
|
||||
// Q23:达上限走 finalize 降级收口(completed/degraded/质检挂起),
|
||||
// 不再是「运行停止」的裸失败。
|
||||
if res.Status == RunFailed && strings.Contains(res.Error, "运行停止") {
|
||||
t.Fatalf("Q23 closeout should not be a bare failure, got %s err=%s", res.Status, res.Error)
|
||||
}
|
||||
if res.Status != RunCompleted && res.Status != RunDegraded && res.Status != RunQualityReview {
|
||||
t.Fatalf("Q23 closeout status = %s err=%s", res.Status, res.Error)
|
||||
}
|
||||
if b.turn < 3 {
|
||||
t.Fatalf("leader should have run %d turns, ran %d", 3, b.turn)
|
||||
@@ -385,18 +388,18 @@ func TestWakeupGuardMaxFires(t *testing.T) {
|
||||
type eventingBackend struct {
|
||||
inner *leaderScriptBackend
|
||||
mu sync.Mutex
|
||||
reqs []exec.Request
|
||||
reqs []ExecRequest
|
||||
}
|
||||
|
||||
func (b *eventingBackend) Name() string { return "leader-script" }
|
||||
|
||||
func (b *eventingBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
|
||||
func (b *eventingBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
|
||||
b.mu.Lock()
|
||||
b.reqs = append(b.reqs, req)
|
||||
b.mu.Unlock()
|
||||
if req.OnEvent != nil {
|
||||
req.OnEvent(exec.ProcessEvent{Type: "tool_execution_start", Tool: "grep", Detail: `{"q":"x"}`})
|
||||
req.OnEvent(exec.ProcessEvent{Type: "tool_execution_end", Tool: "grep", Text: "found"})
|
||||
req.OnEvent(ProcessEvent{Type: "tool_execution_start", Tool: "grep", Detail: `{"q":"x"}`})
|
||||
req.OnEvent(ProcessEvent{Type: "tool_execution_end", Tool: "grep", Text: "found"})
|
||||
}
|
||||
return b.inner.Execute(ctx, req)
|
||||
}
|
||||
@@ -435,7 +438,7 @@ func TestLeaderTurnsEmitDetailEventsAndCarryProfile(t *testing.T) {
|
||||
t.Fatalf("want completed, got %s err=%s", res.Status, res.Error)
|
||||
}
|
||||
// leader 轮的请求带上了 ToolProfile/白名单/技能与标识(修平 leader 请求缺口)。
|
||||
var leaderReq *exec.Request
|
||||
var leaderReq *ExecRequest
|
||||
for i := range eb.reqs {
|
||||
if strings.HasSuffix(eb.reqs[i].WorkDir, "00_leader") {
|
||||
leaderReq = &eb.reqs[i]
|
||||
@@ -444,7 +447,7 @@ func TestLeaderTurnsEmitDetailEventsAndCarryProfile(t *testing.T) {
|
||||
if leaderReq == nil {
|
||||
t.Fatal("leader request missing")
|
||||
}
|
||||
if leaderReq.ToolProfile != exec.ToolProfileWorkspace || len(leaderReq.ToolAllowlist) != 2 || len(leaderReq.Skills) != 1 {
|
||||
if leaderReq.ToolProfile != ToolProfileWorkspace || len(leaderReq.ToolAllowlist) != 2 || len(leaderReq.Skills) != 1 {
|
||||
t.Fatalf("leader request not fully assembled: %+v", leaderReq)
|
||||
}
|
||||
if !strings.Contains(leaderReq.ExecutionID, "leader_turn_") {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build !unix
|
||||
|
||||
package squad
|
||||
|
||||
import "context"
|
||||
|
||||
// lockRunFile 在非 unix 平台退回进程内锁(由 SquadEngine.runLock 提供)。
|
||||
// 跨进程互斥不可用;调用方不要假设两个进程能靠这把锁互相拒绝。
|
||||
func lockRunFile(ctx context.Context, dir string) (func(), error) {
|
||||
return func() {}, nil
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//go:build unix
|
||||
|
||||
package squad
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// lockRunFile 对 runs/<id>/.lock 做非阻塞独占锁,并按 ctx 轮询。
|
||||
// 直到拿到锁或 ctx 结束。ctx 结束时返回 ErrRunBusy。
|
||||
func lockRunFile(ctx context.Context, dir string) (func(), error) {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := os.OpenFile(filepath.Join(dir, ".lock"), os.O_CREATE|os.O_RDWR, 0o644)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for {
|
||||
err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
|
||||
if err == nil {
|
||||
return func() {
|
||||
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
|
||||
_ = f.Close()
|
||||
}, nil
|
||||
}
|
||||
if err != syscall.EWOULDBLOCK && err != syscall.EAGAIN {
|
||||
_ = f.Close()
|
||||
return nil, err
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = f.Close()
|
||||
return nil, ErrRunBusy
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// mailbox.go — 邮箱原语(Q2 WP2):运行内点对点消息。
|
||||
// 安全边界(验收三的构造性保证):投递路径永不经过 Respond/gate——
|
||||
// 消息只在步骤启动时并入任务书;挂起中的人工门(permission/confirmation/
|
||||
// quality_review)不可能被邮箱消息决议。RespondDecision.Actor 提供
|
||||
// 第二道防线:非 human actor 的 choice 决议一律拒绝。
|
||||
|
||||
// MailMessage 是一条运行内消息。To 是角色 key,"*" = 广播给之后的每个步骤。
|
||||
type MailMessage struct {
|
||||
ID string `json:"id"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Text string `json:"text"`
|
||||
At time.Time `json:"at"`
|
||||
Delivered bool `json:"delivered,omitempty"`
|
||||
}
|
||||
|
||||
// PostMessage 向一个运行投递邮箱消息。运行执行中(锁被占)返回 ErrRunBusy
|
||||
// ——挂起等待人工时与回合间隙可投递。消息随 RunState 持久化,重启不丢。
|
||||
// 本方法不触碰任何 HITL 门(验收三:邮箱永不决议挂起的审批)。
|
||||
func (e *SquadEngine) PostMessage(ctx context.Context, runID string, msg MailMessage) error {
|
||||
if strings.TrimSpace(msg.Text) == "" {
|
||||
return fmt.Errorf("mailbox: 消息正文为空")
|
||||
}
|
||||
if strings.TrimSpace(msg.To) == "" {
|
||||
return fmt.Errorf("mailbox: 收件人必填(角色 key 或 *)")
|
||||
}
|
||||
release, err := e.acquire(ctx, runID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
wsDir := e.runDir(runID)
|
||||
st, err := e.loadRun(wsDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load run %s: %w", runID, err)
|
||||
}
|
||||
if msg.ID == "" {
|
||||
msg.ID = newRequestID()
|
||||
}
|
||||
if msg.At.IsZero() {
|
||||
msg.At = time.Now()
|
||||
}
|
||||
st.mu.Lock()
|
||||
st.Mailbox = append(st.Mailbox, msg)
|
||||
err = st.save()
|
||||
st.mu.Unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.deps.Sink.Emit(Event{Type: EvPhase, RunID: runID, Text: fmt.Sprintf("邮箱消息 %s → %s", msg.From, msg.To), At: time.Now()})
|
||||
return nil
|
||||
}
|
||||
|
||||
// takeMailFor 取出发给某角色的未投递消息(含广播),标记已投递。
|
||||
// 调用方负责随后 st.save()(或在临界区内)。
|
||||
func takeMailFor(st *RunState, role string) []MailMessage {
|
||||
var out []MailMessage
|
||||
for i := range st.Mailbox {
|
||||
m := &st.Mailbox[i]
|
||||
if m.Delivered {
|
||||
continue
|
||||
}
|
||||
if m.To != role && m.To != "*" {
|
||||
continue
|
||||
}
|
||||
m.Delivered = true
|
||||
out = append(out, *m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// formatMailSection 渲染任务书里的邮箱段(空则返回空串)。
|
||||
func formatMailSection(msgs []MailMessage) string {
|
||||
if len(msgs) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("\n\n## 邮箱消息\n")
|
||||
for _, m := range msgs {
|
||||
fmt.Fprintf(&b, "- [%s] %s:%s\n", m.At.Format(time.RFC3339), m.From, m.Text)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
+57
-5
@@ -66,9 +66,12 @@ type Blueprint struct {
|
||||
Title string `json:"title"`
|
||||
Match []string `json:"match,omitempty"` // 关键词命中即视为命中模板
|
||||
Description string `json:"description,omitempty"`
|
||||
Agents []RoleSpec `json:"agents,omitempty"` // 默认编制(引用已注册角色或内联角色)
|
||||
Steps []StepSpec `json:"steps"`
|
||||
Template string `json:"template,omitempty"` // 交付物骨架模板(Markdown)
|
||||
// Topology 声明模板的拓扑(Q2;空 = standard 图行为)。
|
||||
// RunOptions.Topology 优先于本字段。
|
||||
Topology string `json:"topology,omitempty"`
|
||||
Agents []RoleSpec `json:"agents,omitempty"` // 默认编制(引用已注册角色或内联角色)
|
||||
Steps []StepSpec `json:"steps"`
|
||||
Template string `json:"template,omitempty"` // 交付物骨架模板(Markdown)
|
||||
}
|
||||
|
||||
// PlanningMode 规划模式。
|
||||
@@ -88,10 +91,18 @@ type StepSpec struct {
|
||||
Mode string `json:"mode,omitempty"` // 预留
|
||||
Parallel bool `json:"parallel"`
|
||||
Needs []string `json:"needs,omitempty"`
|
||||
// Writes 写集:本步骤会写入的工件相对路径(调度依据)。
|
||||
// 同一 superstep 内写集相交的步骤强制串行,不相交才可并行(Q1 WP1)。
|
||||
Writes []string `json:"writes,omitempty"`
|
||||
Targets []string `json:"targets,omitempty"` // 期望交付物相对路径,缺省 output.md
|
||||
Status string `json:"status"` // pending, running, completed, failed, skipped
|
||||
DurationMs int64 `json:"duration_ms,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
// Attempts 该步骤进入 running 的次数。中断恢复用它决定 auto 策略是否还能重试。
|
||||
Attempts int `json:"attempts,omitempty"`
|
||||
// AttemptID 当前这次尝试的稳定标识:runID/stepID/N(N=Attempts)。
|
||||
// 执行事件、产物与副作用记录都挂它;瞬态重试共享同一 AttemptID(Q1 WP3)。
|
||||
AttemptID string `json:"attempt_id,omitempty"`
|
||||
DurationMs int64 `json:"duration_ms,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
// RequiresApproval 为 true 时,该步骤启动前进入 HITL 计划确认。
|
||||
RequiresApproval bool `json:"requires_approval,omitempty"`
|
||||
// IntentID 非空 = 系统功能步:不经 BackendResolver/智能体,由 IntentRunner 直接执行
|
||||
@@ -103,6 +114,47 @@ type StepSpec struct {
|
||||
AllowDynamicAdjustment bool `json:"allow_dynamic_adjustment,omitempty"`
|
||||
// Critical: nil(默认)= 关键步骤失败即终止;false = 非关键,失败降级继续。
|
||||
Critical *bool `json:"critical,omitempty"`
|
||||
// Skills 覆盖角色默认技能。空 = 用角色自己的技能列表。
|
||||
Skills []string `json:"skills,omitempty"`
|
||||
// MinScore 质检总分下限。0 = 不额外设门槛,只看 Checker.Passed。
|
||||
MinScore int `json:"min_score,omitempty"`
|
||||
// Loop 非空 = 循环步(Q2 loop 拓扑):完成后条件不满足且预算未尽时
|
||||
// 重置为 pending 再武装(图不允许环,循环由引擎再武装实现)。
|
||||
Loop *LoopSpec `json:"loop,omitempty"`
|
||||
// LoopRuns 已再武装次数(引擎维护,随计划持久化)。
|
||||
LoopRuns int `json:"loop_runs,omitempty"`
|
||||
// Branches 非空 = 路由步(Q2 router 拓扑):完成后按黑板字段判定,
|
||||
// 未选中分支的目标步骤置 skipped(skipped=依赖已满足,join 步照常推进)。
|
||||
Branches []Branch `json:"branches,omitempty"`
|
||||
// Handoff 非空 = 完成即把最终答案所有权移交给该角色(Q2 handoff 原语)。
|
||||
Handoff string `json:"handoff,omitempty"`
|
||||
}
|
||||
|
||||
// LoopSpec 是循环步的预算与停机条件(Q2)。
|
||||
type LoopSpec struct {
|
||||
// MaxIterations 再武装上限(必须 >0;触顶即停,带 loop_exhausted 事件)。
|
||||
MaxIterations int `json:"max_iterations"`
|
||||
// UntilField 停机判据的黑板字段名;空 = 只按 MaxIterations 迭代。
|
||||
UntilField string `json:"until_field,omitempty"`
|
||||
// UntilEquals 停机值:Fields[UntilField] == UntilEquals 时停。
|
||||
UntilEquals string `json:"until_equals,omitempty"`
|
||||
// Body 与本步一起再武装的其他步骤 ID(循环体;如 evaluator-optimizer
|
||||
// 的评估步)。空 = 只再武装本步。
|
||||
Body []string `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
// Branch 是路由步的一条分支(Q2)。When 按声明序取首个命中。
|
||||
type Branch struct {
|
||||
When BranchCond `json:"when"`
|
||||
// Then 是本分支保留的步骤 ID(其余分支目标置 skipped)。
|
||||
Then []string `json:"then"`
|
||||
}
|
||||
|
||||
// BranchCond 是分支判据:黑板字段等值/不等值。
|
||||
type BranchCond struct {
|
||||
Field string `json:"field"`
|
||||
Equals string `json:"equals,omitempty"`
|
||||
NotEquals string `json:"not_equals,omitempty"`
|
||||
}
|
||||
|
||||
// IsCritical 报告步骤是否关键(nil 视为关键)。
|
||||
|
||||
+58
-31
@@ -5,23 +5,25 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"agent-native/runtime/llm"
|
||||
)
|
||||
|
||||
// LLMPlanner LLM 规划器:用模型理解目标 → 拆解任务 → 选/派角色 → 生成步骤。
|
||||
// 模型客户端与默认模型在构造时注入(角色级模型在派生角色时由模型自行指定)。
|
||||
// 输出为严格 JSON;解析失败时重试一次,仍失败则 fail-closed 返回错误。
|
||||
type LLMPlanner struct {
|
||||
Client *llm.Client
|
||||
Client ModelClient
|
||||
Model string
|
||||
APIBase string
|
||||
APIKey string
|
||||
MaxTokens int
|
||||
// Fallback 在模型调用或解析失败时接手。空则保持 fail-closed。
|
||||
Fallback Planner
|
||||
// MaxSteps 计划步骤硬上限(默认 32)。
|
||||
MaxSteps int
|
||||
}
|
||||
|
||||
// NewLLMPlanner 创建 LLM 规划器。
|
||||
func NewLLMPlanner(client *llm.Client, model string) *LLMPlanner {
|
||||
func NewLLMPlanner(client ModelClient, model string) *LLMPlanner {
|
||||
return &LLMPlanner{Client: client, Model: model}
|
||||
}
|
||||
|
||||
@@ -82,6 +84,9 @@ const llmPlannerSystem = `你是智能体小队的规划器。给定用户目标
|
||||
|
||||
func (p *LLMPlanner) Plan(ctx context.Context, input PlanInput) (*PlanOutput, error) {
|
||||
if p.Client == nil || p.Model == "" {
|
||||
if p.Fallback != nil {
|
||||
return p.Fallback.Plan(ctx, input)
|
||||
}
|
||||
return nil, fmt.Errorf("llm planner requires client and model")
|
||||
}
|
||||
userMsg := p.buildPrompt(input)
|
||||
@@ -95,7 +100,8 @@ func (p *LLMPlanner) Plan(ctx context.Context, input PlanInput) (*PlanOutput, er
|
||||
}
|
||||
raw, err := p.complete(ctx, sys, userMsg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("llm planner call failed: %w", err)
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
schema, err := parseLLMPlan(raw)
|
||||
if err != nil {
|
||||
@@ -106,6 +112,9 @@ func (p *LLMPlanner) Plan(ctx context.Context, input PlanInput) (*PlanOutput, er
|
||||
break
|
||||
}
|
||||
if parsed == nil {
|
||||
if p.Fallback != nil {
|
||||
return p.Fallback.Plan(ctx, input)
|
||||
}
|
||||
return nil, fmt.Errorf("llm planner output unparsable after retry: %w", lastErr)
|
||||
}
|
||||
|
||||
@@ -164,11 +173,15 @@ func (p *LLMPlanner) materialize(input PlanInput, parsed *llmPlanSchema) (*PlanO
|
||||
|
||||
steps := make([]StepSpec, 0, len(parsed.Steps))
|
||||
for i, ls := range parsed.Steps {
|
||||
if ls.Role == "" {
|
||||
if ls.IntentID == "" && ls.Role == "" {
|
||||
return nil, fmt.Errorf("step %d missing role", i)
|
||||
}
|
||||
if _, ok := input.Registry.Get(ls.Role); !ok {
|
||||
return nil, fmt.Errorf("step %d references unregistered role %q", i, ls.Role)
|
||||
if ls.IntentID == "" {
|
||||
if _, ok := input.Registry.Get(ls.Role); !ok {
|
||||
return nil, fmt.Errorf("step %d references unregistered role %q", i, ls.Role)
|
||||
}
|
||||
} else if ls.Role == "" {
|
||||
ls.Role = "intent"
|
||||
}
|
||||
if _, inSquad := findAgent(agents, ls.Role); !inSquad {
|
||||
if spec, ok := input.Registry.Get(ls.Role); ok {
|
||||
@@ -189,6 +202,13 @@ func (p *LLMPlanner) materialize(input PlanInput, parsed *llmPlanSchema) (*PlanO
|
||||
IntentID: ls.IntentID, IntentSlots: ls.IntentSlots,
|
||||
})
|
||||
}
|
||||
maxSteps := p.MaxSteps
|
||||
if maxSteps <= 0 {
|
||||
maxSteps = 32
|
||||
}
|
||||
if len(steps) > maxSteps {
|
||||
return nil, fmt.Errorf("plan has %d steps, max %d", len(steps), maxSteps)
|
||||
}
|
||||
|
||||
plan := &SquadPlan{
|
||||
Squad: SquadConfig{SquadName: truncate(input.Goal.Text, 40), Version: "1", Agents: agents},
|
||||
@@ -207,7 +227,8 @@ func (p *LLMPlanner) materialize(input PlanInput, parsed *llmPlanSchema) (*PlanO
|
||||
func (p *LLMPlanner) buildPrompt(input PlanInput) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("## 用户目标\n")
|
||||
b.WriteString(input.Goal.Text + "\n")
|
||||
b.WriteString(segmentGoal(input.Goal.Text))
|
||||
b.WriteString("\n")
|
||||
if len(input.Goal.Constraints) > 0 {
|
||||
b.WriteString("\n## 约束\n")
|
||||
for k, v := range input.Goal.Constraints {
|
||||
@@ -239,35 +260,41 @@ func (p *LLMPlanner) buildPrompt(input PlanInput) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
const goalSegmentRunes = 2000
|
||||
|
||||
// segmentGoal 把长目标按段标出,避免整段原文无界注入提示词。
|
||||
func segmentGoal(text string) string {
|
||||
runes := []rune(text)
|
||||
if len(runes) <= goalSegmentRunes {
|
||||
return text + "\n"
|
||||
}
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(runes); i += goalSegmentRunes {
|
||||
end := i + goalSegmentRunes
|
||||
if end > len(runes) {
|
||||
end = len(runes)
|
||||
}
|
||||
fmt.Fprintf(&b, "\n### 目标第 %d 段\n%s\n", i/goalSegmentRunes+1, string(runes[i:end]))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// complete 调一次模型并拼接完整文本。
|
||||
func (p *LLMPlanner) complete(ctx context.Context, system, user string) (string, error) {
|
||||
msgs := []llm.Message{
|
||||
{Role: llm.RoleSystem, Content: system},
|
||||
{Role: llm.RoleUser, Content: user},
|
||||
if p.Client == nil {
|
||||
return "", fmt.Errorf("llm planner: no model client")
|
||||
}
|
||||
opts := llm.StreamOptions{MaxTokens: p.MaxTokens, APIBase: p.APIBase, APIKey: p.APIKey}
|
||||
var sb strings.Builder
|
||||
for ev := range p.Client.Stream(ctx, p.Model, msgs, nil, opts) {
|
||||
if ev.Kind == llm.KindError {
|
||||
return "", fmt.Errorf("%s", ev.ErrorMessage)
|
||||
}
|
||||
if ev.Kind == llm.KindTextDelta {
|
||||
sb.WriteString(ev.Text)
|
||||
}
|
||||
if ev.Kind == llm.KindMessageEnd {
|
||||
if ev.Message.IsError {
|
||||
return "", fmt.Errorf("assistant error: %s", ev.Message.Content)
|
||||
}
|
||||
if sb.Len() == 0 {
|
||||
sb.WriteString(ev.Message.Content)
|
||||
}
|
||||
break
|
||||
}
|
||||
text, err := p.Client.Complete(ctx, ModelTurn{
|
||||
Model: p.Model, System: system, User: user,
|
||||
MaxTokens: p.MaxTokens, APIBase: p.APIBase, APIKey: p.APIKey,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return "", ctx.Err()
|
||||
}
|
||||
return sb.String(), nil
|
||||
return text, nil
|
||||
}
|
||||
|
||||
// parseLLMPlan 从模型输出稳健解析 JSON(剥离 think 块/代码围栏/前后杂文)。
|
||||
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Executor 是小队核心对一次智能体执行的端口。实现放在适配器模块。
|
||||
type Executor interface {
|
||||
Name() string
|
||||
Execute(ctx context.Context, req ExecRequest) (*ExecResult, error)
|
||||
}
|
||||
|
||||
// ModelClient 是规划器对一次模型补全的端口。实现放在适配器模块。
|
||||
type ModelClient interface {
|
||||
Complete(ctx context.Context, turn ModelTurn) (string, error)
|
||||
}
|
||||
|
||||
// ModelTurn 是一次规划补全的输入。适配器负责把它变成供应商协议。
|
||||
type ModelTurn struct {
|
||||
Model string
|
||||
System string
|
||||
User string
|
||||
MaxTokens int
|
||||
APIBase string
|
||||
APIKey string
|
||||
}
|
||||
|
||||
const (
|
||||
ToolProfileWorkspace = "workspace"
|
||||
ToolProfileReasoning = "reasoning"
|
||||
)
|
||||
|
||||
// ExecRequest 是一次步骤执行的输入。字段与 native exec.Request 的 JSON 对齐,类型不共享。
|
||||
type ExecRequest struct {
|
||||
WorkDir string `json:"work_dir"`
|
||||
Prompt string `json:"prompt"`
|
||||
Instructions string `json:"instructions"`
|
||||
Targets []string `json:"targets"`
|
||||
Timeout time.Duration `json:"timeout"`
|
||||
ToolProfile string `json:"tool_profile,omitempty"`
|
||||
OnEvent func(ProcessEvent) `json:"-"`
|
||||
Model string `json:"model,omitempty"`
|
||||
ToolAllowlist []string `json:"tool_allowlist,omitempty"`
|
||||
Skills []string `json:"skills,omitempty"`
|
||||
Budget *ExecBudget `json:"budget,omitempty"`
|
||||
SessionScope string `json:"session_scope,omitempty"`
|
||||
ExecutionID string `json:"execution_id,omitempty"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
}
|
||||
|
||||
// ExecBudget 是一次步骤执行的共享预算。
|
||||
type ExecBudget struct {
|
||||
MaxPromptTokens int `json:"max_prompt_tokens,omitempty"`
|
||||
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
|
||||
MaxToolCalls int `json:"max_tool_calls,omitempty"`
|
||||
Deadline time.Time `json:"deadline,omitempty"`
|
||||
}
|
||||
|
||||
// ProcessEvent 是过程帧。
|
||||
type ProcessEvent struct {
|
||||
Type string
|
||||
Text string
|
||||
Tool string
|
||||
Detail string
|
||||
PromptTokens int
|
||||
CompletionTokens int
|
||||
// 以下在 Type=usage 时填写(N3 WP6:上一请求与缓存命中拆分)。
|
||||
LastPromptTokens int
|
||||
LastCompletionTokens int
|
||||
CacheReadTokens int
|
||||
CacheWriteTokens int
|
||||
}
|
||||
|
||||
// ExecResult 是一次步骤执行的终态。
|
||||
type ExecResult struct {
|
||||
Success bool `json:"success"`
|
||||
Patches map[string][]byte `json:"patches"`
|
||||
Logs string `json:"logs"`
|
||||
ErrorReason string `json:"error_reason"`
|
||||
Retryable bool `json:"retryable,omitempty"`
|
||||
PromptTokens int `json:"prompt_tokens,omitempty"`
|
||||
CompletionTokens int `json:"completion_tokens,omitempty"`
|
||||
}
|
||||
|
||||
// StubExecutor 是离线桩,测试与未接模型时使用。
|
||||
type StubExecutor struct{}
|
||||
|
||||
func (s *StubExecutor) Name() string { return "stub" }
|
||||
|
||||
func (s *StubExecutor) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
|
||||
patches := make(map[string][]byte)
|
||||
for _, target := range req.Targets {
|
||||
outPath := filepath.Join(req.WorkDir, target)
|
||||
_ = os.MkdirAll(filepath.Dir(outPath), 0o755)
|
||||
var content []byte
|
||||
if strings.HasSuffix(target, ".json") {
|
||||
if existing, err := os.ReadFile(outPath); err == nil {
|
||||
var dummy any
|
||||
if json.Unmarshal(existing, &dummy) == nil {
|
||||
content = existing
|
||||
}
|
||||
}
|
||||
if len(content) == 0 {
|
||||
content = []byte(`{"status":"mock_ok"}`)
|
||||
}
|
||||
} else {
|
||||
content = []byte(fmt.Sprintf("# 模拟生成结果\n\n- 目标文件: %s\n- 提示词摘要: %s\n", target, req.Prompt))
|
||||
}
|
||||
_ = os.WriteFile(outPath, content, 0o644)
|
||||
patches[target] = content
|
||||
}
|
||||
return &ExecResult{Success: true, Patches: patches, Logs: "Stub agent execution completed."}, nil
|
||||
}
|
||||
|
||||
// LimitingExecutor 用有界信号量限制底层执行并发。
|
||||
type LimitingExecutor struct {
|
||||
inner Executor
|
||||
sem chan struct{}
|
||||
}
|
||||
|
||||
// WrapWithConcurrencyLimit 包装执行器;max<=0 时默认 8。
|
||||
func WrapWithConcurrencyLimit(inner Executor, max int) Executor {
|
||||
if inner == nil {
|
||||
return nil
|
||||
}
|
||||
if max <= 0 {
|
||||
max = 8
|
||||
}
|
||||
return &LimitingExecutor{inner: inner, sem: make(chan struct{}, max)}
|
||||
}
|
||||
|
||||
func (l *LimitingExecutor) Name() string {
|
||||
if l.inner == nil {
|
||||
return "limiting"
|
||||
}
|
||||
return l.inner.Name()
|
||||
}
|
||||
|
||||
func (l *LimitingExecutor) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
|
||||
select {
|
||||
case l.sem <- struct{}{}:
|
||||
defer func() { <-l.sem }()
|
||||
case <-ctx.Done():
|
||||
return &ExecResult{
|
||||
Success: false,
|
||||
ErrorReason: fmt.Sprintf("agent concurrency wait canceled: %v", ctx.Err()),
|
||||
}, ctx.Err()
|
||||
}
|
||||
return l.inner.Execute(ctx, req)
|
||||
}
|
||||
+12
-1
@@ -99,12 +99,23 @@ func (s *FanoutSink) Subscribe(cursor int) (<-chan Event, int, func()) {
|
||||
copy(replay, s.buffer[cursor:])
|
||||
next := len(s.buffer)
|
||||
s.mu.Unlock()
|
||||
done := make(chan struct{})
|
||||
var once sync.Once
|
||||
var replayDone sync.WaitGroup
|
||||
replayDone.Add(1)
|
||||
go func() {
|
||||
defer replayDone.Done()
|
||||
for _, ev := range replay {
|
||||
ch <- ev
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case ch <- ev:
|
||||
}
|
||||
}
|
||||
}()
|
||||
return ch, next, func() {
|
||||
once.Do(func() { close(done) })
|
||||
replayDone.Wait()
|
||||
s.mu.Lock()
|
||||
delete(s.subs, id)
|
||||
s.mu.Unlock()
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestR2ExpiredDecisionUsesCancel(t *testing.T) {
|
||||
created := time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC)
|
||||
decision, due := expiredDecision(HumanRequest{ID: "hr_1", CreatedAt: created, Status: "pending"},
|
||||
HitlPolicy{TimeoutAfter: time.Minute, ExpireAction: "cancel"}, created.Add(2*time.Minute))
|
||||
if !due || decision.Choice != "cancel" || decision.RequestID != "hr_1" {
|
||||
t.Fatalf("decision %+v due %v", decision, due)
|
||||
}
|
||||
if _, early := expiredDecision(HumanRequest{ID: "hr_1", CreatedAt: created},
|
||||
HitlPolicy{TimeoutAfter: time.Minute}, created.Add(30*time.Second)); early {
|
||||
t.Fatal("not due yet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestR2IntentStepSkipsRoleRegistry(t *testing.T) {
|
||||
p := &LLMPlanner{MaxSteps: 4}
|
||||
out, err := p.materialize(PlanInput{Goal: Goal{Text: "查进度"}, Registry: NewRoleRegistry()}, &llmPlanSchema{
|
||||
Steps: []llmStep{{IntentID: "demo.run.status_query", Title: "查"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Plan.Workflow.Steps[0].IntentID != "demo.run.status_query" || out.Plan.Workflow.Steps[0].Role != "intent" {
|
||||
t.Fatalf("step %+v", out.Plan.Workflow.Steps[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestR2PlannerFallsBackAndCapsSteps(t *testing.T) {
|
||||
fallback := NewRulePlanner()
|
||||
p := &LLMPlanner{Fallback: fallback}
|
||||
out, err := p.Plan(context.Background(), PlanInput{Goal: Goal{Text: "写一份说明"}, Registry: NewRoleRegistry()})
|
||||
if err != nil || out == nil || (out.Plan == nil && out.Clarification == nil) {
|
||||
t.Fatalf("fallback: %+v %v", out, err)
|
||||
}
|
||||
tooMany := &llmPlanSchema{}
|
||||
for i := 0; i < 3; i++ {
|
||||
tooMany.Steps = append(tooMany.Steps, llmStep{IntentID: "demo.x", Title: "t"})
|
||||
}
|
||||
if _, err := (&LLMPlanner{MaxSteps: 2}).materialize(PlanInput{Goal: Goal{Text: "x"}, Registry: NewRoleRegistry()}, tooMany); err == nil || !strings.Contains(err.Error(), "max 2") {
|
||||
t.Fatalf("cap: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestR2GoalIsSegmented(t *testing.T) {
|
||||
text := strings.Repeat("标", goalSegmentRunes+1)
|
||||
got := segmentGoal(text)
|
||||
if !strings.Contains(got, "目标第 2 段") {
|
||||
t.Fatalf("segments missing: %s", got[:80])
|
||||
}
|
||||
}
|
||||
|
||||
func TestR2TaskFileUsesLatestBlackboard(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ws := &WorkspaceEngine{}
|
||||
step := &StepSpec{ID: "s", Role: "writer", Title: "写", Targets: []string{"output.md"}}
|
||||
role := RoleSpec{Role: "writer", Name: "写手"}
|
||||
bb := &BlackboardState{Fields: map[string]string{"fact": "old"}}
|
||||
if err := ws.writeTaskFile(dir, step, role, bb); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bb.Fields["fact"] = "new"
|
||||
if err := ws.writeTaskFile(dir, step, role, bb); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(dir, "TASK.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(b), "fact = new") || strings.Contains(string(b), "fact = old") {
|
||||
t.Fatalf("task file: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestR2FanoutCancelDoesNotPanic(t *testing.T) {
|
||||
sink := NewFanoutSink(8)
|
||||
ch, _, cancel := sink.Subscribe(0)
|
||||
cancel()
|
||||
sink.Emit(Event{Type: "phase", Text: "later"})
|
||||
if _, ok := <-ch; ok {
|
||||
t.Fatal("channel should be closed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func approvalScript() map[string]map[string]string {
|
||||
return map[string]map[string]string{
|
||||
"01_researcher": {"notes.md": "n", "fields.json": `{}`},
|
||||
"02_writer": {"output.md": goodReport},
|
||||
"03_reviewer": {"output.md": goodReport},
|
||||
}
|
||||
}
|
||||
|
||||
func TestStepApprovalApproveExecutes(t *testing.T) {
|
||||
steps := baseSteps()
|
||||
steps[0].RequiresApproval = true
|
||||
eng, _ := newTestEngine(t, mockScriptPlanner(steps...), approvalScript(), nil)
|
||||
res, err := eng.Run(context.Background(), Goal{Text: "需要审批"}, RunOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != RunAwaitingConfirmation || res.Pending == nil || res.Pending.Reason != "step_approval" {
|
||||
t.Fatalf("want step approval hold, got %s %+v", res.Status, res.Pending)
|
||||
}
|
||||
res, err = eng.Respond(context.Background(), res.RunID, RespondDecision{Choice: "approve"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != RunCompleted {
|
||||
t.Fatalf("approve should execute, got %s %s", res.Status, res.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStepApprovalSkipDoesNotCancel(t *testing.T) {
|
||||
steps := baseSteps()
|
||||
steps[0].RequiresApproval = true
|
||||
eng, _ := newTestEngine(t, mockScriptPlanner(steps...), approvalScript(), nil)
|
||||
res, err := eng.Run(context.Background(), Goal{Text: "跳过一步"}, RunOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err = eng.Respond(context.Background(), res.RunID, RespondDecision{Choice: "skip"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status == RunCancelled || res.Status == RunFailed {
|
||||
t.Fatalf("skip must not cancel the run, got %s %s", res.Status, res.Error)
|
||||
}
|
||||
if res.Plan == nil || len(res.Plan.Steps) == 0 || res.Plan.Steps[0].Status != "skipped" {
|
||||
t.Fatalf("first step should be skipped: %+v", res.Plan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalSurvivesRestart(t *testing.T) {
|
||||
steps := baseSteps()
|
||||
steps[0].RequiresApproval = true
|
||||
eng, dir := newTestEngine(t, mockScriptPlanner(steps...), approvalScript(), nil)
|
||||
res, err := eng.Run(context.Background(), Goal{Text: "授权要留下"}, RunOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := eng.Respond(context.Background(), res.RunID, RespondDecision{Choice: "approve"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := loadRunState(res.WorkDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !st.Approved["step:s1"] {
|
||||
t.Fatalf("approval not stored: %+v", st.Approved)
|
||||
}
|
||||
st.Meta.Status = string(RunExecuting)
|
||||
st.Meta.CompletedAt = nil
|
||||
wf := st.Workflow()
|
||||
wf.Status = "running"
|
||||
wf.Steps[0].Status = "pending"
|
||||
st.setWorkflow(wf)
|
||||
if err := st.save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eng2, err := NewEngine(Options{
|
||||
DataDir: dir, Registry: eng.Registry(), Planner: mockScriptPlanner(steps...),
|
||||
Backends: eng.opts.Backends, Sink: NopSink{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err = eng2.Resume(context.Background(), res.RunID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status == RunAwaitingConfirmation {
|
||||
t.Fatal("restart asked for approval again")
|
||||
}
|
||||
if res.Status != RunCompleted {
|
||||
t.Fatalf("resume after restart: %s %s", res.Status, res.Error)
|
||||
}
|
||||
}
|
||||
|
||||
type countBackend struct {
|
||||
mu sync.Mutex
|
||||
n int
|
||||
}
|
||||
|
||||
func (b *countBackend) Name() string { return "script" }
|
||||
|
||||
func (b *countBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
|
||||
b.mu.Lock()
|
||||
b.n++
|
||||
b.mu.Unlock()
|
||||
return (&scriptBackend{script: approvalScript()}).Execute(ctx, req)
|
||||
}
|
||||
|
||||
func (b *countBackend) calls() int {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.n
|
||||
}
|
||||
|
||||
func TestInterruptedRunningStepStaysHeld(t *testing.T) {
|
||||
backend := &countBackend{}
|
||||
dir := t.TempDir()
|
||||
reg := NewRoleRegistry()
|
||||
for _, role := range []string{"researcher", "writer", "reviewer"} {
|
||||
if err := reg.Register(RoleSpec{Role: role, Backend: "script"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
backends := NewBackendResolver()
|
||||
backends.RegisterInstance("script", backend)
|
||||
eng, err := NewEngine(Options{
|
||||
DataDir: dir, Registry: reg, Planner: mockScriptPlanner(baseSteps()...),
|
||||
Backends: backends, Sink: NopSink{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := eng.Run(context.Background(), Goal{Text: "先跑完再假装中断"}, RunOptions{})
|
||||
if err != nil || res.Status != RunCompleted {
|
||||
t.Fatalf("setup run: %v %+v", err, res)
|
||||
}
|
||||
before := backend.calls()
|
||||
st, err := loadRunState(res.WorkDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st.Meta.Status = string(RunExecuting)
|
||||
st.Meta.CompletedAt = nil
|
||||
wf := st.Workflow()
|
||||
wf.Status = "running"
|
||||
wf.Steps[0].Status = "running"
|
||||
wf.Steps[0].Attempts = 1
|
||||
st.setWorkflow(wf)
|
||||
if err := st.save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eng2, err := NewEngine(Options{DataDir: dir, Registry: reg, Planner: eng.opts.Planner, Backends: backends, Sink: NopSink{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err = eng2.Resume(context.Background(), res.RunID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != RunAwaitingConfirmation || res.Pending == nil || res.Pending.Reason != "interrupted" {
|
||||
t.Fatalf("want interrupted hold, got %s %+v", res.Status, res.Pending)
|
||||
}
|
||||
if backend.calls() != before {
|
||||
t.Fatalf("interrupted step executed again: calls %d -> %d", before, backend.calls())
|
||||
}
|
||||
}
|
||||
|
||||
type blockBackend struct {
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (b *blockBackend) Name() string { return "script" }
|
||||
|
||||
func (b *blockBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
|
||||
b.once.Do(func() { close(b.started) })
|
||||
select {
|
||||
case <-b.release:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return (&scriptBackend{script: approvalScript()}).Execute(ctx, req)
|
||||
}
|
||||
|
||||
func TestTwoEnginesOneRunIsBusy(t *testing.T) {
|
||||
backend := &blockBackend{started: make(chan struct{}), release: make(chan struct{})}
|
||||
dir := t.TempDir()
|
||||
reg := NewRoleRegistry()
|
||||
for _, role := range []string{"researcher", "writer", "reviewer"} {
|
||||
_ = reg.Register(RoleSpec{Role: role, Backend: "script"})
|
||||
}
|
||||
backends := NewBackendResolver()
|
||||
backends.RegisterInstance("script", backend)
|
||||
mk := func() *SquadEngine {
|
||||
t.Helper()
|
||||
eng, err := NewEngine(Options{
|
||||
DataDir: dir, Registry: reg, Planner: mockScriptPlanner(baseSteps()...),
|
||||
Backends: backends, Sink: NopSink{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return eng
|
||||
}
|
||||
engA := mk()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
_, _ = engA.Run(context.Background(), Goal{Text: "占住锁"}, RunOptions{})
|
||||
}()
|
||||
select {
|
||||
case <-backend.started:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("run did not start")
|
||||
}
|
||||
runs, err := engA.ListRuns()
|
||||
if err != nil || len(runs) != 1 {
|
||||
t.Fatalf("runs: %v %+v", err, runs)
|
||||
}
|
||||
engB := mk()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond)
|
||||
defer cancel()
|
||||
_, err = engB.Resume(ctx, runs[0].ID)
|
||||
if !errors.Is(err, ErrRunBusy) {
|
||||
t.Fatalf("want busy, got %v", err)
|
||||
}
|
||||
close(backend.release)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("holder did not finish")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWakeupDoesNotApprovePermission(t *testing.T) {
|
||||
var kinds []string
|
||||
sink := ProgressFunc(func(ev Event) { kinds = append(kinds, ev.Type) })
|
||||
steps := gapSteps()
|
||||
script := map[string]map[string]string{
|
||||
"01_researcher": {"notes.md": "n", "fields.json": `{}`, "gaps.json": `[{"item":"等字段","blocking":true,"wait_field":"ext"}]`},
|
||||
"02_writer": {"output.md": goodReport},
|
||||
"03_reviewer": {"output.md": goodReport},
|
||||
}
|
||||
eng, dir := newTestEngine(t, mockScriptPlanner(steps...), script, sink)
|
||||
res, err := eng.Run(context.Background(), Goal{Text: "权限不能被唤醒放行"}, RunOptions{})
|
||||
if err != nil || res.Status != RunWaitingHuman {
|
||||
t.Fatalf("setup: %v %+v", err, res)
|
||||
}
|
||||
gate, err := loadGate(filepath.Join(dir, "runs", res.RunID))
|
||||
if err != nil || gate == nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gate.Request.Kind = HitlPermission
|
||||
gate.Request.WaitCondition = &WaitCondition{Type: "field_set", Field: "ext"}
|
||||
if err := writeJSON(filepath.Join(dir, "runs", res.RunID, "pending.json"), gate); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := eng.SetBlackboardField(res.RunID, "ext", "ready"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := eng.SweepWakeups(context.Background()); n != 0 {
|
||||
t.Fatalf("permission wakeup must not count as resolved, got %d", n)
|
||||
}
|
||||
insp, err := eng.Inspect(res.RunID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if insp.Status != RunWaitingHuman || insp.Pending == nil || insp.Pending.Kind != HitlPermission {
|
||||
t.Fatalf("permission hold changed: %s %+v", insp.Status, insp.Pending)
|
||||
}
|
||||
saw := false
|
||||
for _, k := range kinds {
|
||||
if k == "wakeup_held" {
|
||||
saw = true
|
||||
}
|
||||
}
|
||||
if !saw {
|
||||
t.Fatalf("expected wakeup_held event, got %v", kinds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelGroupSecondStepApprovalHolds(t *testing.T) {
|
||||
steps := baseSteps()
|
||||
steps[0].Parallel = true
|
||||
steps[1].Parallel = true
|
||||
steps[1].RequiresApproval = true
|
||||
eng, _ := newTestEngine(t, mockScriptPlanner(steps...), approvalScript(), nil)
|
||||
res, err := eng.Run(context.Background(), Goal{Text: "并行组第二步要审批"}, RunOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != RunAwaitingConfirmation || res.Pending == nil || res.Pending.StepID != "s2" {
|
||||
t.Fatalf("group should hold on the second step, got %s %+v", res.Status, res.Pending)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(res.WorkDir, "tasks", "01_researcher", "notes.md")); !os.IsNotExist(err) {
|
||||
t.Fatalf("parallel group ran before approval: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelGroupSecondStepPermissionHolds(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg := NewRoleRegistry()
|
||||
for _, spec := range []RoleSpec{
|
||||
{Role: "researcher", Name: "调研", Backend: "script"},
|
||||
{Role: "writer", Name: "撰写", Backend: "script", BashPermission: BashPermissionAsk},
|
||||
{Role: "reviewer", Name: "审核", Backend: "script"},
|
||||
} {
|
||||
if err := reg.Register(spec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
backends := NewBackendResolver()
|
||||
backends.RegisterInstance("script", &scriptBackend{script: approvalScript()})
|
||||
steps := baseSteps()
|
||||
steps[0].Parallel = true
|
||||
steps[1].Parallel = true
|
||||
eng, err := NewEngine(Options{
|
||||
DataDir: dir, Registry: reg, Planner: mockScriptPlanner(steps...), Backends: backends, Sink: NopSink{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var res *RunResult
|
||||
res, err = eng.Run(context.Background(), Goal{Text: "并行组第二步要授权"}, RunOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != RunWaitingHuman || res.Pending == nil || res.Pending.Kind != HitlPermission || res.Pending.StepID != "s2" {
|
||||
t.Fatalf("group should hold on writer permission, got %s %+v", res.Status, res.Pending)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(res.WorkDir, "tasks", "01_researcher", "notes.md")); !os.IsNotExist(err) {
|
||||
t.Fatalf("parallel group ran before permission: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreeTextKeepsChoiceHold(t *testing.T) {
|
||||
for _, kind := range []HumanRequestKind{HitlConfirmation, HitlPermission, HitlQualityReview} {
|
||||
t.Run(string(kind), func(t *testing.T) {
|
||||
steps := baseSteps()
|
||||
steps[0].RequiresApproval = true
|
||||
eng, dir := newTestEngine(t, mockScriptPlanner(steps...), approvalScript(), nil)
|
||||
res, err := eng.Run(context.Background(), Goal{Text: "自由文本不能取消"}, RunOptions{})
|
||||
if err != nil || res.Pending == nil {
|
||||
t.Fatalf("setup: %v %+v", err, res)
|
||||
}
|
||||
work := filepath.Join(dir, "runs", res.RunID)
|
||||
gate, err := loadGate(work)
|
||||
if err != nil || gate == nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := gate.Request.ID
|
||||
gate.Request.Kind = kind
|
||||
if err := writeJSON(filepath.Join(work, "pending.json"), gate); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err = eng.Respond(context.Background(), res.RunID, RespondDecision{Answer: "补充一句"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Pending == nil || res.Pending.ID != id || res.Pending.Status != "pending" {
|
||||
t.Fatalf("hold was cleared: %+v", res.Pending)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(work, "steering.json"))
|
||||
if err != nil || !strings.Contains(string(data), "补充一句") {
|
||||
t.Fatalf("steering not recorded: %v %s", err, data)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedDecisionRestoresPending(t *testing.T) {
|
||||
steps := gapSteps()
|
||||
script := map[string]map[string]string{
|
||||
"01_researcher": {"notes.md": "n", "fields.json": `{}`, "gaps.json": `[{"item":"等字段","blocking":true,"wait_field":"ext"}]`},
|
||||
"02_writer": {"output.md": goodReport},
|
||||
"03_reviewer": {"output.md": goodReport},
|
||||
}
|
||||
eng, _ := newTestEngine(t, mockScriptPlanner(steps...), script, nil)
|
||||
res, err := eng.Run(context.Background(), Goal{Text: "错误选择后仍可回应"}, RunOptions{})
|
||||
if err != nil || res.Pending == nil {
|
||||
t.Fatalf("setup: %v %+v", err, res)
|
||||
}
|
||||
if _, err := eng.Respond(context.Background(), res.RunID, RespondDecision{Choice: "nope"}); err == nil {
|
||||
t.Fatal("unknown choice should fail")
|
||||
}
|
||||
res, err = eng.Respond(context.Background(), res.RunID, RespondDecision{Choice: "resolved"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != RunCompleted {
|
||||
t.Fatalf("restored hold should still be answerable, got %s %s", res.Status, res.Error)
|
||||
}
|
||||
}
|
||||
+6
-4
@@ -13,7 +13,7 @@ type RoleRegistry struct {
|
||||
mu sync.RWMutex
|
||||
roles map[string]RoleSpec
|
||||
// OnChange 可选钩子:角色注册/派生后回调(store 层用于落盘)。
|
||||
OnChange func(spec RoleSpec)
|
||||
OnChange func(spec RoleSpec) error
|
||||
}
|
||||
|
||||
// NewRoleRegistry 创建空注册表。
|
||||
@@ -23,8 +23,8 @@ func NewRoleRegistry() *RoleRegistry {
|
||||
|
||||
// Register 注册或更新角色;Role 为空或 BashPermission 非法时返回错误。
|
||||
func (r *RoleRegistry) Register(spec RoleSpec) error {
|
||||
if strings.TrimSpace(spec.Role) == "" {
|
||||
return fmt.Errorf("role key cannot be empty")
|
||||
if !ValidID(spec.Role) {
|
||||
return invalidID(spec.Role)
|
||||
}
|
||||
switch spec.BashPermission {
|
||||
case "", "deny", "ask", "allow":
|
||||
@@ -44,7 +44,9 @@ func (r *RoleRegistry) Register(spec RoleSpec) error {
|
||||
r.roles[spec.Role] = spec
|
||||
r.mu.Unlock()
|
||||
if r.OnChange != nil {
|
||||
r.OnChange(spec)
|
||||
if err := r.OnChange(spec); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,22 +8,18 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-native/exec"
|
||||
"agent-native/runtime/llm"
|
||||
"agent-native/runtime/tools"
|
||||
)
|
||||
|
||||
// captureBackend 记录收到的请求(v2 字段贯通断言用)。
|
||||
type captureBackend struct {
|
||||
mu sync.Mutex
|
||||
reqs []exec.Request
|
||||
reqs []ExecRequest
|
||||
script map[string]string // 子目录名 → 单一目标内容
|
||||
}
|
||||
|
||||
func (b *captureBackend) Name() string { return "capture" }
|
||||
|
||||
func (b *captureBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
|
||||
func (b *captureBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
|
||||
b.mu.Lock()
|
||||
b.reqs = append(b.reqs, req)
|
||||
b.mu.Unlock()
|
||||
@@ -39,7 +35,7 @@ func (b *captureBackend) Execute(ctx context.Context, req exec.Request) (*exec.R
|
||||
_ = os.WriteFile(out, []byte(content), 0o644)
|
||||
patches[t] = []byte(content)
|
||||
}
|
||||
return &exec.Result{Success: true, Patches: patches}, nil
|
||||
return &ExecResult{Success: true, Patches: patches}, nil
|
||||
}
|
||||
|
||||
func TestStepRequestCarriesRoleSpecV2Fields(t *testing.T) {
|
||||
@@ -88,48 +84,6 @@ func TestStepRequestCarriesRoleSpecV2Fields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAgentNativeOptionsBashPermissionGating(t *testing.T) {
|
||||
base := AgentNativeConfig{
|
||||
Client: &llm.Client{BaseURL: "http://x", APIKey: "k"},
|
||||
DefaultModel: "m1", MaxTurns: 5, Timeout: time.Minute,
|
||||
Exec: &tools.ExecConfig{Bash: "/bin/bash"},
|
||||
SkillsRoots: []string{"/skills"},
|
||||
}
|
||||
deny, err := buildAgentNativeOptions(base, RoleSpec{Role: "r", BashPermission: BashPermissionDeny})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if deny.Exec != nil {
|
||||
t.Fatal("deny role must not get exec tools even when config provides them")
|
||||
}
|
||||
ask, _ := buildAgentNativeOptions(base, RoleSpec{Role: "r", BashPermission: BashPermissionAsk})
|
||||
if ask.Exec == nil {
|
||||
t.Fatal("ask role gets exec tools (the HITL gate runs before the step)")
|
||||
}
|
||||
allow, _ := buildAgentNativeOptions(base, RoleSpec{Role: "r", BashPermission: BashPermissionAllow})
|
||||
if allow.Exec == nil {
|
||||
t.Fatal("allow role gets exec tools")
|
||||
}
|
||||
// 空白名 = 注册时默认 deny,同样不装配。
|
||||
empty, _ := buildAgentNativeOptions(base, RoleSpec{Role: "r"})
|
||||
if empty.Exec != nil {
|
||||
t.Fatal("unset bash_permission defaults to deny at registration; no exec tools")
|
||||
}
|
||||
// 无 Exec 配置时任何档位都不装配。
|
||||
none, _ := buildAgentNativeOptions(AgentNativeConfig{Client: base.Client, DefaultModel: "m"}, RoleSpec{Role: "r", BashPermission: BashPermissionAllow})
|
||||
if none.Exec != nil {
|
||||
t.Fatal("no Exec config = no exec tools regardless of permission")
|
||||
}
|
||||
// 模型解析:角色级覆盖默认。
|
||||
roleModel, _ := buildAgentNativeOptions(base, RoleSpec{Role: "r", Model: "big"})
|
||||
if roleModel.Model != "big" {
|
||||
t.Fatalf("role model must win, got %s", roleModel.Model)
|
||||
}
|
||||
if _, err := buildAgentNativeOptions(AgentNativeConfig{Client: base.Client}, RoleSpec{Role: "r"}); err == nil {
|
||||
t.Fatal("missing model must fail-closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveInheritsExecutionConfig(t *testing.T) {
|
||||
reg := NewRoleRegistry()
|
||||
base := RoleSpec{
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// RouteMode 是分诊结果。direct 不进入多角色流程;plan 进入 leader 委派。
|
||||
type RouteMode string
|
||||
|
||||
const (
|
||||
RouteDirect RouteMode = "direct"
|
||||
RoutePlan RouteMode = "plan"
|
||||
)
|
||||
|
||||
// RouteDecision 描述这一次目标怎么跑,以及它属于哪一族交付物。
|
||||
type RouteDecision struct {
|
||||
Mode RouteMode
|
||||
Family string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// Router 在规划之前做一次分诊。nil 决策表示不干预。
|
||||
type Router interface {
|
||||
Route(ctx context.Context, goal Goal) (*RouteDecision, error)
|
||||
}
|
||||
|
||||
// applyRoute 把分诊写进运行元数据。显式 RunOptions.Mode 优先,分诊不覆盖。
|
||||
func applyRoute(meta *RunMetadata, dec *RouteDecision, explicitMode string) {
|
||||
if dec == nil || explicitMode != "" {
|
||||
return
|
||||
}
|
||||
if meta.Metadata == nil {
|
||||
meta.Metadata = map[string]string{}
|
||||
}
|
||||
meta.Metadata["route"] = string(dec.Mode)
|
||||
if dec.Family != "" {
|
||||
meta.Metadata["deliverable_family"] = dec.Family
|
||||
}
|
||||
if dec.Reason != "" {
|
||||
meta.Metadata["route_reason"] = dec.Reason
|
||||
}
|
||||
switch dec.Mode {
|
||||
case RouteDirect:
|
||||
meta.Metadata["route_direct"] = "1"
|
||||
case RoutePlan:
|
||||
meta.Metadata["mode"] = "leader"
|
||||
if meta.Metadata["leader_role"] == "" {
|
||||
meta.Metadata["leader_role"] = "leader"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// directAssistantPlan 生成只有 assistant 一步的计划。
|
||||
func directAssistantPlan(reg *RoleRegistry, goal Goal) (*SquadPlan, error) {
|
||||
spec, ok := reg.Get("assistant")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("direct route requires a registered assistant role")
|
||||
}
|
||||
return &SquadPlan{
|
||||
Squad: SquadConfig{Agents: []RoleSpec{spec}},
|
||||
Workflow: WorkflowPlan{
|
||||
WorkflowID: NewPlanID(),
|
||||
Title: goal.Text,
|
||||
Status: "pending",
|
||||
Steps: []StepSpec{{
|
||||
ID: "assistant",
|
||||
Role: "assistant",
|
||||
Title: goal.Text,
|
||||
Targets: []string{"output.md"},
|
||||
SubWorkspace: "tasks/01_assistant",
|
||||
Status: "pending",
|
||||
}},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fixedRouter struct{ dec *RouteDecision }
|
||||
|
||||
func (r fixedRouter) Route(ctx context.Context, goal Goal) (*RouteDecision, error) {
|
||||
return r.dec, nil
|
||||
}
|
||||
|
||||
func TestRouterDirectRunsAssistantOnly(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg := NewRoleRegistry()
|
||||
if err := reg.Register(RoleSpec{Role: "assistant", Name: "助手", Backend: "script"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backends := NewBackendResolver()
|
||||
backends.RegisterInstance("script", &scriptBackend{script: map[string]map[string]string{
|
||||
"01_assistant": {"output.md": goodReport},
|
||||
}})
|
||||
engine, err := NewEngine(Options{
|
||||
DataDir: dir, Registry: reg, Planner: NewMockPlanner(), Backends: backends,
|
||||
HitlPolicy: HitlPolicy{Mode: HitlDisabled},
|
||||
Router: fixedRouter{dec: &RouteDecision{Mode: RouteDirect, Family: "minutes", Reason: "短任务"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := engine.Run(context.Background(), Goal{Text: "写一句纪要"}, RunOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != RunCompleted {
|
||||
t.Fatalf("status %s err=%s", res.Status, res.Error)
|
||||
}
|
||||
if res.Plan == nil || len(res.Plan.Steps) != 1 || res.Plan.Steps[0].Role != "assistant" {
|
||||
t.Fatalf("plan = %+v", res.Plan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterPlanEntersLeaderMode(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg := NewRoleRegistry()
|
||||
_ = reg.Register(RoleSpec{Role: "writer", Name: "撰写", Backend: "script"})
|
||||
backends := NewBackendResolver()
|
||||
backends.RegisterInstance("script", &scriptBackend{script: map[string]map[string]string{}})
|
||||
engine, err := NewEngine(Options{
|
||||
DataDir: dir, Registry: reg,
|
||||
Planner: mockScriptPlanner(StepSpec{ID: "w", Role: "writer", Title: "写", Targets: []string{"output.md"}}),
|
||||
Backends: backends,
|
||||
HitlPolicy: HitlPolicy{Mode: HitlDisabled},
|
||||
Router: fixedRouter{dec: &RouteDecision{Mode: RoutePlan, Family: "report"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := engine.Run(context.Background(), Goal{Text: "写完整报告"}, RunOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != RunFailed || res.Error == "" {
|
||||
t.Fatalf("missing leader should fail closed, got %s %s", res.Status, res.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelegationCarriesSkillsAndScore(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
body := []byte(`{"action":"delegate","member":"writer","title":"成文","skills":["docx-author"],"min_score":70}`)
|
||||
if err := os.WriteFile(filepath.Join(dir, "delegation.json"), body, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dec, err := parseDelegation(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(dec.Skills) != 1 || dec.Skills[0] != "docx-author" || dec.MinScore != 70 {
|
||||
t.Fatalf("delegation = %+v", dec)
|
||||
}
|
||||
}
|
||||
|
||||
type captureSkillsBackend struct{ got []string }
|
||||
|
||||
func (b *captureSkillsBackend) Name() string { return "capture-skills" }
|
||||
|
||||
func (b *captureSkillsBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
|
||||
b.got = append([]string{}, req.Skills...)
|
||||
for _, target := range req.Targets {
|
||||
path := filepath.Join(req.WorkDir, target)
|
||||
_ = os.MkdirAll(filepath.Dir(path), 0o755)
|
||||
_ = os.WriteFile(path, []byte(goodReport), 0o644)
|
||||
}
|
||||
return &ExecResult{Success: true, Patches: map[string][]byte{"output.md": []byte(goodReport)}}, nil
|
||||
}
|
||||
|
||||
func TestStepSkillsOverrideRoleSkills(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg := NewRoleRegistry()
|
||||
_ = reg.Register(RoleSpec{Role: "writer", Name: "撰写", Backend: "capture-skills", Skills: []string{"role-skill"}})
|
||||
got := &captureSkillsBackend{}
|
||||
backends := NewBackendResolver()
|
||||
backends.RegisterInstance("capture-skills", got)
|
||||
engine, err := NewEngine(Options{
|
||||
DataDir: dir, Registry: reg, Backends: backends,
|
||||
HitlPolicy: HitlPolicy{Mode: HitlDisabled},
|
||||
Planner: &MockPlanner{Script: []StepSpec{{
|
||||
ID: "w", Role: "writer", Title: "写", Targets: []string{"output.md"},
|
||||
SubWorkspace: "tasks/01_writer", Skills: []string{"docx-author"}, MinScore: 10,
|
||||
}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := engine.Run(context.Background(), Goal{Text: "写"}, RunOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != RunCompleted {
|
||||
t.Fatalf("status %s err=%s", res.Status, res.Error)
|
||||
}
|
||||
if len(got.got) != 1 || got.got[0] != "docx-author" {
|
||||
t.Fatalf("request skills = %v", got.got)
|
||||
}
|
||||
}
|
||||
+80
-17
@@ -10,9 +10,9 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// runState 引擎内部运行状态(磁盘即权威:workspace.json / workflow.json /
|
||||
// RunState 引擎内部运行状态(磁盘即权威:workspace.json / workflow.json /
|
||||
// squad.json / blackboard.json / pending.json 全部落盘,进程重启可恢复)。
|
||||
type runState struct {
|
||||
type RunState struct {
|
||||
Meta RunMetadata `json:"meta"`
|
||||
Plan SquadPlan `json:"plan"`
|
||||
BB BlackboardState `json:"bb"`
|
||||
@@ -21,19 +21,35 @@ type runState struct {
|
||||
Approved map[string]bool `json:"approved,omitempty"`
|
||||
// RepairBudgetRemaining 质检失败后的自动重跑剩余次数。
|
||||
RepairBudgetRemaining int `json:"repair_budget_remaining,omitempty"`
|
||||
// RepairBudgetGranted 人工 retry 累计追加次数(Q26 上限判定用)。
|
||||
RepairBudgetGranted int `json:"repair_budget_granted,omitempty"`
|
||||
// Clarifications 已澄清问答(追加进 Goal 上下文)。
|
||||
Clarifications []string `json:"clarifications,omitempty"`
|
||||
// Steering 运行中人工补充指令(在下一步骤边界注入后清空)。
|
||||
Steering []string `json:"steering,omitempty"`
|
||||
// Mailbox 运行内点对点消息(Q2):随 RunState 持久化;只在步骤启动时
|
||||
// 并入任务书,永不触碰 HITL 门。
|
||||
Mailbox []MailMessage `json:"mailbox,omitempty"`
|
||||
// Usage 按角色累计的 token 用量。
|
||||
Usage map[string]*Usage `json:"usage,omitempty"`
|
||||
// Leader leader-delegation 模式的循环状态(workspace 内 leader.json 持久化)。
|
||||
Leader *LeaderState `json:"-"`
|
||||
// store 运行状态持久化端口(Q1 WP4);nil 回落 FileStore(现状文件态)。
|
||||
// 替换实现(如 SQLiteStore)在引擎装配期注入。
|
||||
store Store `json:"-"`
|
||||
// mu 保护并行步骤对共享状态(黑板/save/steering/usage)的变更。
|
||||
mu sync.Mutex `json:"-"`
|
||||
}
|
||||
|
||||
func (st *runState) policy() HitlPolicy {
|
||||
// storeOr 返回注入的 Store,缺省 FileStore。
|
||||
func (st *RunState) storeOr() Store {
|
||||
if st.store != nil {
|
||||
return st.store
|
||||
}
|
||||
return FileStore{}
|
||||
}
|
||||
|
||||
func (st *RunState) policy() HitlPolicy {
|
||||
return mergeHitlPolicy(HitlPolicy{}, st.Meta.HitlPolicyOf())
|
||||
}
|
||||
|
||||
@@ -49,8 +65,14 @@ func (m RunMetadata) HitlPolicyOf() *HitlPolicy {
|
||||
return p
|
||||
}
|
||||
|
||||
func (st *runState) save() error {
|
||||
func (st *RunState) save() error {
|
||||
st.Meta.UpdatedAt = time.Now()
|
||||
return st.storeOr().SaveRun(st)
|
||||
}
|
||||
|
||||
// persist 是 FileStore 语义的低层落盘(全部写文件)。Store.SaveRun 调它;
|
||||
// RunState.save 走 Store 端口,替换实现(SQLiteStore)在装配期注入后自然接管。
|
||||
func (st *RunState) persist() error {
|
||||
if err := writeJSON(filepath.Join(st.Meta.WorkDir, "workspace.json"), &st.Meta); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -68,17 +90,31 @@ func (st *runState) save() error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return writeJSON(filepath.Join(st.Meta.WorkDir, "runstate.json"), diskRunState{
|
||||
Approved: st.Approved,
|
||||
RepairBudgetRemaining: st.RepairBudgetRemaining,
|
||||
RepairBudgetGranted: st.RepairBudgetGranted,
|
||||
Clarifications: st.Clarifications,
|
||||
Mailbox: st.Mailbox,
|
||||
})
|
||||
}
|
||||
|
||||
type diskRunState struct {
|
||||
Approved map[string]bool `json:"approved,omitempty"`
|
||||
RepairBudgetRemaining int `json:"repair_budget_remaining,omitempty"`
|
||||
RepairBudgetGranted int `json:"repair_budget_granted,omitempty"`
|
||||
Clarifications []string `json:"clarifications,omitempty"`
|
||||
Mailbox []MailMessage `json:"mailbox,omitempty"`
|
||||
}
|
||||
|
||||
// Workflow 返回计划的编排清单(简洁访问器)。
|
||||
func (st *runState) Workflow() WorkflowPlan { return st.Plan.Workflow }
|
||||
func (st *RunState) Workflow() WorkflowPlan { return st.Plan.Workflow }
|
||||
|
||||
// setWorkflow 写回编排清单。
|
||||
func (st *runState) setWorkflow(wf WorkflowPlan) { st.Plan.Workflow = wf }
|
||||
func (st *RunState) setWorkflow(wf WorkflowPlan) { st.Plan.Workflow = wf }
|
||||
|
||||
// saveGate 落盘 HITL 挂起上下文(pending.json)。
|
||||
func (st *runState) saveGate(gate *hitlGate) error {
|
||||
func (st *RunState) saveGate(gate *HitlGate) error {
|
||||
if gate == nil {
|
||||
err := os.Remove(filepath.Join(st.Meta.WorkDir, "pending.json"))
|
||||
if os.IsNotExist(err) {
|
||||
@@ -90,8 +126,8 @@ func (st *runState) saveGate(gate *hitlGate) error {
|
||||
}
|
||||
|
||||
// loadRunState 从工作区恢复运行状态。
|
||||
func loadRunState(wsDir string) (*runState, error) {
|
||||
st := &runState{}
|
||||
func loadRunState(wsDir string) (*RunState, error) {
|
||||
st := &RunState{}
|
||||
if err := readJSON(filepath.Join(wsDir, "workspace.json"), &st.Meta); err != nil {
|
||||
return nil, fmt.Errorf("load workspace.json: %w", err)
|
||||
}
|
||||
@@ -112,6 +148,21 @@ func loadRunState(wsDir string) (*runState, error) {
|
||||
if st.Approved == nil {
|
||||
st.Approved = map[string]bool{}
|
||||
}
|
||||
if data, err := os.ReadFile(filepath.Join(wsDir, "runstate.json")); err == nil {
|
||||
var disk diskRunState
|
||||
if err := json.Unmarshal(data, &disk); err != nil {
|
||||
return nil, fmt.Errorf("parse runstate.json: %w", err)
|
||||
}
|
||||
if disk.Approved != nil {
|
||||
st.Approved = disk.Approved
|
||||
}
|
||||
st.RepairBudgetRemaining = disk.RepairBudgetRemaining
|
||||
st.RepairBudgetGranted = disk.RepairBudgetGranted
|
||||
st.Clarifications = disk.Clarifications
|
||||
st.Mailbox = disk.Mailbox
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
st.loadSteering()
|
||||
if data, err := os.ReadFile(filepath.Join(wsDir, "usage.json")); err == nil {
|
||||
_ = json.Unmarshal(data, &st.Usage)
|
||||
@@ -120,7 +171,7 @@ func loadRunState(wsDir string) (*runState, error) {
|
||||
}
|
||||
|
||||
// loadGate 恢复挂起上下文;无挂起返回 nil。
|
||||
func loadGate(wsDir string) (*hitlGate, error) {
|
||||
func loadGate(wsDir string) (*HitlGate, error) {
|
||||
data, err := os.ReadFile(filepath.Join(wsDir, "pending.json"))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
@@ -128,7 +179,7 @@ func loadGate(wsDir string) (*hitlGate, error) {
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
gate := &hitlGate{}
|
||||
gate := &HitlGate{}
|
||||
if err := json.Unmarshal(data, gate); err != nil {
|
||||
return nil, fmt.Errorf("parse pending.json: %w", err)
|
||||
}
|
||||
@@ -144,7 +195,7 @@ func readJSON(path string, v any) error {
|
||||
}
|
||||
|
||||
// setStep 并行安全地回填步骤状态(与 save 的全量 marshal 互斥)。
|
||||
func (st *runState) setStep(idx int, s StepSpec) {
|
||||
func (st *RunState) setStep(idx int, s StepSpec) {
|
||||
st.mu.Lock()
|
||||
st.Plan.Workflow.Steps[idx] = s
|
||||
st.mu.Unlock()
|
||||
@@ -152,7 +203,7 @@ func (st *runState) setStep(idx int, s StepSpec) {
|
||||
|
||||
// injectSteering 把挂起的运行中人工补充指令追加到 prompt(下一步骤边界注入),
|
||||
// 注入后清空并落盘。注入发生在步骤开始前,不会污染正在执行的步骤。
|
||||
func (st *runState) injectSteering(prompt string) string {
|
||||
func (st *RunState) injectSteering(prompt string) string {
|
||||
st.mu.Lock()
|
||||
defer st.mu.Unlock()
|
||||
if len(st.Steering) == 0 {
|
||||
@@ -170,8 +221,20 @@ func (st *runState) injectSteering(prompt string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// addSteering 追加一条尚未注入的人工补充,并立刻落盘。
|
||||
func (st *RunState) addSteering(text string) error {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
st.mu.Lock()
|
||||
defer st.mu.Unlock()
|
||||
st.Steering = append(st.Steering, text)
|
||||
return writeJSON(filepath.Join(st.Meta.WorkDir, "steering.json"), st.Steering)
|
||||
}
|
||||
|
||||
// loadSteering 恢复未消费的补充指令。
|
||||
func (st *runState) loadSteering() {
|
||||
func (st *RunState) loadSteering() {
|
||||
data, err := os.ReadFile(filepath.Join(st.Meta.WorkDir, "steering.json"))
|
||||
if err != nil {
|
||||
return
|
||||
@@ -183,7 +246,7 @@ func (st *runState) loadSteering() {
|
||||
}
|
||||
|
||||
// addUsage 累计角色用量。
|
||||
func (st *runState) addUsage(role string, promptTokens, completionTokens int) {
|
||||
func (st *RunState) addUsage(role string, promptTokens, completionTokens int) {
|
||||
if promptTokens == 0 && completionTokens == 0 {
|
||||
return
|
||||
}
|
||||
@@ -203,7 +266,7 @@ func (st *runState) addUsage(role string, promptTokens, completionTokens int) {
|
||||
}
|
||||
|
||||
// usageSnapshot 输出用量快照(值拷贝)。
|
||||
func (st *runState) usageSnapshot() map[string]Usage {
|
||||
func (st *RunState) usageSnapshot() map[string]Usage {
|
||||
if len(st.Usage) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Store 是运行状态持久化端口(Q1 WP4):run/gate/checkpoint 的读写以
|
||||
// 运行工作区为键。现状文件态收编为 FileStore(行为与此前逐文件直写完全一致);
|
||||
// SQLite 实现进 adapters/sqlite(WP5)。事件流仍归 store/eventsink(子包),
|
||||
// 不在本端口内重复。
|
||||
type Store interface {
|
||||
SaveRun(st *RunState) error
|
||||
LoadRun(wsDir string) (*RunState, error)
|
||||
SaveGate(wsDir string, gate *HitlGate) error
|
||||
LoadGate(wsDir string) (*HitlGate, error)
|
||||
SaveCheckpoint(wsDir string, cp Checkpoint) error
|
||||
LoadLatestCheckpoint(wsDir string) (*Checkpoint, error)
|
||||
// SaveLeader/LoadLeader 持久化 leader 循环状态(raw JSON bytes;
|
||||
// 文件态即 leader.json)。LoadLeader 不存在时返回 (nil, nil)。
|
||||
SaveLeader(wsDir string, data []byte) error
|
||||
LoadLeader(wsDir string) ([]byte, error)
|
||||
}
|
||||
|
||||
// FileStore 把 runs/<id>/ 目录布局呈现为 Store(现状文件态)。
|
||||
type FileStore struct{}
|
||||
|
||||
func (FileStore) SaveRun(st *RunState) error {
|
||||
return st.persist()
|
||||
}
|
||||
|
||||
func (FileStore) LoadRun(wsDir string) (*RunState, error) {
|
||||
return loadRunState(wsDir)
|
||||
}
|
||||
|
||||
func (FileStore) SaveGate(wsDir string, gate *HitlGate) error {
|
||||
st := &RunState{}
|
||||
st.Meta.WorkDir = wsDir
|
||||
return st.saveGate(gate)
|
||||
}
|
||||
|
||||
func (FileStore) LoadGate(wsDir string) (*HitlGate, error) {
|
||||
return loadGate(wsDir)
|
||||
}
|
||||
|
||||
func (FileStore) SaveLeader(wsDir string, data []byte) error {
|
||||
return os.WriteFile(filepath.Join(wsDir, "leader.json"), data, 0o644)
|
||||
}
|
||||
|
||||
func (FileStore) LoadLeader(wsDir string) ([]byte, error) {
|
||||
data, err := os.ReadFile(filepath.Join(wsDir, "leader.json"))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (FileStore) SaveCheckpoint(wsDir string, cp Checkpoint) error {
|
||||
dir := filepath.Join(wsDir, "checkpoints")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(dir, fmt.Sprintf("%03d.json", cp.Superstep))
|
||||
if err := writeJSON(path, cp); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(filepath.Join(dir, "latest.json"), cp)
|
||||
}
|
||||
|
||||
func (FileStore) LoadLatestCheckpoint(wsDir string) (*Checkpoint, error) {
|
||||
data, err := os.ReadFile(filepath.Join(wsDir, "checkpoints", "latest.json"))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var cp Checkpoint
|
||||
if err := json.Unmarshal(data, &cp); err != nil {
|
||||
return nil, fmt.Errorf("parse latest checkpoint: %w", err)
|
||||
}
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
// Checkpoint 是一个 superstep 边界的状态快照(Q1 WP4)。
|
||||
// GraphHash 是图结构指纹:恢复时与当前 Steps 重编译比对,不一致说明崩溃
|
||||
// 期间图形态被改过(动态调整或外力),按 WP1 裁定拒绝恢复并提示重新规划。
|
||||
type Checkpoint struct {
|
||||
Superstep int `json:"superstep"`
|
||||
At time.Time `json:"at"`
|
||||
GraphHash string `json:"graph_hash"`
|
||||
StepStates []StepSnapshot `json:"steps"`
|
||||
wsDir string `json:"-"`
|
||||
}
|
||||
|
||||
// write 立即同步落盘(经当前 Store 端口——SQLite 后端时进数据库)。
|
||||
func (cp Checkpoint) write(store Store) error {
|
||||
return store.SaveCheckpoint(cp.wsDir, cp)
|
||||
}
|
||||
|
||||
// StepSnapshot 是检查点里的单步快照。
|
||||
type StepSnapshot struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
AttemptID string `json:"attempt_id,omitempty"`
|
||||
}
|
||||
|
||||
// CheckpointMode 快照写盘档位(Q1 WP4 三档)。
|
||||
type CheckpointMode string
|
||||
|
||||
const (
|
||||
// CheckpointSync 每个 superstep 边界同步写(默认,与现状「逐步全量落盘」一致)。
|
||||
CheckpointSync CheckpointMode = "sync"
|
||||
// CheckpointAsync 边界快照进队列由单 goroutine 串行写(边界不阻塞;恢复粒度同 sync)。
|
||||
CheckpointAsync CheckpointMode = "async"
|
||||
// CheckpointOnExit 只在推进退出(终态/挂起/错误)时写最终快照。
|
||||
CheckpointOnExit CheckpointMode = "on_exit"
|
||||
)
|
||||
|
||||
// checkpointWriter 按档位执行边界快照;async 档单 goroutine 串行落盘。
|
||||
type checkpointWriter struct {
|
||||
mode CheckpointMode
|
||||
store Store
|
||||
ch chan Checkpoint
|
||||
done chan error
|
||||
}
|
||||
|
||||
func newCheckpointWriter(mode CheckpointMode, store Store) *checkpointWriter {
|
||||
if mode == "" {
|
||||
mode = CheckpointSync
|
||||
}
|
||||
if store == nil {
|
||||
store = FileStore{}
|
||||
}
|
||||
w := &checkpointWriter{mode: mode, store: store}
|
||||
if mode == CheckpointAsync {
|
||||
w.ch = make(chan Checkpoint, 16)
|
||||
w.done = make(chan error, 1)
|
||||
go func() {
|
||||
var first error
|
||||
for cp := range w.ch {
|
||||
if first != nil {
|
||||
continue
|
||||
}
|
||||
if err := cp.write(w.store); err != nil {
|
||||
first = err
|
||||
}
|
||||
}
|
||||
w.done <- first
|
||||
}()
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// boundary 由调用方(flowDriver)在 superstep 边界触发:
|
||||
// sync 立即写;async 入队(满即错,不静默丢);on_exit 跳过。
|
||||
func (w *checkpointWriter) boundary(wsDir string, cp Checkpoint) error {
|
||||
cp.wsDir = wsDir
|
||||
switch w.mode {
|
||||
case CheckpointAsync:
|
||||
select {
|
||||
case w.ch <- cp:
|
||||
default:
|
||||
return fmt.Errorf("checkpoint queue full at superstep %d", cp.Superstep)
|
||||
}
|
||||
return nil
|
||||
case CheckpointOnExit:
|
||||
return nil
|
||||
default:
|
||||
return cp.write(w.store)
|
||||
}
|
||||
}
|
||||
|
||||
// flush 在推进退出前调用:async 落完并报告首个错误;on_exit 写最终快照。
|
||||
func (w *checkpointWriter) flush(wsDir string, cp Checkpoint) error {
|
||||
cp.wsDir = wsDir
|
||||
switch w.mode {
|
||||
case CheckpointAsync:
|
||||
close(w.ch)
|
||||
return <-w.done
|
||||
case CheckpointOnExit:
|
||||
return cp.write(w.store)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// validateGraphContinuity 恢复连续性校验(Q1 WP4;WP1 的图版本裁定)。
|
||||
// 最新检查点存在且其图指纹与当前 Steps 重编译不一致时拒绝恢复——
|
||||
// 崩溃期间图形态被改过,继续推进会把已完成的结果接到错误的图上。
|
||||
// 检查点不存在(旧运行)或指纹一致则放行。
|
||||
func validateGraphContinuity(st *RunState) error {
|
||||
latest, err := st.storeOr().LoadLatestCheckpoint(st.Meta.WorkDir)
|
||||
if err != nil || latest == nil {
|
||||
return err
|
||||
}
|
||||
g, gerr := compileGraph(st.Plan.Workflow.Steps)
|
||||
if gerr != nil {
|
||||
return fmt.Errorf("graph continuity: 当前 Steps 无法编译:%w(请人工介入或重新规划)", gerr)
|
||||
}
|
||||
if latest.GraphHash != g.hash() {
|
||||
return fmt.Errorf("graph continuity: 崩溃前后图形态不一致(checkpoint superstep=%d hash=%s,当前=%s)——可能在动态调整或外部修改后崩溃,请重新规划或人工介入",
|
||||
latest.Superstep, latest.GraphHash, g.hash())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -8,8 +8,6 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-native/exec"
|
||||
)
|
||||
|
||||
// recordingBackend 记录每次收到的 prompt(按目录),产出按 script。
|
||||
@@ -22,7 +20,7 @@ type recordingBackend struct {
|
||||
|
||||
func (b *recordingBackend) Name() string { return "rec" }
|
||||
|
||||
func (b *recordingBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
|
||||
func (b *recordingBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
|
||||
key := filepath.Base(filepath.Clean(req.WorkDir))
|
||||
b.mu.Lock()
|
||||
b.prompts[key] = append(b.prompts[key], req.Prompt)
|
||||
@@ -43,7 +41,7 @@ func (b *recordingBackend) Execute(ctx context.Context, req exec.Request) (*exec
|
||||
if b.tokens {
|
||||
pt, ct = 120, 80
|
||||
}
|
||||
return &exec.Result{Success: true, Patches: patches, PromptTokens: pt, CompletionTokens: ct}, nil
|
||||
return &ExecResult{Success: true, Patches: patches, PromptTokens: pt, CompletionTokens: ct}, nil
|
||||
}
|
||||
|
||||
func TestSteeringInjectedAtNextStep(t *testing.T) {
|
||||
@@ -137,7 +135,7 @@ type slowCountBackend struct {
|
||||
|
||||
func (b *slowCountBackend) Name() string { return "slow" }
|
||||
|
||||
func (b *slowCountBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
|
||||
func (b *slowCountBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
|
||||
b.mu.Lock()
|
||||
b.cur++
|
||||
if b.cur > b.peak {
|
||||
@@ -153,7 +151,7 @@ func (b *slowCountBackend) Execute(ctx context.Context, req exec.Request) (*exec
|
||||
_ = os.MkdirAll(filepath.Dir(out), 0o755)
|
||||
_ = os.WriteFile(out, []byte(goodReport), 0o644)
|
||||
}
|
||||
return &exec.Result{Success: true, Patches: map[string][]byte{}}, nil
|
||||
return &ExecResult{Success: true, Patches: map[string][]byte{}}, nil
|
||||
}
|
||||
|
||||
func TestRoleConcurrencyLimit(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// MaxTransientStepInvocations is the product-layer bound: the first
|
||||
// invocation plus one fresh re-invocation. SDK in-process auto_retry is
|
||||
// separate and must not be confused with this.
|
||||
const MaxTransientStepInvocations = 2
|
||||
|
||||
type FailureClass int
|
||||
|
||||
const (
|
||||
FailureNone FailureClass = iota
|
||||
FailureTransient
|
||||
FailurePermanent
|
||||
)
|
||||
|
||||
func (c FailureClass) String() string {
|
||||
switch c {
|
||||
case FailureTransient:
|
||||
return "transient"
|
||||
case FailurePermanent:
|
||||
return "permanent"
|
||||
default:
|
||||
return "none"
|
||||
}
|
||||
}
|
||||
|
||||
type TraceSignals struct {
|
||||
ProviderTransient bool
|
||||
Settled bool
|
||||
RetryExhausted bool
|
||||
HasUsableDeliverable bool // always false: write() args in traces are not deliverables
|
||||
}
|
||||
|
||||
type StepFailureSignals struct {
|
||||
Err error
|
||||
Result *ExecResult
|
||||
WorkDir string
|
||||
ExtraText string
|
||||
TargetMissing bool
|
||||
// TargetExists means this invocation wrote a non-empty file that differs
|
||||
// from the pre-invoke baseline. Do not retry or overwrite it. The step
|
||||
// still fails unless the backend already reported a trusted success.
|
||||
TargetExists bool
|
||||
}
|
||||
|
||||
type FailureAssessment struct {
|
||||
Class FailureClass
|
||||
Reason string
|
||||
}
|
||||
|
||||
func ScanTraceText(text string) TraceSignals {
|
||||
var out TraceSignals
|
||||
if text == "" {
|
||||
return out
|
||||
}
|
||||
// Line scan only. Never parse write.arguments.content into a body.
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
lower := strings.ToLower(line)
|
||||
if strings.Contains(lower, `"type":"agent_settled"`) || strings.Contains(lower, `"type": "agent_settled"`) {
|
||||
out.Settled = true
|
||||
}
|
||||
if strings.Contains(lower, `"type":"auto_retry_end"`) && strings.Contains(lower, `"success":false`) {
|
||||
out.ProviderTransient = true
|
||||
out.RetryExhausted = true
|
||||
}
|
||||
if strings.Contains(lower, `"type":"auto_retry_start"`) {
|
||||
out.ProviderTransient = true
|
||||
}
|
||||
if strings.Contains(lower, `"stopreason":"error"`) {
|
||||
out.ProviderTransient = true
|
||||
}
|
||||
if strings.Contains(lower, "connection error") {
|
||||
out.ProviderTransient = true
|
||||
}
|
||||
if strings.Contains(lower, `"errormessage":"terminated"`) || strings.Contains(lower, `"errormessage": "terminated"`) {
|
||||
out.ProviderTransient = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ReadWorkDirTraces(workDir string) string {
|
||||
if strings.TrimSpace(workDir) == "" {
|
||||
return ""
|
||||
}
|
||||
p := filepath.Join(workDir, "execution.log")
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
const capBytes = 512 << 10
|
||||
if len(b) > capBytes {
|
||||
b = b[len(b)-capBytes:]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func AssessStepFailure(sig StepFailureSignals) FailureAssessment {
|
||||
reason := combinedFailureText(sig)
|
||||
lower := strings.ToLower(reason)
|
||||
trace := ScanTraceText(sig.ExtraText + "\n" + ReadWorkDirTraces(sig.WorkDir))
|
||||
missing := sig.TargetMissing || strings.Contains(lower, "missing target deliverable")
|
||||
if sig.TargetExists {
|
||||
return FailureAssessment{Class: FailurePermanent, Reason: "target-already-present: do not overwrite"}
|
||||
}
|
||||
if isPermanentReason(lower) {
|
||||
return FailureAssessment{Class: FailurePermanent, Reason: "permanent: " + compactReason(reason)}
|
||||
}
|
||||
|
||||
runnerFailed := (sig.Result != nil && sig.Result.Retryable) || isRunnerExplicitFailure(lower)
|
||||
provider := trace.ProviderTransient || hasProviderTermOrConn(lower)
|
||||
if runnerFailed {
|
||||
return FailureAssessment{Class: FailureTransient, Reason: "runner-explicit-failure: " + compactReason(reason)}
|
||||
}
|
||||
if provider && missing {
|
||||
kind := "provider-transient-missing-target"
|
||||
if trace.Settled {
|
||||
kind = "settled-failure-missing-target"
|
||||
}
|
||||
return FailureAssessment{Class: FailureTransient, Reason: kind + ": " + compactReason(reason)}
|
||||
}
|
||||
// Model settled cleanly but never wrote the target (e.g. text-only reply,
|
||||
// no write tool). One product-layer re-invocation with a corrective prompt
|
||||
// is worth trying; still bounded by MaxTransientStepInvocations.
|
||||
if missing && trace.Settled && !provider {
|
||||
return FailureAssessment{Class: FailureTransient, Reason: "settled-no-deliverable: " + compactReason(reason)}
|
||||
}
|
||||
if missing {
|
||||
return FailureAssessment{Class: FailurePermanent, Reason: "permanent-missing: " + compactReason(reason)}
|
||||
}
|
||||
return FailureAssessment{Class: FailurePermanent, Reason: "fail-closed: " + compactReason(reason)}
|
||||
}
|
||||
|
||||
func combinedFailureText(sig StepFailureSignals) string {
|
||||
var parts []string
|
||||
if sig.Err != nil {
|
||||
parts = append(parts, sig.Err.Error())
|
||||
}
|
||||
if sig.Result != nil {
|
||||
if sig.Result.ErrorReason != "" {
|
||||
parts = append(parts, sig.Result.ErrorReason)
|
||||
}
|
||||
if sig.Result.Logs != "" {
|
||||
parts = append(parts, sig.Result.Logs)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func isPermanentReason(lower string) bool {
|
||||
return strings.Contains(lower, "prompt_empty") ||
|
||||
strings.Contains(lower, "unauthorized") ||
|
||||
strings.Contains(lower, "budget_exceeded") ||
|
||||
strings.Contains(lower, "治理强制终止") ||
|
||||
strings.Contains(lower, "治理拦截")
|
||||
}
|
||||
|
||||
func hasProviderTermOrConn(lower string) bool {
|
||||
return strings.Contains(lower, "connection error") ||
|
||||
strings.Contains(lower, "terminated") ||
|
||||
strings.Contains(lower, "econnreset") ||
|
||||
strings.Contains(lower, "socket hang up")
|
||||
}
|
||||
|
||||
func isRunnerExplicitFailure(lower string) bool {
|
||||
return strings.Contains(lower, "pi remote runner call failed") ||
|
||||
strings.Contains(lower, "auto_retry_failed") ||
|
||||
strings.Contains(lower, "auto_retry_end") ||
|
||||
(hasProviderTermOrConn(lower) && !strings.Contains(lower, "missing target deliverable"))
|
||||
}
|
||||
|
||||
func compactReason(s string) string {
|
||||
s = strings.Join(strings.FieldsFunc(s, func(r rune) bool {
|
||||
return r == '\n' || r == '\r'
|
||||
}), " ")
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return "unknown"
|
||||
}
|
||||
// Drop accidental write-argument bodies if a caller stuffed logs here.
|
||||
if len(s) > 240 {
|
||||
s = s[:240]
|
||||
}
|
||||
return strings.Map(func(r rune) rune {
|
||||
if unicode.IsControl(r) && r != '\t' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Termination 运行级停止条件的单一注册点(Q1 WP6):leader 轮次、修复预算、
|
||||
// 唤醒次数与步骤超时集中声明;复合判定用 And/Or 原语(AutoGen 终止条件语义)。
|
||||
// 默认值与现网一致;引擎选项在 terminationOf 覆盖对应字段。
|
||||
type Termination struct {
|
||||
MaxLeaderTurns int
|
||||
RepairBudget int
|
||||
MaxRepairGranted int // 人工 retry 累计加预算上限(Q26):默认 RepairBudget*2
|
||||
MaxWakeupFires int
|
||||
StepTimeout time.Duration
|
||||
}
|
||||
|
||||
// DefaultTermination 与现网四处的硬编码取值一致。
|
||||
func DefaultTermination() Termination {
|
||||
return Termination{
|
||||
MaxLeaderTurns: DefaultMaxLeaderTurns,
|
||||
RepairBudget: 1,
|
||||
MaxRepairGranted: 2,
|
||||
MaxWakeupFires: MaxWakeupFires,
|
||||
StepTimeout: 10 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// terminationOf 取本运行的停止条件:引擎选项与运行状态覆盖默认值。
|
||||
func (e *SquadEngine) terminationOf(st *RunState) Termination {
|
||||
t := DefaultTermination()
|
||||
if e.opts.RepairBudget > 0 {
|
||||
t.RepairBudget = e.opts.RepairBudget
|
||||
t.MaxRepairGranted = e.opts.RepairBudget * 2
|
||||
}
|
||||
if e.opts.StepTimeout > 0 {
|
||||
t.StepTimeout = e.opts.StepTimeout
|
||||
}
|
||||
if st != nil && st.Leader != nil && st.Leader.MaxTurns > 0 {
|
||||
t.MaxLeaderTurns = st.Leader.MaxTurns
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// Counters 运行期计数,由各判定处喂入(值语义快照)。
|
||||
type Counters struct {
|
||||
LeaderTurns int
|
||||
RepairSpent int
|
||||
RepairGranted int
|
||||
WakeupFires int
|
||||
Deadline time.Time
|
||||
}
|
||||
|
||||
// Cond 是一条停止条件:返回(原因,是否命中)。
|
||||
type Cond func(c Counters, now time.Time) (string, bool)
|
||||
|
||||
// MaxTurnsC 轮次上限。
|
||||
func MaxTurnsC(n int) Cond {
|
||||
return func(c Counters, _ time.Time) (string, bool) {
|
||||
if c.LeaderTurns >= n {
|
||||
return fmt.Sprintf("leader 轮次达上限 %d", n), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// RepairBudgetC 修复预算耗尽。
|
||||
func RepairBudgetC(n int) Cond {
|
||||
return func(c Counters, _ time.Time) (string, bool) {
|
||||
if c.RepairSpent > n {
|
||||
return fmt.Sprintf("修复预算耗尽(%d/%d)", c.RepairSpent, n), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// WakeupsC 自动唤醒次数超限。
|
||||
func WakeupsC(n int) Cond {
|
||||
return func(c Counters, _ time.Time) (string, bool) {
|
||||
if c.WakeupFires > n {
|
||||
return fmt.Sprintf("自动唤醒次数超限(%d/%d)", c.WakeupFires, n), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// DeadlineC 超过截止时刻。
|
||||
func DeadlineC() Cond {
|
||||
return func(c Counters, now time.Time) (string, bool) {
|
||||
if !c.Deadline.IsZero() && now.After(c.Deadline) {
|
||||
return "超过截止时刻", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// And 全部条件命中才成立。
|
||||
func And(conds ...Cond) Cond {
|
||||
return func(c Counters, now time.Time) (string, bool) {
|
||||
for _, k := range conds {
|
||||
if _, hit := k(c, now); !hit {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return "all conditions met", true
|
||||
}
|
||||
}
|
||||
|
||||
// Or 任一条件命中即成立(默认的停止语义)。
|
||||
func Or(conds ...Cond) Cond {
|
||||
return func(c Counters, now time.Time) (string, bool) {
|
||||
for _, k := range conds {
|
||||
if r, hit := k(c, now); hit {
|
||||
return r, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestTerminationAndOr:And 全中才成立、Or 任一中即成立(WP6 原语)。
|
||||
func TestTerminationAndOr(t *testing.T) {
|
||||
c := Counters{LeaderTurns: 8, RepairSpent: 2, WakeupFires: 0}
|
||||
or := Or(MaxTurnsC(8), WakeupsC(1))
|
||||
if _, hit := or(c, time.Now()); !hit {
|
||||
t.Fatal("or should hit on turns")
|
||||
}
|
||||
and := And(MaxTurnsC(8), WakeupsC(1))
|
||||
if _, hit := and(c, time.Now()); hit {
|
||||
t.Fatal("and should not hit with zero wakeups")
|
||||
}
|
||||
and2 := And(MaxTurnsC(8), RepairBudgetC(1))
|
||||
if _, hit := and2(c, time.Now()); !hit {
|
||||
t.Fatal("and should hit when all met")
|
||||
}
|
||||
dl := DeadlineC()
|
||||
past := Counters{Deadline: time.Now().Add(-time.Minute)}
|
||||
if _, hit := dl(past, time.Now()); !hit {
|
||||
t.Fatal("deadline should hit when past")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRepairGrantCap:人工 retry 累计追加有上限(Q26)。
|
||||
func TestRepairGrantCap(t *testing.T) {
|
||||
engine, _ := newTestEngine(t, mockScriptPlanner(baseSteps()...), map[string]map[string]string{
|
||||
"01_researcher": {"notes.md": goodReport, "fields.json": "{}"},
|
||||
"02_writer": {"output.md": goodReport},
|
||||
"03_reviewer": {"output.md": goodReport},
|
||||
}, NopSink{})
|
||||
res, err := engine.Run(context.Background(), Goal{Text: "先跑完再验", HitlMode: HitlAuto}, RunOptions{})
|
||||
if err != nil || res.Status != RunCompleted {
|
||||
t.Fatalf("setup run: %v %+v", err, res)
|
||||
}
|
||||
// 预置:修复授予已达上限 + 一个真实的质检挂起门(与 finalize 的形态一致)。
|
||||
st, err := loadRunState(res.WorkDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st.RepairBudgetGranted = engine.terminationOf(st).MaxRepairGranted
|
||||
st.Meta.CompletedAt = nil
|
||||
st.Meta.Status = string(RunQualityReview)
|
||||
req := &HumanRequest{
|
||||
ID: newRequestID(), RunID: st.Meta.ID, Kind: HitlQualityReview, Severity: SeverityActionRequired,
|
||||
Question: "交付物质检未通过且自动修复预算已用尽,请人工裁决:",
|
||||
Options: []HumanOption{
|
||||
{Value: "approve", Label: "人工放行(记录人工通过)"},
|
||||
{Value: "retry", Label: "再修复一次(重跑交付步骤)"},
|
||||
{Value: "abort", Label: "判定失败"},
|
||||
},
|
||||
CreatedAt: time.Now(), Status: "pending",
|
||||
}
|
||||
if err := st.saveGate(&HitlGate{Request: *req, PrevStatus: RunExecuting}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 人工 retry 应被拒绝而不是再追加。
|
||||
out, err := engine.Respond(context.Background(), res.RunID, RespondDecision{Choice: "retry"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out == nil || !strings.Contains(out.Error, "grants exhausted") {
|
||||
t.Fatalf("expected grant-cap failure, got %+v err=%v", out, err)
|
||||
}
|
||||
st2, err := loadRunState(res.WorkDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st2.RepairBudgetGranted != engine.terminationOf(st2).MaxRepairGranted {
|
||||
t.Fatalf("granted changed after cap: %d", st2.RepairBudgetGranted)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package squad
|
||||
|
||||
import "fmt"
|
||||
|
||||
// topology.go — 拓扑一等化(Q2):内置拓扑名注册表。
|
||||
// 拓扑不新造执行面:graph 类编译为 Steps 图(Q1 机制),leader 走既有
|
||||
// Mode 委派循环,driver 类由引擎按名分发(review/group_chat/task_board),
|
||||
// primitive 类是跨拓扑原语(handoff/mailbox),不可单独作为运行拓扑。
|
||||
// 未知名与不可运行Kind一律 fail-closed。
|
||||
|
||||
// 内置拓扑名(completion doc Q2 清单的 12 种)。
|
||||
const (
|
||||
TopologySequential = "sequential"
|
||||
TopologyParallel = "parallel"
|
||||
TopologyLoop = "loop"
|
||||
TopologyRouter = "router"
|
||||
TopologyLeader = "leader"
|
||||
TopologyHandoff = "handoff"
|
||||
TopologyGroupChat = "group_chat"
|
||||
TopologyReview = "review"
|
||||
TopologyEvaluatorOptimizer = "evaluator_optimizer"
|
||||
TopologyLongRunning = "long_running"
|
||||
TopologyTaskBoard = "task_board"
|
||||
TopologyMailbox = "mailbox"
|
||||
)
|
||||
|
||||
// TopologyKind 是拓扑的执行形态。
|
||||
type TopologyKind string
|
||||
|
||||
const (
|
||||
// TopologyGraph 编译为 Steps 图,由 flowDriver.advance 执行。
|
||||
TopologyGraph TopologyKind = "graph"
|
||||
// TopologyMode 走既有 Mode 分支(leader 委派循环)。
|
||||
TopologyMode TopologyKind = "mode"
|
||||
// TopologyDriver 由引擎按名分发到专用驱动。
|
||||
TopologyDriver TopologyKind = "driver"
|
||||
// TopologyPrimitive 是跨拓扑原语,不可单独运行。
|
||||
TopologyPrimitive TopologyKind = "primitive"
|
||||
)
|
||||
|
||||
// TopologySpec 是一个内置拓扑的注册项。
|
||||
type TopologySpec struct {
|
||||
Name string
|
||||
Kind TopologyKind
|
||||
}
|
||||
|
||||
// builtinTopologies 是 12 种内置拓扑的注册表(唯一权威清单)。
|
||||
var builtinTopologies = []TopologySpec{
|
||||
{Name: TopologySequential, Kind: TopologyGraph},
|
||||
{Name: TopologyParallel, Kind: TopologyGraph},
|
||||
{Name: TopologyLoop, Kind: TopologyGraph},
|
||||
{Name: TopologyRouter, Kind: TopologyGraph},
|
||||
{Name: TopologyLeader, Kind: TopologyMode},
|
||||
{Name: TopologyHandoff, Kind: TopologyPrimitive},
|
||||
{Name: TopologyGroupChat, Kind: TopologyDriver},
|
||||
{Name: TopologyReview, Kind: TopologyDriver},
|
||||
{Name: TopologyEvaluatorOptimizer, Kind: TopologyGraph},
|
||||
{Name: TopologyLongRunning, Kind: TopologyGraph},
|
||||
{Name: TopologyTaskBoard, Kind: TopologyDriver},
|
||||
{Name: TopologyMailbox, Kind: TopologyPrimitive},
|
||||
}
|
||||
|
||||
// BuiltinTopologies 返回内置拓扑清单(只读副本)。
|
||||
func BuiltinTopologies() []TopologySpec {
|
||||
out := make([]TopologySpec, len(builtinTopologies))
|
||||
copy(out, builtinTopologies)
|
||||
return out
|
||||
}
|
||||
|
||||
// LookupTopology 按名查注册项。
|
||||
func LookupTopology(name string) (TopologySpec, bool) {
|
||||
for _, t := range builtinTopologies {
|
||||
if t.Name == name {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return TopologySpec{}, false
|
||||
}
|
||||
|
||||
// registeredDrivers 是已接线的 driver 拓扑(WP 增量逐个点亮;未点亮 = fail-closed)。
|
||||
var registeredDrivers = map[string]bool{}
|
||||
|
||||
// RegisterTopologyDriver 由驱动实现方在 init/装配时点亮一个 driver 拓扑。
|
||||
func RegisterTopologyDriver(name string) error {
|
||||
spec, ok := LookupTopology(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("topology: 未注册的拓扑名 %q", name)
|
||||
}
|
||||
if spec.Kind != TopologyDriver {
|
||||
return fmt.Errorf("topology: %q 不是 driver 类(kind=%s)", name, spec.Kind)
|
||||
}
|
||||
registeredDrivers[name] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkTopology 校验一次运行的有效拓扑名,返回其 Kind。
|
||||
// 空名 = 未声明(standard 图行为,兼容既有调用方)。
|
||||
// 未注册名 / primitive 单跑 / driver 未点亮都 fail-closed。
|
||||
func checkTopology(name string) (TopologyKind, error) {
|
||||
if name == "" {
|
||||
return TopologyGraph, nil
|
||||
}
|
||||
spec, ok := LookupTopology(name)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("topology: 未知拓扑 %q(内置清单:%s)", name, topologyNames())
|
||||
}
|
||||
switch spec.Kind {
|
||||
case TopologyGraph, TopologyMode:
|
||||
return spec.Kind, nil
|
||||
case TopologyPrimitive:
|
||||
return "", fmt.Errorf("topology: %q 是跨拓扑原语,不可单独作为运行拓扑", name)
|
||||
case TopologyDriver:
|
||||
if !registeredDrivers[name] {
|
||||
return "", fmt.Errorf("topology: %q 驱动未注册(fail-closed)", name)
|
||||
}
|
||||
return spec.Kind, nil
|
||||
}
|
||||
return "", fmt.Errorf("topology: %q kind 非法", name)
|
||||
}
|
||||
|
||||
func topologyNames() string {
|
||||
s := ""
|
||||
for i, t := range builtinTopologies {
|
||||
if i > 0 {
|
||||
s += ", "
|
||||
}
|
||||
s += t.Name
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// topology_conformance_test.go — Q2 验收一的机器证据:每种拓扑一个
|
||||
// 脚本化后端的 conformance(零模型、零网络)。
|
||||
|
||||
// ---- 脚本化夹具 ----
|
||||
|
||||
// collectSink 收集事件流供断言。
|
||||
type collectSink struct {
|
||||
mu sync.Mutex
|
||||
events []Event
|
||||
}
|
||||
|
||||
func (c *collectSink) Emit(ev Event) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.events = append(c.events, ev)
|
||||
}
|
||||
|
||||
func (c *collectSink) snapshot() []Event {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return append([]Event(nil), c.events...)
|
||||
}
|
||||
|
||||
func (c *collectSink) countText(sub string) int {
|
||||
n := 0
|
||||
for _, ev := range c.snapshot() {
|
||||
if strings.Contains(ev.Text, sub) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// iterBackend 按「步骤子目录 × 第几次执行」回放内容(循环/评估拓扑需要
|
||||
// 逐轮不同的 fields.json)。
|
||||
type iterBackend struct {
|
||||
mu sync.Mutex
|
||||
calls map[string]int
|
||||
content func(stepKey string, iter int, target string) []byte
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
func (b *iterBackend) Name() string { return "iter" }
|
||||
|
||||
func (b *iterBackend) Execute(_ context.Context, req ExecRequest) (*ExecResult, error) {
|
||||
key := filepath.Base(filepath.Clean(req.WorkDir))
|
||||
if b.delay > 0 {
|
||||
time.Sleep(b.delay)
|
||||
}
|
||||
b.mu.Lock()
|
||||
b.calls[key]++
|
||||
iter := b.calls[key]
|
||||
b.mu.Unlock()
|
||||
patches := map[string][]byte{}
|
||||
for _, t := range req.Targets {
|
||||
var data []byte
|
||||
if b.content != nil {
|
||||
data = b.content(key, iter, t)
|
||||
}
|
||||
if data == nil {
|
||||
data = []byte(goodReport)
|
||||
}
|
||||
out := filepath.Join(req.WorkDir, t)
|
||||
_ = os.MkdirAll(filepath.Dir(out), 0o755)
|
||||
if err := os.WriteFile(out, data, 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
patches[t] = data
|
||||
}
|
||||
return &ExecResult{Success: true, Patches: patches}, nil
|
||||
}
|
||||
|
||||
func (b *iterBackend) totalCalls() int {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
n := 0
|
||||
for _, v := range b.calls {
|
||||
n += v
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func newTopologyEngine(t *testing.T, planner Planner, be Executor, sink ProgressSink) *SquadEngine {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
reg := NewRoleRegistry()
|
||||
for _, spec := range []RoleSpec{
|
||||
{Role: "researcher", Name: "调研", Backend: "iter"},
|
||||
{Role: "writer", Name: "撰写", Backend: "iter"},
|
||||
{Role: "reviewer", Name: "审核", Backend: "iter"},
|
||||
} {
|
||||
if err := reg.Register(spec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
backends := NewBackendResolver()
|
||||
backends.RegisterInstance("iter", be)
|
||||
if sink == nil {
|
||||
sink = NopSink{}
|
||||
}
|
||||
engine, err := NewEngine(Options{
|
||||
DataDir: dir, Registry: reg, Planner: planner, Backends: backends, Sink: sink,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return engine
|
||||
}
|
||||
|
||||
func fieldsJSON(kv map[string]string) []byte {
|
||||
b, _ := json.Marshal(kv)
|
||||
return b
|
||||
}
|
||||
|
||||
// ---- sequential / parallel:既有图语义 + 拓扑名受理 ----
|
||||
|
||||
func TestTopologyConformance_sequential(t *testing.T) {
|
||||
be := &iterBackend{calls: map[string]int{}}
|
||||
sink := &collectSink{}
|
||||
engine := newTopologyEngine(t, mockScriptPlanner(
|
||||
StepSpec{ID: "s1", Role: "researcher", Title: "一", Targets: []string{"notes.md"}},
|
||||
StepSpec{ID: "s2", Role: "writer", Title: "二", Targets: []string{"output.md"}},
|
||||
), be, sink)
|
||||
res, err := engine.Run(context.Background(), Goal{Text: "顺序任务"}, RunOptions{Topology: TopologySequential})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != RunCompleted {
|
||||
t.Fatalf("status = %s err = %v", res.Status, res.Error)
|
||||
}
|
||||
if be.totalCalls() != 2 {
|
||||
t.Fatalf("执行次数 = %d", be.totalCalls())
|
||||
}
|
||||
if sink.countText("拓扑 sequential") != 1 {
|
||||
t.Fatal("缺拓扑受理事件")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopologyConformance_parallel(t *testing.T) {
|
||||
be := &iterBackend{calls: map[string]int{}}
|
||||
engine := newTopologyEngine(t, mockScriptPlanner(
|
||||
StepSpec{ID: "s1", Role: "researcher", Title: "并行甲", Parallel: true, Targets: []string{"a.md"}},
|
||||
StepSpec{ID: "s2", Role: "writer", Title: "并行乙", Parallel: true, Targets: []string{"b.md"}},
|
||||
StepSpec{ID: "s3", Role: "reviewer", Title: "汇合", Targets: []string{"output.md"}},
|
||||
), be, nil)
|
||||
res, err := engine.Run(context.Background(), Goal{Text: "并行任务"}, RunOptions{Topology: TopologyParallel})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != RunCompleted || be.totalCalls() != 3 {
|
||||
t.Fatalf("status = %s calls = %d err = %v", res.Status, be.totalCalls(), res.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- loop:条件停机 + 预算触顶 ----
|
||||
|
||||
func TestTopologyConformance_loopUntilField(t *testing.T) {
|
||||
// 第 1-2 轮 done=no,第 3 轮 done=yes → 恰执行 3 次。
|
||||
be := &iterBackend{calls: map[string]int{}, content: func(_ string, iter int, target string) []byte {
|
||||
if target == "fields.json" {
|
||||
if iter >= 3 {
|
||||
return fieldsJSON(map[string]string{"done": "yes"})
|
||||
}
|
||||
return fieldsJSON(map[string]string{"done": "no"})
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
sink := &collectSink{}
|
||||
engine := newTopologyEngine(t, mockScriptPlanner(
|
||||
StepSpec{ID: "s1", Role: "researcher", Title: "循环体", Targets: []string{"output.md", "fields.json"},
|
||||
Loop: &LoopSpec{MaxIterations: 5, UntilField: "done", UntilEquals: "yes"}},
|
||||
), be, sink)
|
||||
res, err := engine.Run(context.Background(), Goal{Text: "循环任务"}, RunOptions{Topology: TopologyLoop})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != RunCompleted {
|
||||
t.Fatalf("status = %s err = %v", res.Status, res.Error)
|
||||
}
|
||||
if be.totalCalls() != 3 {
|
||||
t.Fatalf("应在条件满足时停机(3 次),实际 %d", be.totalCalls())
|
||||
}
|
||||
if sink.countText("再武装") != 2 {
|
||||
t.Fatalf("再武装事件应 2 次:%d", sink.countText("再武装"))
|
||||
}
|
||||
// 计划里的 LoopRuns 持久化为 2。
|
||||
if len(res.Plan.Steps) != 1 || res.Plan.Steps[0].LoopRuns != 2 {
|
||||
t.Fatalf("LoopRuns = %+v", res.Plan.Steps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopologyConformance_loopBudgetExhausted(t *testing.T) {
|
||||
// 条件永不满足:恰执行 MaxIterations 次后触顶(非失败)。
|
||||
be := &iterBackend{calls: map[string]int{}, content: func(_ string, _ int, target string) []byte {
|
||||
if target == "fields.json" {
|
||||
return fieldsJSON(map[string]string{"done": "no"})
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
sink := &collectSink{}
|
||||
engine := newTopologyEngine(t, mockScriptPlanner(
|
||||
StepSpec{ID: "s1", Role: "researcher", Title: "循环体", Targets: []string{"output.md", "fields.json"},
|
||||
Loop: &LoopSpec{MaxIterations: 3, UntilField: "done", UntilEquals: "yes"}},
|
||||
), be, sink)
|
||||
res, err := engine.Run(context.Background(), Goal{Text: "循环任务"}, RunOptions{Topology: TopologyLoop})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != RunCompleted {
|
||||
t.Fatalf("触顶应是完成而非失败:%s %v", res.Status, res.Error)
|
||||
}
|
||||
if be.totalCalls() != 3 {
|
||||
t.Fatalf("恰 3 次,实际 %d", be.totalCalls())
|
||||
}
|
||||
if sink.countText("loop_exhausted") != 1 {
|
||||
t.Fatal("缺 loop_exhausted 事件")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- router:分支选中/落选跳过/无命中 fail-closed ----
|
||||
|
||||
func routerSteps() []StepSpec {
|
||||
return []StepSpec{
|
||||
{ID: "r1", Role: "researcher", Title: "路由", Targets: []string{"output.md", "fields.json"},
|
||||
Branches: []Branch{
|
||||
{When: BranchCond{Field: "route", Equals: "fast"}, Then: []string{"fast"}},
|
||||
{When: BranchCond{Field: "route", NotEquals: "fast"}, Then: []string{"slow"}},
|
||||
}},
|
||||
{ID: "fast", Role: "writer", Title: "快线", Needs: []string{"r1"}, Targets: []string{"output.md"}},
|
||||
{ID: "slow", Role: "writer", Title: "慢线", Needs: []string{"r1"}, Targets: []string{"output.md"}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopologyConformance_routerSelectsBranch(t *testing.T) {
|
||||
be := &iterBackend{calls: map[string]int{}, content: func(_ string, _ int, target string) []byte {
|
||||
if target == "fields.json" {
|
||||
return fieldsJSON(map[string]string{"route": "fast"})
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
engine := newTopologyEngine(t, mockScriptPlanner(routerSteps()...), be, nil)
|
||||
res, err := engine.Run(context.Background(), Goal{Text: "路由任务"}, RunOptions{Topology: TopologyRouter})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != RunCompleted {
|
||||
t.Fatalf("分支落选是预期路径,不得降级/失败:%s %v", res.Status, res.Error)
|
||||
}
|
||||
byID := map[string]StepSpec{}
|
||||
for _, s := range res.Plan.Steps {
|
||||
byID[s.ID] = s
|
||||
}
|
||||
if byID["fast"].Status != "completed" {
|
||||
t.Fatalf("fast = %s", byID["fast"].Status)
|
||||
}
|
||||
if byID["slow"].Status != "skipped" || byID["slow"].Error != routerSkipReason {
|
||||
t.Fatalf("slow = %s %q", byID["slow"].Status, byID["slow"].Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopologyConformance_routerNoMatchFailsClosed(t *testing.T) {
|
||||
be := &iterBackend{calls: map[string]int{}, content: func(_ string, _ int, target string) []byte {
|
||||
if target == "fields.json" {
|
||||
return fieldsJSON(map[string]string{"route": ""}) // 两条分支都不命中
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
steps := routerSteps()
|
||||
// 去掉 NotEquals 兜底分支,制造无命中。
|
||||
steps[0].Branches = []Branch{{When: BranchCond{Field: "route", Equals: "fast"}, Then: []string{"fast"}}}
|
||||
engine := newTopologyEngine(t, mockScriptPlanner(steps...), be, nil)
|
||||
res, err := engine.Run(context.Background(), Goal{Text: "路由任务"}, RunOptions{Topology: TopologyRouter})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != RunFailed || !strings.Contains(res.Error, "无分支命中") {
|
||||
t.Fatalf("无命中必须 fail-closed:%s %v", res.Status, res.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- evaluator-optimizer:draft→eval 循环复合,收敛即停 ----
|
||||
|
||||
func TestTopologyConformance_evaluatorOptimizer(t *testing.T) {
|
||||
// eval 第 1-2 轮 verdict=fail,第 3 轮 pass;draft 随 body 再武装。
|
||||
be := &iterBackend{calls: map[string]int{}, content: func(key string, iter int, target string) []byte {
|
||||
if target == "fields.json" {
|
||||
if iter >= 3 {
|
||||
return fieldsJSON(map[string]string{"verdict": "pass"})
|
||||
}
|
||||
return fieldsJSON(map[string]string{"verdict": "fail"})
|
||||
}
|
||||
if target == "verdict.md" {
|
||||
return []byte(fmt.Sprintf("评审第 %d 轮\n\n%s", iter, goodReport))
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
engine := newTopologyEngine(t, mockScriptPlanner(
|
||||
StepSpec{ID: "draft", Role: "writer", Title: "起草", Targets: []string{"output.md"}},
|
||||
StepSpec{ID: "eval", Role: "reviewer", Title: "评审", Targets: []string{"verdict.md", "fields.json"},
|
||||
Loop: &LoopSpec{MaxIterations: 4, UntilField: "verdict", UntilEquals: "pass", Body: []string{"draft"}}},
|
||||
), be, nil)
|
||||
res, err := engine.Run(context.Background(), Goal{Text: "起草并评审"}, RunOptions{Topology: TopologyEvaluatorOptimizer})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != RunCompleted {
|
||||
t.Fatalf("status = %s err = %v", res.Status, res.Error)
|
||||
}
|
||||
byID := map[string]StepSpec{}
|
||||
for _, s := range res.Plan.Steps {
|
||||
byID[s.ID] = s
|
||||
}
|
||||
if byID["eval"].LoopRuns != 2 {
|
||||
t.Fatalf("eval 应再武装 2 轮:%+v", byID["eval"])
|
||||
}
|
||||
// draft 3 次 + eval 3 次。
|
||||
if be.totalCalls() != 6 {
|
||||
t.Fatalf("总执行次数 = %d,期望 6", be.totalCalls())
|
||||
}
|
||||
}
|
||||
|
||||
// ---- long-running:截止时刻 → 未完步骤 skipped、降级收口 ----
|
||||
|
||||
func TestTopologyConformance_longRunningDeadline(t *testing.T) {
|
||||
// 时序鲁棒设计:每步 300ms、截止 +500ms——装配耗时不影响断言核心:
|
||||
// s1 必完成(首个 superstep 在截止前开跑),s3 必被跳过(第三个
|
||||
// superstep 边界必超期),收口必为 degraded。s2 完成与否取决于装配
|
||||
// 耗时,不做断言。
|
||||
be := &iterBackend{calls: map[string]int{}, delay: 300 * time.Millisecond}
|
||||
sink := &collectSink{}
|
||||
engine := newTopologyEngine(t, mockScriptPlanner(
|
||||
StepSpec{ID: "s1", Role: "researcher", Title: "一", Targets: []string{"notes.md"}},
|
||||
StepSpec{ID: "s2", Role: "writer", Title: "二", Targets: []string{"output.md"}},
|
||||
StepSpec{ID: "s3", Role: "reviewer", Title: "三", Targets: []string{"output.md"}},
|
||||
), be, sink)
|
||||
res, err := engine.Run(context.Background(), Goal{Text: "长任务"}, RunOptions{
|
||||
Topology: TopologyLongRunning,
|
||||
Deadline: time.Now().Add(500 * time.Millisecond),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != RunDegraded {
|
||||
t.Fatalf("截止后应降级收口:%s %v", res.Status, res.Error)
|
||||
}
|
||||
byID := map[string]StepSpec{}
|
||||
for _, s := range res.Plan.Steps {
|
||||
byID[s.ID] = s
|
||||
}
|
||||
if byID["s1"].Status != "completed" {
|
||||
t.Fatalf("s1 = %s", byID["s1"].Status)
|
||||
}
|
||||
if byID["s3"].Status != "skipped" || byID["s3"].Error != "deadline exceeded" {
|
||||
t.Fatalf("s3 = %s %q", byID["s3"].Status, byID["s3"].Error)
|
||||
}
|
||||
if sink.countText("截止时刻") != 1 {
|
||||
t.Fatal("缺截止事件")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 拓扑校验 fail-closed ----
|
||||
|
||||
func TestTopologyValidationFailsClosed(t *testing.T) {
|
||||
be := &iterBackend{calls: map[string]int{}}
|
||||
cases := []struct {
|
||||
name string
|
||||
topo string
|
||||
wantErr string
|
||||
}{
|
||||
{"未注册名", "bogus_topology", "未知拓扑"},
|
||||
{"primitive 单跑", TopologyMailbox, "跨拓扑原语"},
|
||||
{"primitive 单跑 handoff", TopologyHandoff, "跨拓扑原语"},
|
||||
{"driver 未点亮", TopologyReview, "驱动未注册"},
|
||||
{"driver 未点亮 group_chat", TopologyGroupChat, "驱动未注册"},
|
||||
{"driver 未点亮 task_board", TopologyTaskBoard, "驱动未注册"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
engine := newTopologyEngine(t, mockScriptPlanner(
|
||||
StepSpec{ID: "s1", Role: "researcher", Title: "一", Targets: []string{"output.md"}},
|
||||
), be, nil)
|
||||
res, err := engine.Run(context.Background(), Goal{Text: "x"}, RunOptions{Topology: tc.topo})
|
||||
if err != nil {
|
||||
t.Fatalf("%s:err = %v", tc.name, err)
|
||||
}
|
||||
if res.Status != RunFailed || !strings.Contains(res.Error, tc.wantErr) {
|
||||
t.Fatalf("%s:%s %q(期望含 %q)", tc.name, res.Status, res.Error, tc.wantErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// leader 拓扑名映射到既有 Mode 委派循环(本测只钉「受理且映射」,
|
||||
// leader 行为 conformance 在 leader_test.go / WP2 增强)。
|
||||
func TestTopologyLeaderMapsToMode(t *testing.T) {
|
||||
spec, ok := LookupTopology(TopologyLeader)
|
||||
if !ok || spec.Kind != TopologyMode {
|
||||
t.Fatalf("leader 注册形态:%+v %v", spec, ok)
|
||||
}
|
||||
if _, err := checkTopology(TopologyLeader); err != nil {
|
||||
t.Fatalf("leader 拓扑应可受理:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 12 种内置拓扑全部注册且 Kind 合法(清单完整性)。
|
||||
func TestBuiltinTopologiesComplete(t *testing.T) {
|
||||
want := map[string]TopologyKind{
|
||||
TopologySequential: TopologyGraph, TopologyParallel: TopologyGraph,
|
||||
TopologyLoop: TopologyGraph, TopologyRouter: TopologyGraph,
|
||||
TopologyLeader: TopologyMode, TopologyHandoff: TopologyPrimitive,
|
||||
TopologyGroupChat: TopologyDriver, TopologyReview: TopologyDriver,
|
||||
TopologyEvaluatorOptimizer: TopologyGraph, TopologyLongRunning: TopologyGraph,
|
||||
TopologyTaskBoard: TopologyDriver, TopologyMailbox: TopologyPrimitive,
|
||||
}
|
||||
got := BuiltinTopologies()
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("注册数 = %d,期望 %d", len(got), len(want))
|
||||
}
|
||||
for _, spec := range got {
|
||||
if k, ok := want[spec.Name]; !ok || k != spec.Kind {
|
||||
t.Fatalf("拓扑 %s kind = %s(期望 %s)", spec.Name, spec.Kind, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
+412
-126
@@ -1,6 +1,7 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -9,137 +10,235 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"agent-native/exec"
|
||||
|
||||
"error-code-system/errcode"
|
||||
)
|
||||
|
||||
// flowDriver 工作流驱动器:推进 runState 直到终态或 HITL 挂起边界。
|
||||
// flowDriver 工作流驱动器:推进 RunState 直到终态或 HITL 挂起边界。
|
||||
type flowDriver struct {
|
||||
opts engineDeps
|
||||
}
|
||||
|
||||
// advance 执行到边界;返回 (结果, 挂起上下文),二者互斥非 nil(infra 错误时都为 nil 且 err 非 nil)。
|
||||
func (d *flowDriver) advance(ctx context.Context, st *runState) (res *RunResult, gate *hitlGate, err error) {
|
||||
// 推进模型(Q1 WP1/WP2):每轮从权威 Steps 现场编译调度图,按 superstep
|
||||
// 分层推进——层内无写集冲突的步骤并行(runParallel 复用),有冲突的按声明序
|
||||
// 串行(含动态调整);动态调整改 Steps 后下一轮自然生效。
|
||||
func (d *flowDriver) advance(ctx context.Context, st *RunState) (res *RunResult, gate *HitlGate, err error) {
|
||||
wf := st.Workflow()
|
||||
wf.Status = "in_progress"
|
||||
st.setWorkflow(wf)
|
||||
d.emit(st, Event{Type: EvPhase, Text: "开始执行工作流"})
|
||||
|
||||
i := 0
|
||||
for i < len(st.Plan.Workflow.Steps) {
|
||||
step := st.Plan.Workflow.Steps[i]
|
||||
switch step.Status {
|
||||
case "completed", "skipped", "failed":
|
||||
i++
|
||||
continue
|
||||
superNo := 0
|
||||
// 运行级截止(Q2 long_running):superstep 边界检查,超期未完步骤置
|
||||
// skipped 并降级收口;零值/不可解析 = 不设限。
|
||||
var runDeadline time.Time
|
||||
if v := st.Meta.Metadata["deadline"]; v != "" {
|
||||
// RFC3339Nano 同时接受秒级与纳秒级两种写法。
|
||||
if t, terr := time.Parse(time.RFC3339Nano, v); terr == nil {
|
||||
runDeadline = t
|
||||
}
|
||||
|
||||
// HITL:角色权限升级(bash=ask 且本运行未授权过该角色)。
|
||||
role, _ := findAgent(st.Plan.Squad.Agents, step.Role)
|
||||
if role.BashPermission == BashPermissionAsk && !st.Approved["role:"+role.Role] {
|
||||
switch d.policyRole(st, role).mode() {
|
||||
case HitlDisabled, HitlAuto:
|
||||
step.Error = "permission denied: role " + role.Role + " requires bash approval (hitl mode=" + string(d.policyRole(st, role).mode()) + ")"
|
||||
st.Plan.Workflow.Steps[i] = step
|
||||
return d.failStep(ctx, st, i)
|
||||
default:
|
||||
return d.hold(st, HitlPermission, step.ID, step.Role,
|
||||
fmt.Sprintf("角色 %s(%s)声明 bash_permission=ask,是否授权其在本次运行中使用更高权限?", role.Name, role.Role),
|
||||
[]HumanOption{
|
||||
{Value: "approve", Label: "授权本次运行"},
|
||||
{Value: "reject", Label: "拒绝,步骤失败"},
|
||||
{Value: "abort", Label: "中止运行"},
|
||||
},
|
||||
fmt.Sprintf("步骤:%s\n角色:%s\n禁忌:%v", step.Title, role.Role, role.ForbiddenActions), nil)
|
||||
}
|
||||
cpw := newCheckpointWriter(CheckpointMode(st.Meta.Metadata["checkpoint_mode"]), st.storeOr())
|
||||
// 推进退出前 flush:async 落完、on_exit 写最终快照(Q1 WP4 三档的出口语义)。
|
||||
var lastGraph *graph
|
||||
defer func() {
|
||||
if lastGraph == nil {
|
||||
if g, gerr := compileGraph(st.Plan.Workflow.Steps); gerr == nil {
|
||||
lastGraph = g
|
||||
}
|
||||
}
|
||||
|
||||
// HITL:步骤显式要求审批(角色级策略参与判定)。
|
||||
if step.RequiresApproval && !st.Approved["step:"+step.ID] {
|
||||
switch d.policyRole(st, role).mode() {
|
||||
case HitlDisabled, HitlAuto:
|
||||
step.Error = "approval required but hitl mode is " + string(d.policy(st).mode())
|
||||
st.Plan.Workflow.Steps[i] = step
|
||||
return d.failStep(ctx, st, i)
|
||||
default:
|
||||
return d.hold(st, HitlConfirmation, step.ID, step.Role,
|
||||
fmt.Sprintf("步骤「%s」标记为需人工审批后才能执行,是否批准?", step.Title),
|
||||
[]HumanOption{
|
||||
{Value: "approve", Label: "批准执行"},
|
||||
{Value: "skip", Label: "跳过该步骤"},
|
||||
{Value: "abort", Label: "中止运行"},
|
||||
},
|
||||
fmt.Sprintf("角色:%s\n产出:%v", step.Role, step.Targets), nil)
|
||||
}
|
||||
if lastGraph != nil {
|
||||
_ = cpw.flush(st.Meta.WorkDir, snapshotOf(superNo, lastGraph, st.Plan.Workflow.Steps))
|
||||
}
|
||||
|
||||
if step.Parallel {
|
||||
// 收集相邻并行组,组内并发、组间顺序。
|
||||
var group []int
|
||||
for i < len(st.Plan.Workflow.Steps) {
|
||||
s := st.Plan.Workflow.Steps[i]
|
||||
if s.Parallel && s.Status != "completed" && s.Status != "skipped" && s.Status != "failed" {
|
||||
group = append(group, i)
|
||||
i++
|
||||
continue
|
||||
}()
|
||||
for {
|
||||
if !runDeadline.IsZero() && time.Now().After(runDeadline) {
|
||||
d.emit(st, Event{Type: EvPhase, Text: "运行截止时刻已到,未完步骤按 skipped 收口"})
|
||||
st.mu.Lock()
|
||||
for i := range st.Plan.Workflow.Steps {
|
||||
if st.Plan.Workflow.Steps[i].Status == "pending" || st.Plan.Workflow.Steps[i].Status == "running" {
|
||||
st.Plan.Workflow.Steps[i].Status = "skipped"
|
||||
st.Plan.Workflow.Steps[i].Error = "deadline exceeded"
|
||||
}
|
||||
break
|
||||
}
|
||||
if out, g, err := d.runParallel(ctx, st, group); out != nil || g != nil || err != nil {
|
||||
if out == nil && g != nil {
|
||||
out = heldResult(st, g)
|
||||
_ = st.save()
|
||||
st.mu.Unlock()
|
||||
return d.finalize(ctx, st)
|
||||
}
|
||||
g, gerr := compileGraph(st.Plan.Workflow.Steps)
|
||||
if gerr != nil {
|
||||
wf2 := st.Workflow()
|
||||
wf2.Status = "failed"
|
||||
st.setWorkflow(wf2)
|
||||
return &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
|
||||
Error: "graph: " + gerr.Error(), WorkDir: st.Meta.WorkDir}, nil, nil
|
||||
}
|
||||
layer, ok := g.nextLayer(st.Plan.Workflow.Steps)
|
||||
if !ok {
|
||||
if n := pendingCount(st.Plan.Workflow.Steps); n > 0 {
|
||||
wf2 := st.Workflow()
|
||||
wf2.Status = "failed"
|
||||
st.setWorkflow(wf2)
|
||||
return &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
|
||||
Error: fmt.Sprintf("graph: 依赖无法满足,仍有 %d 个步骤无法调度", n), WorkDir: st.Meta.WorkDir}, nil, nil
|
||||
}
|
||||
break
|
||||
}
|
||||
superNo++
|
||||
d.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf("superstep %d 推进(并行 %d 步,串行 %d 步)", superNo, len(layer.par), len(layer.seq))})
|
||||
|
||||
// 并行段:组内每一步都先过审批和权限,任一需要挂起则整组不执行。
|
||||
if len(layer.par) > 0 {
|
||||
for _, idx := range layer.par {
|
||||
if out, g2, err := d.gateBeforeRun(ctx, st, idx); out != nil || g2 != nil || err != nil {
|
||||
if out == nil && g2 != nil {
|
||||
out = heldResult(st, g2)
|
||||
}
|
||||
return out, g2, err
|
||||
}
|
||||
return out, g, err
|
||||
}
|
||||
if out, g2, err := d.runParallel(ctx, st, layer.par); out != nil || g2 != nil || err != nil {
|
||||
if out == nil && g2 != nil {
|
||||
out = heldResult(st, g2)
|
||||
}
|
||||
return out, g2, err
|
||||
}
|
||||
if g2 := d.checkBlockingGaps(st); g2 != nil {
|
||||
return heldResult(st, g2), g2, nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
out, g, err := d.runOneStep(ctx, st, i)
|
||||
if out != nil || g != nil || err != nil {
|
||||
if out == nil && g != nil {
|
||||
out = heldResult(st, g)
|
||||
}
|
||||
return out, g, err
|
||||
}
|
||||
i++
|
||||
|
||||
// 阻塞缺口挂起。
|
||||
if g2 := d.checkBlockingGaps(st); g2 != nil {
|
||||
return heldResult(st, g2), g2, nil
|
||||
}
|
||||
|
||||
// 动态调整(仅顺序步骤后,避免并发竞态)。
|
||||
if st.Plan.Workflow.Steps[i-1].AllowDynamicAdjustment && d.opts.Evaluator != nil {
|
||||
out, g, err := d.applyDynamicAdjustment(ctx, st, i-1)
|
||||
if out != nil || g != nil || err != nil {
|
||||
if out == nil && g != nil {
|
||||
out = heldResult(st, g)
|
||||
// 单成员并行组实际是顺序执行:与旧线性扫描一致,其后可动态调整
|
||||
// (多成员组不做,避免并发竞态)。
|
||||
if len(layer.par) == 1 {
|
||||
idx := layer.par[0]
|
||||
if st.Plan.Workflow.Steps[idx].AllowDynamicAdjustment && d.opts.Evaluator != nil {
|
||||
out, g2, err := d.applyDynamicAdjustment(ctx, st, idx)
|
||||
if out != nil || g2 != nil || err != nil {
|
||||
if out == nil && g2 != nil {
|
||||
out = heldResult(st, g2)
|
||||
}
|
||||
return out, g2, err
|
||||
}
|
||||
}
|
||||
return out, g, err
|
||||
}
|
||||
}
|
||||
|
||||
// 串行段(写集冲突步骤,按声明序逐个执行;顺序步骤后可动态调整)。
|
||||
for _, idx := range layer.seq {
|
||||
if out, g2, err := d.gateBeforeRun(ctx, st, idx); out != nil || g2 != nil || err != nil {
|
||||
if out == nil && g2 != nil {
|
||||
out = heldResult(st, g2)
|
||||
}
|
||||
return out, g2, err
|
||||
}
|
||||
out, g2, err := d.runOneStep(ctx, st, idx)
|
||||
if out != nil || g2 != nil || err != nil {
|
||||
if out == nil && g2 != nil {
|
||||
out = heldResult(st, g2)
|
||||
}
|
||||
return out, g2, err
|
||||
}
|
||||
if g2 := d.checkBlockingGaps(st); g2 != nil {
|
||||
return heldResult(st, g2), g2, nil
|
||||
}
|
||||
if st.Plan.Workflow.Steps[idx].AllowDynamicAdjustment && d.opts.Evaluator != nil {
|
||||
out, g2, err := d.applyDynamicAdjustment(ctx, st, idx)
|
||||
if out != nil || g2 != nil || err != nil {
|
||||
if out == nil && g2 != nil {
|
||||
out = heldResult(st, g2)
|
||||
}
|
||||
return out, g2, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// superstep 边界快照(sync 立即 / async 入队;on_exit 由 defer 的 flush 写)。
|
||||
lastGraph = g
|
||||
if err := cpw.boundary(st.Meta.WorkDir, snapshotOf(superNo, g, st.Plan.Workflow.Steps)); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return d.finalize(ctx, st)
|
||||
}
|
||||
|
||||
// snapshotOf 组装一个 superstep 边界快照:序号、图结构指纹与全量步骤状态。
|
||||
func snapshotOf(superNo int, g *graph, steps []StepSpec) Checkpoint {
|
||||
cp := Checkpoint{Superstep: superNo, At: time.Now(), GraphHash: g.hash()}
|
||||
for _, s := range steps {
|
||||
cp.StepStates = append(cp.StepStates, StepSnapshot{ID: s.ID, Status: s.Status, AttemptID: s.AttemptID})
|
||||
}
|
||||
return cp
|
||||
}
|
||||
|
||||
// gateBeforeRun 在步骤真正执行前检查权限升级和显式审批。
|
||||
// 返回非空结果或挂起时,调用方不得执行该步骤,也不得执行它所在的并行组。
|
||||
func (d *flowDriver) gateBeforeRun(ctx context.Context, st *RunState, i int) (*RunResult, *HitlGate, error) {
|
||||
step := st.Plan.Workflow.Steps[i]
|
||||
role, _ := findAgent(st.Plan.Squad.Agents, step.Role)
|
||||
if role.BashPermission == BashPermissionAsk && !st.Approved["role:"+role.Role] {
|
||||
switch d.policyRole(st, role).mode() {
|
||||
case HitlDisabled, HitlAuto:
|
||||
step.Error = "permission denied: role " + role.Role + " requires bash approval (hitl mode=" + string(d.policyRole(st, role).mode()) + ")"
|
||||
st.Plan.Workflow.Steps[i] = step
|
||||
return d.failStep(ctx, st, i)
|
||||
default:
|
||||
return d.hold(st, HitlPermission, step.ID, step.Role,
|
||||
fmt.Sprintf("角色 %s(%s)声明 bash_permission=ask,是否授权其在本次运行中使用更高权限?", role.Name, role.Role),
|
||||
[]HumanOption{
|
||||
{Value: "approve", Label: "授权本次运行"},
|
||||
{Value: "reject", Label: "拒绝,步骤失败"},
|
||||
{Value: "abort", Label: "中止运行"},
|
||||
},
|
||||
fmt.Sprintf("步骤:%s\n角色:%s\n禁忌:%v", step.Title, role.Role, role.ForbiddenActions), "", nil)
|
||||
}
|
||||
}
|
||||
if step.RequiresApproval && !st.Approved["step:"+step.ID] {
|
||||
switch d.policyRole(st, role).mode() {
|
||||
case HitlDisabled, HitlAuto:
|
||||
step.Error = "approval required but hitl mode is " + string(d.policy(st).mode())
|
||||
st.Plan.Workflow.Steps[i] = step
|
||||
return d.failStep(ctx, st, i)
|
||||
default:
|
||||
return d.hold(st, HitlConfirmation, step.ID, step.Role,
|
||||
fmt.Sprintf("步骤「%s」标记为需人工审批后才能执行,是否批准?", step.Title),
|
||||
[]HumanOption{
|
||||
{Value: "approve", Label: "批准执行"},
|
||||
{Value: "skip", Label: "跳过该步骤"},
|
||||
{Value: "abort", Label: "中止运行"},
|
||||
},
|
||||
fmt.Sprintf("角色:%s\n产出:%v", step.Role, step.Targets), "step_approval", nil)
|
||||
}
|
||||
}
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
// runParallel 并发执行一个并行组;关键步骤失败即终止整个运行。
|
||||
func (d *flowDriver) runParallel(ctx context.Context, st *runState, idxs []int) (*RunResult, *hitlGate, error) {
|
||||
func (d *flowDriver) runParallel(ctx context.Context, st *RunState, idxs []int) (*RunResult, *HitlGate, error) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
failedCritical := -1
|
||||
var firstFail *RunResult
|
||||
var gate *HitlGate
|
||||
for _, idx := range idxs {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
out, _, err := d.runOneStep(ctx, st, idx)
|
||||
out, g, err := d.runOneStep(ctx, st, idx)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if g != nil && gate == nil {
|
||||
gate = g
|
||||
cancel()
|
||||
}
|
||||
if (out != nil && out.Status == RunFailed) || err != nil {
|
||||
cancel()
|
||||
// 保留首个带具体错误的失败结果(如 router 无命中);
|
||||
// failStep 的通用格式只在没有更具体结果时兜底。
|
||||
if out != nil && out.Status == RunFailed && firstFail == nil {
|
||||
firstFail = out
|
||||
}
|
||||
if st.Plan.Workflow.Steps[idx].IsCritical() && failedCritical < 0 {
|
||||
failedCritical = idx
|
||||
}
|
||||
@@ -147,6 +246,12 @@ func (d *flowDriver) runParallel(ctx context.Context, st *runState, idxs []int)
|
||||
}(idx)
|
||||
}
|
||||
wg.Wait()
|
||||
if gate != nil {
|
||||
return heldResult(st, gate), gate, nil
|
||||
}
|
||||
if firstFail != nil {
|
||||
return firstFail, nil, nil
|
||||
}
|
||||
if failedCritical >= 0 {
|
||||
return d.failStep(ctx, st, failedCritical)
|
||||
}
|
||||
@@ -154,7 +259,10 @@ func (d *flowDriver) runParallel(ctx context.Context, st *runState, idxs []int)
|
||||
}
|
||||
|
||||
// runOneStep 执行单个步骤(含瞬态重试与交付物校验)。
|
||||
func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*RunResult, *hitlGate, error) {
|
||||
func (d *flowDriver) runOneStep(ctx context.Context, st *RunState, idx int) (*RunResult, *HitlGate, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return &RunResult{RunID: st.Meta.ID, Status: RunCancelled, Error: err.Error()}, nil, err
|
||||
}
|
||||
step := st.Plan.Workflow.Steps[idx]
|
||||
role, ok := findAgent(st.Plan.Squad.Agents, step.Role)
|
||||
if !ok {
|
||||
@@ -163,6 +271,8 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru
|
||||
st.mu.Lock()
|
||||
st.Plan.Squad.Agents = append(st.Plan.Squad.Agents, r)
|
||||
st.mu.Unlock()
|
||||
} else if step.IntentID != "" {
|
||||
role = RoleSpec{Role: step.Role, Name: step.Role}
|
||||
} else {
|
||||
step.Status, step.Error = "failed", "role not found: "+step.Role
|
||||
st.setStep(idx, step)
|
||||
@@ -179,15 +289,38 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru
|
||||
|
||||
d.emit(st, Event{Type: EvStepStarted, StepID: step.ID, Role: step.Role, Text: step.Title})
|
||||
step.Status = "running"
|
||||
step.Attempts++
|
||||
step.AttemptID = fmt.Sprintf("%s/%s/%d", st.Meta.ID, step.ID, step.Attempts)
|
||||
step.Error = ""
|
||||
st.setStep(idx, step)
|
||||
// 与收口落盘同一把锁:并行步骤一边改 Steps、一边全量 marshal 会数据竞争。
|
||||
st.mu.Lock()
|
||||
st.Plan.Workflow.Steps[idx] = step
|
||||
saveErr := st.save()
|
||||
st.mu.Unlock()
|
||||
if saveErr != nil {
|
||||
return nil, nil, saveErr
|
||||
}
|
||||
started := time.Now()
|
||||
|
||||
if d.opts.Workspaces != nil {
|
||||
if err := d.opts.Workspaces.writeTaskFile(subDir, &step, role, &st.BB); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
prompt := readFileOr(filepath.Join(subDir, "TASK.md"), step.Title)
|
||||
prompt = st.injectSteering(prompt)
|
||||
// 邮箱投递(Q2):本步骤启动时把发给该角色(含广播)的未读消息并入
|
||||
// 任务书;只走 prompt 面,永不触碰 HITL 门(验收三构造性保证)。
|
||||
st.mu.Lock()
|
||||
mail := takeMailFor(st, step.Role)
|
||||
if len(mail) > 0 {
|
||||
_ = st.save()
|
||||
}
|
||||
st.mu.Unlock()
|
||||
prompt += formatMailSection(mail)
|
||||
instructions := readFileOr(filepath.Join(subDir, "AGENTS.md"), d.opts.Workspaces.ResolveInstructions(role))
|
||||
|
||||
var backend exec.Backend
|
||||
var backend Executor
|
||||
backendName := ""
|
||||
if step.IntentID != "" {
|
||||
backendName = "intent:" + step.IntentID
|
||||
@@ -207,13 +340,13 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru
|
||||
}
|
||||
// 步骤级共享预算:重试与首次尝试共用同一截止时刻
|
||||
//(agent-execution 标准:重试层共享总预算,不随次数翻倍)。
|
||||
var stepBudget *exec.Budget
|
||||
var stepBudget *ExecBudget
|
||||
if d.opts.StepTimeout > 0 {
|
||||
stepBudget = &exec.Budget{Deadline: time.Now().Add(d.opts.StepTimeout)}
|
||||
stepBudget = &ExecBudget{Deadline: time.Now().Add(d.opts.StepTimeout)}
|
||||
}
|
||||
var result *exec.Result
|
||||
var result *ExecResult
|
||||
var err error
|
||||
for attempt := 1; attempt <= exec.MaxTransientStepInvocations; attempt++ {
|
||||
for attempt := 1; attempt <= MaxTransientStepInvocations; attempt++ {
|
||||
reqPrompt := prompt
|
||||
if attempt > 1 {
|
||||
prevReason := "交付物缺失"
|
||||
@@ -224,22 +357,22 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru
|
||||
}
|
||||
reqPrompt += "\n\n【重试提示】上一次执行未成功(" + prevReason + ")。请务必把结果写入:" + strings.Join(targets, ", ")
|
||||
}
|
||||
req := exec.Request{
|
||||
req := ExecRequest{
|
||||
WorkDir: subDir,
|
||||
Prompt: reqPrompt,
|
||||
Instructions: instructions,
|
||||
Targets: targets,
|
||||
Timeout: d.opts.StepTimeout,
|
||||
ToolProfile: orDefault(role.ToolProfile, exec.ToolProfileWorkspace),
|
||||
OnEvent: func(ev exec.ProcessEvent) {
|
||||
ToolProfile: orDefault(role.ToolProfile, ToolProfileWorkspace),
|
||||
OnEvent: func(ev ProcessEvent) {
|
||||
d.emit(st, Event{Type: EvDetail, StepID: step.ID, Role: step.Role, Text: ev.Text,
|
||||
Data: marshalJSON(map[string]string{"process_type": ev.Type, "tool": ev.Tool, "detail": ev.Detail})})
|
||||
},
|
||||
// RoleSpec v2 贯通:工具白名单 / 技能 / 标识(原为死字段或缺失)。
|
||||
ToolAllowlist: role.Tools,
|
||||
Skills: role.Skills,
|
||||
Skills: firstNonEmptySlice(step.Skills, role.Skills),
|
||||
SessionScope: st.Meta.ID + "/" + step.ID,
|
||||
ExecutionID: st.Meta.ID + "-" + step.ID,
|
||||
ExecutionID: step.AttemptID,
|
||||
Budget: stepBudget,
|
||||
}
|
||||
if step.IntentID != "" {
|
||||
@@ -255,7 +388,7 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru
|
||||
Title: step.Title, WorkDir: subDir, Targets: targets,
|
||||
})
|
||||
if err == nil && result != nil && len(result.Patches) > 0 {
|
||||
_, _ = exec.MaterializeTargets(subDir, targets, result.Patches)
|
||||
_, _ = MaterializeTargets(subDir, targets, result.Patches)
|
||||
}
|
||||
} else {
|
||||
result, err = backend.Execute(ctx, req)
|
||||
@@ -265,19 +398,19 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru
|
||||
}
|
||||
// 交付物物化:后端返回 patches 但未写盘时补写。
|
||||
if err == nil && len(result.Patches) > 0 {
|
||||
if _, merr := exec.MaterializeTargets(subDir, targets, result.Patches); merr == nil {
|
||||
if _, merr := MaterializeTargets(subDir, targets, result.Patches); merr == nil {
|
||||
if allTargetsPresent(subDir, targets) {
|
||||
result.Success = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
assessment := exec.AssessStepFailure(exec.StepFailureSignals{
|
||||
assessment := AssessStepFailure(StepFailureSignals{
|
||||
Err: err, Result: result, WorkDir: subDir,
|
||||
ExtraText: fmt.Sprintf("step=%s backend=%s", step.ID, backendName),
|
||||
TargetMissing: !allTargetsPresent(subDir, targets),
|
||||
})
|
||||
if assessment.Class != exec.FailureTransient || attempt == exec.MaxTransientStepInvocations {
|
||||
if assessment.Class != FailureTransient || attempt == MaxTransientStepInvocations {
|
||||
step.Status = "failed"
|
||||
if err != nil {
|
||||
step.Error = err.Error()
|
||||
@@ -330,11 +463,150 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru
|
||||
}
|
||||
d.emit(st, Event{Type: EvStepCompleted, StepID: step.ID, Role: step.Role, Text: step.Title,
|
||||
Data: marshalJSON(map[string]any{"duration_ms": step.DurationMs, "targets": targets})})
|
||||
|
||||
// 循环步再武装(Q2 loop):停机条件不满足且预算未尽 → 本步与 loop.body
|
||||
// 一起重置为 pending;触顶发 loop_exhausted 事件后按完成继续。
|
||||
if step.Loop != nil {
|
||||
if d.loopShouldRearm(st, step) {
|
||||
if out, g2, err2 := d.rearmLoop(ctx, st, idx, step); out != nil || g2 != nil || err2 != nil {
|
||||
return out, g2, err2
|
||||
}
|
||||
return nil, nil, nil
|
||||
}
|
||||
if step.Loop.UntilField != "" && step.LoopRuns+1 >= step.Loop.MaxIterations {
|
||||
d.emit(st, Event{Type: EvPhase, StepID: step.ID, Text: fmt.Sprintf("loop_exhausted:%s 达 %d 轮上限,停机条件未满足", step.ID, step.Loop.MaxIterations)})
|
||||
}
|
||||
}
|
||||
// 路由步分支裁决(Q2 router):首个命中的分支保留,其余分支目标置
|
||||
// skipped;无分支命中 fail-closed 成失败运行。
|
||||
if len(step.Branches) > 0 {
|
||||
return d.applyBranches(ctx, st, idx, step)
|
||||
}
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
// loopShouldRearm 判定循环步是否再武装:停机条件(黑板字段等值)满足或
|
||||
// 预算用尽则停。字段判据读的是本轮 mergeStepArtifacts 之后的黑板。
|
||||
func (d *flowDriver) loopShouldRearm(st *RunState, step StepSpec) bool {
|
||||
// MaxIterations 是总迭代上限(含首跑):已跑 LoopRuns+1 次。
|
||||
if step.LoopRuns+1 >= step.Loop.MaxIterations {
|
||||
return false
|
||||
}
|
||||
if step.Loop.UntilField != "" {
|
||||
st.mu.Lock()
|
||||
v := st.BB.Fields[step.Loop.UntilField]
|
||||
st.mu.Unlock()
|
||||
if v == step.Loop.UntilEquals {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// rearmLoop 把循环步与 loop.body 一起重置为 pending(同一临界区内落盘)。
|
||||
func (d *flowDriver) rearmLoop(_ context.Context, st *RunState, idx int, step StepSpec) (*RunResult, *HitlGate, error) {
|
||||
st.mu.Lock()
|
||||
step.LoopRuns++
|
||||
step.Status = "pending"
|
||||
step.Error = ""
|
||||
st.Plan.Workflow.Steps[idx] = step
|
||||
for _, bodyID := range step.Loop.Body {
|
||||
for j := range st.Plan.Workflow.Steps {
|
||||
if st.Plan.Workflow.Steps[j].ID == bodyID && st.Plan.Workflow.Steps[j].Status == "completed" {
|
||||
st.Plan.Workflow.Steps[j].Status = "pending"
|
||||
st.Plan.Workflow.Steps[j].Error = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
err := st.save()
|
||||
round := step.LoopRuns
|
||||
st.mu.Unlock()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
d.emit(st, Event{Type: EvPhase, StepID: step.ID, Text: fmt.Sprintf("loop 第 %d 轮再武装:%s", round+1, step.ID)})
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
// applyBranches 裁决路由步分支:When 按声明序取首个命中;未选中分支的
|
||||
// pending 目标置 skipped(skipped=依赖已满足,join 步照常推进)。
|
||||
func (d *flowDriver) applyBranches(_ context.Context, st *RunState, _ int, step StepSpec) (*RunResult, *HitlGate, error) {
|
||||
st.mu.Lock()
|
||||
fields := map[string]string{}
|
||||
for k, v := range st.BB.Fields {
|
||||
fields[k] = v
|
||||
}
|
||||
st.mu.Unlock()
|
||||
var selected *Branch
|
||||
for i := range step.Branches {
|
||||
if branchMatches(fields, step.Branches[i].When) {
|
||||
selected = &step.Branches[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if selected == nil {
|
||||
wf := st.Workflow()
|
||||
wf.Status = "failed"
|
||||
st.setWorkflow(wf)
|
||||
_ = st.save()
|
||||
return &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
|
||||
Error: fmt.Sprintf("router: 步骤 %s 无分支命中(fail-closed)", step.ID), WorkDir: st.Meta.WorkDir}, nil, nil
|
||||
}
|
||||
keep := map[string]bool{}
|
||||
for _, t := range selected.Then {
|
||||
keep[t] = true
|
||||
}
|
||||
skipped := 0
|
||||
st.mu.Lock()
|
||||
for j := range st.Plan.Workflow.Steps {
|
||||
sj := &st.Plan.Workflow.Steps[j]
|
||||
if sj.ID == step.ID || keep[sj.ID] || sj.Status != "pending" {
|
||||
continue
|
||||
}
|
||||
for _, br := range step.Branches {
|
||||
inBranch := false
|
||||
for _, t := range br.Then {
|
||||
if t == sj.ID {
|
||||
inBranch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if inBranch {
|
||||
sj.Status = "skipped"
|
||||
sj.Error = routerSkipReason
|
||||
skipped++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
err := st.save()
|
||||
st.mu.Unlock()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
d.emit(st, Event{Type: EvPhase, StepID: step.ID,
|
||||
Text: fmt.Sprintf("router %s 选中分支 %v(跳过 %d 步)", step.ID, selected.Then, skipped)})
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
// routerSkipReason 是分支落选的 skipped 标记(finalize 的降级豁免凭据)。
|
||||
const routerSkipReason = "router: 分支未选中"
|
||||
|
||||
// branchMatches 判定分支条件:Equals 非空须相等,NotEquals 非空须不等;
|
||||
// 字段未设时按零值 "" 参与比较(compileGraph 已保证两者至少其一非空)。
|
||||
func branchMatches(fields map[string]string, c BranchCond) bool {
|
||||
v := fields[c.Field]
|
||||
if c.Equals != "" && v != c.Equals {
|
||||
return false
|
||||
}
|
||||
if c.NotEquals != "" && v == c.NotEquals {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// failStep 按关键性处理失败步骤:关键 → 运行失败;非关键 → 标记后继续(返回 nil 让循环推进)。
|
||||
func (d *flowDriver) failStep(ctx context.Context, st *runState, idx int) (*RunResult, *hitlGate, error) {
|
||||
func (d *flowDriver) failStep(ctx context.Context, st *RunState, idx int) (*RunResult, *HitlGate, error) {
|
||||
st.mu.Lock()
|
||||
step := st.Plan.Workflow.Steps[idx]
|
||||
critical := step.IsCritical()
|
||||
@@ -358,7 +630,7 @@ func (d *flowDriver) failStep(ctx context.Context, st *runState, idx int) (*RunR
|
||||
}
|
||||
|
||||
// checkBlockingGaps 检查未解决阻塞缺口;interactive 挂起,auto 记录兜底假设并继续。
|
||||
func (d *flowDriver) checkBlockingGaps(st *runState) *hitlGate {
|
||||
func (d *flowDriver) checkBlockingGaps(st *RunState) *HitlGate {
|
||||
gaps := st.BB.UnresolvedBlockingGaps()
|
||||
if len(gaps) == 0 {
|
||||
return nil
|
||||
@@ -400,7 +672,7 @@ func (d *flowDriver) checkBlockingGaps(st *runState) *hitlGate {
|
||||
Context: strings.Join(lines, "\n"),
|
||||
CreatedAt: time.Now(), Status: "pending",
|
||||
}
|
||||
gate := &hitlGate{Request: *req, PrevStatus: RunExecuting}
|
||||
gate := &HitlGate{Request: *req, PrevStatus: RunExecuting}
|
||||
if err := st.saveGate(gate); err == nil {
|
||||
st.Meta.Status = string(RunWaitingHuman)
|
||||
_ = st.save()
|
||||
@@ -410,7 +682,7 @@ func (d *flowDriver) checkBlockingGaps(st *runState) *hitlGate {
|
||||
}
|
||||
|
||||
// applyDynamicAdjustment 评估步骤产出并应用/审批动态调整。
|
||||
func (d *flowDriver) applyDynamicAdjustment(ctx context.Context, st *runState, idx int) (*RunResult, *hitlGate, error) {
|
||||
func (d *flowDriver) applyDynamicAdjustment(ctx context.Context, st *RunState, idx int) (*RunResult, *HitlGate, error) {
|
||||
step := st.Plan.Workflow.Steps[idx]
|
||||
subDir := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(step.SubWorkspace))
|
||||
output := readFileOr(filepath.Join(subDir, firstTarget(step.Targets)), "")
|
||||
@@ -450,7 +722,7 @@ func (d *flowDriver) applyDynamicAdjustment(ctx context.Context, st *runState, i
|
||||
evalRes.Reason, strings.Join(addLines, "\n"), strings.Join(skipLines, "\n")),
|
||||
CreatedAt: time.Now(), Status: "pending",
|
||||
}
|
||||
gate := &hitlGate{Request: *req, Steps: evalRes.DynamicStepsToAdd, SkipIDs: evalRes.StepsToSkip, PrevStatus: RunExecuting}
|
||||
gate := &HitlGate{Request: *req, Steps: evalRes.DynamicStepsToAdd, SkipIDs: evalRes.StepsToSkip, PrevStatus: RunExecuting}
|
||||
if err := st.saveGate(gate); err == nil {
|
||||
st.Meta.Status = string(RunWaitingHuman)
|
||||
_ = st.save()
|
||||
@@ -463,7 +735,7 @@ func (d *flowDriver) applyDynamicAdjustment(ctx context.Context, st *runState, i
|
||||
}
|
||||
|
||||
// applyAdjustment 应用动态调整:新步骤插到最后一步之前,跳过步骤标记 skipped。
|
||||
func applyAdjustment(st *runState, add []StepSpec, skipIDs []string) {
|
||||
func applyAdjustment(st *RunState, add []StepSpec, skipIDs []string) {
|
||||
steps := st.Plan.Workflow.Steps
|
||||
for _, id := range skipIDs {
|
||||
for i := range steps {
|
||||
@@ -490,11 +762,15 @@ func applyAdjustment(st *runState, add []StepSpec, skipIDs []string) {
|
||||
}
|
||||
|
||||
// finalize 收口:汇总交付物 → 质检 → 自动修复 → (必要时)人工裁决 → 清单。
|
||||
func (d *flowDriver) finalize(ctx context.Context, st *runState) (*RunResult, *hitlGate, error) {
|
||||
func (d *flowDriver) finalize(ctx context.Context, st *RunState) (*RunResult, *HitlGate, error) {
|
||||
wf := st.Workflow()
|
||||
degraded := false
|
||||
for _, s := range wf.Steps {
|
||||
if s.Status == "failed" || s.Status == "skipped" {
|
||||
if s.Status == "failed" {
|
||||
degraded = true
|
||||
}
|
||||
// router 分支落选是预期路径,不算降级(Q2)。
|
||||
if s.Status == "skipped" && s.Error != routerSkipReason {
|
||||
degraded = true
|
||||
}
|
||||
}
|
||||
@@ -532,6 +808,10 @@ func (d *flowDriver) finalize(ctx context.Context, st *runState) (*RunResult, *h
|
||||
RunID: st.Meta.ID, Workspace: st.Meta.WorkDir, Deliverable: deliverable,
|
||||
Plan: planPtr(st), Squad: &st.Plan.Squad, Template: st.Template,
|
||||
})
|
||||
if srcStep.MinScore > 0 && report.Score < float64(srcStep.MinScore) {
|
||||
report.Passed = false
|
||||
report.Violations = append(report.Violations, fmt.Sprintf("总分 %.0f 低于步骤门槛 %d", report.Score, srcStep.MinScore))
|
||||
}
|
||||
if !report.Passed && st.RepairBudgetRemaining > 0 {
|
||||
st.RepairBudgetRemaining--
|
||||
_ = st.save()
|
||||
@@ -568,7 +848,7 @@ func (d *flowDriver) finalize(ctx context.Context, st *runState) (*RunResult, *h
|
||||
Context: string(marshalJSON(report)),
|
||||
CreatedAt: time.Now(), Status: "pending",
|
||||
}
|
||||
gate := &hitlGate{Request: *req, PrevStatus: RunExecuting}
|
||||
gate := &HitlGate{Request: *req, PrevStatus: RunExecuting}
|
||||
if err := st.saveGate(gate); err == nil {
|
||||
st.Meta.Status = string(RunQualityReview)
|
||||
_ = st.save()
|
||||
@@ -602,11 +882,17 @@ func (d *flowDriver) finalize(ctx context.Context, st *runState) (*RunResult, *h
|
||||
}
|
||||
|
||||
// rerunForRepair 重置交付物源步骤并重新执行到收口。
|
||||
func (d *flowDriver) rerunForRepair(ctx context.Context, st *runState, stepID string) (*RunResult, *hitlGate, error) {
|
||||
func (d *flowDriver) rerunForRepair(ctx context.Context, st *RunState, stepID string) (*RunResult, *HitlGate, error) {
|
||||
for i := range st.Plan.Workflow.Steps {
|
||||
if st.Plan.Workflow.Steps[i].ID == stepID {
|
||||
st.Plan.Workflow.Steps[i].Status = "pending"
|
||||
st.Plan.Workflow.Steps[i].Error = ""
|
||||
note, err := os.ReadFile(filepath.Join(st.Meta.WorkDir, "quality-feedback.md"))
|
||||
if err == nil && len(bytes.TrimSpace(note)) > 0 {
|
||||
taskPath := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(st.Plan.Workflow.Steps[i].SubWorkspace), "TASK.md")
|
||||
existing, _ := os.ReadFile(taskPath)
|
||||
_ = os.WriteFile(taskPath, append(existing, append([]byte("\n\n"), note...)...), 0o644)
|
||||
}
|
||||
}
|
||||
}
|
||||
d.emit(st, Event{Type: EvPhase, Text: "质检未通过,自动修复:重跑交付步骤 " + stepID})
|
||||
@@ -614,7 +900,7 @@ func (d *flowDriver) rerunForRepair(ctx context.Context, st *runState, stepID st
|
||||
}
|
||||
|
||||
// heldResult 由挂起上下文构造对外结果(挂起路径统一返回非 nil 结果)。
|
||||
func heldResult(st *runState, gate *hitlGate) *RunResult {
|
||||
func heldResult(st *RunState, gate *HitlGate) *RunResult {
|
||||
status := RunWaitingHuman
|
||||
switch gate.Request.Kind {
|
||||
case HitlClarification:
|
||||
@@ -644,11 +930,11 @@ func waitConditionFrom(gaps []GapItem) *WaitCondition {
|
||||
}
|
||||
|
||||
// hold 生成挂起请求并返回结果。
|
||||
func (d *flowDriver) hold(st *runState, kind HumanRequestKind, stepID, role, question string, options []HumanOption, context string, extra *hitlGate) (*RunResult, *hitlGate, error) {
|
||||
func (d *flowDriver) hold(st *RunState, kind HumanRequestKind, stepID, role, question string, options []HumanOption, context, reason string, extra *HitlGate) (*RunResult, *HitlGate, error) {
|
||||
req := &HumanRequest{
|
||||
ID: newRequestID(), RunID: st.Meta.ID, StepID: stepID, Kind: kind,
|
||||
Severity: SeverityActionRequired,
|
||||
Question: question, Options: options, Context: context,
|
||||
Question: question, Options: options, Context: context, Reason: reason,
|
||||
CreatedAt: time.Now(), Status: "pending",
|
||||
}
|
||||
status := RunWaitingHuman
|
||||
@@ -660,7 +946,7 @@ func (d *flowDriver) hold(st *runState, kind HumanRequestKind, stepID, role, que
|
||||
}
|
||||
gate := extra
|
||||
if gate == nil {
|
||||
gate = &hitlGate{}
|
||||
gate = &HitlGate{}
|
||||
}
|
||||
gate.Request = *req
|
||||
gate.PrevStatus = RunExecuting
|
||||
@@ -677,7 +963,7 @@ func (d *flowDriver) hold(st *runState, kind HumanRequestKind, stepID, role, que
|
||||
// policy 合并引擎全局策略与 Goal 覆盖(窄者胜)。
|
||||
// policyRole 在引擎全局/Goal 链之上叠加角色级 HitlPolicy
|
||||
// (窄者胜;Goal 仍最高)。
|
||||
func (d *flowDriver) policyRole(st *runState, role RoleSpec) HitlPolicy {
|
||||
func (d *flowDriver) policyRole(st *RunState, role RoleSpec) HitlPolicy {
|
||||
p := d.policy(st)
|
||||
if role.HitlPolicy != nil {
|
||||
p = mergeHitlPolicy(p, role.HitlPolicy)
|
||||
@@ -685,11 +971,11 @@ func (d *flowDriver) policyRole(st *runState, role RoleSpec) HitlPolicy {
|
||||
return p
|
||||
}
|
||||
|
||||
func (d *flowDriver) policy(st *runState) HitlPolicy {
|
||||
func (d *flowDriver) policy(st *RunState) HitlPolicy {
|
||||
return mergeHitlPolicy(d.opts.HitlPolicy, st.Meta.HitlPolicyOf())
|
||||
}
|
||||
|
||||
func (d *flowDriver) emit(st *runState, ev Event) {
|
||||
func (d *flowDriver) emit(st *RunState, ev Event) {
|
||||
if d.opts.Sink == nil {
|
||||
return
|
||||
}
|
||||
@@ -700,11 +986,11 @@ func (d *flowDriver) emit(st *runState, ev Event) {
|
||||
d.opts.Sink.Emit(ev)
|
||||
}
|
||||
|
||||
func (d *flowDriver) emitHuman(st *runState, req *HumanRequest) {
|
||||
func (d *flowDriver) emitHuman(st *RunState, req *HumanRequest) {
|
||||
d.emit(st, Event{Type: EvHumanInputRequired, Text: req.Question, Data: marshalJSON(req)})
|
||||
}
|
||||
|
||||
func summarize(st *runState, report QualityReport, status RunStatus) string {
|
||||
func summarize(st *RunState, report QualityReport, status RunStatus) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "运行 %s:%s。步骤 %d 个(完成 %d/跳过 %d/失败 %d)。质检得分 %.0f。",
|
||||
st.Meta.ID, status, len(st.Plan.Workflow.Steps),
|
||||
@@ -715,7 +1001,7 @@ func summarize(st *runState, report QualityReport, status RunStatus) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func countStatus(st *runState, status string) int {
|
||||
func countStatus(st *RunState, status string) int {
|
||||
n := 0
|
||||
for _, s := range st.Plan.Workflow.Steps {
|
||||
if s.Status == status {
|
||||
@@ -725,7 +1011,7 @@ func countStatus(st *runState, status string) int {
|
||||
return n
|
||||
}
|
||||
|
||||
func buildManifest(st *runState, deliverable string, report QualityReport) Manifest {
|
||||
func buildManifest(st *RunState, deliverable string, report QualityReport) Manifest {
|
||||
files := map[string]string{}
|
||||
_ = filepath.Walk(st.Meta.WorkDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
@@ -784,7 +1070,7 @@ func appendLog(dir, line string) {
|
||||
_, _ = f.WriteString(line)
|
||||
}
|
||||
|
||||
func planPtr(st *runState) *WorkflowPlan {
|
||||
func planPtr(st *RunState) *WorkflowPlan {
|
||||
wf := st.Workflow()
|
||||
return &wf
|
||||
}
|
||||
|
||||
+40
-13
@@ -34,18 +34,9 @@ func (e *WorkspaceEngine) PrepareWorkspace(meta *RunMetadata, plan *SquadPlan) (
|
||||
}
|
||||
meta.WorkDir = wsDir
|
||||
meta.UpdatedAt = time.Now()
|
||||
if err := writeJSON(filepath.Join(wsDir, "workspace.json"), meta); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := writeJSON(filepath.Join(wsDir, "squad.json"), &plan.Squad); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := writeJSON(filepath.Join(wsDir, "workflow.json"), &plan.Workflow); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := saveBlackboard(wsDir, &BlackboardState{Fields: map[string]string{}}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// 注意:run 级状态 JSON 不在此处写——由 st.save() 经 Store 端口统一落盘
|
||||
// (Q1 WP5:SQLite 后端时这些状态进数据库而非文件)。这里只建目录与
|
||||
// 上下文/模板等运行无关状态的工作区资产。
|
||||
ctxIdx, err := e.writeContextAssets(wsDir, &meta.Goal, plan)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -309,5 +300,41 @@ func writeJSON(path string, v any) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0o644)
|
||||
data = append(data, '\n')
|
||||
return AtomicWrite(path, data)
|
||||
}
|
||||
|
||||
// AtomicWrite 在同目录创建临时文件,写入后 Sync、Close,再 rename 到目标路径。
|
||||
func AtomicWrite(path string, data []byte) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.CreateTemp(dir, ".tmp-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := f.Name()
|
||||
ok := false
|
||||
defer func() {
|
||||
if !ok {
|
||||
_ = os.Remove(tmp)
|
||||
}
|
||||
}()
|
||||
if _, err := f.Write(data); err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return err
|
||||
}
|
||||
ok = true
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -58,6 +59,7 @@ func (s *FileEventSink) Emit(ev squad.Event) {
|
||||
}
|
||||
f = fp
|
||||
s.files[ev.RunID] = f
|
||||
s.seqs[ev.RunID] = lastEventSeq(filepath.Join(dir, "events.jsonl"))
|
||||
}
|
||||
s.seqs[ev.RunID]++
|
||||
line := persistedEvent{Seq: s.seqs[ev.RunID], Type: ev.Type, StepID: ev.StepID,
|
||||
@@ -72,6 +74,23 @@ func (s *FileEventSink) Emit(ev squad.Event) {
|
||||
_, _ = f.Write(append(data, '\n'))
|
||||
}
|
||||
|
||||
func lastEventSeq(path string) int64 {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
defer f.Close()
|
||||
var last int64
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
var line persistedEvent
|
||||
if err := json.Unmarshal(sc.Bytes(), &line); err == nil && line.Seq > last {
|
||||
last = line.Seq
|
||||
}
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
// Close 关闭全部文件句柄。
|
||||
func (s *FileEventSink) Close() {
|
||||
s.mu.Lock()
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"agent-squad/squad"
|
||||
)
|
||||
|
||||
func TestR2EventSeqContinuesAfterRestart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
run := "run_1"
|
||||
path := filepath.Join(dir, "runs", run)
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(path, "events.jsonl"), []byte("{\"seq\":7,\"type\":\"phase\"}\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sink := NewFileEventSink(dir, nil)
|
||||
sink.Emit(squad.Event{Type: "phase", RunID: run, Text: "next"})
|
||||
sink.Close()
|
||||
if got := lastEventSeq(filepath.Join(path, "events.jsonl")); got != 8 {
|
||||
t.Fatalf("seq %d", got)
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -56,12 +56,12 @@ func (s *FileStore) LoadRoles(reg *squad.RoleRegistry) error {
|
||||
}
|
||||
|
||||
// SaveRole 落盘单个角色。
|
||||
func (s *FileStore) SaveRole(spec squad.RoleSpec) {
|
||||
func (s *FileStore) SaveRole(spec squad.RoleSpec) error {
|
||||
data, err := json.MarshalIndent(spec, "", " ")
|
||||
if err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
_ = os.WriteFile(filepath.Join(s.DataDir, "roles.d", spec.Role+".json"), data, 0o644)
|
||||
return squad.AtomicWrite(filepath.Join(s.DataDir, "roles.d", spec.Role+".json"), append(data, '\n'))
|
||||
}
|
||||
|
||||
// DeleteRole 删除角色文件(注册表删除由调用方完成)。
|
||||
@@ -96,12 +96,12 @@ func (s *FileStore) LoadBlueprints(bps *squad.BlueprintRegistry) error {
|
||||
}
|
||||
|
||||
// SaveBlueprint 落盘单个蓝图。
|
||||
func (s *FileStore) SaveBlueprint(bp squad.Blueprint) {
|
||||
func (s *FileStore) SaveBlueprint(bp squad.Blueprint) error {
|
||||
data, err := json.MarshalIndent(bp, "", " ")
|
||||
if err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
_ = os.WriteFile(filepath.Join(s.DataDir, "blueprints.d", bp.Key+".json"), data, 0o644)
|
||||
return squad.AtomicWrite(filepath.Join(s.DataDir, "blueprints.d", bp.Key+".json"), append(data, '\n'))
|
||||
}
|
||||
|
||||
// DeleteBlueprint 删除蓝图文件。
|
||||
|
||||
Reference in New Issue
Block a user