Skip to content
Draft
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
33 changes: 10 additions & 23 deletions cmd/codeaf/do.go
Original file line number Diff line number Diff line change
Expand Up @@ -3927,44 +3927,31 @@ func runSpendBound(request doRequest, profileDir string, now time.Time) (runSpen
if err != nil {
return runSpend{}, err
}
daily, err := config.DailyBudgetUSDAt(profileDir)
if err != nil {
return runSpend{}, err
}
bound := runSpend{}
if consent > 0 {
bound = runSpend{usd: consent, stop: stopPrice, words: fmt.Sprintf(
"the run reached $%.2f, the price above which codeaf asks before it spends more; "+
"rerun with --yes-spend to let it go past that", consent)}
}
if daily > 0 {
left := daily - spentToday(now)
if left <= 0 {
return runSpend{refused: true, stop: stopBudget, words: fmt.Sprintf(
"today's spending limit of $%.2f is spent, so nothing was started; "+
"rerun with --yes-spend to spend past it", daily)}, nil
daily, err := session.DailySpendAt(profileDir, now)
if err != nil {
return runSpend{}, err
}
if daily.Limit > 0 {
left := daily.Limit - daily.Spent
if daily.Reached || left <= 0 {
return runSpend{refused: true, stop: stopBudget, words: session.DailySpendAction(daily.Limit) +
"; rerun with --yes-spend to spend past it"}, nil
}
if bound.usd == 0 || left < bound.usd {
bound = runSpend{usd: left, stop: stopBudget, words: fmt.Sprintf(
"the run reached what was left of today's spending limit of $%.2f; "+
"rerun with --yes-spend to spend past it", daily)}
"rerun with --yes-spend to spend past it", daily.Limit)}
}
}
return bound, nil
}

// spentToday is what today has cost on this machine, read off the usage ledger
// every conversation and every run worker writes ([session.SpendToday]). A
// ledger that cannot be read is a day that has spent nothing as far as this
// door can tell; the plan-price rung still bounds the run.
func spentToday(now time.Time) float64 {
lines, err := session.ReadUsage(session.UsageLedgerPath(), now.Add(-48*time.Hour))
if err != nil {
return 0
}
return session.SpendToday(lines, now)
}

// crewCompleters turns the run road's provider seam into the per-model
// completer [runengine.CrewFactory] asks for. The factory reads the crew at every
// launch, so a task's seat model is not known until the task is handed over;
Expand Down
12 changes: 12 additions & 0 deletions docs/changes/unreleased/1670-task-run-audit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
kind: fixed
title: Held work, kept branches and spend limits say what is true
pr: 1670
surface: [chat, engine, remote, docs]
invalidates:
- "A chat turn or a chat task past today's spending limit was refused with a bare line, or a task said it started and then sat held. Both now ask first with a card to raise the limit for today or stop, and the top bar shows a raised limit at once."
- "The read hand-off helper could write, commit and merge. It is now read-only, and a failed hand-off no longer leaves a stopped card for a helper that never started."
- "Every check in a run drew from one shared ceiling, so later checks stopped having spent almost nothing, and a run with unfinished checks could end as done. Each check now has its own ceiling, and unfinished checks fail the run and are named."
- "A kept task or run branch was invisible to /land, and the landing line gave no reason. /land now lists it, reaches the engine over the local host, and says why the branch was kept."
- "A scheduled firing that only reported a sentence was logged as landed. It now comes to said."
---
12 changes: 12 additions & 0 deletions internal/manual/chat/choosing-a-folder.md
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,18 @@ and notes · say which one · /land agentfield`. Then `/land agentfield now`.
**A folder lands whole.** There is no way to land some of the files and keep the rest
today — you either put the folder's changes in or leave them waiting.

## /land after a task kept its branch in this folder

A task's retained branch is waiting even when it belongs to the folder the
conversation is standing in. `/land` shows the folder and changed files;
`/land now` explicitly merges that branch into your current checkout, including
`main`. Automatic task landing still leaves protected branches alone.

This works through the local session host used by ordinary chat, and the waiting
branch remains available after reopening the conversation. A dirty checkout or a
merge conflict can still prevent the merge; the retained branch stays available.
The `--host` landing restriction is unchanged.

## Work in a folder directly, without keeping the changes aside

Say so in your own words — "work in ~/code/notes directly", "edit it in place", "just
Expand Down
31 changes: 14 additions & 17 deletions internal/manual/chat/models-and-cost.md
Original file line number Diff line number Diff line change
Expand Up @@ -3003,8 +3003,11 @@ one it was:
row to `none` and it never asks.
- **`per conversation` — it stops.** `conversation limit reached · $2.05 spent of $2 ·
/budget changes it`. The section on that below has the whole of it.
- **`per day` — the day's work waits.** When the day's calls reach the daily limit, new
work waits for midnight or for you to raise it. `/budget 800` raises it where you stand.
- **`per day` — the day's work asks before it starts.** When today's calls reach the daily
limit, a new chat turn or `/task` opens the same two-choice card used for a bounded team:
`1 Raise to $X` or `2 Stop for today`. Raise it to continue with that larger limit for
today's local day; stop refuses the work with `today's spending limit of $X is spent, so
nothing was started`. Headless `codeaf do` keeps its `--yes-spend` escape hatch.
- **A task's own cap.** A task started from the composer layer (`alt+enter`) carries the
figure on that layer's third line — `it may spend up to $100.00 before it asks` — and
stops before its next turn when it reaches it. That figure is set where the task is
Expand Down Expand Up @@ -3106,21 +3109,15 @@ conversation has spent four fifths of its own limit — the figure leaves the di
nothing else changes. With no `per conversation` limit set there is no fraction and no
colour.

## I started a task after my dollar limit was spent — why did it still pay for a call

**A task started after this conversation's dollar limit is already spent still gets
one paid call before it ends.** `/task` is not a turn, so the refusal that stops the
next turn — `conversation limit reached · … · /budget changes it` — is not asked in
front of it. The run is handed the smallest figure above nothing rather than zero,
because zero would mean no limit at all. Its first worker makes one model call, that
call puts the run over the figure, and the run ends there: its row says
`a dollar limit you set stopped it`. The call is small, but it is real money, and it
shows in `/cost`.

The dollar limit here is the smaller of `per conversation` and `--max-cost`, measured
against what this conversation has already spent. To let the task do its work, raise
`per conversation` first — `/budget conversation 20`, or `/budget conversation none`
to remove it — or relaunch with a larger `--max-cost`, then start the task again.
## I started a task after my daily limit was spent — why did it wait

**A chat turn and a `/task` both stop before a provider call when today's daily limit
is already spent.** The card offers `Raise to $X` and `Stop for today`, just like a
bounded team. Raising persists the larger limit for today's local day and resumes the
held turn or task. `/task` and approved `propose_task` work are not started or
listed as working while this card waits. The top bar shows the raised limit.
Stopping says `today's spending limit of $X is spent, so nothing was
started`. The per-conversation limit and a task's own cap remain separate rails.

`codeaf do` has no such call: when today's spending limit is already spent it starts
nothing and says, for a $5 limit,
Expand Down
27 changes: 20 additions & 7 deletions internal/manual/chat/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -1728,6 +1728,7 @@ the card, the rail, the roster and in the chat:
| its brief no longer described the world | `incomplete · its brief went stale` |
| it would not take a step it was asked to | `incomplete · would not take a step it was asked to` |
| a check looked and named what is missing | `incomplete · the check found gaps: <the gaps>` |
| a fan-out check did not finish | `incomplete · unfinished checks: <the check names>` |
| something broke | `incomplete · a fault: <the first line of the error>` |

**`incomplete` is not `stopped`.** `stopped` is *you* ending the work and means nothing else
Expand Down Expand Up @@ -1835,12 +1836,18 @@ After the name the card carries the span, the file count, and how the branch cam
`merged`, `in your own folder`, `conflicted · <branch>`, or `branch kept · <branch>` —
each its own fact, so a task you ended reads `stopped · branch kept · <branch>`.

`branch kept · <branch>` on a **done** task means the work finished but your checkout was
on a protected branch, was on a different branch than when the work was cut, moved
to a different commit by your own work after the cut, or was detached. The branch
named there holds the finished work; the how-tasks-run page explains the exact reason.
`branch kept · <branch> · <reason>` on a **done** task means the work finished but your
checkout was on a protected branch, was on a different branch than when the work was
cut, moved to a different commit by your own work after the cut, or was detached. For
example, it can say `branch kept · task/port · your checkout is on main, which tasks do
not merge into automatically`. The branch named there holds the finished work; the
how-tasks-run page explains the exact reason.
Inspect that branch and keep the delivery workflow you requested. A task finishing
does not by itself request a merge or a checkout change.
does not by itself request a merge or a checkout change. If you want codeaf to bring
the retained work into the checkout, `/land` lists the waiting folder and `/land now`
merges the named branch, whether it came from one task or a retained run. This also
works in an ordinary local-host conversation, before and after reopening it, including
when the task's repository is the folder the conversation is standing in.

Click anywhere on the card, or press `ctrl+o` with it selected, to expand it. `enter` on the
selected card opens the task's room instead. What the expansion holds, and in what order, is
Expand Down Expand Up @@ -4390,6 +4397,11 @@ today's crew spend ($5.01) has reached the daily cap of $5.00 · raise it or tur

The day turns over at midnight on this machine's clock, and the spend starts again from nothing.

The machine-wide daily limit in `/settings` → **Spending** and `/budget` is a separate
rail over every chat turn and task. If it is already spent, a task opened from chat waits
on the same card shape: `1 Raise to $X` continues with that amount for today, and `2 Stop
for today` refuses it with `today's spending limit of $X is spent, so nothing was started`.

## Naming a model for one task

You ask in words — "let opus do this one", "run that on gpt-5". There is no key, command or
Expand Down Expand Up @@ -4681,8 +4693,9 @@ Whenever a task stops for any reason it wears its own word — `stopped` when yo
`incomplete · <the reason>` otherwise — with `branch kept` and the branch name beside it.
Nothing is thrown away: on every ending except a clean merge the branch is kept and named,
and what the task made is committed onto that branch before it lands — so the files it
produced are listed under `changed:` and `git merge task/…` brings them over. The merge is
never done for you, because only work that was checked reaches your branch.
produced are listed under `changed:` and `git merge task/…` brings them over. A kept
branch from either a single task or a retained run appears in the `/land` waiting list;
`/land now` is the explicit merge door when you want that work in your checkout.

## Continue task N — keep going on a failed or finished task, No task 1 in this project

Expand Down
2 changes: 1 addition & 1 deletion internal/remote/callclass.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ func classify(method string) callClass {
MethodAttachedSkills, MethodSkillShelf,
MethodSessionsRecent, MethodHeldQuestions,
MethodStandingItems, MethodStandingWatch,
MethodPlacesWorld, MethodPlacesTask, MethodPlacesLedger, MethodPlacesSearch,
MethodPlacesWorld, MethodPlacesTask, MethodPlacesLedger, MethodPlacesSearch, MethodLandingPreview,
MethodMemorySnapshot, MethodMemoryChanged, MethodMemoryList, MethodMemoryProvenance,
MethodTaskRoom, MethodTaskPending, MethodTaskEffort,
MethodListDir, MethodStatPaths, MethodFetchFile,
Expand Down
81 changes: 81 additions & 0 deletions internal/remote/landing.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package remote

import (
"encoding/json"
"errors"

"github.com/Agent-Field/codeaf/internal/session"
)

// These additive doors carry explicit landing gestures to the session that
// owns the checkout. Ordinary local chat reaches that session through this wire.
const (
MethodLandingPreview = "Places.LandingPreview"
MethodLand = "Places.Land"
)

type folderLander interface {
LandingFor(string) (session.FolderLanding, bool)
Land(string) (session.FolderLanding, error)
}

type landingPreview struct {
Landing session.FolderLanding `json:"landing"`
Found bool `json:"found"`
}

func (s *server) landingCall(call Frame) (json.RawMessage, bool, error) {
folder, err := arg[string](call)
if err != nil {
return nil, true, err
}
door, ok := s.session.current().(folderLander)
if !ok {
return nil, true, errors.New("this conversation cannot land changes")
}
if call.Method == MethodLandingPreview {
landing, found := door.LandingFor(folder)
payload, err := json.Marshal(landingPreview{Landing: landing, Found: found})
return payload, true, err
}
landing, err := door.Land(folder)
if err != nil {
return nil, true, err
}
// Every attached window must lose the waiting row when the work lands.
s.session.announce()
payload, err := json.Marshal(landing)
return payload, true, err
}

// UnlandedChanges is a memory read because the composer asks on every frame.
func (a *Agent) UnlandedChanges() []session.StandingChange {
return a.c.facts.read().Unlanded
}

// LandingFor asks the owning session only when the person requests a preview.
func (a *Agent) LandingFor(folder string) (session.FolderLanding, bool) {
payload, err := a.c.call(nil, MethodLandingPreview, folder)
if err != nil {
return session.FolderLanding{}, false
}
var preview landingPreview
if err := json.Unmarshal(payload, &preview); err != nil {
return session.FolderLanding{}, false
}
return preview.Landing, preview.Found
}

// Land keeps Git and copy operations on the session's machine, including when
// the window and its local session host are separate processes on one machine.
func (a *Agent) Land(folder string) (session.FolderLanding, error) {
payload, err := a.c.call(nil, MethodLand, folder)
if err != nil {
return session.FolderLanding{}, err
}
var landing session.FolderLanding
if err := json.Unmarshal(payload, &landing); err != nil {
return session.FolderLanding{}, err
}
return landing, nil
}
74 changes: 74 additions & 0 deletions internal/remote/landing_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package remote

import (
"errors"
"reflect"
"testing"

"github.com/Agent-Field/codeaf/internal/session"
)

var _ folderLander = (*session.Agent)(nil)

type landingTestAgent struct {
*fakeAgent
waiting []session.StandingChange
result session.FolderLanding
fail error
}

func (a *landingTestAgent) UnlandedChanges() []session.StandingChange { return a.waiting }
func (a *landingTestAgent) LandingFor(folder string) (session.FolderLanding, bool) {
return a.result, len(a.waiting) > 0 && folder == a.result.Folder
}
func (a *landingTestAgent) Land(string) (session.FolderLanding, error) {
if a.fail == nil {
a.waiting = nil
}
return a.result, a.fail
}

func TestLandingCrossesTheHostAndWaitingReadsStayOffTheWire(t *testing.T) {
for _, refuse := range []bool{false, true} {
t.Run(map[bool]string{false: "lands", true: "refuses"}[refuse], func(t *testing.T) {
far := &landingTestAgent{fakeAgent: &fakeAgent{model: "m"}, waiting: []session.StandingChange{{Folder: "/srv/repo", Name: "repo", Files: 1}}, result: session.FolderLanding{Folder: "/srv/repo", Name: "repo", Files: []string{"README.md"}, Merged: "merged"}}
if refuse {
far.fail = errors.New("landing refused")
}
loop := foldersLoop(t, far)
door, ok := any(loop.Client.Agent()).(interface {
UnlandedChanges() []session.StandingChange
LandingFor(string) (session.FolderLanding, bool)
Land(string) (session.FolderLanding, error)
})
if !ok {
t.Fatal("the local-host adapter has no landing door")
}
if got := door.UnlandedChanges(); !reflect.DeepEqual(got, far.waiting) {
t.Fatalf("welcome waiting = %+v", got)
}
if got, ok := door.LandingFor("/srv/repo"); !ok || !reflect.DeepEqual(got, far.result) {
t.Fatalf("preview = %+v, %t", got, ok)
}
if _, ok := door.LandingFor("/srv/other"); ok {
t.Fatal("preview invented a folder")
}
got, err := door.Land("")
if refuse {
if err == nil || err.Error() != far.fail.Error() {
t.Fatalf("refusal = %v", err)
}
} else if err != nil || !reflect.DeepEqual(got, far.result) {
t.Fatalf("landing = %+v, %v", got, err)
}
if err := loop.Close(); err != nil {
t.Fatal(err)
}
// The last stated waiting set remains readable after disconnection, so
// repainting cannot depend on an RPC or silently erase retained work.
if got := door.UnlandedChanges(); !reflect.DeepEqual(got, far.waiting) {
t.Fatalf("cached waiting = %+v, want %+v", got, far.waiting)
}
})
}
}
2 changes: 2 additions & 0 deletions internal/remote/places.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ func (s *server) placesCall(call Frame) (json.RawMessage, bool, error) {
sess.mu.Unlock()

switch call.Method {
case MethodLandingPreview, MethodLand:
return s.landingCall(call)
case MethodPlacesArchive:
args, err := arg[ArchiveArgs](call)
if err != nil {
Expand Down
7 changes: 1 addition & 6 deletions internal/remote/surfacedoors_law_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,6 @@ var doorsThatHaveNotCrossed = map[string]absentDoor{
says: "memory is off for this session · turn it on under /settings",
loses: "/remember, /forget, /memories, /memory <query> and the memory place — memory is not off, it is unreachable",
},
"folderLander": {
says: "nothing is waiting · what this conversation writes in the folder it is standing in is already there",
loses: "/land; and the `changes for … · /land` row above the box goes quiet too",
},
"subharnessAgent": {
says: "no subharnesses here yet — a subharness is a saved program for work that comes round again.",
loses: "the subharness list, its intake form and running one",
Expand Down Expand Up @@ -120,14 +116,13 @@ var doorsThatHaveNotCrossed = map[string]absentDoor{
"taskWeightDoor": {loses: "one task's context tokens (the conversation's own ContextTokens crosses; the task's does not)"},
"turnResumer": {loses: "resuming a turn that was stopped"},
"wakeAgent": {loses: "reading wakes"},
"interface{ LandingFor/1/2 }": {loses: "the landing a folder already has, beside folderLander"},
"interface{ PendingConsent/0/1 }": {loses: "which approvals are still open when a surface detaches"},
}

// surfaceDoorLedger is the ratchet: the ledger above may shrink and may never
// grow, and shrinking it without lowering this number in the same commit is a
// red as well ([ratchetComplaint]).
const surfaceDoorLedger = 21
const surfaceDoorLedger = 19

// TestEverySurfaceDoorTheEngineHasCrossesTheWire is the law above.
func TestEverySurfaceDoorTheEngineHasCrossesTheWire(t *testing.T) {
Expand Down
Loading
Loading