From 65230020b966e3cc5f548a033a9f99467c2b36b4 Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 28 Aug 2026 01:00:04 +0400 Subject: [PATCH] Budget the handoff action for the two lines it is made of MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseHandoffAnswer joins the agent's NEXT and WHY answers with " — ", and the prompt asks for a sentence each without naming any budget. Validate then held that join to one authored line's 200 characters. Two ordinary sentences do not fit, so every rotation failed. The failure was invisible twice over. The message said "prose smuggled into list", which named a branch the answer cannot reach: parseHandoffAnswer splits on newlines and trims, so no authored field ever contains "\n#". The only reachable cause was length, and the agent was never told what to shorten. Seen live on two tasks, and it left the release transaction stuck at "prepared" that pinned workpc-claude's only session slot (F30). Give Action the budget of both lines, name the length in the error, and put the limit in the prompt the agent actually reads. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011xsXyr5J1RACo71YeKG3Pu --- internal/continuity/continuity.go | 21 ++++++++++++-- internal/continuity/continuity_test.go | 40 ++++++++++++++++++++++++++ internal/herdr/adapter.go | 6 ++-- 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/internal/continuity/continuity.go b/internal/continuity/continuity.go index 6358932..446c5e2 100644 --- a/internal/continuity/continuity.go +++ b/internal/continuity/continuity.go @@ -136,6 +136,13 @@ var reasons = map[string]bool{"threshold": true, "milestone": true, "thrash": tr const maxAuthoredLine = 200 +// Action is not one authored line. parseHandoffAnswer joins the agent's NEXT +// and WHY answers with " — ", and the prompt asks for a sentence each without +// naming any budget. Two ordinary sentences cleared 200 characters and every +// rotation on workpc failed at "prose smuggled into list" (F31). Budget the +// joined field for the two lines it is actually made of. +const maxAuthoredAction = 2*maxAuthoredLine + len(" — ") + var circularAction = regexp.MustCompile(`(?i)handoff|report\.md|^continue the task`) var circularCommand = regexp.MustCompile(`(?i)\.orchestra-handoff|handoff-report|report\.md`) @@ -149,7 +156,7 @@ func (h Handoff) Validate() error { if circularAction.MatchString(h.Action) { return errors.New("invalid handoff action: must name concrete next work, not a handoff") } - if err := validateAuthoredLine(h.Action); err != nil { + if err := validateAuthored(h.Action, maxAuthoredAction); err != nil { return err } if circularCommand.MatchString(h.Command) { @@ -180,12 +187,22 @@ func (h Handoff) Validate() error { } func validateAuthoredLine(s string) error { + return validateAuthored(s, maxAuthoredLine) +} + +// The two rejections used to share one message, which named the cause the +// agent had not hit. An answer over budget was reported as smuggled prose, so +// the agent could not tell what to shorten and retried the same text. +func validateAuthored(s string, limit int) error { if strings.TrimSpace(s) == "" { return errors.New("invalid handoff authored field: empty item") } - if strings.Contains(s, "\n#") || len(s) > maxAuthoredLine { + if strings.Contains(s, "\n#") { return errors.New("invalid handoff authored field: prose smuggled into list") } + if len(s) > limit { + return fmt.Errorf("invalid handoff authored field: %d characters, limit %d", len(s), limit) + } return nil } func Encode(h Handoff) ([]byte, error) { diff --git a/internal/continuity/continuity_test.go b/internal/continuity/continuity_test.go index 5585695..151dcec 100644 --- a/internal/continuity/continuity_test.go +++ b/internal/continuity/continuity_test.go @@ -165,3 +165,43 @@ func TestVerifyTaskFileRejectsMutation(t *testing.T) { t.Fatal("expected immutable task check to fail") } } + +// TestActionCarriesTheBudgetOfBothLinesItIsMadeOf guards F31. Action is not one +// authored line: parseHandoffAnswer joins the agent's NEXT and WHY answers, and +// the prompt asks for a sentence each. Holding the join to a single line's +// budget rejected two ordinary sentences, which failed every rotation on +// workpc at "prose smuggled into list" and pinned the worker slot behind it. +func TestActionCarriesTheBudgetOfBothLinesItIsMadeOf(t *testing.T) { + next := strings.Repeat("a", maxAuthoredLine) + why := strings.Repeat("b", maxAuthoredLine) + h := Handoff{ + Meta: Meta{ID: "h1", Reason: "threshold"}, + Anchor: Anchor{GitSHA: strings.Repeat("0", 40), Branch: "main"}, + Action: next + " — " + why, + } + if err := h.Validate(); err != nil { + t.Fatalf("two full-length answers rejected: %v", err) + } + h.Action = next + " — " + why + "c" + if err := h.Validate(); err == nil { + t.Fatal("an action past both budgets was accepted") + } +} + +// An over-budget field must not be reported as smuggled prose. The agent +// cannot shorten what it is not told is too long, so it retried the same text. +func TestOverBudgetFieldNamesLengthNotProse(t *testing.T) { + h := Handoff{ + Meta: Meta{ID: "h1", Reason: "threshold"}, + Anchor: Anchor{GitSHA: strings.Repeat("0", 40), Branch: "main"}, + Action: "do the next thing", + Remaining: []string{strings.Repeat("x", maxAuthoredLine+1)}, + } + err := h.Validate() + if err == nil { + t.Fatal("over-budget item accepted") + } + if !strings.Contains(err.Error(), "limit") || strings.Contains(err.Error(), "smuggled") { + t.Fatalf("error names the wrong cause: %v", err) + } +} diff --git a/internal/herdr/adapter.go b/internal/herdr/adapter.go index 732d38e..e6a57e4 100644 --- a/internal/herdr/adapter.go +++ b/internal/herdr/adapter.go @@ -195,9 +195,9 @@ func (a CLIAdapter) prompt(ctx context.Context, s Session, text string, wait tim const handoffPrompt = `Orchestra is about to rotate this task. Write ONLY the following labelled answers to ` + HandoffReportFile + `, then stop. Output nothing else. -NEXT: the single next action (one line). -WHY: why that is next (one line). -REMAINING: outstanding items, one line each. If none: NONE. +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. 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.