Stop losing a lease to an arrow character

Run 9's planner wrote a dead end as "tried X -> failed because Y". The content
was exactly what the protocol asks for. The parser split on "→" only, refused
the handoff, and a refused handoff fails the release rather than the turn, so
the lease expired with the work intact and unrecorded.

Accept either arrow. A separator is not the thing being validated.

The prefix stripping was also wrong on the right-hand side: it trimmed
"failed because " from an untrimmed string that started with a space, so every
parsed dead end kept the literal prefix in why_failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
This commit is contained in:
2026-08-28 15:11:06 +04:00
parent 4712c7dc0e
commit 98f1b2dccc
2 changed files with 40 additions and 4 deletions
+12 -4
View File
@@ -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'")
}