From 46ef88be323aa9aab9ea242633a71acf4a953600 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 11 Nov 2026 19:04:34 -0500 Subject: [PATCH 1/4] test(session): isolate paired team fixture homes Give sharing fixtures their own paired home before custody saves, preserving explicit solo delivery and device cursor assertions. --- .../unreleased/0-post-wave-test-isolation.md | 11 ++++++++++ internal/session/team_locality_test.go | 7 +++++- internal/session/team_relay_test.go | 22 ++++++++----------- 3 files changed, 26 insertions(+), 14 deletions(-) create mode 100644 docs/changes/unreleased/0-post-wave-test-isolation.md diff --git a/docs/changes/unreleased/0-post-wave-test-isolation.md b/docs/changes/unreleased/0-post-wave-test-isolation.md new file mode 100644 index 0000000000..f3b3bf8df2 --- /dev/null +++ b/docs/changes/unreleased/0-post-wave-test-isolation.md @@ -0,0 +1,11 @@ +--- +kind: internal +title: Give shared team fixtures their own paired home +pr: 0 +surface: [engine, build] +invalidates: [] +--- + +Shared team fixtures create a paired identity in a test-owned home instead of +inheriting a solo identity from an earlier custody save. Explicit solo delivery +and device cursor assertions retain their original postures. diff --git a/internal/session/team_locality_test.go b/internal/session/team_locality_test.go index a54e221aeb..1341d37ac7 100644 --- a/internal/session/team_locality_test.go +++ b/internal/session/team_locality_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/identity" "github.com/Agent-Field/codeaf/internal/teams" ) @@ -20,7 +21,11 @@ func TestLocalTeamDeliverySurvivesMissingFilesAndLookupFailure(t *testing.T) { t.Fatal(err) } f := newTeamFixture(t, true) - enableSessionTeamSharing(t, f.profile) + // This fixture deliberately remains solo with sharing requested. + row, _ := config.NewSettings(config.SettingsOptions{ProfileDir: f.profile}).Row(config.KeyTeamsShare) + if err := row.Apply("on"); err != nil { + t.Fatal(err) + } a := teamAgent(t, f, f.manager, nil, manualRelayTeamClock) a.config.TeamComputers = func(context.Context, []teams.Member) (map[string]string, error) { return nil, errors.New("offline") } if err := teams.Update(f.profile, func(file *teams.File) error { diff --git a/internal/session/team_relay_test.go b/internal/session/team_relay_test.go index b9cc62be0b..d8d7909578 100644 --- a/internal/session/team_relay_test.go +++ b/internal/session/team_relay_test.go @@ -88,17 +88,13 @@ func TestTrafficMirrorNeverDeliversStartsQuestionsWakesOrStops(t *testing.T) { } func TestForeignDeliveryCursorStartsAtThisComputersOpeningAndDeliversOnce(t *testing.T) { - root := t.TempDir() - t.Setenv("CODEAF_HOME", root) - if _, err := identity.Ensure(root); err != nil { - t.Fatal(err) - } + f := newWakingTeamFixture(t) + enableSessionTeamSharing(t, f.profile) + root := home.Dir() dev, err := identity.Device(root) if err != nil { t.Fatal(err) } - f := newWakingTeamFixture(t) - enableSessionTeamSharing(t, f.profile) // A line from long before the chat arrived is history; the directive sent // a second before the engine opened is the one a rouse opened it for, which // a conversation meeting a team for the first time is always handed. @@ -207,12 +203,12 @@ func TestRemoteTeamSendsStartsPostsAndRousesRefuseBeforeTraffic(t *testing.T) { func enableSessionTeamSharing(t *testing.T, profile string) { t.Helper() - if _, err := identity.Load(home.Dir()); err != nil { - root := t.TempDir() - t.Setenv("CODEAF_HOME", root) - if _, err := identity.Ensure(root); err != nil { - t.Fatal(err) - } + // Custody saves in other fixtures can leave the process home solo. + // Sharing needs its own paired home, restored after this test's agents close. + root := t.TempDir() + t.Setenv("CODEAF_HOME", root) + if _, err := identity.Ensure(root); err != nil { + t.Fatal(err) } row, _ := config.NewSettings(config.SettingsOptions{ProfileDir: profile}).Row(config.KeyTeamsShare) if err := row.Apply("on"); err != nil { From 4d9b15f30f7082bc573c4d0f55977671a8f98457 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 11 Nov 2026 19:04:45 -0500 Subject: [PATCH 2/4] fix(headless): join cancelled accounting before closing usage Track leaf receipt settlement in the existing background wait, retain its ledger path, and close usage writers after command producers stop. Preserve the existing stop bounds and cover the real timeout door. --- cmd/codeaf/chat.go | 23 ++++++++---- cmd/codeaf/do.go | 2 ++ cmd/codeaf/keptnothing_test.go | 36 +++++++++++++++++++ .../unreleased/0-post-wave-test-isolation.md | 15 +++++--- internal/manual/chat/adaptive-runs.md | 12 +++++++ 5 files changed, 78 insertions(+), 10 deletions(-) diff --git a/cmd/codeaf/chat.go b/cmd/codeaf/chat.go index f68ec2024e..4ebe20a906 100644 --- a/cmd/codeaf/chat.go +++ b/cmd/codeaf/chat.go @@ -955,6 +955,7 @@ func buildBrain(w *chatWindow, session string, opts brainOptions) (*chatBrain, e // Every call this leaf makes, banked under its node as the provider // answers. See leafBanker. banker := newLeafBanker(graph, node.ID) + banker.receipts = &brain.background var spent exec.Usage // The same spend with its per-turn shape kept, accumulated across every // attempt this node makes. See leafShape. @@ -2673,10 +2674,14 @@ func atLeastZero(count int) int { // and a leaf whose calls were banked here is a leaf whose total is already on // disk (see resident.ExecResult.SpendBanked). type leafBanker struct { - graph *store.Store - nodeID string - mutex sync.Mutex - rows int + usageLedger string + // Cancelled arms can still owe receipts after the worker lands. Join them + // within the brain's existing background stop bound before closing its store. + receipts *sync.WaitGroup + graph *store.Store + nodeID string + mutex sync.Mutex + rows int // total is what this banker has actually written, so the landing can journal // the REMAINDER rather than the whole — see leafSpend. total exec.Usage @@ -2687,7 +2692,7 @@ type leafBanker struct { } func newLeafBanker(graph *store.Store, nodeID string) *leafBanker { - return &leafBanker{graph: graph, nodeID: nodeID} + return &leafBanker{graph: graph, nodeID: nodeID, usageLedger: session.UsageLedgerPath()} } // bank writes one call's row. It is called from the provider's decode, which is @@ -2729,7 +2734,7 @@ func (b *leafBanker) reconciled(receipt provider.Reconciled) { return } if !receipt.Found { - session.RecordUnbilledCall(session.UsageLedgerPath(), session.UsageLine{Task: b.nodeID, Model: receipt.Model}) + session.RecordUnbilledCall(b.usageLedger, session.UsageLine{Task: b.nodeID, Model: receipt.Model}) return } b.bank(receipt.Billed) @@ -2756,6 +2761,12 @@ func armBilling(ctx context.Context, banker *leafBanker) context.Context { return ctx } ctx = provider.WithCallNode(ctx, banker.nodeID) + if banker.receipts != nil { + ctx = provider.WithReceiptSettlement(ctx, func() func() { + banker.receipts.Add(1) + return banker.receipts.Done + }) + } ctx = provider.WithBilling(ctx, banker.bank) return provider.WithReconcile(ctx, banker.reconciled) } diff --git a/cmd/codeaf/do.go b/cmd/codeaf/do.go index 88f9d5208b..a6421b07a8 100644 --- a/cmd/codeaf/do.go +++ b/cmd/codeaf/do.go @@ -572,6 +572,8 @@ func applyContextLaw(fillPercent, completionReserve int) error { // to main, which says it on stderr — an ordinary run has no object to put it in // and never wanted one. func doErrand(request doRequest) error { + // Producers stop before the process-owned usage writers are joined. + defer session.CloseUsage() started := time.Now() // The two seats, resolved before anything is opened or built, so the run // says which models it is about to use and on whose authority — and says it diff --git a/cmd/codeaf/keptnothing_test.go b/cmd/codeaf/keptnothing_test.go index 1fe948d785..0257008c49 100644 --- a/cmd/codeaf/keptnothing_test.go +++ b/cmd/codeaf/keptnothing_test.go @@ -12,6 +12,8 @@ import ( "strings" "testing" "time" + + "github.com/Agent-Field/codeaf/internal/session" ) // TestARefusedRunThatWroteNothingNamesTheDirectory proves C1: a refused run @@ -341,3 +343,37 @@ func TestAPriceRefusalDoesNotClaimTheRunEditedAWorkspace(t *testing.T) { t.Fatalf("a price refusal claims it worked in the directory: %q", got) } } + +// A cancelled provider arm can owe an unbilled marker after the worker lands. +// The command owns both that producer and the writer it starts. +func TestDoReturnsAfterCancelledAccountingAndItsWriterStop(t *testing.T) { + session.CloseUsage() + t.Setenv("CODEAF_HOME", t.TempDir()) + script := newScriptedBrain(t) + script.stall = true + defer script.close() + t.Cleanup(session.CloseUsage) + var stdout, stderr strings.Builder + err := doErrand(doRequest{task: "write the release note and include migration steps", workspace: t.TempDir(), + timeout: 2 * time.Second, stdout: &stdout, stderr: &stderr, newClient: script.client}) + var status exitStatus + if !asExitStatus(err, &status) || status != exitLimit { + t.Fatalf("timeout exit = %v; stderr %s", err, stderr.String()) + } + rows, err := session.ReadUsage(session.UsageLedgerPath(), time.Time{}) + if err != nil { + t.Fatal(err) + } + found := false + for _, row := range rows { + if row.Unbilled && row.Task != "" { + found = true + } + } + if !found { + t.Fatalf("command returned before cancelled-call accounting: %+v", rows) + } + if n := liveUsageWriters(); n != 0 { + t.Fatalf("command returned with %d usage writers still running", n) + } +} diff --git a/docs/changes/unreleased/0-post-wave-test-isolation.md b/docs/changes/unreleased/0-post-wave-test-isolation.md index f3b3bf8df2..0a8274a40a 100644 --- a/docs/changes/unreleased/0-post-wave-test-isolation.md +++ b/docs/changes/unreleased/0-post-wave-test-isolation.md @@ -1,11 +1,18 @@ --- -kind: internal -title: Give shared team fixtures their own paired home +kind: fixed +title: Join cancelled headless accounting before returning and isolate shared team fixtures pr: 0 -surface: [engine, build] -invalidates: [] +surface: [chat, engine, build, docs] +invalidates: + - "A local headless task could return before cancelled-call accounting created its usage writer; it now joins that producer before closing usage." --- +Headless tasks join admitted leaf receipt settlement within the existing background +stop wait, then close the process usage writers under their existing bound. A +leaf's late accounting retains the ledger chosen when the leaf was constructed. +Cancelled requests still keep their unbilled markers, and journal sync and retry +retain their existing durability and shutdown limits. + Shared team fixtures create a paired identity in a test-owned home instead of inheriting a solo identity from an earlier custody save. Explicit solo delivery and device cursor assertions retain their original postures. diff --git a/internal/manual/chat/adaptive-runs.md b/internal/manual/chat/adaptive-runs.md index 6837d68522..4abed82c86 100644 --- a/internal/manual/chat/adaptive-runs.md +++ b/internal/manual/chat/adaptive-runs.md @@ -983,6 +983,18 @@ no turns at all is not offered again either, because there is nothing for a next carry on from, and it says that instead: `…with none of its work recorded, so there was nothing for another attempt to carry on from`. +## What do and exec wait for at shutdown — usage writes after a timeout + +`codeaf do` joins its cancelled workers' admitted receipt accounting before closing +the process's usage writers, including when the run times out. Receipt settlement +shares the existing five-second background stop wait. Usage shutdown has a separate +two-second bound. With a responsive provider and disk, the command returns after +those accounting writes finish. A stalled provider or disk can exhaust a bound; +complete rows already in the pending usage journal remain recoverable on restart. + +`codeaf exec` also closes its usage writer before returning, under the same +two-second usage shutdown bound. + ## How long a headless run gets — the `-timeout` wall, how long does codeaf do wait, can I write 5m or 2h `codeaf do` runs under a hard wall, and `-timeout` is where you set it. **Left alone it is From 10055b67f89e70742f6a40094f87379567fdd5aa Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 11 Nov 2026 19:30:57 -0500 Subject: [PATCH 3/4] docs: say when a headless run's late accounting is joined The manual page for adaptive runs states the usage-writer close every run gets and the bound it works within; the change entry records what was true before and what is true now. Co-Authored-By: Claude Fable 5.2 --- .../unreleased/0-post-wave-test-isolation.md | 7 ++++--- internal/manual/chat/adaptive-runs.md | 13 +++++++------ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/docs/changes/unreleased/0-post-wave-test-isolation.md b/docs/changes/unreleased/0-post-wave-test-isolation.md index 0a8274a40a..7016cf8ae6 100644 --- a/docs/changes/unreleased/0-post-wave-test-isolation.md +++ b/docs/changes/unreleased/0-post-wave-test-isolation.md @@ -7,9 +7,10 @@ invalidates: - "A local headless task could return before cancelled-call accounting created its usage writer; it now joins that producer before closing usage." --- -Headless tasks join admitted leaf receipt settlement within the existing background -stop wait, then close the process usage writers under their existing bound. A -leaf's late accounting retains the ledger chosen when the leaf was constructed. +The node engine joins admitted leaf receipt settlement within its existing +background stop wait. Every `codeaf do` invocation closes the process usage writers +after its workers stop, under the existing usage close bound. Each leaf's late +accounting retains the ledger chosen when the leaf was constructed. Cancelled requests still keep their unbilled markers, and journal sync and retry retain their existing durability and shutdown limits. diff --git a/internal/manual/chat/adaptive-runs.md b/internal/manual/chat/adaptive-runs.md index 4abed82c86..b16351576c 100644 --- a/internal/manual/chat/adaptive-runs.md +++ b/internal/manual/chat/adaptive-runs.md @@ -985,12 +985,13 @@ nothing for another attempt to carry on from`. ## What do and exec wait for at shutdown — usage writes after a timeout -`codeaf do` joins its cancelled workers' admitted receipt accounting before closing -the process's usage writers, including when the run times out. Receipt settlement -shares the existing five-second background stop wait. Usage shutdown has a separate -two-second bound. With a responsive provider and disk, the command returns after -those accounting writes finish. A stalled provider or disk can exhaust a bound; -complete rows already in the pending usage journal remain recoverable on restart. +`codeaf do` closes the process's usage writers after its workers stop, including +when the run times out. With `CODEAF_TASK_BELT=node`, cancelled workers' admitted +receipt accounting joins inside the existing five-second background stop wait. +Usage shutdown has a separate two-second bound. With a responsive provider and +disk, the command returns after those accounting writes finish. A stalled +provider or disk can exhaust a bound; complete rows already in the pending usage +journal remain recoverable on restart. `codeaf exec` also closes its usage writer before returning, under the same two-second usage shutdown bound. From 053f6476ca3f4dfe45d66f2cbcfd96bf89799a44 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 11 Nov 2026 19:31:15 -0500 Subject: [PATCH 4/4] changes: the post-wave test isolation entry names its pull request Co-Authored-By: Claude Fable 5.2 --- ...-wave-test-isolation.md => 2604-post-wave-test-isolation.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename docs/changes/unreleased/{0-post-wave-test-isolation.md => 2604-post-wave-test-isolation.md} (99%) diff --git a/docs/changes/unreleased/0-post-wave-test-isolation.md b/docs/changes/unreleased/2604-post-wave-test-isolation.md similarity index 99% rename from docs/changes/unreleased/0-post-wave-test-isolation.md rename to docs/changes/unreleased/2604-post-wave-test-isolation.md index 7016cf8ae6..7f22a6dfe7 100644 --- a/docs/changes/unreleased/0-post-wave-test-isolation.md +++ b/docs/changes/unreleased/2604-post-wave-test-isolation.md @@ -1,7 +1,7 @@ --- kind: fixed title: Join cancelled headless accounting before returning and isolate shared team fixtures -pr: 0 +pr: 2604 surface: [chat, engine, build, docs] invalidates: - "A local headless task could return before cancelled-call accounting created its usage writer; it now joins that producer before closing usage."