diff --git a/internal/herdr/adapter.go b/internal/herdr/adapter.go index 7113d62..199f790 100644 --- a/internal/herdr/adapter.go +++ b/internal/herdr/adapter.go @@ -198,7 +198,7 @@ const handoffPrompt = `Orchestra is about to rotate this task. Write ONLY the fo NEXT: the single next action (one line, at most 200 characters). WHY: why that is next (one line, at most 200 characters). REMAINING: outstanding items, one line each, at most 200 characters each. If none: NONE. -DEAD ENDS: approaches tried that failed — "tried X → failed because Y", one per line. If none: NONE. +DEAD ENDS: approaches tried that failed — "tried X → failed because Y", one per line ("->" is also accepted). If none: NONE. OPEN Q: unresolved decisions, one line each. If none: NONE. LEARNED: constraints discovered that are NOT in TASK.md, one line each. If none: NONE. @@ -500,15 +500,23 @@ func parseHandoffAnswer(answer string) (handoffAnswer, error) { return out, fmt.Errorf("DEAD ENDS: %w", err) } for _, item := range deadEnds { + // Accept the ASCII arrow as well as the typographic one. Run 9 lost a + // lease to this: the agent wrote "->", the content was exactly right, + // and the refused handoff failed the release rather than the turn. + // A separator is not the thing being validated. parts := strings.SplitN(item, "→", 2) + if len(parts) != 2 { + parts = strings.SplitN(item, "->", 2) + } if len(parts) != 2 { return out, fmt.Errorf("DEAD ENDS: want 'tried X → failed because Y'") } - if !strings.HasPrefix(parts[0], "tried ") || !strings.HasPrefix(strings.TrimSpace(parts[1]), "failed because ") { + left, right := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) + if !strings.HasPrefix(left, "tried ") || !strings.HasPrefix(right, "failed because ") { return out, fmt.Errorf("DEAD ENDS: want 'tried X → failed because Y'") } - tried := strings.TrimSpace(strings.TrimPrefix(parts[0], "tried ")) - why := strings.TrimSpace(strings.TrimPrefix(parts[1], "failed because ")) + tried := strings.TrimSpace(strings.TrimPrefix(left, "tried ")) + why := strings.TrimSpace(strings.TrimPrefix(right, "failed because ")) if tried == "" || why == "" { return out, fmt.Errorf("DEAD ENDS: want 'tried X → failed because Y'") } diff --git a/internal/herdr/adapter_test.go b/internal/herdr/adapter_test.go index 63d9d4e..b850dab 100644 --- a/internal/herdr/adapter_test.go +++ b/internal/herdr/adapter_test.go @@ -365,3 +365,31 @@ func TestLastObservedCommandSkipsOrchestrasOwnHandoffWrite(t *testing.T) { } } } + +// Run 9 lost a lease because the agent wrote "->" instead of "→". The content +// was exactly right, the handoff was refused, and a refused handoff fails the +// release rather than the turn. +func TestDeadEndAcceptsEitherArrowAndStripsThePrefixes(t *testing.T) { + for _, arrow := range []string{"→", "->"} { + a, err := parseHandoffAnswer(`NEXT: inspect the failing integration test +WHY: isolate the regression before changing production code +REMAINING: fix the assertion after identifying the cause +DEAD ENDS: tried finding ids "F1".."F8" ` + arrow + ` failed because the schema requires lowercase +OPEN Q: whether the remote worker has the updated fixture +LEARNED: the fixture requires a committed scratch branch +`) + if err != nil { + t.Fatalf("arrow %q: %v", arrow, err) + } + if len(a.DeadEnds) != 1 { + t.Fatalf("arrow %q: parsed %d dead ends", arrow, len(a.DeadEnds)) + } + d := a.DeadEnds[0] + if d.Tried != `finding ids "F1".."F8"` { + t.Errorf("arrow %q: tried = %q", arrow, d.Tried) + } + if d.WhyFailed != "the schema requires lowercase" { + t.Errorf("arrow %q: why_failed = %q", arrow, d.WhyFailed) + } + } +}