Why: A step confirmation cancelled the whole run, approvals and repair budget vanished on restart, and the HTTP server accepted anonymous and oversized requests. Evidence: GOWORK=off go vet ./... && GOWORK=off go test -count=1 ./... && GOWORK=off go test -race ./squad/... passed. scripts/submodule-consistency.sh ok. Impact: Approve and skip apply to one step; runstate.json and unix flock survive restart; wakeup leaves permission holds pending; serve defaults to loopback and requires a token off-loopback. Refs: Q1 Q2 Q3 Q4 Q5 Q8 Q9 Q14 Q15 Q16 Co-authored-by: Cursor <cursoragent@cursor.com>
113 lines
2.8 KiB
Go
113 lines
2.8 KiB
Go
package squad
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"time"
|
||
)
|
||
|
||
// loadBlackboard 读取黑板;不存在时返回初始黑板。
|
||
func loadBlackboard(wsDir string) (*BlackboardState, error) {
|
||
bb := &BlackboardState{Fields: map[string]string{}, UpdatedAt: time.Now()}
|
||
data, err := os.ReadFile(filepath.Join(wsDir, "blackboard.json"))
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
return bb, nil
|
||
}
|
||
return nil, err
|
||
}
|
||
if err := json.Unmarshal(data, bb); err != nil {
|
||
return nil, fmt.Errorf("parse blackboard.json: %w", err)
|
||
}
|
||
if bb.Fields == nil {
|
||
bb.Fields = map[string]string{}
|
||
}
|
||
return bb, nil
|
||
}
|
||
|
||
// saveBlackboard 原子写回黑板。
|
||
func saveBlackboard(wsDir string, bb *BlackboardState) error {
|
||
bb.UpdatedAt = time.Now()
|
||
data, err := json.MarshalIndent(bb, "", " ")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return AtomicWrite(filepath.Join(wsDir, "blackboard.json"), append(data, '\n'))
|
||
}
|
||
|
||
// mergeStepArtifacts 合并步骤产出:fields.json(字符串键值)与
|
||
// gaps.json([]GapItem,角色可声明 blocking 缺口)合入黑板。
|
||
// 返回本次新合入的阻塞缺口(用于 HITL 挂起判断)。
|
||
func mergeStepArtifacts(bb *BlackboardState, subDir string) []GapItem {
|
||
var newBlocking []GapItem
|
||
|
||
if data, err := os.ReadFile(filepath.Join(subDir, "fields.json")); err == nil {
|
||
var fields map[string]string
|
||
if json.Unmarshal(data, &fields) == nil {
|
||
for k, v := range fields {
|
||
bb.Fields[k] = v
|
||
}
|
||
}
|
||
}
|
||
|
||
if data, err := os.ReadFile(filepath.Join(subDir, "gaps.json")); err == nil {
|
||
var gaps []GapItem
|
||
if json.Unmarshal(data, &gaps) == nil {
|
||
for _, g := range gaps {
|
||
if g.ID == "" {
|
||
g.ID = fmt.Sprintf("gap_%d", len(bb.Gaps)+1)
|
||
}
|
||
if exists, _ := findGap(bb, g.ID, g.Item); exists {
|
||
continue
|
||
}
|
||
if g.Owner == "" {
|
||
g.Owner = "agent"
|
||
}
|
||
bb.Gaps = append(bb.Gaps, g)
|
||
if g.Blocking && !g.Resolved {
|
||
newBlocking = append(newBlocking, g)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return newBlocking
|
||
}
|
||
|
||
func findGap(bb *BlackboardState, id, item string) (bool, bool) {
|
||
for _, g := range bb.Gaps {
|
||
if (id != "" && g.ID == id) || (item != "" && g.Item == item) {
|
||
return true, g.Resolved
|
||
}
|
||
}
|
||
return false, false
|
||
}
|
||
|
||
// UnresolvedBlockingGaps 返回未解决的阻塞缺口。
|
||
func (bb *BlackboardState) UnresolvedBlockingGaps() []GapItem {
|
||
var out []GapItem
|
||
for _, g := range bb.Gaps {
|
||
if g.Blocking && !g.Resolved {
|
||
out = append(out, g)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ResolveGapByID 将缺口标记为已解决,并把人工回答落成事实/字段。
|
||
func (bb *BlackboardState) ResolveGapByID(id, answer string) bool {
|
||
for i := range bb.Gaps {
|
||
if bb.Gaps[i].ID == id {
|
||
bb.Gaps[i].Resolved = true
|
||
if answer != "" {
|
||
bb.Facts = append(bb.Facts, FactItem{
|
||
ID: fmt.Sprintf("fact_%d", len(bb.Facts)+1), Claim: answer, Source: "human",
|
||
})
|
||
}
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|