Reconcile docs with reality; fix module graph, token compare, health #1
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user