Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90ac98ea65 | ||
|
|
1c3995beaa | ||
|
|
626b2a3f34 | ||
|
|
b34d6ed187 | ||
|
|
96d1e0e49c | ||
|
|
3870489614 | ||
|
|
f90abafe79 | ||
|
|
bb02683d89 | ||
|
|
4e8d684125 | ||
|
|
5403b6e7a9 | ||
|
|
ebbebdf20b | ||
|
|
592a410288 | ||
|
|
7fe41e86a1 | ||
|
|
6b7a991750 | ||
|
|
cb398dadc6 | ||
|
|
7ecc04ee61 |
@@ -33,11 +33,13 @@ type Options struct {
|
|||||||
|
|
||||||
// NewFromOverlay 从 JSON overlay 构造闸门,并把 guard 域标成受保护域。
|
// NewFromOverlay 从 JSON overlay 构造闸门,并把 guard 域标成受保护域。
|
||||||
// 命令行用它,这样命令本身不必 import agent-intent。
|
// 命令行用它,这样命令本身不必 import agent-intent。
|
||||||
|
// NFKC 开启:全角/兼容等价输入与词条同形折叠(与 backend intentgate 一致)。
|
||||||
func NewFromOverlay(ctx context.Context, fsys fs.FS, roots ...string) (*Gate, error) {
|
func NewFromOverlay(ctx context.Context, fsys fs.FS, roots ...string) (*Gate, error) {
|
||||||
return New(ctx, Options{
|
return New(ctx, Options{
|
||||||
IntentOptions: intent.Options{
|
IntentOptions: intent.Options{
|
||||||
GuardDomains: []string{"guard"},
|
GuardDomains: []string{"guard"},
|
||||||
ProtectedDomains: []string{"guard"},
|
ProtectedDomains: []string{"guard"},
|
||||||
|
NFKC: true,
|
||||||
},
|
},
|
||||||
FS: fsys,
|
FS: fsys,
|
||||||
Roots: roots,
|
Roots: roots,
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ go 1.27
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
agent-native v0.0.0
|
agent-native v0.0.0
|
||||||
|
agent-squad v0.0.0
|
||||||
modernc.org/sqlite v1.37.0
|
modernc.org/sqlite v1.37.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
error-code-system v0.0.0 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"agent-native/runtime/llm"
|
agentsquad "agent-squad/squad"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestOrchestrationFlow(t *testing.T) {
|
func TestOrchestrationFlow(t *testing.T) {
|
||||||
@@ -116,12 +116,10 @@ func TestOrchestrationFlow(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
text, err := s.RunInProcess(context.Background(), woke, work, func(ctx context.Context, model string, messages []llm.Message, td []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
text, err := s.RunInProcess(context.Background(), woke, work, stubExecutor{result: &agentsquad.ExecResult{
|
||||||
ch := make(chan llm.Event, 1)
|
Success: true, Logs: "done",
|
||||||
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}}
|
Patches: map[string][]byte{"output.md": []byte("done")},
|
||||||
close(ch)
|
}})
|
||||||
return ch
|
|
||||||
})
|
|
||||||
if err != nil || text != "done" {
|
if err != nil || text != "done" {
|
||||||
t.Fatalf("in-process %q %v", text, err)
|
t.Fatalf("in-process %q %v", text, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
+35
-18
@@ -9,8 +9,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"agent-native/runtime"
|
"agent-squad/squad"
|
||||||
"agent-native/runtime/llm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// CLICommands are the external tools a daemon probes, in order.
|
// CLICommands are the external tools a daemon probes, in order.
|
||||||
@@ -196,36 +195,54 @@ func (s *Store) RunCommand(ctx context.Context, task Task, command, workdir stri
|
|||||||
return s.FinishTask(task.ID, "completed", "")
|
return s.FinishTask(task.ID, "completed", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunInProcess executes the task with AgentSession. stream is required; the
|
// RunInProcess executes the task through the squad.Executor port(Q1 WP5 修 X6:
|
||||||
// product supplies a provider, tests supply a script.
|
// 不再绕过执行端口直驱 agent-native runtime)。调用方经 adapters/native 等
|
||||||
func (s *Store) RunInProcess(ctx context.Context, task Task, workdir string, stream llm.StreamFunc) (string, error) {
|
// 适配器构造 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 {
|
if err := s.MarkRunning(task.ID); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
issue, _ := s.GetIssue(task.IssueID)
|
issue, _ := s.GetIssue(task.IssueID)
|
||||||
agent := runtime.NewAgentSession(runtime.Config{
|
res, err := exec.Execute(ctx, squad.ExecRequest{
|
||||||
Model: "agent-native",
|
WorkDir: workdir,
|
||||||
Stream: stream,
|
Prompt: issue.Title + "\n" + issue.Body,
|
||||||
MaxTurns: 4,
|
Targets: []string{"output.md"},
|
||||||
OnEvent: func(ev runtime.Event) {
|
OnEvent: func(ev squad.ProcessEvent) {
|
||||||
_ = s.AddTaskMessage(task.ID, ev.Type, ev.Text)
|
_ = s.AddTaskMessage(task.ID, ev.Type, ev.Text)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
res, err := agent.Prompt(ctx, llm.Message{Role: llm.RoleUser, Content: issue.Title + "\n" + issue.Body})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = s.FinishTask(task.ID, "failed", "config")
|
_ = s.FinishTask(task.ID, "failed", "config")
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if res.Err != nil {
|
if res == nil || !res.Success {
|
||||||
_ = s.FinishTask(task.ID, "failed", "model")
|
reason := "model"
|
||||||
return res.Text, res.Err
|
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")
|
||||||
}
|
}
|
||||||
_ = s.AddUsage(task.ID, "agent-native", res.Usage.PromptTokens, res.Usage.CompletionTokens, res.Usage.CacheReadTokens, res.Usage.CacheWriteTokens)
|
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 {
|
if err := s.FinishTask(task.ID, "completed", ""); err != nil {
|
||||||
return res.Text, err
|
return res.Logs, err
|
||||||
}
|
}
|
||||||
_ = os.WriteFile(filepath.Join(workdir, "output.md"), []byte(res.Text), 0o644)
|
if data, rerr := os.ReadFile(filepath.Join(workdir, "output.md")); rerr == nil {
|
||||||
return res.Text, 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.
|
// AgentCLI applies a task-token command: comment, status, sub-issue, or activity.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -86,6 +86,7 @@ type app struct {
|
|||||||
files *store.FileEventSink
|
files *store.FileEventSink
|
||||||
st *store.FileStore
|
st *store.FileStore
|
||||||
log *logsys.Logger
|
log *logsys.Logger
|
||||||
|
plat *platform.Store
|
||||||
}
|
}
|
||||||
|
|
||||||
func dataDir() string {
|
func dataDir() string {
|
||||||
@@ -159,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{
|
engine, err := squad.NewEngine(squad.Options{
|
||||||
DataDir: dir, Registry: reg, Blueprints: bps, Planner: planner,
|
DataDir: dir, Registry: reg, Blueprints: bps, Planner: planner,
|
||||||
Backends: backends, Sink: fileSink, IntentGate: gate,
|
Backends: backends, Sink: fileSink, IntentGate: gate,
|
||||||
IntentCatalog: catalog, IntentRunner: runner,
|
IntentCatalog: catalog, IntentRunner: runner,
|
||||||
|
Store: plat.RunStore(),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
_ = plat.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
logCfg, err := config.Load("")
|
logCfg, err := config.Load("")
|
||||||
@@ -178,7 +187,7 @@ func assemble(dir, plannerName, modelOverride, intentRoot string) (*app, error)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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() {
|
func (a *app) close() {
|
||||||
@@ -223,12 +232,9 @@ func cmdServe(args []string) int {
|
|||||||
srv := server.New(a.engine, a.sink, a.st)
|
srv := server.New(a.engine, a.sink, a.st)
|
||||||
srv.SetToken(*token)
|
srv.SetToken(*token)
|
||||||
srv.SetLogger(a.log)
|
srv.SetLogger(a.log)
|
||||||
if plat, err := platform.Open(filepath.Join(*data, "orchestration.db")); err != nil {
|
if a.plat != nil {
|
||||||
fmt.Fprintln(os.Stderr, "编排库打开失败:", err)
|
srv.SetPlatform(a.plat)
|
||||||
return 1
|
defer a.plat.Close()
|
||||||
} else {
|
|
||||||
srv.SetPlatform(plat)
|
|
||||||
defer plat.Close()
|
|
||||||
}
|
}
|
||||||
stopSweep := srv.StartWakeupSweeper(15 * time.Second)
|
stopSweep := srv.StartWakeupSweeper(15 * time.Second)
|
||||||
defer stopSweep()
|
defer stopSweep()
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"agent-native/runtime/llm"
|
|
||||||
platform "agent-squad/adapters/sqlite"
|
platform "agent-squad/adapters/sqlite"
|
||||||
|
"agent-squad/squad"
|
||||||
)
|
)
|
||||||
|
|
||||||
func cmdPlatform(args []string) int {
|
func cmdPlatform(args []string) int {
|
||||||
@@ -99,7 +99,7 @@ func cmdPlatform(args []string) int {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
text, err := st.RunInProcess(context.Background(), task, fs.Arg(1), scriptedOK)
|
text, err := st.RunInProcess(context.Background(), task, fs.Arg(1), scriptedExecutor{})
|
||||||
return printPlatform(map[string]string{"text": text}, err)
|
return printPlatform(map[string]string{"text": text}, err)
|
||||||
default:
|
default:
|
||||||
fmt.Fprintln(os.Stderr, "未知编排命令", args[0], args[1])
|
fmt.Fprintln(os.Stderr, "未知编排命令", args[0], args[1])
|
||||||
@@ -111,11 +111,16 @@ func taskByID(st *platform.Store, id string) (platform.Task, error) {
|
|||||||
return st.TaskByToken(id)
|
return st.TaskByToken(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func scriptedOK(ctx context.Context, model string, messages []llm.Message, td []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
// scriptedExecutor 是 daemon run-native 冒烟路径的脚本执行器(X6 后经端口注入)。
|
||||||
ch := make(chan llm.Event, 1)
|
type scriptedExecutor struct{}
|
||||||
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}}
|
|
||||||
close(ch)
|
func (scriptedExecutor) Name() string { return "scripted" }
|
||||||
return ch
|
|
||||||
|
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 {
|
func printPlatform(v any, err error) int {
|
||||||
|
|||||||
@@ -462,6 +462,8 @@ func (s *Server) StartWakeupSweeper(interval time.Duration) (stop func()) {
|
|||||||
return
|
return
|
||||||
case <-t.C:
|
case <-t.C:
|
||||||
s.engine.SweepWakeups(context.Background())
|
s.engine.SweepWakeups(context.Background())
|
||||||
|
// Q6:与事件唤醒并列,过期挂起按 ExpireAction 合成决策(此前是死代码)。
|
||||||
|
s.engine.SweepExpired(context.Background(), time.Now())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|||||||
Vendored
+1
-1
Submodule deps/agent-intent updated: 65aaebca1e...f57645c645
Vendored
+1
-1
Submodule deps/agent-native updated: c9c2f23990...78b6c6477c
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+98
-33
@@ -35,6 +35,9 @@ type Options struct {
|
|||||||
HitlPolicy HitlPolicy
|
HitlPolicy HitlPolicy
|
||||||
// RepairBudget 质检失败自动修复次数(默认 1)。
|
// RepairBudget 质检失败自动修复次数(默认 1)。
|
||||||
RepairBudget int
|
RepairBudget int
|
||||||
|
// Store 运行状态持久化端口(Q1 WP4/WP5);nil 回落 FileStore。
|
||||||
|
// 注入 SQLiteStore 后运行状态进 SQLite(装配期一次,跨 run 一致)。
|
||||||
|
Store Store
|
||||||
// IntentGate 可选意图闸门:Run 入口一次确定性裁决(nil = 不启用,
|
// IntentGate 可选意图闸门:Run 入口一次确定性裁决(nil = 不启用,
|
||||||
// 行为与原实现一致)。见 intent.go;实现方为 squadintent 适配器。
|
// 行为与原实现一致)。见 intent.go;实现方为 squadintent 适配器。
|
||||||
IntentGate IntentGate
|
IntentGate IntentGate
|
||||||
@@ -60,6 +63,26 @@ type engineDeps struct {
|
|||||||
StepTimeout time.Duration
|
StepTimeout time.Duration
|
||||||
HitlPolicy HitlPolicy
|
HitlPolicy HitlPolicy
|
||||||
IntentRunner IntentRunner
|
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 自然走同一实现)。
|
||||||
|
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 单次运行的可选项。
|
// RunOptions 单次运行的可选项。
|
||||||
@@ -75,6 +98,8 @@ type RunOptions struct {
|
|||||||
LeaderRole string `json:"leader_role,omitempty"`
|
LeaderRole string `json:"leader_role,omitempty"`
|
||||||
// MaxLeaderTurns leader 模式每运行的 leader 轮次上限(默认 8,防失控)。
|
// MaxLeaderTurns leader 模式每运行的 leader 轮次上限(默认 8,防失控)。
|
||||||
MaxLeaderTurns int `json:"max_leader_turns,omitempty"`
|
MaxLeaderTurns int `json:"max_leader_turns,omitempty"`
|
||||||
|
// CheckpointMode superstep 边界快照档位(Q1 WP4):sync(默认)| async | on_exit。
|
||||||
|
CheckpointMode string `json:"checkpoint_mode,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SquadEngine 小队引擎门面:Run / Respond / Resume / Inspect / ListRuns。
|
// SquadEngine 小队引擎门面:Run / Respond / Resume / Inspect / ListRuns。
|
||||||
@@ -131,7 +156,7 @@ func NewEngine(opts Options) (*SquadEngine, error) {
|
|||||||
Registry: opts.Registry, Backends: opts.Backends, Evaluator: opts.Evaluator,
|
Registry: opts.Registry, Backends: opts.Backends, Evaluator: opts.Evaluator,
|
||||||
Checker: opts.Checker, Sink: opts.Sink, Workspaces: ws,
|
Checker: opts.Checker, Sink: opts.Sink, Workspaces: ws,
|
||||||
StepTimeout: opts.StepTimeout, HitlPolicy: opts.HitlPolicy,
|
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
|
return &SquadEngine{opts: opts, deps: deps, ws: ws, runLocks: map[string]*sync.Mutex{}}, nil
|
||||||
}
|
}
|
||||||
@@ -206,6 +231,9 @@ func (e *SquadEngine) Run(ctx context.Context, goal Goal, runOpts RunOptions) (*
|
|||||||
meta.Metadata["max_leader_turns"] = fmt.Sprintf("%d", runOpts.MaxLeaderTurns)
|
meta.Metadata["max_leader_turns"] = fmt.Sprintf("%d", runOpts.MaxLeaderTurns)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if runOpts.CheckpointMode != "" {
|
||||||
|
meta.Metadata["checkpoint_mode"] = runOpts.CheckpointMode
|
||||||
|
}
|
||||||
e.deps.Sink.Emit(Event{Type: EvRunStarted, RunID: runID, Text: goal.Text, At: time.Now()})
|
e.deps.Sink.Emit(Event{Type: EvRunStarted, RunID: runID, Text: goal.Text, At: time.Now()})
|
||||||
|
|
||||||
// 意图闸门(可选,入口一次裁决;显式 TaskKey 优先,不重复过闸)。
|
// 意图闸门(可选,入口一次裁决;显式 TaskKey 优先,不重复过闸)。
|
||||||
@@ -239,12 +267,12 @@ 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)
|
return e.planAndExecute(ctx, st, runOpts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// planAndExecute 规划 → 挂起或执行;st 可为已有运行状态(澄清续跑复用)。
|
// 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}
|
drv := &flowDriver{opts: e.deps}
|
||||||
meta := &st.Meta
|
meta := &st.Meta
|
||||||
|
|
||||||
@@ -299,7 +327,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")),
|
Context: fmt.Sprintf("原因:%s\n问题:\n%s", planOut.Clarification.Reason, strings.Join(qs, "\n")),
|
||||||
CreatedAt: time.Now(), Status: "pending",
|
CreatedAt: time.Now(), Status: "pending",
|
||||||
}
|
}
|
||||||
gate := &hitlGate{Request: *req, PrevStatus: RunPlanning}
|
gate := &HitlGate{Request: *req, PrevStatus: RunPlanning}
|
||||||
// 澄清挂起也需要工作区承载 pending.json。
|
// 澄清挂起也需要工作区承载 pending.json。
|
||||||
if err := e.materializeWorkspace(meta, st, &SquadPlan{Workflow: WorkflowPlan{WorkflowID: NewPlanID(), Status: "pending", Steps: nil}}); err != nil {
|
if err := e.materializeWorkspace(meta, st, &SquadPlan{Workflow: WorkflowPlan{WorkflowID: NewPlanID(), Status: "pending", Steps: nil}}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -339,7 +367,7 @@ func (e *SquadEngine) planAndExecute(ctx context.Context, st *runState, runOpts
|
|||||||
CreatedAt: time.Now(), Status: "pending",
|
CreatedAt: time.Now(), Status: "pending",
|
||||||
Reason: "plan",
|
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 {
|
if err := st.saveGate(gate); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -373,7 +401,7 @@ func (e *SquadEngine) planAndExecute(ctx context.Context, st *runState, runOpts
|
|||||||
}
|
}
|
||||||
|
|
||||||
// initLeaderState 按运行选项初始化 leader 循环状态。
|
// initLeaderState 按运行选项初始化 leader 循环状态。
|
||||||
func (e *SquadEngine) initLeaderState(st *runState) {
|
func (e *SquadEngine) initLeaderState(st *RunState) {
|
||||||
leaderRole := st.Meta.Metadata["leader_role"]
|
leaderRole := st.Meta.Metadata["leader_role"]
|
||||||
if leaderRole == "" {
|
if leaderRole == "" {
|
||||||
if _, ok := findAgent(st.Plan.Squad.Agents, "leader"); ok {
|
if _, ok := findAgent(st.Plan.Squad.Agents, "leader"); ok {
|
||||||
@@ -402,12 +430,12 @@ func parseIntDefault(s string, def int) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// isLeaderRun 该运行是否处于 leader-delegation 模式。
|
// isLeaderRun 该运行是否处于 leader-delegation 模式。
|
||||||
func (e *SquadEngine) isLeaderRun(st *runState) bool {
|
func (e *SquadEngine) isLeaderRun(st *RunState) bool {
|
||||||
return st.Meta.Metadata["mode"] == "leader"
|
return st.Meta.Metadata["mode"] == "leader"
|
||||||
}
|
}
|
||||||
|
|
||||||
// resumeAfterHold 挂起解除后的统一续跑入口(按模式分发)。
|
// 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) {
|
if e.isLeaderRun(st) {
|
||||||
out, gate, err := e.runLeaderLoop(ctx, st)
|
out, gate, err := e.runLeaderLoop(ctx, st)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -428,16 +456,16 @@ func (e *SquadEngine) resumeAfterHold(ctx context.Context, st *runState) (*RunRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
// materializeWorkspace 落盘工作区与子工作区(已存在则只更新计划并重建待执行子目录)。
|
// 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 == "" {
|
if st.Meta.WorkDir == "" {
|
||||||
st.Meta = *meta
|
st.Meta = *meta
|
||||||
wsDir, err := e.ws.PrepareWorkspace(&st.Meta, plan)
|
_, err := e.ws.PrepareWorkspace(&st.Meta, plan)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
st.Template = plan.Template
|
st.Template = plan.Template
|
||||||
bb, _ := loadBlackboard(wsDir)
|
// 新运行的黑板在内存初始化;持久化由后续 st.save() 经 Store 端口完成。
|
||||||
st.BB = *bb
|
st.BB = BlackboardState{Fields: map[string]string{}}
|
||||||
} else {
|
} else {
|
||||||
// 复用既有工作区:磁盘黑板为权威(澄清续跑/计划更新场景)。
|
// 复用既有工作区:磁盘黑板为权威(澄清续跑/计划更新场景)。
|
||||||
if bb, err := loadBlackboard(st.Meta.WorkDir); err == nil {
|
if bb, err := loadBlackboard(st.Meta.WorkDir); err == nil {
|
||||||
@@ -489,7 +517,7 @@ func (e *SquadEngine) Respond(ctx context.Context, runID string, decision Respon
|
|||||||
|
|
||||||
func (e *SquadEngine) respondLocked(ctx context.Context, runID string, decision RespondDecision) (*RunResult, error) {
|
func (e *SquadEngine) respondLocked(ctx context.Context, runID string, decision RespondDecision) (*RunResult, error) {
|
||||||
wsDir := filepath.Join(e.opts.DataDir, "runs", runID)
|
wsDir := filepath.Join(e.opts.DataDir, "runs", runID)
|
||||||
st, err := loadRunState(wsDir)
|
st, err := e.loadRun(wsDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("load run %s: %w", runID, err)
|
return nil, fmt.Errorf("load run %s: %w", runID, err)
|
||||||
}
|
}
|
||||||
@@ -543,7 +571,7 @@ func choiceHoldKind(kind HumanRequestKind) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *SquadEngine) noteAndKeepHold(st *runState, gate *hitlGate, decision RespondDecision) (*RunResult, error) {
|
func (e *SquadEngine) noteAndKeepHold(st *RunState, gate *HitlGate, decision RespondDecision) (*RunResult, error) {
|
||||||
if err := st.addSteering(decision.Answer); err != nil {
|
if err := st.addSteering(decision.Answer); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -555,7 +583,7 @@ func (e *SquadEngine) noteAndKeepHold(st *runState, gate *hitlGate, decision Res
|
|||||||
return heldResult(st, gate), nil
|
return heldResult(st, gate), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearSameGate(st *runState, requestID string) error {
|
func clearSameGate(st *RunState, requestID string) error {
|
||||||
current, err := loadGate(st.Meta.WorkDir)
|
current, err := loadGate(st.Meta.WorkDir)
|
||||||
if err != nil || current == nil || current.Request.ID != requestID {
|
if err != nil || current == nil || current.Request.ID != requestID {
|
||||||
return err
|
return err
|
||||||
@@ -563,7 +591,7 @@ func clearSameGate(st *runState, requestID string) error {
|
|||||||
return st.saveGate(nil)
|
return st.saveGate(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *SquadEngine) applyDecision(ctx context.Context, st *runState, gate *hitlGate, decision RespondDecision) (*RunResult, error) {
|
func (e *SquadEngine) applyDecision(ctx context.Context, st *RunState, gate *HitlGate, decision RespondDecision) (*RunResult, error) {
|
||||||
req := &gate.Request
|
req := &gate.Request
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
drv := &flowDriver{opts: e.deps}
|
drv := &flowDriver{opts: e.deps}
|
||||||
@@ -694,8 +722,17 @@ func (e *SquadEngine) applyDecision(ctx context.Context, st *runState, gate *hit
|
|||||||
case "approve":
|
case "approve":
|
||||||
return e.manualPass(st, decision)
|
return e.manualPass(st, decision)
|
||||||
case "retry":
|
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.RepairBudgetRemaining++
|
||||||
|
st.RepairBudgetGranted++
|
||||||
st.Meta.Status = string(RunExecuting)
|
st.Meta.Status = string(RunExecuting)
|
||||||
_ = st.save()
|
_ = st.save()
|
||||||
deliverStepID := lastCompletedStepID(st)
|
deliverStepID := lastCompletedStepID(st)
|
||||||
@@ -722,7 +759,7 @@ func (e *SquadEngine) applyDecision(ctx context.Context, st *runState, gate *hit
|
|||||||
}
|
}
|
||||||
|
|
||||||
// respondClarification 澄清回应:答案并入目标后重新规划。
|
// 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)
|
choice := strings.ToLower(decision.Choice)
|
||||||
if choice == "abort" {
|
if choice == "abort" {
|
||||||
return e.cancelRun(st, "人工中止(澄清阶段)"), nil
|
return e.cancelRun(st, "人工中止(澄清阶段)"), nil
|
||||||
@@ -757,7 +794,7 @@ func (e *SquadEngine) respondClarification(ctx context.Context, st *runState, ga
|
|||||||
}
|
}
|
||||||
|
|
||||||
// bootstrapMinimalSquad 注册通用最小编制(organizer/specialist/writer/leader)。
|
// bootstrapMinimalSquad 注册通用最小编制(organizer/specialist/writer/leader)。
|
||||||
func (e *SquadEngine) bootstrapMinimalSquad(st *runState) {
|
func (e *SquadEngine) bootstrapMinimalSquad(st *RunState) {
|
||||||
backend := st.Meta.Backend
|
backend := st.Meta.Backend
|
||||||
if backend == "" {
|
if backend == "" {
|
||||||
backend = e.opts.DefaultBackend
|
backend = e.opts.DefaultBackend
|
||||||
@@ -777,7 +814,7 @@ func (e *SquadEngine) bootstrapMinimalSquad(st *runState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// manualPass 人工放行质检:标记 ManualPass 并收口。
|
// 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{
|
report := e.deps.Checker.Check(CheckRequest{
|
||||||
RunID: st.Meta.ID, Workspace: st.Meta.WorkDir, Deliverable: "output.md",
|
RunID: st.Meta.ID, Workspace: st.Meta.WorkDir, Deliverable: "output.md",
|
||||||
Plan: planPtr(st), Squad: &st.Plan.Squad, Template: st.Template,
|
Plan: planPtr(st), Squad: &st.Plan.Squad, Template: st.Template,
|
||||||
@@ -806,7 +843,7 @@ func (e *SquadEngine) manualPass(st *runState, decision RespondDecision) (*RunRe
|
|||||||
Summary: summarize(st, *report, status) + "(质检人工放行)"}, nil
|
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()
|
now := time.Now()
|
||||||
st.Meta.Status = string(RunCancelled)
|
st.Meta.Status = string(RunCancelled)
|
||||||
st.Meta.CompletedAt = &now
|
st.Meta.CompletedAt = &now
|
||||||
@@ -820,11 +857,11 @@ func (e *SquadEngine) cancelRun(st *runState, reason string) *RunResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// failStepResume 权限拒绝后的失败步骤处理(关键失败/非关键降级)。
|
// 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)
|
return d.failStep(ctx, st, idx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *SquadEngine) respondStepApproval(ctx context.Context, st *runState, req *HumanRequest, decision RespondDecision) (*RunResult, error) {
|
func (e *SquadEngine) respondStepApproval(ctx context.Context, st *RunState, req *HumanRequest, decision RespondDecision) (*RunResult, error) {
|
||||||
switch strings.ToLower(decision.Choice) {
|
switch strings.ToLower(decision.Choice) {
|
||||||
case "approve":
|
case "approve":
|
||||||
st.Approved["step:"+req.StepID] = true
|
st.Approved["step:"+req.StepID] = true
|
||||||
@@ -842,7 +879,7 @@ func (e *SquadEngine) respondStepApproval(ctx context.Context, st *runState, req
|
|||||||
return e.resumeAfterHold(ctx, st)
|
return e.resumeAfterHold(ctx, st)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *SquadEngine) respondInterrupted(ctx context.Context, st *runState, req *HumanRequest, decision RespondDecision) (*RunResult, error) {
|
func (e *SquadEngine) respondInterrupted(ctx context.Context, st *RunState, req *HumanRequest, decision RespondDecision) (*RunResult, error) {
|
||||||
switch strings.ToLower(decision.Choice) {
|
switch strings.ToLower(decision.Choice) {
|
||||||
case "retry":
|
case "retry":
|
||||||
if !markStep(st, req.StepID, "pending", "") {
|
if !markStep(st, req.StepID, "pending", "") {
|
||||||
@@ -862,7 +899,7 @@ func (e *SquadEngine) respondInterrupted(ctx context.Context, st *runState, req
|
|||||||
return e.resumeAfterHold(ctx, st)
|
return e.resumeAfterHold(ctx, st)
|
||||||
}
|
}
|
||||||
|
|
||||||
func markStep(st *runState, id, status, errText string) bool {
|
func markStep(st *RunState, id, status, errText string) bool {
|
||||||
for i := range st.Plan.Workflow.Steps {
|
for i := range st.Plan.Workflow.Steps {
|
||||||
if st.Plan.Workflow.Steps[i].ID != id {
|
if st.Plan.Workflow.Steps[i].ID != id {
|
||||||
continue
|
continue
|
||||||
@@ -876,7 +913,7 @@ func markStep(st *runState, id, status, errText string) bool {
|
|||||||
|
|
||||||
// recoverInterrupted 处理崩溃时停在 running 的步骤。
|
// recoverInterrupted 处理崩溃时停在 running 的步骤。
|
||||||
// interactive 挂起确认,不自动再执行;auto 允许一次重试;disabled 直接失败。
|
// interactive 挂起确认,不自动再执行;auto 允许一次重试;disabled 直接失败。
|
||||||
func (e *SquadEngine) recoverInterrupted(ctx context.Context, st *runState) (*RunResult, error) {
|
func (e *SquadEngine) recoverInterrupted(ctx context.Context, st *RunState) (*RunResult, error) {
|
||||||
for i := range st.Plan.Workflow.Steps {
|
for i := range st.Plan.Workflow.Steps {
|
||||||
step := st.Plan.Workflow.Steps[i]
|
step := st.Plan.Workflow.Steps[i]
|
||||||
if step.Status != "running" {
|
if step.Status != "running" {
|
||||||
@@ -903,6 +940,29 @@ func (e *SquadEngine) recoverInterrupted(ctx context.Context, st *runState) (*Ru
|
|||||||
out, _, err := drv.failStep(ctx, st, i)
|
out, _, err := drv.failStep(ctx, st, i)
|
||||||
return out, err
|
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"
|
st.Plan.Workflow.Steps[i].Status = "pending"
|
||||||
if err := st.save(); err != nil {
|
if err := st.save(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -933,7 +993,7 @@ func (e *SquadEngine) Resume(ctx context.Context, runID string) (*RunResult, err
|
|||||||
defer release()
|
defer release()
|
||||||
|
|
||||||
wsDir := filepath.Join(e.opts.DataDir, "runs", runID)
|
wsDir := filepath.Join(e.opts.DataDir, "runs", runID)
|
||||||
st, err := loadRunState(wsDir)
|
st, err := e.loadRun(wsDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("load run %s: %w", runID, err)
|
return nil, fmt.Errorf("load run %s: %w", runID, err)
|
||||||
}
|
}
|
||||||
@@ -944,6 +1004,11 @@ func (e *SquadEngine) Resume(ctx context.Context, runID string) (*RunResult, err
|
|||||||
if RunStatus(st.Meta.Status).Terminal() {
|
if RunStatus(st.Meta.Status).Terminal() {
|
||||||
return e.Inspect(runID)
|
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 {
|
if out, err := e.recoverInterrupted(ctx, st); out != nil || err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
@@ -956,7 +1021,7 @@ func (e *SquadEngine) Inspect(runID string) (*RunResult, error) {
|
|||||||
return nil, invalidID(runID)
|
return nil, invalidID(runID)
|
||||||
}
|
}
|
||||||
wsDir := filepath.Join(e.opts.DataDir, "runs", runID)
|
wsDir := filepath.Join(e.opts.DataDir, "runs", runID)
|
||||||
st, err := loadRunState(wsDir)
|
st, err := e.loadRun(wsDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("load run %s: %w", runID, err)
|
return nil, fmt.Errorf("load run %s: %w", runID, err)
|
||||||
}
|
}
|
||||||
@@ -1003,7 +1068,7 @@ func (e *SquadEngine) ListRuns() ([]RunMetadata, error) {
|
|||||||
if !ent.IsDir() {
|
if !ent.IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
st, err := loadRunState(filepath.Join(runsDir, ent.Name()))
|
st, err := e.loadRun(filepath.Join(runsDir, ent.Name()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1037,7 +1102,7 @@ func (e *SquadEngine) Steer(runID, text string) error {
|
|||||||
}
|
}
|
||||||
defer release()
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("load run %s: %w", runID, err)
|
return fmt.Errorf("load run %s: %w", runID, err)
|
||||||
}
|
}
|
||||||
@@ -1061,7 +1126,7 @@ func (e *SquadEngine) SetBlackboardField(runID, key, value string) error {
|
|||||||
}
|
}
|
||||||
defer release()
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("load run %s: %w", runID, err)
|
return fmt.Errorf("load run %s: %w", runID, err)
|
||||||
}
|
}
|
||||||
@@ -1143,7 +1208,7 @@ func (e *SquadEngine) fireWakeup(ctx context.Context, runID string) bool {
|
|||||||
if err := writeJSON(filepath.Join(e.opts.DataDir, "runs", runID, "pending.json"), gate); err != nil {
|
if err := writeJSON(filepath.Join(e.opts.DataDir, "runs", runID, "pending.json"), gate); err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if gate.Request.WakeupFires >= MaxWakeupFires {
|
if gate.Request.WakeupFires >= e.terminationOf(nil).MaxWakeupFires {
|
||||||
_, err := e.respondLocked(ctx, runID, RespondDecision{RequestID: gate.Request.ID, Choice: "abort",
|
_, err := e.respondLocked(ctx, runID, RespondDecision{RequestID: gate.Request.ID, Choice: "abort",
|
||||||
Answer: "自动唤醒次数达上限,自动中止"})
|
Answer: "自动唤醒次数达上限,自动中止"})
|
||||||
return err == nil
|
return err == nil
|
||||||
@@ -1248,7 +1313,7 @@ func appendAnswer(d RespondDecision) string {
|
|||||||
return "(" + d.Answer + ")"
|
return "(" + d.Answer + ")"
|
||||||
}
|
}
|
||||||
|
|
||||||
func lastCompletedStepID(st *runState) string {
|
func lastCompletedStepID(st *RunState) string {
|
||||||
id := ""
|
id := ""
|
||||||
for _, s := range st.Plan.Workflow.Steps {
|
for _, s := range st.Plan.Workflow.Steps {
|
||||||
if s.Status == "completed" {
|
if s.Status == "completed" {
|
||||||
|
|||||||
+214
@@ -0,0 +1,214 @@
|
|||||||
|
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, " → "))
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -136,8 +136,8 @@ func expiredDecision(req HumanRequest, policy HitlPolicy, now time.Time) (Respon
|
|||||||
return RespondDecision{RequestID: req.ID, Choice: choice, Answer: "expired"}, true
|
return RespondDecision{RequestID: req.ID, Choice: choice, Answer: "expired"}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// hitlGate 引擎内部:一次挂起的完整上下文(落盘 pending.json)。
|
// HitlGate 引擎内部:一次挂起的完整上下文(落盘 pending.json)。
|
||||||
type hitlGate struct {
|
type HitlGate struct {
|
||||||
Request HumanRequest `json:"request"`
|
Request HumanRequest `json:"request"`
|
||||||
Plan *SquadPlan `json:"plan,omitempty"` // confirmation 时携带候选计划
|
Plan *SquadPlan `json:"plan,omitempty"` // confirmation 时携带候选计划
|
||||||
Steps []StepSpec `json:"steps,omitempty"` // adjustment:建议追加的步骤
|
Steps []StepSpec `json:"steps,omitempty"` // adjustment:建议追加的步骤
|
||||||
|
|||||||
+27
-14
@@ -13,7 +13,7 @@ import (
|
|||||||
// leaderExecRequest 构造 leader 轮次的执行请求:与标准步骤同等装配
|
// leaderExecRequest 构造 leader 轮次的执行请求:与标准步骤同等装配
|
||||||
// (过程事件桥、ToolProfile/Skills/工具白名单、执行标识与预算)——
|
// (过程事件桥、ToolProfile/Skills/工具白名单、执行标识与预算)——
|
||||||
// 修平此前 leader 轮丢弃 OnEvent 与 ToolProfile 的缺口。
|
// 修平此前 leader 轮丢弃 OnEvent 与 ToolProfile 的缺口。
|
||||||
func leaderExecRequest(drv *flowDriver, st *runState, leader RoleSpec, workDir, prompt, instructions string, targets []string, timeout time.Duration) ExecRequest {
|
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)
|
stepID := fmt.Sprintf("leader_turn_%d", st.Leader.Turns)
|
||||||
var budget *ExecBudget
|
var budget *ExecBudget
|
||||||
if timeout > 0 {
|
if timeout > 0 {
|
||||||
@@ -80,22 +80,24 @@ type delegation struct {
|
|||||||
|
|
||||||
const leaderSubWorkspace = "tasks/00_leader"
|
const leaderSubWorkspace = "tasks/00_leader"
|
||||||
|
|
||||||
func (st *runState) loadLeader() {
|
func (st *RunState) loadLeader() {
|
||||||
if st.Leader != nil {
|
if st.Leader != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
st.Leader = &LeaderState{MaxTurns: DefaultMaxLeaderTurns}
|
st.Leader = &LeaderState{MaxTurns: DefaultMaxLeaderTurns}
|
||||||
data, err := os.ReadFile(filepath.Join(st.Meta.WorkDir, "leader.json"))
|
data, err := st.storeOr().LoadLeader(st.Meta.WorkDir)
|
||||||
if err == nil {
|
if err == nil && data != nil {
|
||||||
_ = json.Unmarshal(data, st.Leader)
|
_ = json.Unmarshal(data, st.Leader)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (st *runState) saveLeader() {
|
func (st *RunState) saveLeader() {
|
||||||
if st.Leader == nil {
|
if st.Leader == nil {
|
||||||
return
|
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 的系统指令(协调契约 + 通用守则)。
|
// leaderPrompt 组装 leader 的系统指令(协调契约 + 通用守则)。
|
||||||
@@ -132,7 +134,7 @@ func leaderPrompt(members []RoleSpec, catalogMd string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// leaderTaskBrief 组装 leader 本轮的 TASK.md(目标 + 黑板 + 委派历史)。
|
// leaderTaskBrief 组装 leader 本轮的 TASK.md(目标 + 黑板 + 委派历史)。
|
||||||
func leaderTaskBrief(st *runState) string {
|
func leaderTaskBrief(st *RunState) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("# Leader 轮次:评估并委派\n\n")
|
b.WriteString("# Leader 轮次:评估并委派\n\n")
|
||||||
fmt.Fprintf(&b, "## 运行目标\n%s\n\n", st.Meta.Goal.Text)
|
fmt.Fprintf(&b, "## 运行目标\n%s\n\n", st.Meta.Goal.Text)
|
||||||
@@ -175,7 +177,7 @@ func leaderTaskBrief(st *runState) string {
|
|||||||
|
|
||||||
// runLeaderLoop leader-delegation 主循环:委派→执行→唤醒,直到
|
// runLeaderLoop leader-delegation 主循环:委派→执行→唤醒,直到
|
||||||
// close/failed/上限/挂起(escalation/gap/权限等由 HITL 处理后重入本函数)。
|
// 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()
|
st.loadLeader()
|
||||||
drv := &flowDriver{opts: e.deps}
|
drv := &flowDriver{opts: e.deps}
|
||||||
leader, ok := findAgent(st.Plan.Squad.Agents, st.Leader.LeaderRole)
|
leader, ok := findAgent(st.Plan.Squad.Agents, st.Leader.LeaderRole)
|
||||||
@@ -204,7 +206,8 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
|
|||||||
}
|
}
|
||||||
_ = snapshotContextInto(st.Meta.WorkDir, subDir)
|
_ = snapshotContextInto(st.Meta.WorkDir, subDir)
|
||||||
|
|
||||||
for st.Leader.Turns < st.Leader.MaxTurns {
|
term := e.terminationOf(st)
|
||||||
|
for st.Leader.Turns < term.MaxLeaderTurns {
|
||||||
// HITL:leader 角色权限升级(与标准模式同语义)。
|
// HITL:leader 角色权限升级(与标准模式同语义)。
|
||||||
if leader.BashPermission == "ask" && !st.Approved["role:"+leader.Role] {
|
if leader.BashPermission == "ask" && !st.Approved["role:"+leader.Role] {
|
||||||
if drv.policy(st).mode() != HitlInteractive {
|
if drv.policy(st).mode() != HitlInteractive {
|
||||||
@@ -220,7 +223,7 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
|
|||||||
},
|
},
|
||||||
CreatedAt: time.Now(), Status: "pending",
|
CreatedAt: time.Now(), Status: "pending",
|
||||||
}
|
}
|
||||||
gate := &hitlGate{Request: *req, PrevStatus: RunExecuting}
|
gate := &HitlGate{Request: *req, PrevStatus: RunExecuting}
|
||||||
if err := st.saveGate(gate); err == nil {
|
if err := st.saveGate(gate); err == nil {
|
||||||
st.Meta.Status = string(RunWaitingHuman)
|
st.Meta.Status = string(RunWaitingHuman)
|
||||||
_ = st.save()
|
_ = st.save()
|
||||||
@@ -292,7 +295,7 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
|
|||||||
},
|
},
|
||||||
CreatedAt: time.Now(), Status: "pending",
|
CreatedAt: time.Now(), Status: "pending",
|
||||||
}
|
}
|
||||||
gate := &hitlGate{Request: *req, PrevStatus: RunExecuting}
|
gate := &HitlGate{Request: *req, PrevStatus: RunExecuting}
|
||||||
if err := st.saveGate(gate); err == nil {
|
if err := st.saveGate(gate); err == nil {
|
||||||
st.Meta.Status = string(RunWaitingHuman)
|
st.Meta.Status = string(RunWaitingHuman)
|
||||||
_ = st.save()
|
_ = st.save()
|
||||||
@@ -368,11 +371,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 输出了未知 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 模式失败收口。
|
// leaderFail 统一的 leader 模式失败收口。
|
||||||
func (e *SquadEngine) leaderFail(st *runState, reason string) *RunResult {
|
func (e *SquadEngine) leaderFail(st *RunState, reason string) *RunResult {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
st.Meta.Status = string(RunFailed)
|
st.Meta.Status = string(RunFailed)
|
||||||
st.Meta.CompletedAt = &now
|
st.Meta.CompletedAt = &now
|
||||||
@@ -386,7 +399,7 @@ func (e *SquadEngine) leaderFail(st *runState, reason string) *RunResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// snapshotDeliverablesToLeader 把成员交付文件复制进 leader 工作区 inbox/<步骤id>/。
|
// snapshotDeliverablesToLeader 把成员交付文件复制进 leader 工作区 inbox/<步骤id>/。
|
||||||
func snapshotDeliverablesToLeader(st *runState, step StepSpec) error {
|
func snapshotDeliverablesToLeader(st *RunState, step StepSpec) error {
|
||||||
srcDir := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(step.SubWorkspace))
|
srcDir := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(step.SubWorkspace))
|
||||||
dstDir := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(leaderSubWorkspace), "inbox", step.ID)
|
dstDir := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(leaderSubWorkspace), "inbox", step.ID)
|
||||||
for _, t := range step.Targets {
|
for _, t := range step.Targets {
|
||||||
|
|||||||
@@ -200,8 +200,13 @@ func TestLeaderModeTurnCapGuard(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if res.Status != RunFailed || !strings.Contains(res.Error, "上限") {
|
// Q23:达上限走 finalize 降级收口(completed/degraded/质检挂起),
|
||||||
t.Fatalf("want turn-cap failure, got %s err=%s", res.Status, res.Error)
|
// 不再是「运行停止」的裸失败。
|
||||||
|
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 {
|
if b.turn < 3 {
|
||||||
t.Fatalf("leader should have run %d turns, ran %d", 3, b.turn)
|
t.Fatalf("leader should have run %d turns, ran %d", 3, b.turn)
|
||||||
|
|||||||
+7
-1
@@ -88,10 +88,16 @@ type StepSpec struct {
|
|||||||
Mode string `json:"mode,omitempty"` // 预留
|
Mode string `json:"mode,omitempty"` // 预留
|
||||||
Parallel bool `json:"parallel"`
|
Parallel bool `json:"parallel"`
|
||||||
Needs []string `json:"needs,omitempty"`
|
Needs []string `json:"needs,omitempty"`
|
||||||
|
// Writes 写集:本步骤会写入的工件相对路径(调度依据)。
|
||||||
|
// 同一 superstep 内写集相交的步骤强制串行,不相交才可并行(Q1 WP1)。
|
||||||
|
Writes []string `json:"writes,omitempty"`
|
||||||
Targets []string `json:"targets,omitempty"` // 期望交付物相对路径,缺省 output.md
|
Targets []string `json:"targets,omitempty"` // 期望交付物相对路径,缺省 output.md
|
||||||
Status string `json:"status"` // pending, running, completed, failed, skipped
|
Status string `json:"status"` // pending, running, completed, failed, skipped
|
||||||
// Attempts 该步骤进入 running 的次数。中断恢复用它决定 auto 策略是否还能重试。
|
// Attempts 该步骤进入 running 的次数。中断恢复用它决定 auto 策略是否还能重试。
|
||||||
Attempts int `json:"attempts,omitempty"`
|
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"`
|
DurationMs int64 `json:"duration_ms,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
// RequiresApproval 为 true 时,该步骤启动前进入 HITL 计划确认。
|
// RequiresApproval 为 true 时,该步骤启动前进入 HITL 计划确认。
|
||||||
|
|||||||
+39
-17
@@ -10,9 +10,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// runState 引擎内部运行状态(磁盘即权威:workspace.json / workflow.json /
|
// RunState 引擎内部运行状态(磁盘即权威:workspace.json / workflow.json /
|
||||||
// squad.json / blackboard.json / pending.json 全部落盘,进程重启可恢复)。
|
// squad.json / blackboard.json / pending.json 全部落盘,进程重启可恢复)。
|
||||||
type runState struct {
|
type RunState struct {
|
||||||
Meta RunMetadata `json:"meta"`
|
Meta RunMetadata `json:"meta"`
|
||||||
Plan SquadPlan `json:"plan"`
|
Plan SquadPlan `json:"plan"`
|
||||||
BB BlackboardState `json:"bb"`
|
BB BlackboardState `json:"bb"`
|
||||||
@@ -21,6 +21,8 @@ type runState struct {
|
|||||||
Approved map[string]bool `json:"approved,omitempty"`
|
Approved map[string]bool `json:"approved,omitempty"`
|
||||||
// RepairBudgetRemaining 质检失败后的自动重跑剩余次数。
|
// RepairBudgetRemaining 质检失败后的自动重跑剩余次数。
|
||||||
RepairBudgetRemaining int `json:"repair_budget_remaining,omitempty"`
|
RepairBudgetRemaining int `json:"repair_budget_remaining,omitempty"`
|
||||||
|
// RepairBudgetGranted 人工 retry 累计追加次数(Q26 上限判定用)。
|
||||||
|
RepairBudgetGranted int `json:"repair_budget_granted,omitempty"`
|
||||||
// Clarifications 已澄清问答(追加进 Goal 上下文)。
|
// Clarifications 已澄清问答(追加进 Goal 上下文)。
|
||||||
Clarifications []string `json:"clarifications,omitempty"`
|
Clarifications []string `json:"clarifications,omitempty"`
|
||||||
// Steering 运行中人工补充指令(在下一步骤边界注入后清空)。
|
// Steering 运行中人工补充指令(在下一步骤边界注入后清空)。
|
||||||
@@ -29,11 +31,22 @@ type runState struct {
|
|||||||
Usage map[string]*Usage `json:"usage,omitempty"`
|
Usage map[string]*Usage `json:"usage,omitempty"`
|
||||||
// Leader leader-delegation 模式的循环状态(workspace 内 leader.json 持久化)。
|
// Leader leader-delegation 模式的循环状态(workspace 内 leader.json 持久化)。
|
||||||
Leader *LeaderState `json:"-"`
|
Leader *LeaderState `json:"-"`
|
||||||
|
// store 运行状态持久化端口(Q1 WP4);nil 回落 FileStore(现状文件态)。
|
||||||
|
// 替换实现(如 SQLiteStore)在引擎装配期注入。
|
||||||
|
store Store `json:"-"`
|
||||||
// mu 保护并行步骤对共享状态(黑板/save/steering/usage)的变更。
|
// mu 保护并行步骤对共享状态(黑板/save/steering/usage)的变更。
|
||||||
mu sync.Mutex `json:"-"`
|
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())
|
return mergeHitlPolicy(HitlPolicy{}, st.Meta.HitlPolicyOf())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,8 +62,14 @@ func (m RunMetadata) HitlPolicyOf() *HitlPolicy {
|
|||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
func (st *runState) save() error {
|
func (st *RunState) save() error {
|
||||||
st.Meta.UpdatedAt = time.Now()
|
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 {
|
if err := writeJSON(filepath.Join(st.Meta.WorkDir, "workspace.json"), &st.Meta); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -71,6 +90,7 @@ func (st *runState) save() error {
|
|||||||
return writeJSON(filepath.Join(st.Meta.WorkDir, "runstate.json"), diskRunState{
|
return writeJSON(filepath.Join(st.Meta.WorkDir, "runstate.json"), diskRunState{
|
||||||
Approved: st.Approved,
|
Approved: st.Approved,
|
||||||
RepairBudgetRemaining: st.RepairBudgetRemaining,
|
RepairBudgetRemaining: st.RepairBudgetRemaining,
|
||||||
|
RepairBudgetGranted: st.RepairBudgetGranted,
|
||||||
Clarifications: st.Clarifications,
|
Clarifications: st.Clarifications,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -78,17 +98,18 @@ func (st *runState) save() error {
|
|||||||
type diskRunState struct {
|
type diskRunState struct {
|
||||||
Approved map[string]bool `json:"approved,omitempty"`
|
Approved map[string]bool `json:"approved,omitempty"`
|
||||||
RepairBudgetRemaining int `json:"repair_budget_remaining,omitempty"`
|
RepairBudgetRemaining int `json:"repair_budget_remaining,omitempty"`
|
||||||
|
RepairBudgetGranted int `json:"repair_budget_granted,omitempty"`
|
||||||
Clarifications []string `json:"clarifications,omitempty"`
|
Clarifications []string `json:"clarifications,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Workflow 返回计划的编排清单(简洁访问器)。
|
// Workflow 返回计划的编排清单(简洁访问器)。
|
||||||
func (st *runState) Workflow() WorkflowPlan { return st.Plan.Workflow }
|
func (st *RunState) Workflow() WorkflowPlan { return st.Plan.Workflow }
|
||||||
|
|
||||||
// setWorkflow 写回编排清单。
|
// 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)。
|
// saveGate 落盘 HITL 挂起上下文(pending.json)。
|
||||||
func (st *runState) saveGate(gate *hitlGate) error {
|
func (st *RunState) saveGate(gate *HitlGate) error {
|
||||||
if gate == nil {
|
if gate == nil {
|
||||||
err := os.Remove(filepath.Join(st.Meta.WorkDir, "pending.json"))
|
err := os.Remove(filepath.Join(st.Meta.WorkDir, "pending.json"))
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
@@ -100,8 +121,8 @@ func (st *runState) saveGate(gate *hitlGate) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// loadRunState 从工作区恢复运行状态。
|
// loadRunState 从工作区恢复运行状态。
|
||||||
func loadRunState(wsDir string) (*runState, error) {
|
func loadRunState(wsDir string) (*RunState, error) {
|
||||||
st := &runState{}
|
st := &RunState{}
|
||||||
if err := readJSON(filepath.Join(wsDir, "workspace.json"), &st.Meta); err != nil {
|
if err := readJSON(filepath.Join(wsDir, "workspace.json"), &st.Meta); err != nil {
|
||||||
return nil, fmt.Errorf("load workspace.json: %w", err)
|
return nil, fmt.Errorf("load workspace.json: %w", err)
|
||||||
}
|
}
|
||||||
@@ -131,6 +152,7 @@ func loadRunState(wsDir string) (*runState, error) {
|
|||||||
st.Approved = disk.Approved
|
st.Approved = disk.Approved
|
||||||
}
|
}
|
||||||
st.RepairBudgetRemaining = disk.RepairBudgetRemaining
|
st.RepairBudgetRemaining = disk.RepairBudgetRemaining
|
||||||
|
st.RepairBudgetGranted = disk.RepairBudgetGranted
|
||||||
st.Clarifications = disk.Clarifications
|
st.Clarifications = disk.Clarifications
|
||||||
} else if !os.IsNotExist(err) {
|
} else if !os.IsNotExist(err) {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -143,7 +165,7 @@ func loadRunState(wsDir string) (*runState, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// loadGate 恢复挂起上下文;无挂起返回 nil。
|
// loadGate 恢复挂起上下文;无挂起返回 nil。
|
||||||
func loadGate(wsDir string) (*hitlGate, error) {
|
func loadGate(wsDir string) (*HitlGate, error) {
|
||||||
data, err := os.ReadFile(filepath.Join(wsDir, "pending.json"))
|
data, err := os.ReadFile(filepath.Join(wsDir, "pending.json"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
@@ -151,7 +173,7 @@ func loadGate(wsDir string) (*hitlGate, error) {
|
|||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
gate := &hitlGate{}
|
gate := &HitlGate{}
|
||||||
if err := json.Unmarshal(data, gate); err != nil {
|
if err := json.Unmarshal(data, gate); err != nil {
|
||||||
return nil, fmt.Errorf("parse pending.json: %w", err)
|
return nil, fmt.Errorf("parse pending.json: %w", err)
|
||||||
}
|
}
|
||||||
@@ -167,7 +189,7 @@ func readJSON(path string, v any) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// setStep 并行安全地回填步骤状态(与 save 的全量 marshal 互斥)。
|
// setStep 并行安全地回填步骤状态(与 save 的全量 marshal 互斥)。
|
||||||
func (st *runState) setStep(idx int, s StepSpec) {
|
func (st *RunState) setStep(idx int, s StepSpec) {
|
||||||
st.mu.Lock()
|
st.mu.Lock()
|
||||||
st.Plan.Workflow.Steps[idx] = s
|
st.Plan.Workflow.Steps[idx] = s
|
||||||
st.mu.Unlock()
|
st.mu.Unlock()
|
||||||
@@ -175,7 +197,7 @@ func (st *runState) setStep(idx int, s StepSpec) {
|
|||||||
|
|
||||||
// injectSteering 把挂起的运行中人工补充指令追加到 prompt(下一步骤边界注入),
|
// injectSteering 把挂起的运行中人工补充指令追加到 prompt(下一步骤边界注入),
|
||||||
// 注入后清空并落盘。注入发生在步骤开始前,不会污染正在执行的步骤。
|
// 注入后清空并落盘。注入发生在步骤开始前,不会污染正在执行的步骤。
|
||||||
func (st *runState) injectSteering(prompt string) string {
|
func (st *RunState) injectSteering(prompt string) string {
|
||||||
st.mu.Lock()
|
st.mu.Lock()
|
||||||
defer st.mu.Unlock()
|
defer st.mu.Unlock()
|
||||||
if len(st.Steering) == 0 {
|
if len(st.Steering) == 0 {
|
||||||
@@ -194,7 +216,7 @@ func (st *runState) injectSteering(prompt string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// addSteering 追加一条尚未注入的人工补充,并立刻落盘。
|
// addSteering 追加一条尚未注入的人工补充,并立刻落盘。
|
||||||
func (st *runState) addSteering(text string) error {
|
func (st *RunState) addSteering(text string) error {
|
||||||
text = strings.TrimSpace(text)
|
text = strings.TrimSpace(text)
|
||||||
if text == "" {
|
if text == "" {
|
||||||
return nil
|
return nil
|
||||||
@@ -206,7 +228,7 @@ func (st *runState) addSteering(text string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// loadSteering 恢复未消费的补充指令。
|
// loadSteering 恢复未消费的补充指令。
|
||||||
func (st *runState) loadSteering() {
|
func (st *RunState) loadSteering() {
|
||||||
data, err := os.ReadFile(filepath.Join(st.Meta.WorkDir, "steering.json"))
|
data, err := os.ReadFile(filepath.Join(st.Meta.WorkDir, "steering.json"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@@ -218,7 +240,7 @@ func (st *runState) loadSteering() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// addUsage 累计角色用量。
|
// addUsage 累计角色用量。
|
||||||
func (st *runState) addUsage(role string, promptTokens, completionTokens int) {
|
func (st *RunState) addUsage(role string, promptTokens, completionTokens int) {
|
||||||
if promptTokens == 0 && completionTokens == 0 {
|
if promptTokens == 0 && completionTokens == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -238,7 +260,7 @@ func (st *runState) addUsage(role string, promptTokens, completionTokens int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// usageSnapshot 输出用量快照(值拷贝)。
|
// usageSnapshot 输出用量快照(值拷贝)。
|
||||||
func (st *runState) usageSnapshot() map[string]Usage {
|
func (st *RunState) usageSnapshot() map[string]Usage {
|
||||||
if len(st.Usage) == 0 {
|
if len(st.Usage) == 0 {
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+132
-83
@@ -13,96 +13,144 @@ import (
|
|||||||
"error-code-system/errcode"
|
"error-code-system/errcode"
|
||||||
)
|
)
|
||||||
|
|
||||||
// flowDriver 工作流驱动器:推进 runState 直到终态或 HITL 挂起边界。
|
// flowDriver 工作流驱动器:推进 RunState 直到终态或 HITL 挂起边界。
|
||||||
type flowDriver struct {
|
type flowDriver struct {
|
||||||
opts engineDeps
|
opts engineDeps
|
||||||
}
|
}
|
||||||
|
|
||||||
// advance 执行到边界;返回 (结果, 挂起上下文),二者互斥非 nil(infra 错误时都为 nil 且 err 非 nil)。
|
// 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 := st.Workflow()
|
||||||
wf.Status = "in_progress"
|
wf.Status = "in_progress"
|
||||||
st.setWorkflow(wf)
|
st.setWorkflow(wf)
|
||||||
d.emit(st, Event{Type: EvPhase, Text: "开始执行工作流"})
|
d.emit(st, Event{Type: EvPhase, Text: "开始执行工作流"})
|
||||||
|
|
||||||
i := 0
|
superNo := 0
|
||||||
for i < len(st.Plan.Workflow.Steps) {
|
cpw := newCheckpointWriter(CheckpointMode(st.Meta.Metadata["checkpoint_mode"]), st.storeOr())
|
||||||
step := st.Plan.Workflow.Steps[i]
|
// 推进退出前 flush:async 落完、on_exit 写最终快照(Q1 WP4 三档的出口语义)。
|
||||||
switch step.Status {
|
var lastGraph *graph
|
||||||
case "completed", "skipped", "failed":
|
defer func() {
|
||||||
i++
|
if lastGraph == nil {
|
||||||
continue
|
if g, gerr := compileGraph(st.Plan.Workflow.Steps); gerr == nil {
|
||||||
}
|
lastGraph = g
|
||||||
|
|
||||||
if out, g, err := d.gateBeforeRun(ctx, st, i); out != nil || g != nil || err != nil {
|
|
||||||
if out == nil && g != nil {
|
|
||||||
out = heldResult(st, g)
|
|
||||||
}
|
}
|
||||||
return out, g, err
|
|
||||||
}
|
}
|
||||||
|
if lastGraph != nil {
|
||||||
|
_ = cpw.flush(st.Meta.WorkDir, snapshotOf(superNo, lastGraph, st.Plan.Workflow.Steps))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
for {
|
||||||
|
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 step.Parallel {
|
// 并行段:组内每一步都先过审批和权限,任一需要挂起则整组不执行。
|
||||||
// 收集相邻并行组。组内每一步都先过审批和权限,任一需要挂起则整组不执行。
|
if len(layer.par) > 0 {
|
||||||
var group []int
|
for _, idx := range layer.par {
|
||||||
for i < len(st.Plan.Workflow.Steps) {
|
if out, g2, err := d.gateBeforeRun(ctx, st, idx); out != nil || g2 != nil || err != nil {
|
||||||
s := st.Plan.Workflow.Steps[i]
|
if out == nil && g2 != nil {
|
||||||
if s.Parallel && s.Status != "completed" && s.Status != "skipped" && s.Status != "failed" {
|
out = heldResult(st, g2)
|
||||||
if out, g, err := d.gateBeforeRun(ctx, st, i); out != nil || g != nil || err != nil {
|
|
||||||
if out == nil && g != nil {
|
|
||||||
out = heldResult(st, g)
|
|
||||||
}
|
|
||||||
return out, g, err
|
|
||||||
}
|
}
|
||||||
group = append(group, i)
|
return out, g2, err
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
break
|
|
||||||
}
|
}
|
||||||
if out, g, err := d.runParallel(ctx, st, group); out != nil || g != nil || err != nil {
|
if out, g2, err := d.runParallel(ctx, st, layer.par); out != nil || g2 != nil || err != nil {
|
||||||
if out == nil && g != nil {
|
if out == nil && g2 != nil {
|
||||||
out = heldResult(st, g)
|
out = heldResult(st, g2)
|
||||||
}
|
}
|
||||||
return out, g, err
|
return out, g2, err
|
||||||
}
|
}
|
||||||
if g2 := d.checkBlockingGaps(st); g2 != nil {
|
if g2 := d.checkBlockingGaps(st); g2 != nil {
|
||||||
return heldResult(st, g2), g2, nil
|
return heldResult(st, g2), g2, nil
|
||||||
}
|
}
|
||||||
continue
|
// 单成员并行组实际是顺序执行:与旧线性扫描一致,其后可动态调整
|
||||||
}
|
// (多成员组不做,避免并发竞态)。
|
||||||
|
if len(layer.par) == 1 {
|
||||||
out, g, err := d.runOneStep(ctx, st, i)
|
idx := layer.par[0]
|
||||||
if out != nil || g != nil || err != nil {
|
if st.Plan.Workflow.Steps[idx].AllowDynamicAdjustment && d.opts.Evaluator != nil {
|
||||||
if out == nil && g != nil {
|
out, g2, err := d.applyDynamicAdjustment(ctx, st, idx)
|
||||||
out = heldResult(st, g)
|
if out != nil || g2 != nil || err != nil {
|
||||||
}
|
if out == nil && g2 != nil {
|
||||||
return out, g, err
|
out = heldResult(st, g2)
|
||||||
}
|
}
|
||||||
i++
|
return out, g2, err
|
||||||
|
}
|
||||||
// 阻塞缺口挂起。
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
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)
|
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 在步骤真正执行前检查权限升级和显式审批。
|
// gateBeforeRun 在步骤真正执行前检查权限升级和显式审批。
|
||||||
// 返回非空结果或挂起时,调用方不得执行该步骤,也不得执行它所在的并行组。
|
// 返回非空结果或挂起时,调用方不得执行该步骤,也不得执行它所在的并行组。
|
||||||
func (d *flowDriver) gateBeforeRun(ctx context.Context, st *runState, i int) (*RunResult, *hitlGate, error) {
|
func (d *flowDriver) gateBeforeRun(ctx context.Context, st *RunState, i int) (*RunResult, *HitlGate, error) {
|
||||||
step := st.Plan.Workflow.Steps[i]
|
step := st.Plan.Workflow.Steps[i]
|
||||||
role, _ := findAgent(st.Plan.Squad.Agents, step.Role)
|
role, _ := findAgent(st.Plan.Squad.Agents, step.Role)
|
||||||
if role.BashPermission == BashPermissionAsk && !st.Approved["role:"+role.Role] {
|
if role.BashPermission == BashPermissionAsk && !st.Approved["role:"+role.Role] {
|
||||||
@@ -143,13 +191,13 @@ func (d *flowDriver) gateBeforeRun(ctx context.Context, st *runState, i int) (*R
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runParallel 并发执行一个并行组;关键步骤失败即终止整个运行。
|
// 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)
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
failedCritical := -1
|
failedCritical := -1
|
||||||
var gate *hitlGate
|
var gate *HitlGate
|
||||||
for _, idx := range idxs {
|
for _, idx := range idxs {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(idx int) {
|
go func(idx int) {
|
||||||
@@ -180,7 +228,7 @@ func (d *flowDriver) runParallel(ctx context.Context, st *runState, idxs []int)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runOneStep 执行单个步骤(含瞬态重试与交付物校验)。
|
// 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 {
|
if err := ctx.Err(); err != nil {
|
||||||
return &RunResult{RunID: st.Meta.ID, Status: RunCancelled, Error: err.Error()}, nil, err
|
return &RunResult{RunID: st.Meta.ID, Status: RunCancelled, Error: err.Error()}, nil, err
|
||||||
}
|
}
|
||||||
@@ -211,6 +259,7 @@ 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})
|
d.emit(st, Event{Type: EvStepStarted, StepID: step.ID, Role: step.Role, Text: step.Title})
|
||||||
step.Status = "running"
|
step.Status = "running"
|
||||||
step.Attempts++
|
step.Attempts++
|
||||||
|
step.AttemptID = fmt.Sprintf("%s/%s/%d", st.Meta.ID, step.ID, step.Attempts)
|
||||||
step.Error = ""
|
step.Error = ""
|
||||||
// 与收口落盘同一把锁:并行步骤一边改 Steps、一边全量 marshal 会数据竞争。
|
// 与收口落盘同一把锁:并行步骤一边改 Steps、一边全量 marshal 会数据竞争。
|
||||||
st.mu.Lock()
|
st.mu.Lock()
|
||||||
@@ -283,7 +332,7 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru
|
|||||||
ToolAllowlist: role.Tools,
|
ToolAllowlist: role.Tools,
|
||||||
Skills: firstNonEmptySlice(step.Skills, role.Skills),
|
Skills: firstNonEmptySlice(step.Skills, role.Skills),
|
||||||
SessionScope: st.Meta.ID + "/" + step.ID,
|
SessionScope: st.Meta.ID + "/" + step.ID,
|
||||||
ExecutionID: st.Meta.ID + "-" + step.ID,
|
ExecutionID: step.AttemptID,
|
||||||
Budget: stepBudget,
|
Budget: stepBudget,
|
||||||
}
|
}
|
||||||
if step.IntentID != "" {
|
if step.IntentID != "" {
|
||||||
@@ -378,7 +427,7 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru
|
|||||||
}
|
}
|
||||||
|
|
||||||
// failStep 按关键性处理失败步骤:关键 → 运行失败;非关键 → 标记后继续(返回 nil 让循环推进)。
|
// 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()
|
st.mu.Lock()
|
||||||
step := st.Plan.Workflow.Steps[idx]
|
step := st.Plan.Workflow.Steps[idx]
|
||||||
critical := step.IsCritical()
|
critical := step.IsCritical()
|
||||||
@@ -402,7 +451,7 @@ func (d *flowDriver) failStep(ctx context.Context, st *runState, idx int) (*RunR
|
|||||||
}
|
}
|
||||||
|
|
||||||
// checkBlockingGaps 检查未解决阻塞缺口;interactive 挂起,auto 记录兜底假设并继续。
|
// checkBlockingGaps 检查未解决阻塞缺口;interactive 挂起,auto 记录兜底假设并继续。
|
||||||
func (d *flowDriver) checkBlockingGaps(st *runState) *hitlGate {
|
func (d *flowDriver) checkBlockingGaps(st *RunState) *HitlGate {
|
||||||
gaps := st.BB.UnresolvedBlockingGaps()
|
gaps := st.BB.UnresolvedBlockingGaps()
|
||||||
if len(gaps) == 0 {
|
if len(gaps) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -444,7 +493,7 @@ func (d *flowDriver) checkBlockingGaps(st *runState) *hitlGate {
|
|||||||
Context: strings.Join(lines, "\n"),
|
Context: strings.Join(lines, "\n"),
|
||||||
CreatedAt: time.Now(), Status: "pending",
|
CreatedAt: time.Now(), Status: "pending",
|
||||||
}
|
}
|
||||||
gate := &hitlGate{Request: *req, PrevStatus: RunExecuting}
|
gate := &HitlGate{Request: *req, PrevStatus: RunExecuting}
|
||||||
if err := st.saveGate(gate); err == nil {
|
if err := st.saveGate(gate); err == nil {
|
||||||
st.Meta.Status = string(RunWaitingHuman)
|
st.Meta.Status = string(RunWaitingHuman)
|
||||||
_ = st.save()
|
_ = st.save()
|
||||||
@@ -454,7 +503,7 @@ func (d *flowDriver) checkBlockingGaps(st *runState) *hitlGate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// applyDynamicAdjustment 评估步骤产出并应用/审批动态调整。
|
// 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]
|
step := st.Plan.Workflow.Steps[idx]
|
||||||
subDir := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(step.SubWorkspace))
|
subDir := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(step.SubWorkspace))
|
||||||
output := readFileOr(filepath.Join(subDir, firstTarget(step.Targets)), "")
|
output := readFileOr(filepath.Join(subDir, firstTarget(step.Targets)), "")
|
||||||
@@ -494,7 +543,7 @@ func (d *flowDriver) applyDynamicAdjustment(ctx context.Context, st *runState, i
|
|||||||
evalRes.Reason, strings.Join(addLines, "\n"), strings.Join(skipLines, "\n")),
|
evalRes.Reason, strings.Join(addLines, "\n"), strings.Join(skipLines, "\n")),
|
||||||
CreatedAt: time.Now(), Status: "pending",
|
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 {
|
if err := st.saveGate(gate); err == nil {
|
||||||
st.Meta.Status = string(RunWaitingHuman)
|
st.Meta.Status = string(RunWaitingHuman)
|
||||||
_ = st.save()
|
_ = st.save()
|
||||||
@@ -507,7 +556,7 @@ func (d *flowDriver) applyDynamicAdjustment(ctx context.Context, st *runState, i
|
|||||||
}
|
}
|
||||||
|
|
||||||
// applyAdjustment 应用动态调整:新步骤插到最后一步之前,跳过步骤标记 skipped。
|
// applyAdjustment 应用动态调整:新步骤插到最后一步之前,跳过步骤标记 skipped。
|
||||||
func applyAdjustment(st *runState, add []StepSpec, skipIDs []string) {
|
func applyAdjustment(st *RunState, add []StepSpec, skipIDs []string) {
|
||||||
steps := st.Plan.Workflow.Steps
|
steps := st.Plan.Workflow.Steps
|
||||||
for _, id := range skipIDs {
|
for _, id := range skipIDs {
|
||||||
for i := range steps {
|
for i := range steps {
|
||||||
@@ -534,7 +583,7 @@ func applyAdjustment(st *runState, add []StepSpec, skipIDs []string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// finalize 收口:汇总交付物 → 质检 → 自动修复 → (必要时)人工裁决 → 清单。
|
// 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()
|
wf := st.Workflow()
|
||||||
degraded := false
|
degraded := false
|
||||||
for _, s := range wf.Steps {
|
for _, s := range wf.Steps {
|
||||||
@@ -616,7 +665,7 @@ func (d *flowDriver) finalize(ctx context.Context, st *runState) (*RunResult, *h
|
|||||||
Context: string(marshalJSON(report)),
|
Context: string(marshalJSON(report)),
|
||||||
CreatedAt: time.Now(), Status: "pending",
|
CreatedAt: time.Now(), Status: "pending",
|
||||||
}
|
}
|
||||||
gate := &hitlGate{Request: *req, PrevStatus: RunExecuting}
|
gate := &HitlGate{Request: *req, PrevStatus: RunExecuting}
|
||||||
if err := st.saveGate(gate); err == nil {
|
if err := st.saveGate(gate); err == nil {
|
||||||
st.Meta.Status = string(RunQualityReview)
|
st.Meta.Status = string(RunQualityReview)
|
||||||
_ = st.save()
|
_ = st.save()
|
||||||
@@ -650,7 +699,7 @@ func (d *flowDriver) finalize(ctx context.Context, st *runState) (*RunResult, *h
|
|||||||
}
|
}
|
||||||
|
|
||||||
// rerunForRepair 重置交付物源步骤并重新执行到收口。
|
// 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 {
|
for i := range st.Plan.Workflow.Steps {
|
||||||
if st.Plan.Workflow.Steps[i].ID == stepID {
|
if st.Plan.Workflow.Steps[i].ID == stepID {
|
||||||
st.Plan.Workflow.Steps[i].Status = "pending"
|
st.Plan.Workflow.Steps[i].Status = "pending"
|
||||||
@@ -668,7 +717,7 @@ func (d *flowDriver) rerunForRepair(ctx context.Context, st *runState, stepID st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// heldResult 由挂起上下文构造对外结果(挂起路径统一返回非 nil 结果)。
|
// heldResult 由挂起上下文构造对外结果(挂起路径统一返回非 nil 结果)。
|
||||||
func heldResult(st *runState, gate *hitlGate) *RunResult {
|
func heldResult(st *RunState, gate *HitlGate) *RunResult {
|
||||||
status := RunWaitingHuman
|
status := RunWaitingHuman
|
||||||
switch gate.Request.Kind {
|
switch gate.Request.Kind {
|
||||||
case HitlClarification:
|
case HitlClarification:
|
||||||
@@ -698,7 +747,7 @@ func waitConditionFrom(gaps []GapItem) *WaitCondition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// hold 生成挂起请求并返回结果。
|
// hold 生成挂起请求并返回结果。
|
||||||
func (d *flowDriver) hold(st *runState, kind HumanRequestKind, stepID, role, question string, options []HumanOption, context, reason 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{
|
req := &HumanRequest{
|
||||||
ID: newRequestID(), RunID: st.Meta.ID, StepID: stepID, Kind: kind,
|
ID: newRequestID(), RunID: st.Meta.ID, StepID: stepID, Kind: kind,
|
||||||
Severity: SeverityActionRequired,
|
Severity: SeverityActionRequired,
|
||||||
@@ -714,7 +763,7 @@ func (d *flowDriver) hold(st *runState, kind HumanRequestKind, stepID, role, que
|
|||||||
}
|
}
|
||||||
gate := extra
|
gate := extra
|
||||||
if gate == nil {
|
if gate == nil {
|
||||||
gate = &hitlGate{}
|
gate = &HitlGate{}
|
||||||
}
|
}
|
||||||
gate.Request = *req
|
gate.Request = *req
|
||||||
gate.PrevStatus = RunExecuting
|
gate.PrevStatus = RunExecuting
|
||||||
@@ -731,7 +780,7 @@ func (d *flowDriver) hold(st *runState, kind HumanRequestKind, stepID, role, que
|
|||||||
// policy 合并引擎全局策略与 Goal 覆盖(窄者胜)。
|
// policy 合并引擎全局策略与 Goal 覆盖(窄者胜)。
|
||||||
// policyRole 在引擎全局/Goal 链之上叠加角色级 HitlPolicy
|
// policyRole 在引擎全局/Goal 链之上叠加角色级 HitlPolicy
|
||||||
// (窄者胜;Goal 仍最高)。
|
// (窄者胜;Goal 仍最高)。
|
||||||
func (d *flowDriver) policyRole(st *runState, role RoleSpec) HitlPolicy {
|
func (d *flowDriver) policyRole(st *RunState, role RoleSpec) HitlPolicy {
|
||||||
p := d.policy(st)
|
p := d.policy(st)
|
||||||
if role.HitlPolicy != nil {
|
if role.HitlPolicy != nil {
|
||||||
p = mergeHitlPolicy(p, role.HitlPolicy)
|
p = mergeHitlPolicy(p, role.HitlPolicy)
|
||||||
@@ -739,11 +788,11 @@ func (d *flowDriver) policyRole(st *runState, role RoleSpec) HitlPolicy {
|
|||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *flowDriver) policy(st *runState) HitlPolicy {
|
func (d *flowDriver) policy(st *RunState) HitlPolicy {
|
||||||
return mergeHitlPolicy(d.opts.HitlPolicy, st.Meta.HitlPolicyOf())
|
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 {
|
if d.opts.Sink == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -754,11 +803,11 @@ func (d *flowDriver) emit(st *runState, ev Event) {
|
|||||||
d.opts.Sink.Emit(ev)
|
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)})
|
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
|
var b strings.Builder
|
||||||
fmt.Fprintf(&b, "运行 %s:%s。步骤 %d 个(完成 %d/跳过 %d/失败 %d)。质检得分 %.0f。",
|
fmt.Fprintf(&b, "运行 %s:%s。步骤 %d 个(完成 %d/跳过 %d/失败 %d)。质检得分 %.0f。",
|
||||||
st.Meta.ID, status, len(st.Plan.Workflow.Steps),
|
st.Meta.ID, status, len(st.Plan.Workflow.Steps),
|
||||||
@@ -769,7 +818,7 @@ func summarize(st *runState, report QualityReport, status RunStatus) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func countStatus(st *runState, status string) int {
|
func countStatus(st *RunState, status string) int {
|
||||||
n := 0
|
n := 0
|
||||||
for _, s := range st.Plan.Workflow.Steps {
|
for _, s := range st.Plan.Workflow.Steps {
|
||||||
if s.Status == status {
|
if s.Status == status {
|
||||||
@@ -779,7 +828,7 @@ func countStatus(st *runState, status string) int {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildManifest(st *runState, deliverable string, report QualityReport) Manifest {
|
func buildManifest(st *RunState, deliverable string, report QualityReport) Manifest {
|
||||||
files := map[string]string{}
|
files := map[string]string{}
|
||||||
_ = filepath.Walk(st.Meta.WorkDir, func(path string, info os.FileInfo, err error) error {
|
_ = filepath.Walk(st.Meta.WorkDir, func(path string, info os.FileInfo, err error) error {
|
||||||
if err != nil || info.IsDir() {
|
if err != nil || info.IsDir() {
|
||||||
@@ -838,7 +887,7 @@ func appendLog(dir, line string) {
|
|||||||
_, _ = f.WriteString(line)
|
_, _ = f.WriteString(line)
|
||||||
}
|
}
|
||||||
|
|
||||||
func planPtr(st *runState) *WorkflowPlan {
|
func planPtr(st *RunState) *WorkflowPlan {
|
||||||
wf := st.Workflow()
|
wf := st.Workflow()
|
||||||
return &wf
|
return &wf
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-12
@@ -34,18 +34,9 @@ func (e *WorkspaceEngine) PrepareWorkspace(meta *RunMetadata, plan *SquadPlan) (
|
|||||||
}
|
}
|
||||||
meta.WorkDir = wsDir
|
meta.WorkDir = wsDir
|
||||||
meta.UpdatedAt = time.Now()
|
meta.UpdatedAt = time.Now()
|
||||||
if err := writeJSON(filepath.Join(wsDir, "workspace.json"), meta); err != nil {
|
// 注意:run 级状态 JSON 不在此处写——由 st.save() 经 Store 端口统一落盘
|
||||||
return "", err
|
// (Q1 WP5:SQLite 后端时这些状态进数据库而非文件)。这里只建目录与
|
||||||
}
|
// 上下文/模板等运行无关状态的工作区资产。
|
||||||
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
|
|
||||||
}
|
|
||||||
ctxIdx, err := e.writeContextAssets(wsDir, &meta.Goal, plan)
|
ctxIdx, err := e.writeContextAssets(wsDir, &meta.Goal, plan)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
Reference in New Issue
Block a user