415 lines
14 KiB
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
|
|
}
|