iop/apps/agent/internal/taskloop/review.go

415 lines
14 KiB
Go

package taskloop
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"iop/packages/go/agentconfig"
"iop/packages/go/agentguard"
"iop/packages/go/agenttask"
"iop/packages/go/agentworkspace"
)
// ReviewExecutor invokes the official review provider boundary. Tests inject a
// deterministic fake; production uses the common catalog provider.
type ReviewExecutor interface {
ExecuteReview(context.Context, agenttask.ReviewRequest, string, string, string) error
}
// ArtifactRootResolver resolves only the retained task view associated with an
// exact work attempt. Evidence and recovery never fall back to the canonical
// registered workspace because provider output exists only in the task view.
type ArtifactRootResolver interface {
ArtifactRoot(agenttask.WorkRecord) (string, error)
}
type retainedArtifactResolver struct {
backend *agentworkspace.Backend
}
type retainedIsolationBackend interface {
agenttask.IsolationBackend
LoadRecord(agentguard.IsolationDescriptor) (agentworkspace.OverlayRecord, error)
}
func (resolver retainedArtifactResolver) ArtifactRoot(
work agenttask.WorkRecord,
) (string, error) {
descriptor, err := loadOverlayDescriptor(resolver.backend, work)
if err != nil {
return "", err
}
return descriptor.WorkingDir, nil
}
type Reviewer struct {
snapshot agentconfig.RuntimeSnapshot
backend *agentworkspace.Backend
executor ReviewExecutor
}
var _ agenttask.Reviewer = (*Reviewer)(nil)
func NewReviewer(
snapshot agentconfig.RuntimeSnapshot,
backend *agentworkspace.Backend,
executor ReviewExecutor,
) (*Reviewer, error) {
if backend == nil {
return nil, errors.New("taskloop: review requires the retained workspace backend")
}
return &Reviewer{snapshot: snapshot, backend: backend, executor: executor}, nil
}
func (reviewer *Reviewer) Review(
ctx context.Context,
request agenttask.ReviewRequest,
) (agenttask.ReviewResult, error) {
if err := ctx.Err(); err != nil {
return agenttask.ReviewResult{}, err
}
if request.Work.Submission == nil ||
request.Work.Submission.ArtifactID != request.Submission.ArtifactID ||
request.Work.AttemptID != request.Submission.AttemptID {
return agenttask.ReviewResult{}, errors.New("taskloop: review submission identity mismatch")
}
_, ok := reviewer.snapshot.Project(string(request.Project.ProjectID))
if !ok {
return agenttask.ReviewResult{}, errors.New("taskloop: review project is not registered")
}
planRelative := request.Work.Unit.Metadata["plan_path"]
reviewRelative := request.Work.Unit.Metadata["review_path"]
if planRelative == "" || reviewRelative == "" {
return agenttask.ReviewResult{}, errors.New("taskloop: review artifact locators are incomplete")
}
root, err := reviewer.taskView(request.Work)
if err != nil {
return agenttask.ReviewResult{}, err
}
reviewPath := filepath.Join(root, filepath.FromSlash(reviewRelative))
verdict, message, verdictErr := readReviewVerdict(reviewPath)
if errors.Is(verdictErr, errVerdictMissing) && reviewer.executor != nil {
if err := reviewer.executor.ExecuteReview(
ctx,
request,
root,
planRelative,
reviewRelative,
); err != nil {
return agenttask.ReviewResult{}, fmt.Errorf("taskloop: official review invocation: %w", err)
}
verdict, message, verdictErr = readReviewVerdict(reviewPath)
}
if verdictErr != nil {
return agenttask.ReviewResult{}, verdictErr
}
result := agenttask.ReviewResult{
ProjectID: request.Project.ProjectID,
WorkUnitID: request.Work.Unit.ID,
AttemptID: request.Work.AttemptID,
ArtifactID: request.Submission.ArtifactID,
Verdict: verdict,
Message: message,
Rework: verdict == agenttask.ReviewVerdictWarn || verdict == agenttask.ReviewVerdictFail,
}
if verdict != agenttask.ReviewVerdictPass {
return result, nil
}
descriptor, err := reviewer.overlayDescriptor(request.Work)
if err != nil {
return agenttask.ReviewResult{}, err
}
content, err := os.ReadFile(reviewPath)
if err != nil {
return agenttask.ReviewResult{}, err
}
changeSet, err := reviewer.backend.Freeze(ctx, agentworkspace.FreezeRequest{
Descriptor: descriptor,
ArtifactID: request.Submission.ArtifactID,
ValidationEvidence: []agentworkspace.ValidationEvidence{{
Name: "official-review",
Result: "pass",
Digest: digestBytes(reviewRelative, content),
}},
})
if err != nil {
return agenttask.ReviewResult{}, fmt.Errorf("taskloop: freeze reviewed change set: %w", err)
}
identity := changeSet.Identity()
result.ChangeSet = &identity
return result, nil
}
func (reviewer *Reviewer) taskView(work agenttask.WorkRecord) (string, error) {
return retainedArtifactResolver{backend: reviewer.backend}.ArtifactRoot(work)
}
func (reviewer *Reviewer) overlayDescriptor(
work agenttask.WorkRecord,
) (agentguard.IsolationDescriptor, error) {
return loadOverlayDescriptor(reviewer.backend, work)
}
func loadOverlayDescriptor(
backend retainedIsolationBackend,
work agenttask.WorkRecord,
) (agentguard.IsolationDescriptor, error) {
descriptor, _, err := loadOverlayState(backend, work)
return descriptor, err
}
func loadOverlayState(
backend retainedIsolationBackend,
work agenttask.WorkRecord,
) (agentguard.IsolationDescriptor, agentworkspace.OverlayRecord, error) {
if backend == nil {
return agentguard.IsolationDescriptor{}, agentworkspace.OverlayRecord{},
errors.New("taskloop: retained workspace backend is unavailable")
}
if work.Isolation == nil || work.Isolation.ID == "" || work.Isolation.TaskRoot == "" {
return agentguard.IsolationDescriptor{}, agentworkspace.OverlayRecord{},
errors.New("taskloop: reviewed work has no retained isolation")
}
recordPath := filepath.Join(work.Isolation.TaskRoot, "overlay.json")
content, err := os.ReadFile(recordPath)
if err != nil {
return agentguard.IsolationDescriptor{}, agentworkspace.OverlayRecord{},
fmt.Errorf("taskloop: read retained overlay: %w", err)
}
var record agentworkspace.OverlayRecord
if err := decodeStrictJSON(content, &record); err != nil {
return agentguard.IsolationDescriptor{}, agentworkspace.OverlayRecord{},
fmt.Errorf("taskloop: decode retained overlay: %w", err)
}
descriptor := agentguard.IsolationDescriptor{
ID: record.IsolationID,
Revision: record.Revision,
Mode: record.Mode,
BaseRoot: record.CanonicalRoot,
TaskRoot: filepath.Dir(record.Locator.OverlayRecord),
WorkingDir: record.Locator.ViewRoot,
WritableRoots: []string{record.Locator.ViewRoot, record.Locator.TempRoot, record.Locator.CacheRoot},
PinnedBaseRevision: record.SnapshotRevision,
ConfinementRevision: record.ConfinementRevision,
}
if record.ProjectID == "" ||
record.WorkUnitID != string(work.Unit.ID) ||
record.AttemptID != string(work.AttemptID) ||
record.IsolationID != work.Isolation.ID ||
record.Revision != work.Isolation.Revision {
return agentguard.IsolationDescriptor{}, agentworkspace.OverlayRecord{},
errors.New("taskloop: retained overlay identity mismatch")
}
loaded, err := backend.LoadRecord(descriptor)
if err != nil {
return agentguard.IsolationDescriptor{}, agentworkspace.OverlayRecord{}, err
}
if !reflect.DeepEqual(loaded, record) {
return agentguard.IsolationDescriptor{}, agentworkspace.OverlayRecord{},
errors.New("taskloop: retained overlay record identity mismatch")
}
return descriptor, record, nil
}
var errVerdictMissing = errors.New("taskloop: official review verdict is missing")
func readReviewVerdict(path string) (agenttask.ReviewVerdict, string, error) {
content, err := os.ReadFile(path)
if err != nil {
return "", "", err
}
if len(content) == 0 || len(content) > maxWorkflowArtifactBytes {
return "", "", errors.New("taskloop: official review artifact is not bounded")
}
lines := strings.Split(string(content), "\n")
heading := -1
for index, line := range lines {
if strings.TrimSpace(line) == "## Code Review Result" {
heading = index
}
}
if heading < 0 {
return "", "", errVerdictMissing
}
var values []string
for _, line := range lines[heading+1:] {
text := strings.TrimSpace(line)
if strings.HasPrefix(text, "## ") {
break
}
if strings.HasPrefix(text, "-") {
text = strings.TrimSpace(strings.TrimPrefix(text, "-"))
}
field, value, ok := strings.Cut(text, ":")
if ok && strings.TrimSpace(field) == "Overall Verdict" {
values = append(values, strings.TrimSpace(value))
}
}
if len(values) == 0 {
return "", "", errVerdictMissing
}
if len(values) != 1 {
return "", "", errors.New("taskloop: official review verdict is ambiguous")
}
switch values[0] {
case "PASS":
return agenttask.ReviewVerdictPass, "official review passed", nil
case "WARN":
return agenttask.ReviewVerdictWarn, "official review requested follow-up", nil
case "FAIL":
return agenttask.ReviewVerdictFail, "official review failed", nil
case "USER_REVIEW":
return agenttask.ReviewVerdictUserReview, "official review requires user review", nil
default:
return "", "", errors.New("taskloop: official review verdict is invalid")
}
}
// CatalogReviewExecutor rehydrates the retained isolation and starts the
// official reviewer only through that exact executable confinement proof.
type CatalogReviewExecutor struct {
catalog agentconfig.Catalog
backend retainedIsolationBackend
}
func NewCatalogReviewExecutor(
catalogConfig agentconfig.Catalog,
backend retainedIsolationBackend,
) (*CatalogReviewExecutor, error) {
if backend == nil {
return nil, errors.New("taskloop: official review requires a retained isolation backend")
}
normalized, err := agentconfig.Normalize(catalogConfig)
if err != nil {
return nil, err
}
return &CatalogReviewExecutor{catalog: normalized, backend: backend}, nil
}
func (executor *CatalogReviewExecutor) ExecuteReview(
ctx context.Context,
request agenttask.ReviewRequest,
workspace, planRelative, reviewRelative string,
) error {
if request.Work.Target == nil {
return errors.New("taskloop: official review has no selected provider target")
}
prepared, descriptor, err := rehydrateRetainedIsolation(
ctx,
executor.backend,
executor.catalog,
request.Project,
request.Work,
)
if err != nil {
return err
}
if workspace != descriptor.WorkingDir {
return errors.New("taskloop: official review workspace is not the retained view")
}
resolved, ok := executor.catalog.ResolveProfile(request.Work.Target.ProfileID)
if !ok {
return errors.New("taskloop: official review profile is not declared")
}
prompt := officialReviewPrompt(planRelative, reviewRelative)
command, _, err := prepareFreshCatalogCommand(
resolved,
*request.Work.Target,
prepared.Confinement.Binding(),
descriptor.WorkingDir,
prompt,
request.Project,
request.Work,
"official-review",
)
if err != nil {
return err
}
return invokeConfined(ctx, prepared.Confinement, command)
}
func officialReviewPrompt(plan, review string) string {
return fmt.Sprintf(
"This is a bounded official-review step owned by the task loop, not task "+
"finalization. Review %s against %s without implementing changes or "+
"invoking a task-loop dispatcher. Do not modify review-only checklists, "+
"write completion metadata, update a roadmap, or archive, move, rename, "+
"or delete either active artifact. Append only one `## Code Review Result` "+
"section to the existing review artifact, with exactly one "+
"`Overall Verdict:` field whose value is PASS, WARN, FAIL, or USER_REVIEW.",
plan,
review,
)
}
func rehydrateRetainedIsolation(
ctx context.Context,
backend retainedIsolationBackend,
catalog agentconfig.Catalog,
project agenttask.ProjectRecord,
work agenttask.WorkRecord,
) (agenttask.PreparedIsolation, agentguard.IsolationDescriptor, error) {
if work.Target == nil {
return agenttask.PreparedIsolation{}, agentguard.IsolationDescriptor{},
errors.New("taskloop: retained work has no selected target")
}
descriptor, record, err := loadOverlayState(backend, work)
if err != nil {
return agenttask.PreparedIsolation{}, agentguard.IsolationDescriptor{}, err
}
if record.ProjectID != string(project.ProjectID) ||
record.WorkspaceID != string(project.WorkspaceID) ||
record.ConfigRevision != string(work.Target.ConfigRevision) ||
record.ProfileRevision != work.Target.ProfileRevision {
return agenttask.PreparedIsolation{}, agentguard.IsolationDescriptor{},
errors.New("taskloop: retained overlay policy identity mismatch")
}
resolved, ok := catalog.ResolveProfile(work.Target.ProfileID)
if !ok || validateCatalogTarget(resolved, *work.Target) != nil {
return agenttask.PreparedIsolation{}, agentguard.IsolationDescriptor{},
errors.New("taskloop: retained target does not match the catalog")
}
prepared, err := backend.Prepare(ctx, agenttask.IsolationRequest{
Project: project,
Work: work,
Target: *work.Target,
IdempotencyKey: record.IdempotencyKey,
})
if err != nil {
return agenttask.PreparedIsolation{}, agentguard.IsolationDescriptor{}, err
}
if prepared.Descriptor == nil ||
!reflect.DeepEqual(*prepared.Descriptor, descriptor) ||
!reflect.DeepEqual(prepared.Profile, guardProfile(resolved)) ||
prepared.Confinement == nil {
return agenttask.PreparedIsolation{}, agentguard.IsolationDescriptor{},
errors.New("taskloop: rehydrated isolation identity mismatch")
}
binding := prepared.Confinement.Binding()
if prepared.Confinement.Revision() != record.ConfinementRevision ||
binding.Revision != record.ConfinementRevision ||
binding.IsolationID != record.IsolationID ||
binding.IsolationRevision != record.Revision ||
binding.PinnedBaseRevision != record.SnapshotRevision ||
binding.ConfigRevision != record.ConfigRevision ||
binding.GrantRevision != record.GrantRevision ||
binding.ProfileRevision != record.ProfileRevision ||
binding.BaseRoot != record.CanonicalRoot ||
binding.SnapshotRoot != record.Locator.SnapshotRoot ||
binding.TaskRoot != descriptor.TaskRoot ||
binding.WorkingDir != descriptor.WorkingDir ||
!reflect.DeepEqual(binding.WritableRoots, descriptor.WritableRoots) {
return agenttask.PreparedIsolation{}, agentguard.IsolationDescriptor{},
errors.New("taskloop: rehydrated confinement binding mismatch")
}
if err := prepared.Confinement.Validate(binding); err != nil {
return agenttask.PreparedIsolation{}, agentguard.IsolationDescriptor{},
fmt.Errorf("taskloop: validate retained confinement: %w", err)
}
return prepared, descriptor, nil
}