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:
@@ -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).
|
NEXT: the single next action (one line, at most 200 characters).
|
||||||
WHY: why that is next (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.
|
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.
|
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.
|
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)
|
return out, fmt.Errorf("DEAD ENDS: %w", err)
|
||||||
}
|
}
|
||||||
for _, item := range deadEnds {
|
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)
|
parts := strings.SplitN(item, "→", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
parts = strings.SplitN(item, "->", 2)
|
||||||
|
}
|
||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
return out, fmt.Errorf("DEAD ENDS: want 'tried X → failed because Y'")
|
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'")
|
return out, fmt.Errorf("DEAD ENDS: want 'tried X → failed because Y'")
|
||||||
}
|
}
|
||||||
tried := strings.TrimSpace(strings.TrimPrefix(parts[0], "tried "))
|
tried := strings.TrimSpace(strings.TrimPrefix(left, "tried "))
|
||||||
why := strings.TrimSpace(strings.TrimPrefix(parts[1], "failed because "))
|
why := strings.TrimSpace(strings.TrimPrefix(right, "failed because "))
|
||||||
if tried == "" || why == "" {
|
if tried == "" || why == "" {
|
||||||
return out, fmt.Errorf("DEAD ENDS: want 'tried X → failed because Y'")
|
return out, fmt.Errorf("DEAD ENDS: want 'tried X → failed because Y'")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user