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
2 changes: 1 addition & 1 deletion cmd/codeaf/do.go
Original file line number Diff line number Diff line change
Expand Up @@ -3736,7 +3736,7 @@ func runErrand(request doRequest, seats config.Seats) (outcome headlessOutcome,
Work: seats.Work.Model,
Plan: seats.Plan.Model,
Check: seats.Check.Model,
}, memorySection, completerFor, settings.Sources),
}, memorySection, spendPreauthorized(request.yesSpend, env.Value), completerFor, settings.Sources),
})
errand := runErrandOutcome(summary, workspace, bound)
// THE RUN'S OWN CLOCK, read off the context because the summary's word is
Expand Down
15 changes: 15 additions & 0 deletions docs/changes/unreleased/1842-daily-ceiling-every-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
kind: fixed
title: the daily spend ceiling binds task and errand turns, not interactive chat alone
pr: 1842
surface: [engine]
invalidates:
- "The daily spend ceiling (the machine-wide limit over a day) was enforced only on some paths: `Agent.railBlockLocked` skipped the check for an `InTask` task-node runner (including one under `--one-model`) and the home `Errand`, and the crew guard that also enforces the day cap is absent under `--one-model` and for an `InTask` agent with no crew. So a chat `/task` past the day's limit, and a task under `--one-model`, kept spending with no refusal while headless `codeaf do` and standing firings honoured it. The ceiling is now checked on every turn-starting path through `railBlockLocked`; it is a ceiling read of the shared ledger, never a tally, so a path already held to the crew's own day cap is refused in two places and never charged twice. The rail still stops the next turn rather than cutting one in half."
- "A worker is handed no profile path (it would open the settings door), so the daily ceiling it is held to is now RESOLVED by the root door and carried down (`Config.DailyBudgetUSD`), rather than read from a profile at an empty path - which returned the DEFAULT limit, so a worker refused under a higher selected limit and spent past a lower one. The task-child, the task checker, the adaptive-run worker and the plan-run bash worker all carry the resolved figure. A separate resolved bit preserves an explicitly unlimited worker while ordinary roots, including the empty default-profile path and home errands, keep live profile/environment lookup."
- "`--yes-spend` / `CODEAF_PREAUTHORIZE_SPEND=1`, the standing permission to spend past the daily limit, is now carried into workers (`Config.SpendPreauthorized`) and lifts the daily check there the same way it already lifts it from the crew guard. A separate per-conversation or per-task limit stays in force under it."
---

The gap left open by #1627, which enabled the chat and crew daily check but
kept the `InTask`/`Errand` carve-out. Removing it is what closes the two roads
that had no guard behind them at all - a task under `--one-model` and an
`InTask` turn.
5 changes: 4 additions & 1 deletion internal/manual/chat/models-and-cost.md
Original file line number Diff line number Diff line change
Expand Up @@ -4190,7 +4190,10 @@ estimated cost. Other conversations' completed usage receipts are read on each
check, and completed spending resets at the local date boundary. Calls already
in flight may finish; this is not an atomic reservation across processes.
Use `/budget` to change the limit. Per-conversation and per-task limits still
apply separately.
apply separately. The default profile and home errands follow the saved
or environment daily limit too; `/budget day` changes apply on the next turn.
Task checks use the same selected daily limit and spending permission as the work
they check.

## Which budget takes precedence — daily or per conversation, and why my budget command doesn't work while the conversation is still blocked

Expand Down
14 changes: 14 additions & 0 deletions internal/run/bashworker.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ type BashWorker struct {
// The web pair uses the run profile and resolves its settings per operation.
searchProvider search.Provider
searchFetcher search.Fetcher
// dailyBudgetUSD is the machine-wide daily ceiling this run was started under,
// resolved once by the factory from the run's profile (the worker's own
// session carries no ProfileDir, so [Agent.railBlockLocked] cannot read it).
// Zero is no ceiling. spendPreauthorized is the person's `--yes-spend` /
// `CODEAF_PREAUTHORIZE_SPEND=1` standing permission to spend past it, lifting
// that one limit the same way `doSpendGuard` lifts it from the crew guard.
dailyBudgetUSD float64
spendPreauthorized bool
}

// NewBashWorker builds the seat the run's factory hands each claimed task to.
Expand Down Expand Up @@ -126,6 +134,12 @@ func (w *BashWorker) Run(ctx context.Context, task plandb.Task) (rep Report, run
AuthKeySource: w.authKeySource,
SearchProvider: w.searchProvider,
SearchFetcher: w.searchFetcher,
// The run's daily ceiling and the person's standing permission to spend
// past it travel to the worker's session, which carries no ProfileDir to
// read them from (session.go's [Config.DailyBudgetUSD], #1546).
DailyBudgetUSD: w.dailyBudgetUSD,
DailyBudgetResolved: true,
SpendPreauthorized: w.spendPreauthorized,
}, w.completer, &task, w.store.Path(), w.store.RootID())
if err != nil {
return Report{}, err
Expand Down
2 changes: 1 addition & 1 deletion internal/run/bashworker_web_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ func TestCrewFactoryWiresLiveWebFromItsProfile(t *testing.T) {
}
}
write("exa", "fixture-key")
factory := CrewFactory(nil, t.TempDir(), profile, Seats{One: "test/model"}, "", func(string) session.Completer { return &webSeat{} })
factory := CrewFactory(nil, t.TempDir(), profile, Seats{One: "test/model"}, "", false, func(string) session.Completer { return &webSeat{} })
worker := factory(plandb.Task{}).(*BashWorker)
if worker.searchProvider == nil || worker.searchFetcher == nil {
t.Fatal("run worker lost the web pair")
Expand Down
2 changes: 1 addition & 1 deletion internal/run/chat_check_seat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ func TestUnderOneModelEveryRunSeatIsTheConversationsModel(t *testing.T) {
config.KeyTierMastermindModel: "vendor/profile-thinking",
})
recorder := &recordingCompleter{}
factory := run.CrewFactory(store, t.TempDir(), dir, run.Seats{One: "vendor/one"}, "", recorder.forModel)
factory := run.CrewFactory(store, t.TempDir(), dir, run.Seats{One: "vendor/one"}, "", false, recorder.forModel)
for _, id := range []string{store.RootID(), "one", "review", "discriminate"} {
recorder.models = nil
factory(*store.Task(id))
Expand Down
10 changes: 9 additions & 1 deletion internal/run/crew.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ type Seats struct {
// for a tier key the profile has never held and only a row CLEARED on purpose
// reads empty, so a fallback means somebody emptied a row rather than that the
// profile is old.
func CrewFactory(store *plandb.Store, workspace, profileDir string, seats Seats, always string, completerFor func(model string) session.Completer, sourceSets ...modelsource.Set) WorkerFactory {
func CrewFactory(store *plandb.Store, workspace, profileDir string, seats Seats, always string, preauthorized bool, completerFor func(model string) session.Completer, sourceSets ...modelsource.Set) WorkerFactory {
// The admitted source set explains the refused account without resolving
// worker requests a second time. The completer still owns every route.
var sources modelsource.Set
Expand All @@ -133,8 +133,16 @@ func CrewFactory(store *plandb.Store, workspace, profileDir string, seats Seats,
} else {
sources = config.ResolveSources(profileDir, config.APIKeyAt(profileDir), config.DefaultBaseURL)
}
// The run's daily ceiling, resolved ONCE from the run's profile: a worker's
// own session is handed no ProfileDir, so it cannot read the limit the person
// selected and would otherwise fall back to the default (#1546). Zero when
// there is none. The person's `--yes-spend` rides beside it, lifting that one
// limit in the worker exactly as it does in the crew guard.
dailyBudgetUSD, _ := config.DailyBudgetUSDAt(profileDir)
workerFor := func(model string, completer session.Completer) *BashWorker {
worker := NewBashWorker(store, workspace, model, completer)
worker.dailyBudgetUSD = dailyBudgetUSD
worker.spendPreauthorized = preauthorized
// The memory section rides on every worker this run seats, read once by
// the door that built the factory.
worker.always = always
Expand Down
2 changes: 1 addition & 1 deletion internal/run/crew_context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ func TestCrewFactoryCarriesTheRoleSeatToTheSpendGuard(t *testing.T) {
CeilingAction: "checker ceiling $%.2f",
}
probes := []*crewCallProbe{}
factory := CrewFactory(store, dir, profile, Seats{}, "", func(model string) session.Completer {
factory := CrewFactory(store, dir, profile, Seats{}, "", false, func(model string) session.Completer {
p := &crewCallProbe{}
probes = append(probes, p)
return guard.Wrap(model, p)
Expand Down
16 changes: 8 additions & 8 deletions internal/run/crew_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ func TestCrewFactorySeatsEachRoleInItsTier(t *testing.T) {
config.KeyTierMastermindModel: "vendor/thinking",
})
recorder := &recordingCompleter{}
factory := run.CrewFactory(store, t.TempDir(), dir, run.Seats{}, "", recorder.forModel)
factory := run.CrewFactory(store, t.TempDir(), dir, run.Seats{}, "", false, recorder.forModel)

for _, test := range []struct {
task, want string
Expand Down Expand Up @@ -148,7 +148,7 @@ func TestCrewFactoryRunsTheDoorsSeatsWhateverTheProfileSays(t *testing.T) {
factory := run.CrewFactory(store, t.TempDir(), dir, run.Seats{
Work: "vendor/named-work",
Plan: "vendor/named-plan",
}, "", recorder.forModel)
}, "", false, recorder.forModel)

for _, test := range []struct {
task, want string
Expand Down Expand Up @@ -185,7 +185,7 @@ func TestCrewFactoryFallsBackToTheWorkerRowForAnEmptyTier(t *testing.T) {
config.KeyTierWorkerModel: "vendor/worker",
})
recorder := &recordingCompleter{}
factory := run.CrewFactory(store, t.TempDir(), dir, run.Seats{}, "", recorder.forModel)
factory := run.CrewFactory(store, t.TempDir(), dir, run.Seats{}, "", false, recorder.forModel)

factory(*store.Task("review"))

Expand All @@ -200,7 +200,7 @@ func TestCrewFactoryFallsBackToTheWorkerRowForAnEmptyTier(t *testing.T) {
func TestCrewFactoryRefusesATaskTheCrewCannotSeat(t *testing.T) {
store := runOpenStore(t)
dir := crewProfile(t, map[string]string{config.KeyTierWorkerModel: ""})
factory := run.CrewFactory(store, t.TempDir(), dir, run.Seats{}, "", (&recordingCompleter{}).forModel)
factory := run.CrewFactory(store, t.TempDir(), dir, run.Seats{}, "", false, (&recordingCompleter{}).forModel)

worker := factory(*store.Task(store.RootID()))
_, err := worker.Run(runContext(t), *store.Task(store.RootID()))
Expand All @@ -221,7 +221,7 @@ func TestCrewFactoryReadsTheCrewAgainAtEachLaunch(t *testing.T) {
store := runOpenStore(t)
dir := crewProfile(t, map[string]string{config.KeyTierWorkerModel: "vendor/first"})
recorder := &recordingCompleter{}
factory := run.CrewFactory(store, t.TempDir(), dir, run.Seats{}, "", recorder.forModel)
factory := run.CrewFactory(store, t.TempDir(), dir, run.Seats{}, "", false, recorder.forModel)

factory(*store.Task(store.RootID()))
writeCrew(t, dir, map[string]string{config.KeyTierWorkerModel: "vendor/second-longer"})
Expand Down Expand Up @@ -283,7 +283,7 @@ func TestBashWorkerChargesTheSeatModelToTheTaskSpendRow(t *testing.T) {
return toolReply(finishCommand("root", "the work is done")), nil
},
}}
factory := run.CrewFactory(store, t.TempDir(), dir, run.Seats{}, "", recorder.forModel)
factory := run.CrewFactory(store, t.TempDir(), dir, run.Seats{}, "", false, recorder.forModel)

worker := factory(*store.Task(store.RootID()))
if len(recorder.models) != 1 || recorder.models[0] != "vendor/seat-model" {
Expand Down Expand Up @@ -327,7 +327,7 @@ func TestCrewFactorySeatsACheckOnTheCheckSeat(t *testing.T) {
Work: "vendor/named-work",
Plan: "vendor/named-plan",
Check: "vendor/named-check",
}, "", recorder.forModel)
}, "", false, recorder.forModel)

for _, test := range []struct {
task, want string
Expand Down Expand Up @@ -367,7 +367,7 @@ func TestCrewFactorySeatsTheChatDoorsCheckOnTheCrewsChecker(t *testing.T) {
{},
} {
recorder := &recordingCompleter{}
factory := run.CrewFactory(store, t.TempDir(), dir, seats, "", recorder.forModel)
factory := run.CrewFactory(store, t.TempDir(), dir, seats, "", false, recorder.forModel)
recorder.models = nil
factory(*store.Task("review"))
if len(recorder.models) != 1 || recorder.models[0] != "vendor/profile-careful" {
Expand Down
121 changes: 121 additions & 0 deletions internal/run/daily_authority_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package run_test

import (
"context"
"errors"
"path/filepath"
"sync/atomic"
"testing"
"time"

"github.com/Agent-Field/agentfield/sdk/go/ai"
"github.com/Agent-Field/codeaf/internal/config"
"github.com/Agent-Field/codeaf/internal/plandb"
"github.com/Agent-Field/codeaf/internal/run"
"github.com/Agent-Field/codeaf/internal/session"
)

type dailyAuthoritySeat struct{ calls atomic.Int32 }

func (s *dailyAuthoritySeat) CompleteWithMessages(context.Context, []ai.Message, ...ai.Option) (*ai.Response, error) {
s.calls.Add(1)
return nil, errors.New("fake provider reached")
}

func TestRunFactoryCarriesDailyAuthority(t *testing.T) {
for _, tc := range []struct {
name string
cap, spent float64
allowed bool
}{{"chosen higher allowance", 1000, 600, true}, {"chosen lower allowance", 1, 1.5, false}, {"chosen unlimited", 0, 600, true}} {
t.Run(tc.name, func(t *testing.T) {
root := t.TempDir()
profile := t.TempDir()
t.Setenv("CODEAF_HOME", root)
t.Setenv("CODEAF_PROFILE_DIR", profile)
t.Setenv("CODEAF_TASK_BELT", "bash")
t.Setenv("CODEAF_PLANDB_BIN", filepath.Join(root, "stub-codeaf"))
t.Setenv("CODEAF_DAILY_BUDGET", "")
t.Setenv("CODEAF_PREAUTHORIZE_SPEND", "")
if err := config.WriteDailyBudgetUSD("", 500); err != nil {
t.Fatal(err)
}
if err := config.WriteDailyBudgetUSD(profile, tc.cap); err != nil {
t.Fatal(err)
}
session.RecordUsage(session.UsageLedgerPath(), session.UsageLine{At: time.Now(), USD: tc.spent, Model: "test/model", Calls: 1})
session.FlushUsage()
store, err := plandb.Open(filepath.Join(root, "plan.db"), "daily-authority", "root", "Work", "Work")
if err != nil {
t.Fatal(err)
}
defer store.Close()
seat := &dailyAuthoritySeat{}
factory := run.CrewFactory(store, root, profile, run.Seats{One: "test/model"}, "", false, func(string) session.Completer { return seat })
task := *store.Task(store.RootID())
worker := factory(task)
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
_, err = worker.Run(ctx, task)
gotAllowed := seat.calls.Load() > 0
t.Logf("profile cap=%g spent=%g provider calls=%d ending=%v", tc.cap, tc.spent, seat.calls.Load(), err)
if !tc.allowed && !errors.Is(err, session.ErrSpendRail) {
t.Fatalf("worker stopped for a reason other than its daily limit: %v", err)
}
if gotAllowed != tc.allowed {
t.Fatalf("run factory's worker used wrong daily cap: reached provider=%t want=%t", gotAllowed, tc.allowed)
}
})
}
}

func TestPreauthorizedRunPreservesTaskCaps(t *testing.T) {
for _, limited := range []bool{false, true} {
t.Run(map[bool]string{false: "daily preauthorization", true: "separate task cap"}[limited], func(t *testing.T) {
root := t.TempDir()
profile := t.TempDir()
t.Setenv("CODEAF_HOME", root)
t.Setenv("CODEAF_PROFILE_DIR", profile)
t.Setenv("CODEAF_TASK_BELT", "bash")
t.Setenv("CODEAF_PLANDB_BIN", filepath.Join(root, "stub-codeaf"))
t.Setenv("CODEAF_DAILY_BUDGET", "")
if err := config.WriteDailyBudgetUSD(profile, 1); err != nil {
t.Fatal(err)
}
if err := config.WriteDailyBudgetUSD("", 1); err != nil {
t.Fatal(err)
}
t.Setenv("CODEAF_PREAUTHORIZE_SPEND", "")
session.RecordUsage(session.UsageLedgerPath(), session.UsageLine{At: time.Now(), USD: 1.5, Model: "test/model", Calls: 1})
session.FlushUsage()
store, err := plandb.Open(filepath.Join(root, "plan.db"), "daily-authority", "root", "Work", "Work")
if err != nil {
t.Fatal(err)
}
defer store.Close()
seat := &dailyAuthoritySeat{}
factory := run.CrewFactory(store, root, profile, run.Seats{One: "test/model"}, "", true, func(model string) session.Completer {
if !limited {
return seat
}
guard := session.TaskSpendGuard(profile)
guard.TaskCap = 0.01
guard.TaskAction = "separate task cap"
guard.Price = func(string) (float64, float64, float64, bool) { return 1, 1, 0, true }
return guard.Wrap(model, seat)
})
task := *store.Task(store.RootID())
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
_, err = factory(task).Run(ctx, task)
t.Logf("limited=%t calls=%d ending=%v", limited, seat.calls.Load(), err)
if !limited && seat.calls.Load() != 1 {
t.Fatalf("preauthorized run stopped before provider: %v", err)
}
var stopped session.ErrSpendStopped
if limited && (!errors.As(err, &stopped) || stopped.Action != "separate task cap" || seat.calls.Load() != 0) {
t.Fatalf("preauthorization lifted separate task limit: calls=%d err=%v", seat.calls.Load(), err)
}
})
}
}
5 changes: 4 additions & 1 deletion internal/run/enginewire.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ func (engine) Start(ctx context.Context, spec session.RunSpec) session.RunSummar
// AND UNDER `--one-model` ONE MODEL IS EVERY SEAT, the probe and the
// check's environment rung included ([Seats.One]).
One: spec.OneModel,
}, spec.Memory, spec.CompleterFor, spec.Sources)
}, spec.Memory, false, spec.CompleterFor, spec.Sources)
// A `/task` run from interactive chat is never spend-preauthorized: `--yes-spend`
// is a headless `codeaf do` flag, so a chat run is held to the daily ceiling
// like every interactive turn (crew.go's CrewFactory, #1546).
if spec.Delegate != nil {
// A DELEGATED RUN SEATS THE PROGRAM ON ITS ROOT and has no review
// round: a check seat is a bash-belt worker, which the belt switch may
Expand Down
2 changes: 1 addition & 1 deletion internal/run/note_once_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ func TestAPlanBornWorkerIsHandedThePersonsRules(t *testing.T) {
store := runOpenStore(t)
const rules = "Rules that always hold here:\n\n- never force-push a shared branch"
seat := &seat{ever: echoesUntilTheCap()}
factory := run.CrewFactory(store, filepath.Dir(store.Path()), t.TempDir(), run.Seats{One: "test/model"}, rules,
factory := run.CrewFactory(store, filepath.Dir(store.Path()), t.TempDir(), run.Seats{One: "test/model"}, rules, false,
func(string) session.Completer { return seat })
worker := factory(*store.Task(store.RootID()))
if worker == nil {
Expand Down
Loading
Loading