From 3d75f01aea479fec112882b515ffe7f519119dc7 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 19 Sep 2026 11:01:10 -0400 Subject: [PATCH 1/8] run: end on elapsed limit --- internal/run/elapsed_limit_test.go | 66 ++++++++++++++++++++++++++++++ internal/run/run.go | 17 ++++++++ internal/run/worker.go | 10 +++-- 3 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 internal/run/elapsed_limit_test.go diff --git a/internal/run/elapsed_limit_test.go b/internal/run/elapsed_limit_test.go new file mode 100644 index 0000000000..65e354157c --- /dev/null +++ b/internal/run/elapsed_limit_test.go @@ -0,0 +1,66 @@ +package run + +import ( + "context" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// TestElapsedLimitUsesTheLimitEndingRoad states the run-limit law at the +// boundary where it matters: reaching a time limit while work is in flight +// ends and drains that work, starts nothing afterwards, and reports the same +// outcome as every other run limit. Its timer is driven by the test, never by +// elapsed machine time. +func TestElapsedLimitUsesTheLimitEndingRoad(t *testing.T) { + store, err := plandb.Open(t.TempDir()+"/plan.json", "elapsed-limit", "root", "root", "run until the limit") + if err != nil { + t.Fatalf("open store: %v", err) + } + defer store.Close() + + fired := make(chan time.Time, 1) + started := make(chan struct{}) + ended := make(chan struct{}) + var launches atomic.Int32 + factory := func(task plandb.Task) Worker { + launches.Add(1) + return workerFunc(func(ctx context.Context, task plandb.Task) (Report, error) { + close(started) + <-ctx.Done() + if _, err := store.AddMany([]plandb.TaskSpec{{ID: "after-limit", Title: "must not launch", ParentID: task.ID}}); err != nil { + t.Errorf("add post-limit task: %v", err) + } + close(ended) + return Report{}, ctx.Err() + }) + } + supervisor := NewSupervisor(store, t.TempDir(), 2, Limits{Elapsed: time.Hour}, factory) + supervisor.after = func(time.Duration) <-chan time.Time { return fired } + + result := make(chan Outcome, 1) + go func() { result <- supervisor.Run(context.Background()) }() + <-started + fired <- time.Time{} + + if outcome := <-result; outcome != OutcomeLimit { + t.Fatalf("outcome = %q, want %q", outcome, OutcomeLimit) + } + select { + case <-ended: + default: + t.Fatal("Run returned before its in-flight worker ended and drained") + } + if got := launches.Load(); got != 1 { + t.Fatalf("worker launches = %d, want only the in-flight root", got) + } + if task := store.Task("after-limit"); task == nil || task.Status != plandb.StatusReady { + t.Fatalf("post-limit task = %#v, want ready and unlaunched", task) + } +} + +type workerFunc func(context.Context, plandb.Task) (Report, error) + +func (f workerFunc) Run(ctx context.Context, task plandb.Task) (Report, error) { return f(ctx, task) } diff --git a/internal/run/run.go b/internal/run/run.go index 75610971c7..23ae0a8941 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -76,6 +76,10 @@ type Supervisor struct { limits Limits factory WorkerFactory + // after provides the run elapsed-limit signal. Production uses time.After; + // a test supplies a driven channel so the limit law needs no real sleep. + after func(time.Duration) <-chan time.Time + // staleAfter is how long a claim may go untouched before this pass takes // it over, resolved from Limits (or its default) at the top of Run, so // every pass reads the same window. @@ -142,6 +146,7 @@ func NewSupervisor(store *plandb.Store, workspace string, slots int, limits Limi slots: slots, limits: limits, factory: factory, + after: time.After, finished: make(chan workerReturn, slots+1), cancels: make(map[string]context.CancelFunc), checkOf: make(map[string]string), @@ -201,6 +206,10 @@ func (s *Supervisor) Run(ctx context.Context) Outcome { timer := time.NewTimer(passInterval) defer timer.Stop() + var elapsed <-chan time.Time + if s.limits.Elapsed > 0 { + elapsed = s.after(s.limits.Elapsed) + } for { if outcome := s.pass(ctx, rootID); outcome != "" { s.drain() @@ -212,6 +221,14 @@ func (s *Supervisor) Run(ctx context.Context) Outcome { s.absorb(ret) case <-timer.C: timer.Reset(passInterval) + case <-elapsed: + // TIME AND COST SHARE ONE ENDING. Mark the same limit state the + // spend counter marks, end and drain work already in flight, then + // let pass return the existing OutcomeLimit without launching again. + s.limitHit = true + s.drain() + s.inFlight = 0 + elapsed = nil case <-ctx.Done(): // The caller's wall: workers still out there were handed this // context and end with it. Their endings are absorbed so their diff --git a/internal/run/worker.go b/internal/run/worker.go index c10b17f485..f96bfde4be 100644 --- a/internal/run/worker.go +++ b/internal/run/worker.go @@ -45,14 +45,18 @@ type Worker interface { // say no seat exists for this task; the task then fails rather than hangs. type WorkerFactory func(task plandb.Task) Worker -// Limits bound a run from the outside. Every field is optional: a CostUSD of -// zero (or less) sets no cost limit, a StepsPerTask of zero hands the worker -// no cap, and ReviewRound's false is the run every caller had before it. +// Limits bound a run from the outside. Every field is optional: a CostUSD or +// Elapsed of zero (or less) sets no corresponding run limit, a StepsPerTask +// of zero hands the worker no cap, and ReviewRound's false is the run every caller had before it. type Limits struct { // CostUSD is what the whole run may spend, as the sum of every Report's // USD. When the counter has reached it no new worker starts and the run // ends on the limit word of the outcome ladder. CostUSD float64 + // Elapsed is how long the whole run may remain active. When it passes, + // workers already in flight are ended and drained, no new worker starts, + // and the run ends on the same limit word as the cost counter. + Elapsed time.Duration // StepsPerTask is handed to every worker through its context, so the loop // a worker hosts can cap itself without the supervisor counting its steps. StepsPerTask int From 0580542782a31ed20d21fc3d916411e4c196214e Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 19 Sep 2026 11:08:09 -0400 Subject: [PATCH 2/8] session: pass remaining wall to runs --- internal/run/enginewire.go | 2 +- internal/session/task_run_belt.go | 11 ++++++ internal/session/task_run_belt_test.go | 54 +++++++++++++++++++++++++- 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/internal/run/enginewire.go b/internal/run/enginewire.go index 886cb17abd..6bbee46b66 100644 --- a/internal/run/enginewire.go +++ b/internal/run/enginewire.go @@ -31,7 +31,7 @@ func (engine) Start(ctx context.Context, spec session.RunSpec) session.RunSummar // THE REVIEW ROUND IS ON for every task the chat's door opens: a leaf // that lands done is checked against its acceptance, and a check that // does not hold becomes a fix task the run waits on. - Limits: Limits{CostUSD: spec.CostUSD, StepsPerTask: spec.StepsPerTask, ReviewRound: true}, + Limits: Limits{CostUSD: spec.CostUSD, Elapsed: spec.Elapsed, StepsPerTask: spec.StepsPerTask, ReviewRound: true}, // THE CREW IS THE PROFILE'S, read again at each launch, and the seat's // provider is the door's own completer through the one seam a test // scripts ([CrewFactory]). diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index b96ea50543..646d3a1055 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -63,6 +63,9 @@ type RunSpec struct { // wide as the conversation may. Slots int CostUSD float64 + // Elapsed is the conversation time still available when this run starts. + // Zero means no time ceiling, matching the run engine's Limits contract. + Elapsed time.Duration // StepsPerTask is the per-task step cap, the same figure a node of this // session's own tree carries. StepsPerTask int @@ -293,6 +296,13 @@ func (a *Agent) startKnownTaskRun(ctx context.Context, id uint64, title, brief s workSeat, _ := roles.TierModel(source, roles.TierWorker) planSeat, _ := roles.TierModel(source, roles.TierMastermind) + wallLeft, _ := a.config.Budget.Left() + if a.config.Budget.Wall > 0 && !a.startedAt.IsZero() { + wallLeft = a.config.Budget.Wall - time.Since(a.startedAt) + if wallLeft <= 0 { + wallLeft = time.Nanosecond + } + } spec := RunSpec{ Store: store, Workspace: run.workspace, @@ -300,6 +310,7 @@ func (a *Agent) startKnownTaskRun(ctx context.Context, id uint64, title, brief s Brief: brief, Slots: a.config.TaskParallel, CostUSD: a.config.SpendRailUSD, + Elapsed: wallLeft, // The step cap a node of this session's own tree carries, so a run // worker and a node worker stop at the same figure. StepsPerTask: taskMaxSteps, diff --git a/internal/session/task_run_belt_test.go b/internal/session/task_run_belt_test.go index 7b59ba8c1a..aa1c78c04a 100644 --- a/internal/session/task_run_belt_test.go +++ b/internal/session/task_run_belt_test.go @@ -373,6 +373,52 @@ func TestStartTaskBashBeltStartsARunOnTheStore(t *testing.T) { // while one is live adds its work to that same store — a child of the run's one // root — rather than opening another. The second id is in the store, and the // store still holds one file. +func TestStartTaskBashBeltPassesTheConversationWallLeftToTheRun(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + double := newBeltRunDouble("done") + registerBeltRunEngine(t, double) + + dir := t.TempDir() + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(config *Config) { + config.Workspace = newTestRepo(t) + config.Place = Place{Dir: dir} + config.AskConsent = false + config.Budget = Budget{Wall: 2 * time.Hour} + }) + agent.startedAt = time.Now().Add(-90 * time.Minute) + + if _, _, _, err := agent.StartTask(context.Background(), "finish within the time left", false); err != nil { + t.Fatalf("StartTask: %v", err) + } + <-double.entered + double.mu.Lock() + got := double.spec.Elapsed + double.mu.Unlock() + if got < 29*time.Minute || got > 31*time.Minute { + t.Fatalf("run elapsed limit = %v, want the roughly 30 minutes left, not the original 2 hours", got) + } + endBeltRun(t, agent, double) +} + +func TestDriveBeltRunLimitUsesTheOrdinaryLandingRoad(t *testing.T) { + agent, _, run, _ := landingSummaryFixture(t, &scriptedCompleter{}) + landCalls := 0 + engine := landingRunDouble{ + summary: RunSummary{Outcome: "limit", Result: "a limit you set stopped it"}, + landing: RunLanding{Branch: "task/limited", Changed: []string{"kept.txt"}}, + landCalls: &landCalls, + } + + agent.driveBeltRun(context.Background(), engine, run, RunSpec{}) + + if landCalls != 1 { + t.Fatalf("Land calls = %d, want exactly one ordinary landing", landCalls) + } + if notice := agent.beltRunNotice(run, engine.summary, engine.landing); notice.State != TaskFailed { + t.Fatalf("limited run row state = %q, want the same failed state as a cost-limited run", notice.State) + } +} + func TestStartTaskBashBeltJoinsTheLiveRun(t *testing.T) { t.Setenv("CODEAF_TASK_BELT", "bash") double := newBeltRunDouble("the run did the work") @@ -503,12 +549,16 @@ func TestLandingDigestIsUnchangedWithoutAStoredSummary(t *testing.T) { // the summary refresh must have stored its sentence before the outcome note is // composed. type landingRunDouble struct { - summary RunSummary - landing RunLanding + summary RunSummary + landing RunLanding + landCalls *int } func (d landingRunDouble) Start(context.Context, RunSpec) RunSummary { return d.summary } func (d landingRunDouble) Land(context.Context, *plandb.Store, string, string) (RunLanding, error) { + if d.landCalls != nil { + *d.landCalls++ + } return d.landing, nil } From beff5ed05a65e7449587dd46cb6228e29f58a8fb Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 19 Sep 2026 11:08:30 -0400 Subject: [PATCH 3/8] test: keep belt run cases legible --- internal/session/task_run_belt_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/session/task_run_belt_test.go b/internal/session/task_run_belt_test.go index aa1c78c04a..ecb0b04fe0 100644 --- a/internal/session/task_run_belt_test.go +++ b/internal/session/task_run_belt_test.go @@ -369,10 +369,6 @@ func TestStartTaskBashBeltStartsARunOnTheStore(t *testing.T) { } } -// TestStartTaskBashBeltJoinsTheLiveRun: a run is one store, so a second `/task` -// while one is live adds its work to that same store — a child of the run's one -// root — rather than opening another. The second id is in the store, and the -// store still holds one file. func TestStartTaskBashBeltPassesTheConversationWallLeftToTheRun(t *testing.T) { t.Setenv("CODEAF_TASK_BELT", "bash") double := newBeltRunDouble("done") @@ -419,6 +415,10 @@ func TestDriveBeltRunLimitUsesTheOrdinaryLandingRoad(t *testing.T) { } } +// TestStartTaskBashBeltJoinsTheLiveRun: a run is one store, so a second `/task` +// while one is live adds its work to that same store — a child of the run's one +// root — rather than opening another. The second id is in the store, and the +// store still holds one file. func TestStartTaskBashBeltJoinsTheLiveRun(t *testing.T) { t.Setenv("CODEAF_TASK_BELT", "bash") double := newBeltRunDouble("the run did the work") From 60f533fe305d7e24f839b1d7d31e2603216bdbdc Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 19 Sep 2026 11:09:30 -0400 Subject: [PATCH 4/8] docs: explain time limits on running tasks --- internal/manual/chat/starting-codeaf.md | 6 ++++++ internal/manual/chat_test.go | 12 ++++++++++++ 2 files changed, 18 insertions(+) diff --git a/internal/manual/chat/starting-codeaf.md b/internal/manual/chat/starting-codeaf.md index 3757525fcf..75c8c09a99 100644 --- a/internal/manual/chat/starting-codeaf.md +++ b/internal/manual/chat/starting-codeaf.md @@ -128,6 +128,12 @@ stale is running. | `--max-cost ` | with `--yolo`: dollar limit; interactive chat checks before new turns | | `--one-model` | every text call this session makes runs on the session model | +## Does a time limit stop a running task? + +Yes. When either limit is reached, a running task takes no new steps, ends, and +brings its finished work home. The row says “a limit you set stopped it,” whether +the time limit or the cost limit was reached. + `--yolo` does not make codeaf unstoppable: a small set of destructive commands and anything that acts in your name still ask, whatever the setting says. See the permissions page. diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index ffcf06dcfb..07d8bf997e 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -4,6 +4,7 @@ import ( "go/ast" "go/parser" "go/token" + "os" "regexp" "strconv" "strings" @@ -893,6 +894,7 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"can I leave a headless run going with a budget", "starting-codeaf"}, {"my --once yolo run never started a task", "starting-codeaf"}, {"does a run with no screen carry its own work on", "starting-codeaf"}, + {"does a time limit stop a running task", "starting-codeaf"}, {"it says needs your look but I ran it with yolo", "tasks"}, {"what does taken as it stands mean", "tasks"}, {"it says it could not be brought home", "tasks"}, @@ -3038,3 +3040,13 @@ func TestNoChatPageSaysAPlaceCanRefuseToOpen(t *testing.T) { } } } + +func TestStartingCodeafAnswersWhetherATimeLimitStopsARunningTask(t *testing.T) { + body, err := os.ReadFile("chat/starting-codeaf.md") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(body), "## Does a time limit stop a running task") { + t.Fatal("starting-codeaf.md has no searchable heading answering whether a time limit stops a running task") + } +} From 3262bdf955ca49356d191c252a83e40872e694e9 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 19 Sep 2026 12:12:37 -0400 Subject: [PATCH 5/8] session: show the run limit ending on its row --- internal/session/task_run_belt.go | 3 +++ internal/session/task_run_belt_test.go | 8 ++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index 646d3a1055..6facb8d2f9 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -596,6 +596,9 @@ func (a *Agent) beltRunNotice(run *beltRun, summary RunSummary, landing RunLandi state = TaskFailed } report := strings.TrimSpace(summary.Result) + if report == "" && summary.Outcome != beltRunOutcomeDone { + report = strings.TrimSpace(summary.Outcome) + } if line := beltLandingLine(landing); line != "" { if report != "" { report += "\n" diff --git a/internal/session/task_run_belt_test.go b/internal/session/task_run_belt_test.go index ecb0b04fe0..b97cca86de 100644 --- a/internal/session/task_run_belt_test.go +++ b/internal/session/task_run_belt_test.go @@ -400,7 +400,7 @@ func TestDriveBeltRunLimitUsesTheOrdinaryLandingRoad(t *testing.T) { agent, _, run, _ := landingSummaryFixture(t, &scriptedCompleter{}) landCalls := 0 engine := landingRunDouble{ - summary: RunSummary{Outcome: "limit", Result: "a limit you set stopped it"}, + summary: RunSummary{Outcome: "a limit you set stopped it"}, landing: RunLanding{Branch: "task/limited", Changed: []string{"kept.txt"}}, landCalls: &landCalls, } @@ -410,9 +410,13 @@ func TestDriveBeltRunLimitUsesTheOrdinaryLandingRoad(t *testing.T) { if landCalls != 1 { t.Fatalf("Land calls = %d, want exactly one ordinary landing", landCalls) } - if notice := agent.beltRunNotice(run, engine.summary, engine.landing); notice.State != TaskFailed { + notice := agent.beltRunNotice(run, engine.summary, engine.landing) + if notice.State != TaskFailed { t.Fatalf("limited run row state = %q, want the same failed state as a cost-limited run", notice.State) } + if !strings.Contains(notice.Report, "a limit you set stopped it") { + t.Fatalf("limited run row report = %q, want the existing limit ending sentence", notice.Report) + } } // TestStartTaskBashBeltJoinsTheLiveRun: a run is one store, so a second `/task` From 1c13f2a792bcf390ed11889021d86820d3236bd9 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 19 Sep 2026 12:37:13 -0400 Subject: [PATCH 6/8] run, manual: a time limit absorbs the endings of the work it cuts The elapsed case ended in-flight workers and dropped their returns, which left their tasks claimed on a run that was over and their spend out of the account. It now reads the endings the way the caller's wall does. The manual section moves out of the flags section it had split, and says which limit is which. Co-Authored-By: Claude Fable 5.1 --- internal/manual/chat/starting-codeaf.md | 19 +++++++++++++------ internal/manual/chat/worker-harness.md | 4 ++++ internal/manual/chat_test.go | 11 ----------- internal/run/elapsed_limit_test.go | 10 +++++++++- internal/run/run.go | 25 ++++++++++++++++++++----- 5 files changed, 46 insertions(+), 23 deletions(-) diff --git a/internal/manual/chat/starting-codeaf.md b/internal/manual/chat/starting-codeaf.md index 75c8c09a99..9c11c7b533 100644 --- a/internal/manual/chat/starting-codeaf.md +++ b/internal/manual/chat/starting-codeaf.md @@ -128,16 +128,23 @@ stale is running. | `--max-cost ` | with `--yolo`: dollar limit; interactive chat checks before new turns | | `--one-model` | every text call this session makes runs on the session model | -## Does a time limit stop a running task? - -Yes. When either limit is reached, a running task takes no new steps, ends, and -brings its finished work home. The row says “a limit you set stopped it,” whether -the time limit or the cost limit was reached. - `--yolo` does not make codeaf unstoppable: a small set of destructive commands and anything that acts in your name still ask, whatever the setting says. See the permissions page. +## Does a time limit stop a running task? + +Yes. The elapsed-time limit (`--max-hours`) is measured from when this session opened, +and a run that `/task` starts is handed whatever is left of it. When that time is up the +run starts nothing new, the work still going is ended where it is, and what had finished +comes home the ordinary way. The run's row ends `incomplete` and says +`a limit you set stopped it`, the same sentence a run says when it reaches the +conversation's spend ceiling. A task that was cut part way through reads `incomplete` too. + +The dollar limit on a run is the conversation's spend ceiling, not `--max-cost`. A run +that reaches it lets the work already going finish, because that money is already +committed; a time limit cannot wait, so it ends that work. + ## Leaving it running on its own · leaving a headless run going with a budget · --once yolo · no screen · unattended · overnight · nobody watching `--yolo` changes tool approvals. Normal interactive chat remains a conversation you diff --git a/internal/manual/chat/worker-harness.md b/internal/manual/chat/worker-harness.md index 604873918a..762e7cf17c 100644 --- a/internal/manual/chat/worker-harness.md +++ b/internal/manual/chat/worker-harness.md @@ -405,6 +405,10 @@ requirement in them, and speed is no permission to skip the walk. allows — `/settings` → Spending → **per conversation** — which is off by default. The run's width and its dollar ceiling are the conversation's own numbers, so a run costs what the conversation costs and runs as wide as the conversation may. +- **The time limit.** A session opened with an elapsed-time limit (`--max-hours`) hands + a run whatever is left of it. When the time is up the run starts nothing new, ends the + work still going, brings home what had finished, and its row reads `incomplete` with + `a limit you set stopped it`. A session with no time limit puts none on its runs. - **The step cap.** A worker stops at **200** finished tool calls — the same figure a node worker carries. The cap is a bound on spend and not a finding about the work: the turn is stopped there rather than judged, and a worker stopped this diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 07d8bf997e..36a1cc775e 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -4,7 +4,6 @@ import ( "go/ast" "go/parser" "go/token" - "os" "regexp" "strconv" "strings" @@ -3040,13 +3039,3 @@ func TestNoChatPageSaysAPlaceCanRefuseToOpen(t *testing.T) { } } } - -func TestStartingCodeafAnswersWhetherATimeLimitStopsARunningTask(t *testing.T) { - body, err := os.ReadFile("chat/starting-codeaf.md") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(body), "## Does a time limit stop a running task") { - t.Fatal("starting-codeaf.md has no searchable heading answering whether a time limit stops a running task") - } -} diff --git a/internal/run/elapsed_limit_test.go b/internal/run/elapsed_limit_test.go index 65e354157c..235a9be8f9 100644 --- a/internal/run/elapsed_limit_test.go +++ b/internal/run/elapsed_limit_test.go @@ -34,7 +34,7 @@ func TestElapsedLimitUsesTheLimitEndingRoad(t *testing.T) { t.Errorf("add post-limit task: %v", err) } close(ended) - return Report{}, ctx.Err() + return Report{USD: 0.25}, ctx.Err() }) } supervisor := NewSupervisor(store, t.TempDir(), 2, Limits{Elapsed: time.Hour}, factory) @@ -53,6 +53,14 @@ func TestElapsedLimitUsesTheLimitEndingRoad(t *testing.T) { default: t.Fatal("Run returned before its in-flight worker ended and drained") } + // THE CUT WORKER'S ENDING WAS READ, NOT DROPPED: what it spent before the + // limit is in the run's account, and the run holds no claim on it any more. + if supervisor.spent != 0.25 { + t.Fatalf("run spend = %v, want the cut worker's 0.25: its return was dropped", supervisor.spent) + } + if len(supervisor.cancels) != 0 { + t.Fatalf("the run still holds %d workers after its limit ended it", len(supervisor.cancels)) + } if got := launches.Load(); got != 1 { t.Fatalf("worker launches = %d, want only the in-flight root", got) } diff --git a/internal/run/run.go b/internal/run/run.go index 23ae0a8941..84c233be44 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -223,12 +223,26 @@ func (s *Supervisor) Run(ctx context.Context) Outcome { timer.Reset(passInterval) case <-elapsed: // TIME AND COST SHARE ONE ENDING. Mark the same limit state the - // spend counter marks, end and drain work already in flight, then - // let pass return the existing OutcomeLimit without launching again. + // spend counter marks, so the next pass launches nothing and answers + // the existing OutcomeLimit. + // + // THE DIFFERENCE IS THE WORK IN FLIGHT. A cost limit lets it finish, + // because what it will spend is already committed; a time limit + // cannot, because the time is gone. So every worker is ended here, and + // ITS ENDING IS ABSORBED, the way the caller's wall below reads its + // endings: a return that was dropped would leave its task claimed and + // reading as running on a run that is over, and what the worker spent + // before it was cut would be missing from the run's account. s.limitHit = true - s.drain() - s.inFlight = 0 elapsed = nil + for _, cancel := range s.cancels { + cancel() + } + for s.inFlight > 0 { + ret := <-s.finished + s.inFlight-- + s.absorb(ret) + } case <-ctx.Done(): // The caller's wall: workers still out there were handed this // context and end with it. Their endings are absorbed so their @@ -275,7 +289,8 @@ func (s *Supervisor) pass(ctx context.Context, rootID string) Outcome { if s.inFlight == 0 && (s.rootFailed || s.limitHit) { // Nothing of ours is running and the run cannot complete itself: the - // root's own worker failed, or the cost counter has reached its limit. + // root's own worker failed, or the run has reached a limit a person set, + // in dollars or in time. // The word says which; the store keeps whatever the run reached. if s.limitHit { return OutcomeLimit From 35598352f0f00c18cf3f2c8f9a40282f709e97b6 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 19 Sep 2026 12:37:26 -0400 Subject: [PATCH 7/8] docs: the #1262 change entry Co-Authored-By: Claude Fable 5.1 --- .../unreleased/1262-a-time-limit-ends-a-run.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 docs/changes/unreleased/1262-a-time-limit-ends-a-run.md diff --git a/docs/changes/unreleased/1262-a-time-limit-ends-a-run.md b/docs/changes/unreleased/1262-a-time-limit-ends-a-run.md new file mode 100644 index 0000000000..8f91a3f1e8 --- /dev/null +++ b/docs/changes/unreleased/1262-a-time-limit-ends-a-run.md @@ -0,0 +1,14 @@ +--- +kind: fixed +title: a time limit a person sets now ends a run, by the same road as the cost limit +pr: 1262 +surface: [chat, engine] +invalidates: + - "A session's elapsed-time limit (`--max-hours`) never reached a run that `/task` started. `chatBudget` turns the flag into `session.Budget.Wall`, the interactive check reads it before a new turn, and nothing handed it to the run engine: a run carried a dollar limit (the conversation's spend ceiling) and no time limit at all, so it ran on past the hours its person set." + - "`RunSpec.Elapsed` now carries what is left of the session's wall when the run starts, measured on the same clock the interactive check uses, and `run.Limits.Elapsed` ends the run on it. The ending is the cost limit's: nothing new is launched, the outcome is `a limit you set stopped it`, and what finished lands the ordinary way. A session with no time limit puts none on its runs." + - "THE ONE DIFFERENCE FROM THE COST LIMIT IS THE WORK IN FLIGHT. A cost limit lets it finish. A time limit ends it, and absorbs each ending the way the caller's wall does, so a cut task reads `incomplete` instead of `running` and what it spent is in the run's account." + - "A run that ended on a limit with no result of its own now says the limit's sentence on its row. It said nothing there before." +--- + +`--max-cost` still does not reach a run: a run's dollar limit is the conversation's +spend ceiling. The manual says which limit is which. From d57b3c9557321ae7dbecf36efedb03d730f4bf12 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 19 Sep 2026 15:08:37 -0400 Subject: [PATCH 8/8] manual: the run's page points at the time limit's own section instead of repeating it The longer bullet pulled the costs section out from under the question 'how much will a task cost me'. Co-Authored-By: Claude Fable 5.1 --- internal/manual/chat/worker-harness.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/internal/manual/chat/worker-harness.md b/internal/manual/chat/worker-harness.md index 762e7cf17c..dafc0fbffb 100644 --- a/internal/manual/chat/worker-harness.md +++ b/internal/manual/chat/worker-harness.md @@ -405,10 +405,8 @@ requirement in them, and speed is no permission to skip the walk. allows — `/settings` → Spending → **per conversation** — which is off by default. The run's width and its dollar ceiling are the conversation's own numbers, so a run costs what the conversation costs and runs as wide as the conversation may. -- **The time limit.** A session opened with an elapsed-time limit (`--max-hours`) hands - a run whatever is left of it. When the time is up the run starts nothing new, ends the - work still going, brings home what had finished, and its row reads `incomplete` with - `a limit you set stopped it`. A session with no time limit puts none on its runs. +- **The time limit.** An elapsed-time limit on the session ends a run too: see + "Does a time limit stop a running task?" on the page about starting codeaf. - **The step cap.** A worker stops at **200** finished tool calls — the same figure a node worker carries. The cap is a bound on spend and not a finding about the work: the turn is stopped there rather than judged, and a worker stopped this