Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/changes/unreleased/2464-node-road-tree-bounds.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
kind: fixed
title: the older task road refuses duplicate work in a tree and holds the tree to one spend total
pr: 2464
surface: [engine, chat, docs]
invalidates:
- "Was true: with CODEAF_TASK_BELT=node two tasks in one tree could hand out the same work, and only each node's own bill was limited. Is true now: duplicate titles are refused tree-wide and the whole tree shares one spend total."
---

The duplicate check uses the plan store's title normalization, so both task roads compare
work the same way. The tree total is the lower of the per-task limit (crew.task_cap) and
the conversation's remaining limit; reaching it stops the whole tree and names the limit.
A typed task starts its own tree. The default road is unchanged.
23 changes: 21 additions & 2 deletions internal/manual/chat/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -2322,15 +2322,18 @@ you set it — and under the day's limit above it. An adaptive run they start op
$100.00 default.

**An ordinary `/task`, or any task codeaf's own worker does, also has a money limit of its own: $5 unless you set another**, shown on the
Spending tab's `per task` row and set with `/crew cap task <$>`. On the run road this is
Spending tab's `per task` row and set with `/crew cap task <$>`. On both task roads this is
one ceiling for the whole tree, including planning and checks. The smaller of that limit
and the conversation's remaining allowance wins, even when no crew was routed.
Continuing interrupted work keeps what the same tree already spent: only the unspent
part of its per-task limit remains available.
A routed crew refuses a call that would cross its per-task limit. Reaching the run's
ceiling ends workers in flight and starts no more. When the per-task limit binds, the
ceiling ends workers in flight and starts no more.
On the node belt, the saved ceiling survives restarts; reaching it refuses each worker's next request, queued workers spend nothing, and in-flight calls finish without cancellation.
When the per-task limit binds, the
report says `this task reached its $5 limit · raise it in /crew` (with your configured
amount). Raise it with `/crew cap task <$>`, then rerun the remaining work with `/task`.
When the conversation's smaller limit binds, the report names that amount and `/settings`.
Continuing a run is for interrupted work; a settled dollar-limit ending needs a rerun.
**senior-dev, sec and review have their own dollar and time ceilings for each run**,
separate from that `per task` row; see each program's page for its defaults and how to
Expand Down Expand Up @@ -4307,6 +4310,22 @@ splits.
`task.parallel` still applies to the whole session: pieces queue behind it exactly as
top-level tasks do.

## Duplicate task titles on the node belt

With `CODEAF_TASK_BELT=node`, `legacy` or `off`, a worker cannot add the same work
twice anywhere in its task tree. Letter case is ignored; runs of punctuation or
whitespace fold to one space:
`Read: the LAW!` and `read-the law` name the same work. Ancestors, siblings, cousins and
finished tasks all count; a cancelled task's title may be reused. A division naming
the same work twice is refused whole, before any part starts. Quick tasks use the same
check. Separate conversation tasks have separate trees; a person's typed task stays
exempt.

The refusal says `no: this run already has a task named "Read: the LAW!" (7), so nothing
was added.` It tells the worker to do that work in its own hands, or name different
work for what is different. The shared tree money limit is described above under
*A task started from the composer carries a cap*.

## How the bash belt run splits work instead of nesting node tasks

With the switch unset, or `CODEAF_TASK_BELT=bash`, a task uses the run engine instead of the node tree.
Expand Down
3 changes: 3 additions & 0 deletions internal/plandb/treebound.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ func normalizedTitle(title string) string {
}), " ")
}

// NormalizedTitle is the work identity shared by both task admission roads.
func NormalizedTitle(title string) string { return normalizedTitle(title) }

// GrowthRefusal answers why worker-created tasks cannot join this run, or
// nothing when they fit. Internal review and person-approved writes bypass it.
func (s *Store) GrowthRefusal(parentID string, titles []string) string {
Expand Down
4 changes: 4 additions & 0 deletions internal/session/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -1941,6 +1941,10 @@ func (a *Agent) completeWithRetryReasoning(ctx context.Context, hub *eventHub, m
if err := node.appBoundary(ctx); err != nil {
return nil, model, err
}
if action := node.treeSpendLimit(); action != "" {
node.end(TaskEndingCostLimit)
return nil, model, ErrSpendStopped{Action: action}
}
if a.config.taskCapUSD > 0 && node.spend() >= a.config.taskCapUSD {
return nil, model, ErrSpendStopped{Action: config.CrewTaskCapAction(a.config.taskCapUSD)}
}
Expand Down
2 changes: 1 addition & 1 deletion internal/session/plandb_plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,7 @@ func (g *TaskGraph) planPulse() {
if parent == nil || parent.state == TaskQueued || parent.depth+1 > taskDepthLimit {
continue
}
if refused := g.claimChild(parent.nodeID); refused != "" {
if refused := g.claimChild(parent.nodeID, task.Title); refused != "" {
continue
}
spec := taskSpec{
Expand Down
8 changes: 4 additions & 4 deletions internal/session/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -827,7 +827,7 @@ func (a *Agent) stageTask(ctx context.Context, args json.RawMessage) bare.Staged
// THE SLOT IS TAKEN BEFORE THE QUESTION and handed back by everything that
// is not an admission, so a batch of proposals cannot walk through the fan
// cap together ([TaskGraph.claimChild]).
if refusal := graph.claimChild(spec.parent); refusal != "" {
if refusal := graph.claimChild(spec.parent, spec.title); refusal != "" {
return bare.Settled(refusal, true)
}
id := graph.reserve()
Expand All @@ -843,7 +843,7 @@ func (a *Agent) stageTask(ctx context.Context, args json.RawMessage) bare.Staged
if err != nil {
// The session closed under the question. Nothing was asked and nothing
// admitted, so the proposal simply never became one.
graph.releaseChild(spec.parent)
graph.releaseChild(spec.parent, spec.title)
return bare.Settled(taskNeverAnswered, true)
}
return &stagedProposal{agent: a, graph: graph, id: id, spec: spec, stand: stand, elsewhere: elsewhere, wait: wait}
Expand Down Expand Up @@ -871,7 +871,7 @@ type stagedProposal struct {
// other ending of [stagedProposal.Commit], and exactly one of the two runs.
func (p *stagedProposal) Withdraw() {
p.wait.withdraw()
p.graph.releaseChild(p.spec.parent)
p.graph.releaseChild(p.spec.parent, p.spec.title)
}

// Commit reads what the wait came to and admits on it. It runs once the message
Expand All @@ -885,7 +885,7 @@ func (p *stagedProposal) Commit(ctx context.Context) (string, bool, error) {
admitted := false
defer func() {
if !admitted {
graph.releaseChild(spec.parent)
graph.releaseChild(spec.parent, spec.title)
}
}()
answer, err := p.wait.answer()
Expand Down
3 changes: 2 additions & 1 deletion internal/session/task_app.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,14 +196,15 @@ func (g *TaskGraph) writeAppControl(change func() (func(), error)) error {
store.mu.Lock()
defer store.mu.Unlock()
}
bills := g.nodeTreeBills()
g.mu.Lock()
defer g.mu.Unlock()
rollback, err := change()
if err != nil {
return err
}
if store != nil {
if err := store.writeLocked(g.documentLocked()); err != nil {
if err := store.writeLocked(g.documentLocked(bills)); err != nil {
if rollback != nil {
rollback()
}
Expand Down
15 changes: 11 additions & 4 deletions internal/session/task_bounds.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,22 @@ func (a *Agent) taskRunCost(run *beltRun, cost float64) float64 {
if run.store != nil {
spent = storeSpent(run.store)
}
left := runCostLeft(taskCap, spent)
// Every dispatch passes here, including continuation. The ending names
// the configured cap, while the engine receives only its unspent allowance.
run.costCeiling = taskCap
run.conversationCostLimit = cost > 0 && cost <= left
allowance, conversation := taskTreeCost(taskCap, spent, cost)
run.conversationCostLimit = conversation
return allowance
}

// taskTreeCost chooses the same remaining allowance for either task road.
func taskTreeCost(taskCap, spent, cost float64) (float64, bool) {
left := runCostLeft(taskCap, spent)
conversation := cost > 0 && cost <= left
if cost <= 0 || left < cost {
return left
return left, conversation
}
return cost
return cost, conversation
}

func (run *beltRun) taskLimitWords(summary RunSummary) string {
Expand Down
5 changes: 4 additions & 1 deletion internal/session/task_divide_compose_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,10 @@ func TestAPartsBriefHoldsItsBoundWhateverLengthTheFieldsArriveAt(t *testing.T) {
}, false},
{"every field at once", []dividePart{
{Title: huge, Summary: huge, Brief: huge},
{Title: huge, Summary: huge, Brief: huge},
// A division refuses two parts naming the same work, so the second
// part's title differs from its first word while every field stays
// at full length.
{Title: "the beta adapter " + huge, Summary: huge, Brief: huge},
}, false},
} {
t.Run(test.name, func(t *testing.T) {
Expand Down
4 changes: 3 additions & 1 deletion internal/session/task_divide_family_check_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -706,7 +706,9 @@ func TestAThirdAskAfterARepairNeitherRefusesNorDoublesTheFamilysChecks(t *testin
nest.divide(t, divideArgsFor(wideEvidence, familyParts("rank", "browse")...))
nest.divide(t, divideArgsFor(wideEvidence, familyParts("rank", "browse")...))

third := nest.divide(t, divideArgsFor(wideEvidence, familyParts("rank", "browse")...))
// The repaired ask already admitted the original work. Different work
// can reuse its family check without dispatching those tasks twice.
third := nest.divide(t, divideArgsFor(wideEvidence, familyParts("ranking", "browsing")...))
if !strings.HasPrefix(third, "split into 2 parts:") {
t.Fatalf("the third ask was told %q, want the division admitted", third)
}
Expand Down
14 changes: 10 additions & 4 deletions internal/session/task_divide_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1120,14 +1120,20 @@ func TestAStoppedPartsSpendStillReachesTheSessionLedger(t *testing.T) {

func TestADivisionWiderThanTheCapIsRefusedWholeRatherThanHalfTaken(t *testing.T) {
nest := newDivideNest(t, wideBrief, 0)
// Four parts already handed out leaves room for one, and a division of
// three is not a division of one.
for i := 0; i < taskFanLimit-1; i++ {
if answer := nest.divide(t, divideArgs(wideEvidence, 2)); i == 0 && !strings.Contains(answer, "split into") {
// Distinct parts leave room for two, so a division of three cannot fit.
for i := 0; i < (taskFanLimit-1)/2; i++ {
parts := []dividePart{
{Title: fmt.Sprintf("Existing north %d", i), Summary: "s", Brief: "b", Acceptance: "a"},
{Title: fmt.Sprintf("Existing south %d", i), Summary: "s", Brief: "b", Acceptance: "a"},
}
if answer := nest.divide(t, divideArgsFor(wideEvidence, parts...)); i == 0 && !strings.Contains(answer, "split into") {
t.Fatalf("the first division was refused: %q", answer)
}
}
before := nest.graph.children(nest.parent.id)
if len(before) != taskFanLimit-2 {
t.Fatalf("the fixture admitted %d distinct parts, want %d", len(before), taskFanLimit-2)
}
answer := nest.divide(t, divideArgs(wideEvidence, 3))
if !strings.Contains(answer, "as many as one task may") {
t.Fatalf("the worker was told %q, want the cap said in the words it is said in everywhere else", answer)
Expand Down
24 changes: 21 additions & 3 deletions internal/session/task_divide_wip.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,11 @@ func (a *Agent) startTheParts(node *TaskNode, parts []dividePart, line *journalD
// with one release rather than by a counter each ending has to remember.
hands := divisionHands{graph: graph, parent: a.config.taskID}
defer hands.release()
if refusal := hands.take(len(parts)); refusal != "" {
partTitles := make([]string, len(parts))
for i, part := range parts {
partTitles[i] = part.Title
}
if refusal := hands.take(len(parts), partTitles...); refusal != "" {
line.Decision = divisionRefusedCap
return nil, nil, refusal
}
Expand Down Expand Up @@ -191,6 +195,7 @@ func (a *Agent) startTheParts(node *TaskNode, parts []dividePart, line *journalD
// has already gone back ([TaskGraph.admit] releases it under the graph's
// own lock) and this is the book agreeing with it.
hands.held--
hands.titles = hands.titles[1:]
ids = append(ids, id)
titles = append(titles, part.Title)
}
Expand All @@ -209,11 +214,19 @@ type divisionHands struct {
graph *TaskGraph
parent uint64
held int
titles []string
}

// take claims one hand per part, or answers the cap's own refusal having given
// back whatever it managed to claim first.
func (h *divisionHands) take(parts int) string {
func (h *divisionHands) take(parts int, titles ...string) string {
if len(titles) > 0 {
if refusal := h.graph.claimChild(h.parent, titles...); refusal != "" {
return refusal
}
h.held, h.titles = parts, titles
return ""
}
for i := 0; i < parts; i++ {
if refusal := h.graph.claimChild(h.parent); refusal != "" {
return refusal
Expand All @@ -225,7 +238,12 @@ func (h *divisionHands) take(parts int) string {

func (h *divisionHands) release() {
for ; h.held > 0; h.held-- {
h.graph.releaseChild(h.parent)
if len(h.titles) > 0 {
h.graph.releaseChild(h.parent, h.titles[0])
h.titles = h.titles[1:]
} else {
h.graph.releaseChild(h.parent)
}
}
}

Expand Down
Loading
Loading