298 lines
9.2 KiB
Go
298 lines
9.2 KiB
Go
package taskloop
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
|
|
"iop/packages/go/agentconfig"
|
|
"iop/packages/go/agenttask"
|
|
)
|
|
|
|
const maxWorkflowArtifactBytes = 2 << 20
|
|
|
|
// Evidence applies the same bounded artifact gate to every provider. Pi may
|
|
// receive a repair intent, but repair still requires an explicitly configured
|
|
// native-context executor; this adapter never edits review evidence itself.
|
|
type Evidence struct {
|
|
snapshot agentconfig.RuntimeSnapshot
|
|
roots ArtifactRootResolver
|
|
repair EvidenceRepairer
|
|
}
|
|
|
|
type EvidenceRepairer interface {
|
|
RepairEvidence(context.Context, agenttask.WorkflowEvidenceRepairRequest) error
|
|
}
|
|
|
|
// PiEvidenceRepairer resumes only the exact native session locator persisted
|
|
// for the retained work attempt. It never edits evidence directly; Manager
|
|
// performs a fresh Observe after this confined continuation returns.
|
|
type PiEvidenceRepairer struct {
|
|
catalog agentconfig.Catalog
|
|
backend retainedIsolationBackend
|
|
}
|
|
|
|
func NewPiEvidenceRepairer(
|
|
catalogConfig agentconfig.Catalog,
|
|
backend retainedIsolationBackend,
|
|
) (*PiEvidenceRepairer, error) {
|
|
if backend == nil {
|
|
return nil, errors.New("taskloop: Pi evidence repair requires a retained isolation backend")
|
|
}
|
|
normalized, err := agentconfig.Normalize(catalogConfig)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &PiEvidenceRepairer{catalog: normalized, backend: backend}, nil
|
|
}
|
|
|
|
var _ agenttask.WorkflowEvidence = (*Evidence)(nil)
|
|
|
|
func NewEvidence(
|
|
snapshot agentconfig.RuntimeSnapshot,
|
|
roots ArtifactRootResolver,
|
|
repair EvidenceRepairer,
|
|
) *Evidence {
|
|
return &Evidence{snapshot: snapshot, roots: roots, repair: repair}
|
|
}
|
|
|
|
func (evidence *Evidence) Observe(
|
|
ctx context.Context,
|
|
request agenttask.WorkflowEvidenceRequest,
|
|
) (agenttask.ArtifactEvidence, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return agenttask.ArtifactEvidence{}, err
|
|
}
|
|
registration, ok := evidence.snapshot.Project(string(request.Project.ProjectID))
|
|
if !ok {
|
|
return agenttask.ArtifactEvidence{}, errors.New("taskloop: evidence project is not registered")
|
|
}
|
|
canonicalRoot, err := canonicalDirectory(registration.Workspace)
|
|
if err != nil {
|
|
return agenttask.ArtifactEvidence{}, err
|
|
}
|
|
if WorkspaceIdentity(canonicalRoot) != request.Project.WorkspaceID {
|
|
return agenttask.ArtifactEvidence{}, errors.New("taskloop: evidence workspace identity mismatch")
|
|
}
|
|
if evidence.roots == nil {
|
|
return agenttask.ArtifactEvidence{}, errors.New("taskloop: retained artifact root resolver is unavailable")
|
|
}
|
|
artifactRoot, err := evidence.roots.ArtifactRoot(request.Work)
|
|
if err != nil {
|
|
return agenttask.ArtifactEvidence{}, err
|
|
}
|
|
reviewPath := request.Work.Unit.Metadata["review_path"]
|
|
if reviewPath == "" {
|
|
return agenttask.ArtifactEvidence{}, errors.New("taskloop: review artifact locator is missing")
|
|
}
|
|
inspected, err := inspectReviewArtifact(artifactRoot, reviewPath)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return agenttask.ArtifactEvidence{Active: false}, nil
|
|
}
|
|
return agenttask.ArtifactEvidence{}, err
|
|
}
|
|
identity := agenttask.ArtifactIdentity{
|
|
ProjectID: request.Project.ProjectID,
|
|
WorkspaceID: request.Project.WorkspaceID,
|
|
WorkUnitID: request.Work.Unit.ID,
|
|
AttemptID: request.Work.AttemptID,
|
|
ArtifactID: request.Submission.ArtifactID,
|
|
}
|
|
result := agenttask.ArtifactEvidence{
|
|
Active: true,
|
|
Identity: identity,
|
|
}
|
|
if !inspected.placeholder {
|
|
result.Completeness = agenttask.ArtifactComplete
|
|
return result, nil
|
|
}
|
|
result.Completeness = agenttask.ArtifactPlaceholder
|
|
if request.Work.Target != nil &&
|
|
strings.EqualFold(request.Work.Target.ProviderID, "pi") {
|
|
if locator, ok := request.Work.Locators[agenttask.LocatorSession]; ok {
|
|
result.RepairIntent = &agenttask.EvidenceRepairIntent{
|
|
Identity: identity,
|
|
NativeLocator: locator,
|
|
DispatchOrdinal: request.Work.DispatchOrdinal,
|
|
}
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (evidence *Evidence) Repair(
|
|
ctx context.Context,
|
|
request agenttask.WorkflowEvidenceRepairRequest,
|
|
) error {
|
|
if evidence.repair == nil {
|
|
return errors.New("taskloop: Pi evidence repair executor is not configured")
|
|
}
|
|
return evidence.repair.RepairEvidence(ctx, request)
|
|
}
|
|
|
|
func (repairer *PiEvidenceRepairer) RepairEvidence(
|
|
ctx context.Context,
|
|
request agenttask.WorkflowEvidenceRepairRequest,
|
|
) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if request.Work.Target == nil ||
|
|
!strings.EqualFold(request.Work.Target.ProviderID, "pi") {
|
|
return errors.New("taskloop: evidence repair is restricted to Pi")
|
|
}
|
|
persisted, ok := request.Work.Locators[agenttask.LocatorSession]
|
|
if !ok || persisted.Kind != agenttask.LocatorSession ||
|
|
!reflect.DeepEqual(persisted, request.Intent.NativeLocator) {
|
|
return errors.New("taskloop: Pi repair native session locator mismatch")
|
|
}
|
|
expectedIdentity := agenttask.ArtifactIdentity{
|
|
ProjectID: request.Project.ProjectID,
|
|
WorkspaceID: request.Project.WorkspaceID,
|
|
WorkUnitID: request.Work.Unit.ID,
|
|
AttemptID: request.Work.AttemptID,
|
|
ArtifactID: request.Submission.ArtifactID,
|
|
}
|
|
if !reflect.DeepEqual(request.Intent.Identity, expectedIdentity) ||
|
|
request.Intent.DispatchOrdinal != request.Work.DispatchOrdinal ||
|
|
persisted.ProjectID != request.Project.ProjectID ||
|
|
persisted.WorkspaceID != request.Project.WorkspaceID ||
|
|
persisted.WorkUnitID != request.Work.Unit.ID ||
|
|
persisted.AttemptID != request.Work.AttemptID ||
|
|
persisted.Revision != digestStrings(
|
|
"native-session-locator",
|
|
persisted.Opaque,
|
|
) {
|
|
return errors.New("taskloop: Pi repair intent identity mismatch")
|
|
}
|
|
var reference nativeSessionReference
|
|
if err := decodeStrictJSON([]byte(persisted.Opaque), &reference); err != nil {
|
|
return fmt.Errorf("taskloop: decode Pi native session locator: %w", err)
|
|
}
|
|
prepared, descriptor, err := rehydrateRetainedIsolation(
|
|
ctx,
|
|
repairer.backend,
|
|
repairer.catalog,
|
|
request.Project,
|
|
request.Work,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resolved, ok := repairer.catalog.ResolveProfile(request.Work.Target.ProfileID)
|
|
if !ok {
|
|
return errors.New("taskloop: Pi repair profile is not declared")
|
|
}
|
|
binding := prepared.Confinement.Binding()
|
|
if err := validateNativeSessionReference(
|
|
reference,
|
|
resolved,
|
|
*request.Work.Target,
|
|
binding,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
nativeSessionFile, err := resolveNativeSessionFile(reference)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
planRelative := request.Work.Unit.Metadata["plan_path"]
|
|
reviewRelative := request.Work.Unit.Metadata["review_path"]
|
|
if planRelative == "" || reviewRelative == "" {
|
|
return errors.New("taskloop: Pi repair artifact locators are incomplete")
|
|
}
|
|
prompt := fmt.Sprintf(
|
|
"Continue this exact native session for %s. Complete only the "+
|
|
"implementation-owned evidence fields in %s, keep artifact content "+
|
|
"in English, and stop ready for official review.",
|
|
planRelative,
|
|
reviewRelative,
|
|
)
|
|
command, err := prepareResumeCatalogCommand(
|
|
resolved,
|
|
*request.Work.Target,
|
|
binding,
|
|
descriptor.WorkingDir,
|
|
prompt,
|
|
nativeSessionFile,
|
|
reference,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return invokeConfined(ctx, prepared.Confinement, command)
|
|
}
|
|
|
|
type reviewArtifact struct {
|
|
path string
|
|
placeholder bool
|
|
}
|
|
|
|
func inspectReviewArtifact(root, relative string) (reviewArtifact, error) {
|
|
canonicalRoot, err := canonicalDirectory(root)
|
|
if err != nil {
|
|
return reviewArtifact{}, err
|
|
}
|
|
if filepath.IsAbs(relative) || filepath.Clean(relative) == "." ||
|
|
strings.HasPrefix(filepath.Clean(relative), ".."+string(filepath.Separator)) {
|
|
return reviewArtifact{}, errors.New("taskloop: review artifact must be a contained relative path")
|
|
}
|
|
path := filepath.Join(canonicalRoot, filepath.FromSlash(relative))
|
|
canonicalParent, err := filepath.EvalSymlinks(filepath.Dir(path))
|
|
if err != nil {
|
|
return reviewArtifact{}, err
|
|
}
|
|
relativeParent, err := filepath.Rel(canonicalRoot, canonicalParent)
|
|
if err != nil || relativeParent == ".." ||
|
|
strings.HasPrefix(relativeParent, ".."+string(filepath.Separator)) {
|
|
return reviewArtifact{}, errors.New("taskloop: review artifact escapes the registered workspace")
|
|
}
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return reviewArtifact{}, err
|
|
}
|
|
defer file.Close()
|
|
info, err := file.Stat()
|
|
if err != nil {
|
|
return reviewArtifact{}, err
|
|
}
|
|
if !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > maxWorkflowArtifactBytes {
|
|
return reviewArtifact{}, errors.New("taskloop: review artifact is not one bounded regular file")
|
|
}
|
|
content := make([]byte, info.Size())
|
|
if _, err := io.ReadFull(file, content); err != nil {
|
|
return reviewArtifact{}, err
|
|
}
|
|
return reviewArtifact{
|
|
path: path,
|
|
placeholder: hasImplementationPlaceholder(content),
|
|
}, nil
|
|
}
|
|
|
|
func hasImplementationPlaceholder(content []byte) bool {
|
|
for _, line := range bytes.Split(content, []byte{'\n'}) {
|
|
text := strings.TrimSpace(string(line))
|
|
if strings.HasPrefix(text, "_Paste actual ") ||
|
|
text == "_Record actual deviations or `None`._" ||
|
|
text == "_Record actual decisions._" ||
|
|
strings.Contains(text, "[ ] Fill implementation-owned sections") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func artifactPath(root, relative string) (string, error) {
|
|
artifact, err := inspectReviewArtifact(root, relative)
|
|
if err != nil {
|
|
return "", fmt.Errorf("taskloop: inspect review artifact: %w", err)
|
|
}
|
|
return artifact.path, nil
|
|
}
|