Why: F05 - files-presence could promote failures to success, recovery marked crashed steps completed on files alone or reran them blindly, and retries minted a fresh ExecutionID each attempt. Evidence: squad tests pass incl. executor result contract and recovery tables (verify-u3b.json); (nil,nil), canceled and side-effect-possible semantics handled; interrupted steps without a trusted terminal now hold for reconciliation instead of auto-advancing. Impact: retries reuse the step ExecutionID and shared deadline; no auto-replay of unverified external actions. Refs: feature/agent-stack-audit-remediation-b1 U3B
213 lines
6.5 KiB
Go
213 lines
6.5 KiB
Go
package squad
|
||
|
||
import (
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"unicode"
|
||
)
|
||
|
||
// MaxTransientStepInvocations is the product-layer bound: the first
|
||
// invocation plus one fresh re-invocation. SDK in-process auto_retry is
|
||
// separate and must not be confused with this.
|
||
const MaxTransientStepInvocations = 2
|
||
|
||
type FailureClass int
|
||
|
||
const (
|
||
FailureNone FailureClass = iota
|
||
FailureTransient
|
||
FailurePermanent
|
||
)
|
||
|
||
func (c FailureClass) String() string {
|
||
switch c {
|
||
case FailureTransient:
|
||
return "transient"
|
||
case FailurePermanent:
|
||
return "permanent"
|
||
default:
|
||
return "none"
|
||
}
|
||
}
|
||
|
||
type TraceSignals struct {
|
||
ProviderTransient bool
|
||
Settled bool
|
||
RetryExhausted bool
|
||
HasUsableDeliverable bool // always false: write() args in traces are not deliverables
|
||
}
|
||
|
||
type StepFailureSignals struct {
|
||
Err error
|
||
Result *ExecResult
|
||
WorkDir string
|
||
ExtraText string
|
||
TargetMissing bool
|
||
// TargetExists means this invocation wrote a non-empty file that differs
|
||
// from the pre-invoke baseline. Do not retry or overwrite it. The step
|
||
// still fails unless the backend already reported a trusted success.
|
||
TargetExists bool
|
||
}
|
||
|
||
type FailureAssessment struct {
|
||
Class FailureClass
|
||
Reason string
|
||
}
|
||
|
||
func ScanTraceText(text string) TraceSignals {
|
||
var out TraceSignals
|
||
if text == "" {
|
||
return out
|
||
}
|
||
// Line scan only. Never parse write.arguments.content into a body.
|
||
for _, line := range strings.Split(text, "\n") {
|
||
if line == "" {
|
||
continue
|
||
}
|
||
lower := strings.ToLower(line)
|
||
if strings.Contains(lower, `"type":"agent_settled"`) || strings.Contains(lower, `"type": "agent_settled"`) {
|
||
out.Settled = true
|
||
}
|
||
if strings.Contains(lower, `"type":"auto_retry_end"`) && strings.Contains(lower, `"success":false`) {
|
||
out.ProviderTransient = true
|
||
out.RetryExhausted = true
|
||
}
|
||
if strings.Contains(lower, `"type":"auto_retry_start"`) {
|
||
out.ProviderTransient = true
|
||
}
|
||
if strings.Contains(lower, `"stopreason":"error"`) {
|
||
out.ProviderTransient = true
|
||
}
|
||
if strings.Contains(lower, "connection error") {
|
||
out.ProviderTransient = true
|
||
}
|
||
if strings.Contains(lower, `"errormessage":"terminated"`) || strings.Contains(lower, `"errormessage": "terminated"`) {
|
||
out.ProviderTransient = true
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func ReadWorkDirTraces(workDir string) string {
|
||
if strings.TrimSpace(workDir) == "" {
|
||
return ""
|
||
}
|
||
p := filepath.Join(workDir, "execution.log")
|
||
b, err := os.ReadFile(p)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
const capBytes = 512 << 10
|
||
if len(b) > capBytes {
|
||
b = b[len(b)-capBytes:]
|
||
}
|
||
return string(b)
|
||
}
|
||
|
||
func AssessStepFailure(sig StepFailureSignals) FailureAssessment {
|
||
reason := combinedFailureText(sig)
|
||
lower := strings.ToLower(reason)
|
||
trace := ScanTraceText(sig.ExtraText + "\n" + ReadWorkDirTraces(sig.WorkDir))
|
||
missing := sig.TargetMissing || strings.Contains(lower, "missing target deliverable")
|
||
// F05/KTD4:explicit SideEffects=none 报告 = provably-not-dispatched。
|
||
// 缺则按 possible 对待(与旧语义一致)。这是 caller 自己传达的可重试信令。
|
||
provablyNotDispatched := sig.Result != nil && sig.Result.SideEffects == ExecSideEffectsNone
|
||
if sig.TargetExists {
|
||
return FailureAssessment{Class: FailurePermanent, Reason: "target-already-present: do not overwrite"}
|
||
}
|
||
if isPermanentReason(lower) {
|
||
return FailureAssessment{Class: FailurePermanent, Reason: "permanent: " + compactReason(reason)}
|
||
}
|
||
|
||
runnerFailed := (sig.Result != nil && sig.Result.Retryable) || isRunnerExplicitFailure(lower)
|
||
provider := trace.ProviderTransient || hasProviderTermOrConn(lower)
|
||
if runnerFailed {
|
||
return FailureAssessment{Class: FailureTransient, Reason: "runner-explicit-failure: " + compactReason(reason)}
|
||
}
|
||
if provider && missing {
|
||
kind := "provider-transient-missing-target"
|
||
if trace.Settled {
|
||
kind = "settled-failure-missing-target"
|
||
}
|
||
return FailureAssessment{Class: FailureTransient, Reason: kind + ": " + compactReason(reason)}
|
||
}
|
||
// Model settled cleanly but never wrote the target (e.g. text-only reply,
|
||
// no write tool). One product-layer re-invocation with a corrective prompt
|
||
// is worth trying; still bounded by MaxTransientStepInvocations.
|
||
if missing && trace.Settled && !provider {
|
||
return FailureAssessment{Class: FailureTransient, Reason: "settled-no-deliverable: " + compactReason(reason)}
|
||
}
|
||
// F05/U3b:SideEffects=none 失败原则上可重试(后端已声明未发生外部动作);
|
||
// 跨越该含义补一条有界重试通路,并覆盖纯获取后失败但文件已拼起的边界。
|
||
if provablyNotDispatched && !provider {
|
||
kind := "provably-not-dispatched"
|
||
if missing {
|
||
kind = "provably-not-dispatched-missing-target"
|
||
}
|
||
return FailureAssessment{Class: FailureTransient, Reason: kind + ": " + compactReason(reason)}
|
||
}
|
||
if missing {
|
||
return FailureAssessment{Class: FailurePermanent, Reason: "permanent-missing: " + compactReason(reason)}
|
||
}
|
||
return FailureAssessment{Class: FailurePermanent, Reason: "fail-closed: " + compactReason(reason)}
|
||
}
|
||
|
||
func combinedFailureText(sig StepFailureSignals) string {
|
||
var parts []string
|
||
if sig.Err != nil {
|
||
parts = append(parts, sig.Err.Error())
|
||
}
|
||
if sig.Result != nil {
|
||
if sig.Result.ErrorReason != "" {
|
||
parts = append(parts, sig.Result.ErrorReason)
|
||
}
|
||
if sig.Result.Logs != "" {
|
||
parts = append(parts, sig.Result.Logs)
|
||
}
|
||
}
|
||
return strings.Join(parts, " ")
|
||
}
|
||
|
||
func isPermanentReason(lower string) bool {
|
||
return strings.Contains(lower, "prompt_empty") ||
|
||
strings.Contains(lower, "unauthorized") ||
|
||
strings.Contains(lower, "budget_exceeded") ||
|
||
strings.Contains(lower, "治理强制终止") ||
|
||
strings.Contains(lower, "治理拦截")
|
||
}
|
||
|
||
func hasProviderTermOrConn(lower string) bool {
|
||
return strings.Contains(lower, "connection error") ||
|
||
strings.Contains(lower, "terminated") ||
|
||
strings.Contains(lower, "econnreset") ||
|
||
strings.Contains(lower, "socket hang up")
|
||
}
|
||
|
||
func isRunnerExplicitFailure(lower string) bool {
|
||
return strings.Contains(lower, "pi remote runner call failed") ||
|
||
strings.Contains(lower, "auto_retry_failed") ||
|
||
strings.Contains(lower, "auto_retry_end") ||
|
||
(hasProviderTermOrConn(lower) && !strings.Contains(lower, "missing target deliverable"))
|
||
}
|
||
|
||
func compactReason(s string) string {
|
||
s = strings.Join(strings.FieldsFunc(s, func(r rune) bool {
|
||
return r == '\n' || r == '\r'
|
||
}), " ")
|
||
s = strings.TrimSpace(s)
|
||
if s == "" {
|
||
return "unknown"
|
||
}
|
||
// Drop accidental write-argument bodies if a caller stuffed logs here.
|
||
if len(s) > 240 {
|
||
s = s[:240]
|
||
}
|
||
return strings.Map(func(r rune) rune {
|
||
if unicode.IsControl(r) && r != '\t' {
|
||
return -1
|
||
}
|
||
return r
|
||
}, s)
|
||
}
|