Files
Agent-Squad/squad/runstate.go
T
weichen 90ac98ea65
ci / vet-and-test (pull_request) Successful in 23s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Converge run persistence on SQLite through the Store port
Why: Q1 WP5 - platform's SQLite becomes an implementation of the
squad Store port so run state and orchestration state live in one
database, and platform's in-process execution goes through the
Executor port instead of bypassing it (X6).

Evidence: all five modules go vet clean and go test ok, including
engine-on-SQLite (state in DB, no run-level JSON files, second
engine recovers from the same DB), SQLiteStore round trip, and the
full squad compatibility net.

Impact: runState/hitlGate exported so the port is implementable;
Options.Store wires into engineDeps; leader persistence and
checkpoints route through the port; PrepareWorkspace no longer
writes run-level JSON (st.save owns it); platform RunInProcess
takes squad.Executor (callers updated); serve mounts the engine on
platform's RunStore - single orchestration.db for both.

Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md Q1 WP5
2026-10-07 10:29:47 +00:00

273 lines
8.6 KiB
Go

package squad
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// RunState 引擎内部运行状态(磁盘即权威:workspace.json / workflow.json /
// squad.json / blackboard.json / pending.json 全部落盘,进程重启可恢复)。
type RunState struct {
Meta RunMetadata `json:"meta"`
Plan SquadPlan `json:"plan"`
BB BlackboardState `json:"bb"`
Template string `json:"template,omitempty"`
// Approved 运行内一次性授权(permission/confirmation),键为 role:/step: 前缀。
Approved map[string]bool `json:"approved,omitempty"`
// RepairBudgetRemaining 质检失败后的自动重跑剩余次数。
RepairBudgetRemaining int `json:"repair_budget_remaining,omitempty"`
// RepairBudgetGranted 人工 retry 累计追加次数(Q26 上限判定用)。
RepairBudgetGranted int `json:"repair_budget_granted,omitempty"`
// Clarifications 已澄清问答(追加进 Goal 上下文)。
Clarifications []string `json:"clarifications,omitempty"`
// Steering 运行中人工补充指令(在下一步骤边界注入后清空)。
Steering []string `json:"steering,omitempty"`
// Usage 按角色累计的 token 用量。
Usage map[string]*Usage `json:"usage,omitempty"`
// Leader leader-delegation 模式的循环状态(workspace 内 leader.json 持久化)。
Leader *LeaderState `json:"-"`
// store 运行状态持久化端口(Q1 WP4);nil 回落 FileStore(现状文件态)。
// 替换实现(如 SQLiteStore)在引擎装配期注入。
store Store `json:"-"`
// mu 保护并行步骤对共享状态(黑板/save/steering/usage)的变更。
mu sync.Mutex `json:"-"`
}
// 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())
}
// HitlPolicyOf 从 Goal 推导策略(Goal 覆盖全局)。
func (m RunMetadata) HitlPolicyOf() *HitlPolicy {
p := &HitlPolicy{}
if m.Goal.HitlMode != "" {
p.Mode = m.Goal.HitlMode
}
if m.Goal.HitlTimeout != 0 {
p.TimeoutAfter = m.Goal.HitlTimeout
}
return p
}
func (st *RunState) save() error {
st.Meta.UpdatedAt = time.Now()
return st.storeOr().SaveRun(st)
}
// persist 是 FileStore 语义的低层落盘(全部写文件)。Store.SaveRun 调它;
// RunState.save 走 Store 端口,替换实现(SQLiteStore)在装配期注入后自然接管。
func (st *RunState) persist() error {
if err := writeJSON(filepath.Join(st.Meta.WorkDir, "workspace.json"), &st.Meta); err != nil {
return err
}
if err := writeJSON(filepath.Join(st.Meta.WorkDir, "workflow.json"), st.Plan.Workflow); err != nil {
return err
}
if err := writeJSON(filepath.Join(st.Meta.WorkDir, "squad.json"), &st.Plan.Squad); err != nil {
return err
}
if err := saveBlackboard(st.Meta.WorkDir, &st.BB); err != nil {
return err
}
if len(st.Usage) > 0 {
if err := writeJSON(filepath.Join(st.Meta.WorkDir, "usage.json"), st.Usage); err != nil {
return err
}
}
return writeJSON(filepath.Join(st.Meta.WorkDir, "runstate.json"), diskRunState{
Approved: st.Approved,
RepairBudgetRemaining: st.RepairBudgetRemaining,
RepairBudgetGranted: st.RepairBudgetGranted,
Clarifications: st.Clarifications,
})
}
type diskRunState struct {
Approved map[string]bool `json:"approved,omitempty"`
RepairBudgetRemaining int `json:"repair_budget_remaining,omitempty"`
RepairBudgetGranted int `json:"repair_budget_granted,omitempty"`
Clarifications []string `json:"clarifications,omitempty"`
}
// Workflow 返回计划的编排清单(简洁访问器)。
func (st *RunState) Workflow() WorkflowPlan { return st.Plan.Workflow }
// setWorkflow 写回编排清单。
func (st *RunState) setWorkflow(wf WorkflowPlan) { st.Plan.Workflow = wf }
// saveGate 落盘 HITL 挂起上下文(pending.json)。
func (st *RunState) saveGate(gate *HitlGate) error {
if gate == nil {
err := os.Remove(filepath.Join(st.Meta.WorkDir, "pending.json"))
if os.IsNotExist(err) {
return nil
}
return err
}
return writeJSON(filepath.Join(st.Meta.WorkDir, "pending.json"), gate)
}
// loadRunState 从工作区恢复运行状态。
func loadRunState(wsDir string) (*RunState, error) {
st := &RunState{}
if err := readJSON(filepath.Join(wsDir, "workspace.json"), &st.Meta); err != nil {
return nil, fmt.Errorf("load workspace.json: %w", err)
}
if err := readJSON(filepath.Join(wsDir, "workflow.json"), &st.Plan.Workflow); err != nil {
return nil, fmt.Errorf("load workflow.json: %w", err)
}
if err := readJSON(filepath.Join(wsDir, "squad.json"), &st.Plan.Squad); err != nil {
return nil, fmt.Errorf("load squad.json: %w", err)
}
bb, err := loadBlackboard(wsDir)
if err != nil {
return nil, err
}
st.BB = *bb
if data, err := os.ReadFile(filepath.Join(wsDir, "template.md")); err == nil {
st.Template = string(data)
}
if st.Approved == nil {
st.Approved = map[string]bool{}
}
if data, err := os.ReadFile(filepath.Join(wsDir, "runstate.json")); err == nil {
var disk diskRunState
if err := json.Unmarshal(data, &disk); err != nil {
return nil, fmt.Errorf("parse runstate.json: %w", err)
}
if disk.Approved != nil {
st.Approved = disk.Approved
}
st.RepairBudgetRemaining = disk.RepairBudgetRemaining
st.RepairBudgetGranted = disk.RepairBudgetGranted
st.Clarifications = disk.Clarifications
} else if !os.IsNotExist(err) {
return nil, err
}
st.loadSteering()
if data, err := os.ReadFile(filepath.Join(wsDir, "usage.json")); err == nil {
_ = json.Unmarshal(data, &st.Usage)
}
return st, nil
}
// loadGate 恢复挂起上下文;无挂起返回 nil。
func loadGate(wsDir string) (*HitlGate, error) {
data, err := os.ReadFile(filepath.Join(wsDir, "pending.json"))
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
gate := &HitlGate{}
if err := json.Unmarshal(data, gate); err != nil {
return nil, fmt.Errorf("parse pending.json: %w", err)
}
return gate, nil
}
func readJSON(path string, v any) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
return json.Unmarshal(data, v)
}
// setStep 并行安全地回填步骤状态(与 save 的全量 marshal 互斥)。
func (st *RunState) setStep(idx int, s StepSpec) {
st.mu.Lock()
st.Plan.Workflow.Steps[idx] = s
st.mu.Unlock()
}
// injectSteering 把挂起的运行中人工补充指令追加到 prompt(下一步骤边界注入),
// 注入后清空并落盘。注入发生在步骤开始前,不会污染正在执行的步骤。
func (st *RunState) injectSteering(prompt string) string {
st.mu.Lock()
defer st.mu.Unlock()
if len(st.Steering) == 0 {
return prompt
}
var b strings.Builder
b.WriteString(prompt)
b.WriteString("\n\n【运行中人工补充指令】以下补充来自人工,作用于当前进行中的运行(非新任务)。")
b.WriteString("请把它当作当前任务的补充要求继续执行;仅当人工明确要求放弃原目标时才替换原目标,且不要单独回复确认。\n")
for _, s := range st.Steering {
b.WriteString("- " + s + "\n")
}
st.Steering = nil
_ = writeJSON(filepath.Join(st.Meta.WorkDir, "steering.json"), st.Steering)
return b.String()
}
// addSteering 追加一条尚未注入的人工补充,并立刻落盘。
func (st *RunState) addSteering(text string) error {
text = strings.TrimSpace(text)
if text == "" {
return nil
}
st.mu.Lock()
defer st.mu.Unlock()
st.Steering = append(st.Steering, text)
return writeJSON(filepath.Join(st.Meta.WorkDir, "steering.json"), st.Steering)
}
// loadSteering 恢复未消费的补充指令。
func (st *RunState) loadSteering() {
data, err := os.ReadFile(filepath.Join(st.Meta.WorkDir, "steering.json"))
if err != nil {
return
}
var pending []string
if json.Unmarshal(data, &pending) == nil && len(pending) > 0 {
st.Steering = pending
}
}
// addUsage 累计角色用量。
func (st *RunState) addUsage(role string, promptTokens, completionTokens int) {
if promptTokens == 0 && completionTokens == 0 {
return
}
st.mu.Lock()
defer st.mu.Unlock()
if st.Usage == nil {
st.Usage = map[string]*Usage{}
}
u, ok := st.Usage[role]
if !ok {
u = &Usage{}
st.Usage[role] = u
}
u.PromptTokens += promptTokens
u.CompletionTokens += completionTokens
u.Steps++
}
// usageSnapshot 输出用量快照(值拷贝)。
func (st *RunState) usageSnapshot() map[string]Usage {
if len(st.Usage) == 0 {
return nil
}
out := make(map[string]Usage, len(st.Usage))
for k, v := range st.Usage {
out[k] = *v
}
return out
}