Why: F11 - SQLite runs lost mailbox/handoffs/board across restarts, identical JSON entries collapsed during decode, and checkpoint or terminal-save failures were swallowed behind success events. Evidence: store and checkpoint tests pass incl. injected-failure and round-trip tables (verify-u8.json, exit=0); schema migration preserves rows. Impact: runs survive restarts with collaboration state; save failures are visible before any completion event; async/on_exit semantics kept. Refs: feature/agent-stack-audit-remediation-b2 U8
244 lines
8.1 KiB
Go
244 lines
8.1 KiB
Go
package squad
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"sync"
|
||
"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"`
|
||
// F05/U3b:trusted terminal record;用于 recovery 时仅在「后端显式
|
||
// 声明 success」时把步骤恢复成 completed(即便目标文件缺失也照恢复),
|
||
// files 仍然记入 deliverables 做诊断。SideEffects 报告重放安全性:
|
||
// none → 可重试
|
||
// possible → 需 reconciliation(不禁 rerun;只允许显式 reconcile)
|
||
// Canceled/Aborted 为 true 时本步是取消/中止语义,不应被恢复成 completed。
|
||
Terminal string `json:"terminal,omitempty"`
|
||
// SideEffects 镜像 ExecResult.SideEffects;缺省 = possible(旧读取)。
|
||
SideEffects string `json:"side_effects,omitempty"`
|
||
Canceled bool `json:"canceled,omitempty"`
|
||
Aborted bool `json:"aborted,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
|
||
// flushOnce/flushErr 让出口 flush 幂等:正常收口路径先 flush 一次(失败必须
|
||
// 先于任何完成事件可见),defer 再兜底 flush 时不会二次写、也不会重复报错。
|
||
flushOnce sync.Once
|
||
flushErr 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 写最终快照。
|
||
// 幂等:重复调用返回首次结果(async 的 chan 只关一次)。
|
||
func (w *checkpointWriter) flush(wsDir string, cp Checkpoint) error {
|
||
w.flushOnce.Do(func() { w.flushErr = w.flushOnceImpl(wsDir, cp) })
|
||
return w.flushErr
|
||
}
|
||
|
||
func (w *checkpointWriter) flushOnceImpl(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
|
||
}
|
||
}
|
||
|
||
// persistenceFailurePrefix 标记落盘失败。这类失败必须让运行以可区分的面目收场:
|
||
// 业务动作(步骤执行、交付物写出)已发生,不回滚、不声称完成。
|
||
const persistenceFailurePrefix = "persistence failure"
|
||
|
||
// persistenceFailureText 组装落盘失败的可读说明(stage = 落盘环节)。
|
||
func persistenceFailureText(stage string, err error) string {
|
||
return persistenceFailurePrefix + ": " + stage + ": " + err.Error()
|
||
}
|
||
|
||
// 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
|
||
}
|