Files
Agent-System-Backend/internal/wfcompile/project_sources.go
weichen ecb86f4309 Recheck project workflow sources without storing prompts
Why: Goal-sensitive workflow verification persisted model prompts and prior output text in trusted receipt rows, while cross-step model inputs could reuse stale machine output.

Evidence: RED receipt privacy test; full Go suite, targeted race, isolated PostgreSQL store/runmgr/storage-migrate tests, vet, and static build passed locally before commit. A known unrelated TempDir cleanup flake failed once and passed on targeted and serial full replay.

Impact: Add optional same-provider source recheck, verify every included item and evidence body before reuse, gate cross-step outputs, reject unsettled attempts, remove raw scope columns, scrub isolated candidate v34 SQLite/PG schemas, and reject unsafe backups.

Refs: Approved RDM stage6 W6 source-recheck design adjustment r1; W6 independent review.
2026-10-14 05:38:11 +00:00

144 lines
4.4 KiB
Go

package wfcompile
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"agent-squad/squad"
)
// verifyPriorWorkflowOutputs is shared by model and host steps. The engine's
// task brief/blackboard/handoff can carry earlier output as a fresh user
// prompt, so every completed machine step must have a current source proof.
func verifyPriorWorkflowOutputs(ctx context.Context, rc ProjectStepProvenance, wsDir string, subOf, kinds map[string]string, modelSend bool) error {
state, err := (squad.FileStore{}).LoadRun(wsDir)
if err != nil {
return fmt.Errorf("project workflow state unavailable: %w", err)
}
for _, step := range state.Plan.Workflow.Steps {
if step.Status != "completed" {
continue
}
switch kinds[step.ID] {
case "prompt":
sub := subOf[step.ID]
if sub == "" || step.AttemptID == "" {
return fmt.Errorf("project workflow step %s lacks source identity", step.ID)
}
data, err := os.ReadFile(filepath.Join(wsDir, filepath.FromSlash(sub), "output.md"))
if err != nil {
return fmt.Errorf("project workflow step %s output unavailable: %w", step.ID, err)
}
if err := rc.VerifyProjectStepOutput(ctx, step.ID, step.AttemptID, "prompt", data); err != nil {
return fmt.Errorf("project workflow step %s output source changed: %w", step.ID, err)
}
case "hitl", "critic": // human input or a verdict whose prompt source was checked above
default:
if modelSend {
return fmt.Errorf("project workflow step %s has unproven machine output", step.ID)
}
}
}
return nil
}
// completedStepAttempt comes from the squad engine's persisted state, not a
// model response or plugin argument. It identifies the exact execution that
// produced output.md, including after a workflow resumes.
func completedStepAttempt(wsDir, stepID string) (string, error) {
state, err := (squad.FileStore{}).LoadRun(wsDir)
if err != nil {
return "", err
}
for _, step := range state.Plan.Workflow.Steps {
if step.ID == stepID && step.Status == "completed" && step.AttemptID != "" {
return step.AttemptID, nil
}
}
return "", errors.New("completed step attempt unavailable")
}
// projectOutputSink binds only a completed prompt step's materialized bytes.
// Parallel prompt executions have no unambiguous request-to-step attribution
// in the current backend contract, so they receive no binding and the critic
// fails closed. A recovered completion without its start event likewise needs
// an existing persisted binding to pass its critic.
type projectOutputSink struct {
rc ProjectStepProvenance
ctx context.Context
root string
subOf map[string]string
kinds map[string]string
mu sync.Mutex
started map[string]time.Time
ambiguous map[string]bool
}
func newProjectOutputSink(rc Host, root string, subOf, kinds map[string]string) squad.ProgressSink {
binder := rc.(ProjectStepProvenance)
return &projectOutputSink{rc: binder, ctx: rc.Context(), root: root, subOf: subOf, kinds: kinds,
started: map[string]time.Time{}, ambiguous: map[string]bool{}}
}
func (s *projectOutputSink) Emit(ev squad.Event) {
if ev.StepID == "" {
return
}
s.mu.Lock()
switch ev.Type {
case squad.EvStepStarted:
if s.kinds[ev.StepID] == "prompt" {
if err := s.rc.InvalidateProjectStepOutput(s.ctx, ev.StepID); err != nil {
s.ambiguous[ev.StepID] = true
}
}
if len(s.started) != 0 {
if s.kinds[ev.StepID] == "prompt" {
s.ambiguous[ev.StepID] = true
}
for id := range s.started {
if s.kinds[id] == "prompt" {
s.ambiguous[id] = true
}
}
}
s.started[ev.StepID] = ev.At
s.mu.Unlock()
return
case squad.EvStepCompleted:
startedAt, started := s.started[ev.StepID]
ambiguous := s.ambiguous[ev.StepID]
delete(s.started, ev.StepID)
delete(s.ambiguous, ev.StepID)
s.mu.Unlock()
if s.kinds[ev.StepID] != "prompt" || !started || startedAt.IsZero() || ambiguous {
return
}
sub := s.subOf[ev.StepID]
if sub == "" {
return
}
data, err := os.ReadFile(filepath.Join(s.root, "runs", ev.RunID, filepath.FromSlash(sub), "output.md"))
if err == nil {
wsDir := filepath.Join(s.root, "runs", ev.RunID)
attemptID, attemptErr := completedStepAttempt(wsDir, ev.StepID)
if attemptErr == nil {
_ = s.rc.BindProjectStepOutput(s.ctx, ev.StepID, attemptID, "prompt", startedAt, data)
}
}
return
case squad.EvStepFailed:
delete(s.started, ev.StepID)
delete(s.ambiguous, ev.StepID)
s.mu.Unlock()
return
default:
s.mu.Unlock()
}
}