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.