Skip to content
Closed
9 changes: 7 additions & 2 deletions cmd/codeaf/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -545,9 +545,14 @@ func buildBrain(w *chatWindow, session string, opts brainOptions) (*chatBrain, e
fanIn = store.DependencyFanIn{}
}
// Ordinary leaves retain the byte-identical headless envelope, raised by
// exactly what their own fan-in measures — see gatheringGrant. Reflexes use
// the deliberately tiny rung budget and a seconds-scale watchdog.
// exactly what their own fan-in measures — see gatheringGrant. A leaf
// re-dispatched after running out is granted more than the attempt that
// ran out, or it would buy the same truncated ending again — see
// regrantAfterRunningOut; the wall below follows the larger grant because
// it is arithmetic over the same number. Reflexes use the deliberately
// tiny rung budget and a seconds-scale watchdog.
turns, tokens := gatheringGrant(chatLeafTurns, chatLeafTokens, fanIn)
tokens = regrantAfterRunningOut(tokens, int(node.Attempt))
leafRoom := exec.SubharnessFor(subharness)
wallLeft := remainingWall(ctx)
deadline := leafRoom.DeadlineWithin(tokens, wallLeft)
Expand Down
57 changes: 57 additions & 0 deletions cmd/codeaf/context_budget_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,3 +208,60 @@ func TestAFoldsGrantIsSizedForThePassItActuallyMakes(t *testing.T) {
t.Fatalf("an unsized window granted %d tokens, want the process-wide %d", unknown, want)
}
}

// A RE-DISPATCH IS GRANTED MORE ROOM THAN THE ATTEMPT THAT RAN OUT, or the
// identical budget buys the identical truncated ending. The runner's overrun
// settle releases a leaf that ran out back to pending with its banked turns,
// and while the grant was attempt-blind the next claim re-entered the same
// brief under the same meter and stopped at the same place — one truncation,
// paid for as many times as the round cap allowed.
func TestARedispatchIsGrantedMoreRoomThanTheAttemptThatRanOut(t *testing.T) {
// Attempt zero keeps the grant byte for byte: a leaf on its first claim
// has run out of nothing yet.
if room := regrantAfterRunningOut(chatLeafTokens, 0); room != chatLeafTokens {
t.Fatalf("a first claim was granted %d tokens, want the flat %d it has always had",
room, chatLeafTokens)
}

// Half again per re-dispatch, compounding, and strictly more room each
// time — the growth is the whole point of sending a node round again.
previous := chatLeafTokens
for attempt := 1; attempt <= 3; attempt++ {
room := regrantAfterRunningOut(chatLeafTokens, attempt)
if room <= previous {
t.Fatalf("re-dispatch %d was granted %d tokens, no more than the %d before it",
attempt, room, previous)
}
previous = room
}
// The exact arithmetic, in the integers the grant is stated in: three
// halves, applied per attempt, never rounded up by a shortcut.
if got, want := regrantAfterRunningOut(chatLeafTokens, 2), chatLeafTokens*3/2*3/2; got != want {
t.Fatalf("attempt 2 was granted %d tokens, want the twice-grown %d", got, want)
}

// The ceiling holds at attempts no run can reach, and the ladder stops
// there rather than stepping over it.
for _, attempt := range []int{10, 100} {
if room := regrantAfterRunningOut(chatLeafTokens, attempt); room != overrunGrantCeiling {
t.Fatalf("attempt %d was granted %d tokens, want the ceiling %d",
attempt, room, overrunGrantCeiling)
}
}

// A leaf whose fan-in already measured more than the ceiling keeps every
// token it measured. The bound is on what the ladder adds, and a
// re-dispatch handed less room than its first attempt would be the
// identical-budget defect back by another door.
_, measured := gatheringGrant(chatLeafTurns, chatLeafTokens,
store.DependencyFanIn{Count: 8, Bytes: 8 << 20})
if measured <= overrunGrantCeiling {
t.Fatalf("the wide join measured %d tokens, too small to ask the question", measured)
}
for _, attempt := range []int{1, 3, 100} {
if room := regrantAfterRunningOut(measured, attempt); room != measured {
t.Fatalf("a join measured at %d tokens was re-dispatched at %d on attempt %d",
measured, room, attempt)
}
}
}
77 changes: 55 additions & 22 deletions cmd/codeaf/do.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,14 +116,16 @@ type headlessOutcome struct {
// unexported because the sentence leaves through Deliverable, while Settled
// is already the machine signal and the JSON contract needs no second key.
unfinishedTree string
// Run, Calls and Rounds are what a person went to `calls.jsonl` to
// reconstruct: which run this was, how many model calls it made, and how
// many times it bought more work after looking at what it had. They are
// unexported spellings of the envelope's own keys — the receipt reaches a
// caller through [errandEnvelope] and nowhere else.
run string
calls int
rounds int
// Run, Calls, Rounds and Redispatches are what a person went to
// `calls.jsonl` to reconstruct: which run this was, how many model calls it
// made, how many times it bought more work after looking at what it had,
// and how many times it sent a node round again in place after the node ran
// out of its room. They are unexported spellings of the envelope's own keys
// — the receipt reaches a caller through [errandEnvelope] and nowhere else.
run string
calls int
rounds int
redispatches int
// tokensIn and tokensOut are the token half of the bill, summed out of the
// same journal read that priced the run. They are unexported because they
// reach a caller only through the envelope's `tokens` field, which is the
Expand Down Expand Up @@ -656,6 +658,11 @@ func priceErrand(graph *store.Store, session string, openedAt int64, outcome *he
// work is read from what was written down, for the reason the bill is: a
// counter in this process could not see a round a resident spliced.
outcome.rounds = errandRounds(graph, session)
// AND THE RE-DISPATCHES OFF IT TOO. How many times this run sent a node
// round again in place is read from the releases that handed work on, for
// the same reason: the release is the record of a re-dispatch, and a
// counter in this process could not see one a resident made.
outcome.redispatches = errandRedispatches(graph, session)
}

// errandRounds is how many times this errand bought MORE WORK: every growth
Expand Down Expand Up @@ -685,6 +692,31 @@ func errandRounds(graph *store.Store, session string) int {
return rounds
}

// errandRedispatches is how many times one of this errand's nodes was sent
// round again in place after running out of the room it was granted: every
// hand-on release its nodes journaled (store.NodeRedispatches).
//
// It asks per node rather than across the store because a release is journaled
// against the node that was re-dispatched, and the rule is the bill's and the
// rounds': a run sharing a durable store with another session must not count
// that session's re-dispatches as its own. A read that fails leaves the count
// at zero rather than at a guess.
func errandRedispatches(graph *store.Store, session string) int {
nodes, err := graph.SessionMemberNodes(session)
if err != nil {
return 0
}
redispatches := 0
for _, node := range nodes {
counted, err := graph.NodeRedispatches(node.ID)
if err != nil {
continue
}
redispatches += counted
}
return redispatches
}

// headlessBrain builds and returns the brain this process will run, or nothing
// at all when another process is already the brain for this store.
//
Expand Down Expand Up @@ -2970,20 +3002,21 @@ func reportErrand(request doRequest, outcome headlessOutcome) error {
// `exec` and `run` from publishing three different objects again.
func errandEnvelope(outcome headlessOutcome) resultEnvelope {
return buildResultEnvelope(runResult{
Stop: outcome.resolvedStop(),
Answer: outcome.Deliverable,
Files: outcome.Artifacts,
Error: outcome.Error,
SpendUSD: outcome.Spend,
TokensIn: outcome.tokensIn,
TokensOut: outcome.tokensOut,
Seconds: outcome.Seconds,
Model: outcome.Model,
Steps: outcome.Nodes,
Run: outcome.run,
Calls: outcome.calls,
Rounds: outcome.rounds,
Extra: legacyErrandFields(outcome),
Stop: outcome.resolvedStop(),
Answer: outcome.Deliverable,
Files: outcome.Artifacts,
Error: outcome.Error,
SpendUSD: outcome.Spend,
TokensIn: outcome.tokensIn,
TokensOut: outcome.tokensOut,
Seconds: outcome.Seconds,
Model: outcome.Model,
Steps: outcome.Nodes,
Run: outcome.run,
Calls: outcome.calls,
Rounds: outcome.rounds,
Redispatches: outcome.redispatches,
Extra: legacyErrandFields(outcome),
})
}

Expand Down
67 changes: 67 additions & 0 deletions cmd/codeaf/do_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2330,3 +2330,70 @@ func TestAnErrandCountsTheRoundsItsOwnJobsBought(t *testing.T) {
"a neighbour's rounds as its own", got)
}
}

// TestAnErrandCountsTheRedispatchesItsOwnNodesBought is the figure beside the
// rounds, and the reason it comes off the journal rather than a counter.
//
// `do --json` said how many times a run went back for MORE but never how many
// times it sent a node round again IN PLACE — a leaf that ran out of its room
// going back on the queue to be carried on from, which is the other half of
// what a bill of re-runs is made of.
//
// IT IS COUNTED PER NODE AND NOT ACROSS THE STORE, for the reason the rounds
// are: a run sharing a durable store with another session must not report that
// session's re-dispatches as its own.
func TestAnErrandCountsTheRedispatchesItsOwnNodesBought(t *testing.T) {
path := filepath.Join(t.TempDir(), "redispatches.db")
graph, err := store.Open(path)
if err != nil {
t.Fatal(err)
}
defer graph.Close()

const mine, theirs = "session-mine", "session-theirs"
for _, job := range []struct{ id, session string }{
{"job-a", mine}, {"job-b", mine}, {"job-c", theirs},
} {
if err := graph.Splice(store.RootID, store.Subtree{Nodes: []store.NodeSpec{{
ID: job.id, Brief: "a job", Stage: 1,
}}}, store.Provenance{Origin: store.OriginUser, Intent: "a job", SessionID: job.session}); err != nil {
t.Fatal(err)
}
}
// Two hand-ons on one of this errand's nodes, one on another, a hand-back
// that carried nothing on — which is a claim taken off a worker, not a
// re-dispatch — and one on a node belonging to somebody else entirely.
for _, handed := range []struct {
node string
reason string
recorded int
}{
{"job-a", "it was still working when it ran out of its token budget", 3},
{"job-a", "it was still working when it ran out of its token budget", 3},
{"job-b", "it was still working when it ran out of its token budget", 2},
{"job-b", "no sign of life for the claim", 0},
{"job-c", "it was still working when it ran out of its token budget", 1},
} {
claim, ok, err := graph.Claim(handed.node, "chat-runner")
if err != nil || !ok {
t.Fatalf("claim %s: %v", handed.node, err)
}
if handed.recorded > 0 {
if err := graph.ReleaseWithRecord(claim, handed.reason, handed.recorded); err != nil {
t.Fatal(err)
}
continue
}
if err := graph.ReleaseWithReason(claim, handed.reason); err != nil {
t.Fatal(err)
}
}

if got := errandRedispatches(graph, mine); got != 3 {
t.Errorf("this errand's nodes were sent round again three times between them and it reports %d", got)
}
if got := errandRedispatches(graph, theirs); got != 1 {
t.Errorf("the other session re-dispatched once and it reports %d — a run may not "+
"count a neighbour's re-dispatches as its own", got)
}
}
69 changes: 42 additions & 27 deletions cmd/codeaf/envelope.go
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,19 @@ type resultEnvelope struct {
// both because a caller reaching for a key that vanished is a caller
// crashing.
Rounds int `json:"rounds"`
// Redispatches is how many times one of this run's nodes was re-dispatched
// IN PLACE after running out of the room it was granted — sent round again
// to carry on from what it had banked, rather than grown around. It is
// counted off the journal the way Rounds is (store.NodeRedispatches), for
// the same reason: a run sharing a durable store with another session must
// not count that session's re-dispatches as its own.
//
// It is the one contract field that is ABSENT rather than zero when it never
// happened, which is what `omitempty` buys and why it wears it where Rounds
// does not: a caller of this one is asking whether the run had to send
// anything round again, and "never" is the key not being there. `exec` and
// a saved program never set it, so neither ever carries it.
Redispatches int `json:"redispatches,omitempty"`

// extra is what one verb carries beyond the contract, and it is two things:
// the OLD field names, kept readable for one release so that a tool written
Expand Down Expand Up @@ -384,19 +397,20 @@ func (e resultEnvelope) MarshalJSON() ([]byte, error) {
// result type, and that mapping is the only place a verb's private shape and
// the public contract meet.
type runResult struct {
Stop stopReason
Answer string
Files []string
Error string
SpendUSD float64
TokensIn int
TokensOut int
Seconds float64
Model string
Steps int
Run string
Calls int
Rounds int
Stop stopReason
Answer string
Files []string
Error string
SpendUSD float64
TokensIn int
TokensOut int
Seconds float64
Model string
Steps int
Run string
Calls int
Rounds int
Redispatches int
// Extra is this verb's own fields: its old spellings, and whatever it knows
// that the contract has no room for. Nil for a verb with neither.
Extra map[string]any
Expand All @@ -421,20 +435,21 @@ func buildResultEnvelope(result runResult) resultEnvelope {
files = []string{}
}
return resultEnvelope{
OK: exitFor(stop) == exitDone,
Stop: stop,
Answer: result.Answer,
Files: files,
Error: result.Error,
SpendUSD: result.SpendUSD,
Tokens: envelopeTokens{In: result.TokensIn, Out: result.TokensOut},
Seconds: result.Seconds,
Model: result.Model,
Steps: result.Steps,
Run: result.Run,
Calls: result.Calls,
Rounds: result.Rounds,
extra: result.Extra,
OK: exitFor(stop) == exitDone,
Stop: stop,
Answer: result.Answer,
Files: files,
Error: result.Error,
SpendUSD: result.SpendUSD,
Tokens: envelopeTokens{In: result.TokensIn, Out: result.TokensOut},
Seconds: result.Seconds,
Model: result.Model,
Steps: result.Steps,
Run: result.Run,
Calls: result.Calls,
Rounds: result.Rounds,
Redispatches: result.Redispatches,
extra: result.Extra,
}
}

Expand Down
15 changes: 15 additions & 0 deletions cmd/codeaf/envelope_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -399,4 +399,19 @@ func TestTheEnvelopeNamesItsRunAndCountsItsCallsAndRounds(t *testing.T) {
if fields["rounds"] != float64(8) {
t.Errorf("rounds = %v, want the 8 rounds of work it bought", fields["rounds"])
}
// `redispatches` carries its count the same way, and is the one key that is
// absent rather than zero when it never happened — a caller of it is asking
// whether the run had to send anything round again, and "never" is the key
// not being there.
if _, present := fields["redispatches"]; present {
t.Errorf("redispatches = %v on a run that re-dispatched nothing; the key belongs "+
"to the runs it happened to", fields["redispatches"])
}
fields = envelopeFields(t, buildResultEnvelope(runResult{
Run: "0123456789abcdef", Calls: 422, Rounds: 8, Redispatches: 1,
}))
if fields["redispatches"] != float64(1) {
t.Errorf("redispatches = %v, want the one node the run sent round again in place",
fields["redispatches"])
}
}
Loading
Loading