Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 11 additions & 11 deletions demo/substack-spec-v01.dot

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion internal/attractor/engine/cli_only_models.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import "strings"
// cliOnlyModelIDs lists models that MUST route through CLI backend regardless
// of provider backend configuration. These models have no API endpoint.
var cliOnlyModelIDs = map[string]bool{
"gpt-5.4-spark": true,
"gpt-5.3-codex-spark": true,
"gpt-5.4-spark": true,
}

// isCLIOnlyModel returns true if the given model ID (with or without provider
Expand Down
2 changes: 1 addition & 1 deletion internal/attractor/engine/cli_only_models_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ func TestIsCLIOnlyModel(t *testing.T) {
want bool
}{
{"gpt-5.4-spark", true},
{"GPT-5.3-CODEX-SPARK", true}, // case-insensitive
{"GPT-5.3-CODEX-SPARK", true}, // case-insensitive
{"openai/gpt-5.4-spark", true}, // with provider prefix
{"gpt-5.4", false}, // regular codex
{"gpt-5.4", false},
Expand Down
4 changes: 2 additions & 2 deletions internal/attractor/engine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -1223,7 +1223,7 @@ func (e *Engine) executeWithRetry(ctx context.Context, node *model.Node, retries
_ = writeJSON(filepath.Join(stageDir, "status.json"), fo)
return fo, nil
}
if out.Status == runtime.StatusSuccess || out.Status == runtime.StatusPartialSuccess || out.Status == runtime.StatusSkipped {
if out.Status == runtime.StatusSuccess || out.Status == runtime.StatusDegradedSuccess || out.Status == runtime.StatusPartialSuccess || out.Status == runtime.StatusSkipped {
retries[node.ID] = 0
return out, nil
}
Expand Down Expand Up @@ -1957,7 +1957,7 @@ func checkGoalGates(g *model.Graph, outcomes map[string]runtime.Outcome) (bool,
if !strings.EqualFold(n.Attr("goal_gate", "false"), "true") {
continue
}
if out.Status != runtime.StatusSuccess && out.Status != runtime.StatusPartialSuccess {
if out.Status != runtime.StatusSuccess && out.Status != runtime.StatusDegradedSuccess && out.Status != runtime.StatusPartialSuccess {
return false, id
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@ ${{.StageStatusFallbackPathEnvKey}} = {{.FallbackPath}}
- Write your status JSON to ${{.StageStatusPathEnvKey}}. If that write fails, use run-scoped fallback ${{.StageStatusFallbackPathEnvKey}}.
- Do not write status.json to nested module directories.
- To end this stage: write the status file, then return your response. Do NOT call close_agent or any session-management tool.
- Verification reporting: if you ran verification commands (tests, linters, type checks) and any failed or were blocked by infra issues (missing tools, DNS errors, network failures), you MUST set status to "degraded_success" instead of "success" and include a "verification" object:
{"status": "degraded_success", "verification": {"status": "blocked", "blocked_reason": "npm registry DNS failure", "commands": [{"command": "npx tsc", "exit_code": 1, "blocked": true, "reason": "EAI_AGAIN"}]}, ...}
- Use "success" only when all verification commands actually executed and passed, or when no verification was needed.
43 changes: 31 additions & 12 deletions internal/attractor/runtime/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,20 @@ import (
type StageStatus string

const (
StatusSuccess StageStatus = "success"
StatusPartialSuccess StageStatus = "partial_success"
StatusRetry StageStatus = "retry"
StatusFail StageStatus = "fail"
StatusSkipped StageStatus = "skipped"
StatusSuccess StageStatus = "success"
StatusDegradedSuccess StageStatus = "degraded_success"
StatusPartialSuccess StageStatus = "partial_success"
StatusRetry StageStatus = "retry"
StatusFail StageStatus = "fail"
StatusSkipped StageStatus = "skipped"
)

func ParseStageStatus(s string) (StageStatus, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "success", "ok":
return StatusSuccess, nil
case "degraded_success", "degradedsuccess", "degraded-success":
return StatusDegradedSuccess, nil
case "partial_success", "partialsuccess", "partial-success":
return StatusPartialSuccess, nil
case "retry":
Expand Down Expand Up @@ -49,20 +52,36 @@ func (s StageStatus) Valid() bool {
// (success, partial_success, retry, fail, skipped) rather than a custom routing value.
func (s StageStatus) IsCanonical() bool {
switch s {
case StatusSuccess, StatusPartialSuccess, StatusRetry, StatusFail, StatusSkipped:
case StatusSuccess, StatusDegradedSuccess, StatusPartialSuccess, StatusRetry, StatusFail, StatusSkipped:
return true
default:
return false
}
}

// VerificationResult captures the outcome of verification commands run during a stage.
type VerificationResult struct {
Status string `json:"status"` // "passed", "failed", or "blocked"
BlockedReason string `json:"blocked_reason,omitempty"` // why verification could not run
Commands []VerificationEntry `json:"commands,omitempty"` // individual command results
}

// VerificationEntry records the result of a single verification command.
type VerificationEntry struct {
Command string `json:"command"`
ExitCode int `json:"exit_code"`
Blocked bool `json:"blocked,omitempty"`
Reason string `json:"reason,omitempty"`
}

type Outcome struct {
Status StageStatus `json:"status"`
PreferredLabel string `json:"preferred_label,omitempty"`
SuggestedNextIDs []string `json:"suggested_next_ids,omitempty"`
ContextUpdates map[string]any `json:"context_updates,omitempty"`
Notes string `json:"notes,omitempty"`
FailureReason string `json:"failure_reason,omitempty"`
Status StageStatus `json:"status"`
PreferredLabel string `json:"preferred_label,omitempty"`
SuggestedNextIDs []string `json:"suggested_next_ids,omitempty"`
ContextUpdates map[string]any `json:"context_updates,omitempty"`
Notes string `json:"notes,omitempty"`
FailureReason string `json:"failure_reason,omitempty"`
Verification *VerificationResult `json:"verification,omitempty"`
// Details is optional structured information for failures (or for debugging).
// The engine does not use it for routing, but it must be preserved when present.
Details any `json:"details,omitempty"`
Expand Down
72 changes: 72 additions & 0 deletions internal/attractor/runtime/status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,15 @@ func TestParseStageStatus_CanonicalAndLegacy(t *testing.T) {
want StageStatus
}{
{"success", StatusSuccess},
{"degraded_success", StatusDegradedSuccess},
{"partial_success", StatusPartialSuccess},
{"retry", StatusRetry},
{"fail", StatusFail},
{"skipped", StatusSkipped},
// Compatibility aliases.
{"ok", StatusSuccess},
{"degraded-success", StatusDegradedSuccess},
{"degradedsuccess", StatusDegradedSuccess},
{"error", StatusFail},
{"SUCCESS", StatusSuccess},
{"FAIL", StatusFail},
Expand Down Expand Up @@ -68,6 +71,9 @@ func TestStageStatus_IsCanonical(t *testing.T) {
if !StatusSuccess.IsCanonical() {
t.Fatalf("StatusSuccess should be canonical")
}
if !StatusDegradedSuccess.IsCanonical() {
t.Fatalf("StatusDegradedSuccess should be canonical")
}
if !StatusFail.IsCanonical() {
t.Fatalf("StatusFail should be canonical")
}
Expand Down Expand Up @@ -164,6 +170,72 @@ func TestDecodeOutcomeJSON_LegacyFailDetails_PopulatesFailureReason(t *testing.T
}
}

func TestDecodeOutcomeJSON_DegradedSuccessWithVerification(t *testing.T) {
input := `{
"status": "degraded_success",
"notes": "implementation complete but tsc blocked by DNS failure",
"verification": {
"status": "blocked",
"blocked_reason": "npm registry DNS failure",
"commands": [
{"command": "npx tsc", "exit_code": 1, "blocked": true, "reason": "EAI_AGAIN"}
]
}
}`
o, err := DecodeOutcomeJSON([]byte(input))
if err != nil {
t.Fatalf("DecodeOutcomeJSON: %v", err)
}
if o.Status != StatusDegradedSuccess {
t.Fatalf("status: got %q want %q", o.Status, StatusDegradedSuccess)
}
if o.Verification == nil {
t.Fatal("expected non-nil verification")
}
if o.Verification.Status != "blocked" {
t.Fatalf("verification status: got %q want %q", o.Verification.Status, "blocked")
}
if o.Verification.BlockedReason != "npm registry DNS failure" {
t.Fatalf("verification blocked_reason: got %q", o.Verification.BlockedReason)
}
if len(o.Verification.Commands) != 1 {
t.Fatalf("expected 1 verification command, got %d", len(o.Verification.Commands))
}
cmd := o.Verification.Commands[0]
if cmd.Command != "npx tsc" || cmd.ExitCode != 1 || !cmd.Blocked {
t.Fatalf("unexpected verification command: %+v", cmd)
}
}

func TestDecodeOutcomeJSON_SuccessWithPassedVerification(t *testing.T) {
input := `{
"status": "success",
"verification": {
"status": "passed",
"commands": [
{"command": "go test ./...", "exit_code": 0},
{"command": "go vet ./...", "exit_code": 0}
]
}
}`
o, err := DecodeOutcomeJSON([]byte(input))
if err != nil {
t.Fatalf("DecodeOutcomeJSON: %v", err)
}
if o.Status != StatusSuccess {
t.Fatalf("status: got %q want %q", o.Status, StatusSuccess)
}
if o.Verification == nil {
t.Fatal("expected non-nil verification")
}
if o.Verification.Status != "passed" {
t.Fatalf("verification status: got %q want %q", o.Verification.Status, "passed")
}
if len(o.Verification.Commands) != 2 {
t.Fatalf("expected 2 verification commands, got %d", len(o.Verification.Commands))
}
}

func TestDecodeOutcomeJSON_LegacyRetryDetails_PopulatesFailureReason(t *testing.T) {
o, err := DecodeOutcomeJSON([]byte(`{"outcome":"retry","details":"transient timeout"}`))
if err != nil {
Expand Down
Loading