Skip to content
Closed
51 changes: 51 additions & 0 deletions cmd/codeaf/coredone_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package main

import (
"path/filepath"
"testing"
"time"

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

// THE ENVELOPE SAYS WHEN THE REQUESTED WORK WAS FIRST FOUND DONE, in seconds
// from the run's start, off the journal's own gate event — and leaves the key
// out when no gate ever said so, because a fabricated instant is worse than an
// absent one.
func TestTheEnvelopeSaysWhenTheRequestedWorkWasDone(t *testing.T) {
graph, err := store.Open(filepath.Join(t.TempDir(), "coredone.db"))
if err != nil {
t.Fatal(err)
}
defer graph.Close()

const gated, ungated = "session-gated", "session-ungated"
spliceForErrand(t, graph, gated, []store.NodeSpec{{ID: "task-1", Brief: "add the flag"}})
spliceForErrand(t, graph, ungated, []store.NodeSpec{{ID: "task-2", Brief: "add the other flag"}})
if err := graph.RecordDeliveryGate("task-1", store.DeliveryGate{Pass: true}); err != nil {
t.Fatal(err)
}
_, at, ok, err := graph.DeliveryGateAnchor("task-1")
if err != nil || !ok {
t.Fatalf("the passing gate has no anchor: ok=%t err=%v", ok, err)
}
started := at.Add(-7 * time.Second)

gatedOutcome := headlessOutcome{started: started}
priceErrand(graph, gated, 0, &gatedOutcome)
fields := envelopeFields(t, errandEnvelope(gatedOutcome))
got, present := fields["core_done_seconds"]
if !present {
t.Fatal("a gate found the work done and the envelope says nothing about when")
}
if seconds, ok := got.(float64); !ok || seconds < 6.9 || seconds > 7.1 {
t.Fatalf("core_done_seconds = %v, want about 7", got)
}

// And absent when no gate ever said the work was done.
ungatedOutcome := headlessOutcome{started: started}
priceErrand(graph, ungated, 0, &ungatedOutcome)
if _, present := envelopeFields(t, errandEnvelope(ungatedOutcome))["core_done_seconds"]; present {
t.Fatal("a run whose gate never found the work done reported when it was")
}
}
80 changes: 65 additions & 15 deletions cmd/codeaf/do.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,14 @@ type headlessOutcome struct {

Nodes int `json:"nodes"`
Seconds float64 `json:"seconds"`
Settled bool `json:"settled"`
// started is when this invocation opened, and coreDoneSeconds is how long
// it took to finish the requested work: the first moment a delivery gate
// found that work done. Both are unexported — they reach a caller only
// through the envelope's `core_done_seconds`, which is the one spelling
// every reader shares.
started time.Time
coreDoneSeconds float64
Settled bool `json:"settled"`
// unfinishedTree is the finished-tree reading's own sentence on the one run
// that cannot be called settled: its checks failed to collect. It stays
// unexported because the sentence leaves through Deliverable, while Settled
Expand Down Expand Up @@ -616,6 +623,7 @@ func errandRun(request doRequest, seats config.Seats, started time.Time) (outcom
return headlessOutcome{}, err
}
outcome.Seconds = time.Since(started).Seconds()
outcome.started = started
// The wall is the case that made this necessary. A leaf cancelled by the
// timeout journals its usage row on the way down, which is after the
// watcher has returned and — until this line moved the shutdown ahead of
Expand Down Expand Up @@ -656,6 +664,47 @@ 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 WHEN THE REQUESTED WORK WAS FIRST FOUND DONE, off the same journal.
outcome.coreDoneSeconds = errandCoreDoneSeconds(graph, session, outcome.started)
}

// errandCoreDoneSeconds is how long it took this errand to finish the requested
// work, measured from the run's start to the first delivery gate on one of its
// jobs that found the work done — a pass, or a coverage finding. It is the
// EARLIEST such moment across the errand's jobs, so a run that finished one part
// early and another late reports the first; a run whose gate never said so
// reports zero, which the envelope carries as an absent key rather than a
// fabricated instant.
func errandCoreDoneSeconds(graph *store.Store, session string, started time.Time) float64 {
if graph == nil || started.IsZero() {
return 0
}
nodes, err := graph.SessionMemberNodes(session)
if err != nil {
return 0
}
var earliest int64
var at time.Time
for _, node := range nodes {
if node.Parent != store.RootID {
continue
}
seq, when, ok, err := graph.DeliveryGateAnchor(node.ID)
if err != nil || !ok {
continue
}
if earliest == 0 || seq < earliest {
earliest, at = seq, when
}
}
if earliest == 0 {
return 0
}
seconds := at.Sub(started).Seconds()
if seconds < 0 {
return 0
}
return seconds
}

// errandRounds is how many times this errand bought MORE WORK: every growth
Expand Down Expand Up @@ -2970,20 +3019,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,
CoreDoneSeconds: outcome.coreDoneSeconds,
Model: outcome.Model,
Steps: outcome.Nodes,
Run: outcome.run,
Calls: outcome.calls,
Rounds: outcome.rounds,
Extra: legacyErrandFields(outcome),
})
}

Expand Down
50 changes: 31 additions & 19 deletions cmd/codeaf/envelope.go
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,13 @@ type resultEnvelope struct {
Tokens envelopeTokens `json:"tokens"`
// Seconds is how long it took, wall clock.
Seconds float64 `json:"seconds"`
// CoreDoneSeconds is when the requested work was FIRST found done, in
// seconds from the run's start: the first delivery gate that passed or left
// only a coverage finding — the fix committed, the checks for it still to
// come. It is omitted when no gate ever said so, and it is the figure a
// person reads to see how much of the run was the work and how much came
// after it.
CoreDoneSeconds float64 `json:"core_done_seconds,omitempty"`
// Model is the model the work ran on, as the seat ladder resolved it.
Model string `json:"model"`
// Steps is how many pieces of work ran: `do`'s nodes, `exec`'s turns. A
Expand Down Expand Up @@ -392,11 +399,15 @@ type runResult struct {
TokensIn int
TokensOut int
Seconds float64
Model string
Steps int
Run string
Calls int
Rounds int
// CoreDoneSeconds is `do`'s fact: when its gate first found the requested
// work done, read off the journal. `exec` and `run` have no delivery gate
// and leave it zero, which omits the key.
CoreDoneSeconds float64
Model string
Steps int
Run string
Calls int
Rounds 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 +432,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,
CoreDoneSeconds: result.CoreDoneSeconds,
Model: result.Model,
Steps: result.Steps,
Run: result.Run,
Calls: result.Calls,
Rounds: result.Rounds,
extra: result.Extra,
}
}

Expand Down
16 changes: 16 additions & 0 deletions docs/changes/unreleased/1100-coverage-round-buys-once.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
kind: changed
title: the coverage round buys once, and the envelope says when the requested work was done
pr: 1100
surface: [resident, docs]
invalidates:
- "An unexercised-only finding bought a repair round at every later delivery gate, because the finding stands until a measurement closes it and nothing bounded how many rounds `no check exercises this behaviour` could raise. A job buys at most one such round now (`maxUnexercisedRounds`), and a second finding is recorded unclosed rather than funded."
- "Growth was bounded only by the round counter, the job-size ceiling, the wall and the daily dollar rail. A share-of-spend rail (`growthSpendShare`) refuses a round once growth has spent more than the requested work itself cost, counted from the gate that first found that work done, and journaled as `CauseSpendShare`."
- "`codeaf do --json` carried no figure for when the requested work was finished. `core_done_seconds` now appears, in seconds from the start, when a delivery gate first passed or found only a coverage gap, and is absent when no gate said so."
---

An unexercised-only finding is a measurement, so it stands until a measurement
closes it — right, but unbounded, it raised the same test-writing round at every
gate. A run whose named fix was committed minutes in spent the rest of its wall
and most of its bill writing more tests. The round is bounded to one, and growth
past the point the work was done is bounded against the bill that got it done.
1 change: 1 addition & 0 deletions internal/manual/chat/running-from-the-terminal.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ stdout, always parseable, printed even when the run failed**:
| `spend_usd` | what it cost, whole, in dollars |
| `tokens` | `{"in": …, "out": …}` |
| `seconds` | wall clock |
| `core_done_seconds` | when the requested work was first found done, in seconds from the start; absent when the gate never said so |
| `model` | the model the work ran on |
| `steps` | how many pieces of work ran — `do`'s nodes, `exec`'s turns. A saved program does not measure it: the key is still there, holding `0`, and that `0` is a measurement nobody took rather than a count of none |
| `run` | this invocation's id. It names the folder `--debug` writes into, and every row this run wrote into `~/.codeaf/logs/calls.jsonl` carries it too — so `codeaf logs --run <that id>` is how you get from this object to the calls behind it. Empty on a verb that opened no run of its own |
Expand Down
72 changes: 72 additions & 0 deletions internal/resident/grow.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,14 @@ const (
// is not a cap and not a count: it is this job's OWN measured pace read
// against the clock it is actually running under.
CauseOutOfWall = "out-of-wall"

// CauseSpendShare is the round refused because growth has already spent more
// than the requested work itself cost, measured from the moment a gate first
// found that work done. It is a bound against the bill rather than against a
// count of rounds, and it exists because a count could not stop a run whose
// named fix landed at minute eleven from spending the rest of its wall and
// most of its bill on growth.
CauseSpendShare = "spend-share"
)

// The refusals in the words a person reads. They are constants because two of
Expand Down Expand Up @@ -142,8 +150,19 @@ const (
// it was going to end with: a judgement on what it did. So the job stops
// growing while there is still time to finish and be judged.
RefusedOutOfWall = "there is not enough time left on this run to finish another round of work, so this is handed over while there is still time to check it"

// The refusal that bounds growth against the bill. Once the requested work
// is done, a round that would take the total past twice what that work cost
// is refused and the job is handed over: the work the person asked for is
// finished, and what is left is growth they did not ask for.
RefusedSpendShare = "the requested work is done and growth has already doubled what it cost — handing over what's done"
)

// growthSpendShare bounds how much a job may spend on growth after its
// requested work is first found done: one means growth may at most double the
// bill that got the work done.
const growthSpendShare = 1.0

// GrowthStopped reports whether a refusal cause is one of the two the governor
// reaches by READING THE WORLD rather than by counting — the round before this
// one changed nothing and neither did the work before that, or the remaining
Expand Down Expand Up @@ -652,6 +671,20 @@ func growJob(ctx context.Context, graph *store.Store, ask Satisfier, req GrowReq
}
}

// 3b. Share of spend. Once the requested work is first found done, growth is
// bounded against the bill: a round is refused when more than
// [growthSpendShare] times the spend that got the work done has gone on
// growth since. The moment is the delivery gate's own first "done" — a
// pass or a coverage finding — and not this round's, so the rail holds
// across every later round. A job whose gate has never found its core
// work done, and a job with no session to bill, have no anchor and the
// rail does not apply: it refuses nothing it cannot measure.
if reach, err := growthShareReached(graph, req); err != nil {
return GrowVerdict{Round: round}, err
} else if reach {
return refuse(CauseSpendShare, RefusedSpendShare)
}

// 4. The only question that can say "there is nothing left to do".
//
// IT DOES NOT GET TO REFUSE THE FIRST ROUND AFTER A REVIEW FOUND
Expand Down Expand Up @@ -715,6 +748,45 @@ func growJob(ctx context.Context, graph *store.Store, ask Satisfier, req GrowReq
return GrowVerdict{Allow: true, Round: round, CoveredDespite: covered, Spent: req.spent}, nil
}

// growthShareReached answers whether a job has already spent more on growth
// than [growthSpendShare] times what the requested work cost, measured from the
// moment a gate first found that work done. The read is the one the daily rail
// uses — SpendSinceSeq over the errand's session — so the two rails bill the
// same rows and a run cannot read cheap here and dear there. A job with no gate
// that found its work done, or with no session to bill, has no anchor and the
// rail does not apply; a read that fails is returned rather than guessed at.
func growthShareReached(graph *store.Store, req GrowRequest) (bool, error) {
if graph == nil {
return false, nil
}
jobRoot := strings.TrimSpace(req.JobRoot)
if jobRoot == "" {
jobRoot = req.Node.ID
}
session := strings.TrimSpace(req.Node.Provenance.SessionID)
if session == "" {
return false, nil
}
anchor, _, ok, err := graph.DeliveryGateAnchor(jobRoot)
if err != nil || !ok {
return false, err
}
// The share is read against the spend UP TO the anchor: the total billed so
// far minus what has been billed since the gate. One query per window, both
// off the rail's own read, so the two halves cannot come from two different
// readings of the same journal.
total, err := graph.SpendSinceSeq(session, 0)
if err != nil {
return false, err
}
after, err := graph.SpendSinceSeq(session, anchor)
if err != nil {
return false, err
}
before := total.Cost() - after.Cost()
return after.Cost() > growthSpendShare*before, nil
}

// admitGrowth journals a round that actually landed. It is called after the
// splice rather than at the verdict, because the round counter is derived from
// these events and an admission that never spliced is a round nobody spent —
Expand Down
Loading
Loading