From caa6d393e0dc5831814448f31c69e33a4d01ed4e Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Sat, 26 Sep 2026 22:32:42 -0400 Subject: [PATCH 01/76] plandb: check terminal status before ownership check in Done, prune search guidance In Store.Done(), check if task is already terminal before checking requireOwner(). When a task is cancelled upstream, ClaimedBy is cleared or empty, which previously caused Done to fail obscurely with 'task is not claimed'. Checking terminal status first reports an explicit and actionable error: 'task is already terminal (cancelled)'. Also update bashworker prompt guidance to prune heavy directories when running find, preventing workers from freezing across deep trees. Fixes #1561 Assisted-by: CodeAF (gemini-3.8-flash-high) Co-Authored-By: CodeAF <267109073+agentfield-bot@users.noreply.github.com> --- internal/plandb/done_terminal_test.go | 21 +++++++++++++++++++++ internal/plandb/store.go | 3 +++ internal/session/prompts/bashworker.md | 3 ++- 3 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 internal/plandb/done_terminal_test.go diff --git a/internal/plandb/done_terminal_test.go b/internal/plandb/done_terminal_test.go new file mode 100644 index 0000000000..8719d36481 --- /dev/null +++ b/internal/plandb/done_terminal_test.go @@ -0,0 +1,21 @@ +package plandb + +import ( + "testing" +) + +func TestDoneRefusesAlreadyTerminalTaskExplicitly(t *testing.T) { + store := planOpen(t, "") + planAdd(t, store, TaskSpec{ID: "leaf", Title: "cancelled leaf"}) + if _, err := store.Cancel("leaf", "cancelled by supervisor"); err != nil { + t.Fatalf("Cancel: %v", err) + } + + _, err := store.Done("leaf", "worker1", "all finished", nil, nil) + if err == nil { + t.Fatalf("expected error finishing cancelled task, got nil") + } + if want := `task "leaf" is already terminal (cancelled)`; err.Error() != want { + t.Fatalf("got error %q, want %q", err.Error(), want) + } +} diff --git a/internal/plandb/store.go b/internal/plandb/store.go index b54b2167b9..eca1b22511 100644 --- a/internal/plandb/store.go +++ b/internal/plandb/store.go @@ -611,6 +611,9 @@ func (s *Store) Done(id, agent, result string, artifacts, evidence []string) (*T // worker's own done, a real ending — keeps its words and its owner. placeholder := task.Status == StatusDone && strings.TrimSpace(task.Result) == "" && task.ClaimedBy == "" if !placeholder { + if terminal(task.Status) { + return fmt.Errorf("task %q is already terminal (%s)", id, task.Status) + } // THE ROOT IS NEVER CLAIMED, so its worker cannot answer the ownership // check every other task's worker does. The root's own worker is named // instead, above, and the finish law still holds: the root cannot close diff --git a/internal/session/prompts/bashworker.md b/internal/session/prompts/bashworker.md index 9400b321ab..736f897ee4 100644 --- a/internal/session/prompts/bashworker.md +++ b/internal/session/prompts/bashworker.md @@ -125,7 +125,8 @@ Parallelism lives in the shell, not in the batch: ``` cmd1 & cmd2 & wait # two commands at once, both waited for -find . -type f -name '' | xargs -P 4 grep -l +# When searching filenames, always prune heavy trees (.git, node_modules, vendor, .venv): +find . -type d \( -name .git -o -name node_modules -o -name vendor -o -name .venv \) -prune -o -type f -name '' -print | xargs -P 4 grep -l git grep -n "theSymbol" # one search instead of three ``` From c82483b635bc3de53652de4a68099cfd0174713d Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Sat, 26 Sep 2026 22:38:00 -0400 Subject: [PATCH 02/76] session: fall back to project place workspace when workspace is non-repository When a session operates with workspace outside a repository (e.g. user home folder ~ in a team manager session), task ground derivation would fall through to taskGroundNothing at dir: ~, leading spawned tasks to inherit ~ as ground. Workers looking for repository docs like docs/design/plandb-cli/ would then fail to locate them and launch unpruned find commands across the entire home drive. Fall back to a.config.Place.Workspace when workspace has no repository root, preserving taskGroundStandingIn on the intended project repository. Fixes #1561 Assisted-by: CodeAF (gemini-3.8-flash-high) Co-Authored-By: CodeAF <267109073+agentfield-bot@users.noreply.github.com> --- internal/session/taskstands.go | 5 +++++ internal/session/taskstands_test.go | 11 +++++++++++ 2 files changed, 16 insertions(+) diff --git a/internal/session/taskstands.go b/internal/session/taskstands.go index 65b26c50fa..5c5f76b878 100644 --- a/internal/session/taskstands.go +++ b/internal/session/taskstands.go @@ -365,6 +365,11 @@ func (a *Agent) groundLadder(spec taskSpec, workspace string) taskStand { if root, ok := repositoryRoot(workspace); ok { return taskStand{dir: root, rung: taskGroundStandingIn} } + if projectWorkspace := strings.TrimSpace(a.config.Place.Workspace); projectWorkspace != "" && projectWorkspace != workspace { + if root, ok := repositoryRoot(projectWorkspace); ok { + return taskStand{dir: root, rung: taskGroundStandingIn} + } + } return taskStand{dir: workspace, rung: taskGroundNothing} } diff --git a/internal/session/taskstands_test.go b/internal/session/taskstands_test.go index 94d964e979..f6fe3bbcb6 100644 --- a/internal/session/taskstands_test.go +++ b/internal/session/taskstands_test.go @@ -775,6 +775,17 @@ func TestTheGroundLadderClimbsInOrder(t *testing.T) { } }) + t.Run("workspace outside repo falls back to place workspace", func(t *testing.T) { + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.Workspace = plain + config.Place = Place{Dir: t.TempDir(), Workspace: repo} + }) + stand := agent.resolveTaskGround(taskSpec{deliverable: "an answer", acceptance: "it is written"}) + if stand.dir != canonicalPath(repo) || stand.rung != taskGroundStandingIn { + t.Fatalf("stand = %+v, want dir=%s rung=%s", stand, repo, taskGroundStandingIn) + } + }) + t.Run("a repository the work only reads is not branched", func(t *testing.T) { agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { config.Workspace = plain }) stand := agent.resolveTaskGround(taskSpec{ From 2653fddc1026ff8e87ab1dc7107d209ecef26765 Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Sat, 26 Sep 2026 22:43:14 -0400 Subject: [PATCH 03/76] docs(changes): add changelog entry for PR 1562 Assisted-by: CodeAF (gemini-3.8-flash-high) Co-Authored-By: CodeAF <267109073+agentfield-bot@users.noreply.github.com> --- .../1562-plandb-done-terminal-and-manager-ground.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 docs/changes/unreleased/1562-plandb-done-terminal-and-manager-ground.md diff --git a/docs/changes/unreleased/1562-plandb-done-terminal-and-manager-ground.md b/docs/changes/unreleased/1562-plandb-done-terminal-and-manager-ground.md new file mode 100644 index 0000000000..4ede69d861 --- /dev/null +++ b/docs/changes/unreleased/1562-plandb-done-terminal-and-manager-ground.md @@ -0,0 +1,13 @@ +--- +kind: fixed +title: plandb done refuses terminal tasks and manager tasks derive project workspace +pr: 1562 +surface: [engine, resident] +invalidates: + - "plandb done on a cancelled task failed with 'task is not claimed'; it now explicitly reports that the task is already terminal." + - "a task proposed by a manager whose workspace was ~ inherited ~ as folder ground; it now falls back to Place.Workspace." +--- + +When a task was cancelled upstream, plandb done called requireOwner first, which failed with a misleading 'task is not claimed' error because cancelled tasks clear their claim. Now terminal status is checked before owner verification (preserving placeholder auto-completion). + +In addition, groundLadder now falls back from a non-repository workspace (such as ~) to a configured project Place.Workspace rather than landing tasks directly in ~. From f6aca9bbc7fb8d45ac41b7f5f34d2e160e7425ea Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Sat, 26 Sep 2026 22:47:28 -0400 Subject: [PATCH 04/76] prompts: restore bashworker prompt to keep prompt size under 16 KiB Assisted-by: CodeAF (gemini-3.8-flash-high) Co-Authored-By: CodeAF <267109073+agentfield-bot@users.noreply.github.com> --- internal/session/prompts/bashworker.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/session/prompts/bashworker.md b/internal/session/prompts/bashworker.md index 736f897ee4..9400b321ab 100644 --- a/internal/session/prompts/bashworker.md +++ b/internal/session/prompts/bashworker.md @@ -125,8 +125,7 @@ Parallelism lives in the shell, not in the batch: ``` cmd1 & cmd2 & wait # two commands at once, both waited for -# When searching filenames, always prune heavy trees (.git, node_modules, vendor, .venv): -find . -type d \( -name .git -o -name node_modules -o -name vendor -o -name .venv \) -prune -o -type f -name '' -print | xargs -P 4 grep -l +find . -type f -name '' | xargs -P 4 grep -l git grep -n "theSymbol" # one search instead of three ``` From 07e77ac44244c079969c3bd194f124614d5e6c1d Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 00:01:29 -0400 Subject: [PATCH 05/76] headless: a blank brief is refused before any work starts readText resolved one empty or all-blank argument to an empty goal with no error, so `codeaf do ""` ran a paid job with a goal the planner invented. The blank check now sits where the goal text is resolved, for every road. Fixes #1566 Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/brief_test.go | 10 ++++++++++ cmd/codeaf/main.go | 12 ++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/cmd/codeaf/brief_test.go b/cmd/codeaf/brief_test.go index 5394ec5bfe..f3586e2f5a 100644 --- a/cmd/codeaf/brief_test.go +++ b/cmd/codeaf/brief_test.go @@ -140,3 +140,13 @@ func TestABriefMayArriveOnStandardInput(t *testing.T) { t.Fatalf("brief = %q, want the piped text", text) } } + +func TestReadTextRejectsAnAllBlankArgumentList(t *testing.T) { + want := noGoalGiven("do").Error() + for _, args := range [][]string{{""}, {" "}, {"", "\t"}} { + text, err := readText("do", args) + if text != "" || err == nil || err.Error() != want { + t.Errorf("readText(%q) = %q, %v; want noGoalGiven", args, text, err) + } + } +} diff --git a/cmd/codeaf/main.go b/cmd/codeaf/main.go index 16f3dd417d..063410178b 100644 --- a/cmd/codeaf/main.go +++ b/cmd/codeaf/main.go @@ -1099,11 +1099,15 @@ func emit(graph *plan.Graph, output string, asJSON bool) error { // lines, the environment included — for the sake of one missing quoted string, // and the one line that mattered scrolled off the top of the terminal. func readText(name string, args []string) (string, error) { - if len(args) == 1 && args[0] == "-" { - return readPipedText(name) - } if len(args) > 0 { - return strings.TrimSpace(strings.Join(args, " ")), nil + text := strings.TrimSpace(strings.Join(args, " ")) + if text == "" { + return "", noGoalGiven(name) + } + if len(args) == 1 && args[0] == "-" { + return readPipedText(name) + } + return text, nil } if stdinIsTerminal(os.Stdin) { return "", noGoalGiven(name) From 38861e3528dfe2505dbc08fb4381b77d486e9cef Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 00:01:29 -0400 Subject: [PATCH 06/76] delegate: flags after the brief are parsed, unknown ones refused Go's flag package stops at the first positional word, so the documented `senior-dev "" --max-cost 0.5` form folded every trailing flag into the brief and ran with the default ceiling. Flags are now read wherever they sit for every delegate program; `--` still ends them. Fixes #1567 Co-Authored-By: Claude Opus 5.5 --- internal/delegate/cli.go | 47 ++++++++++++++++++++++++++++++++++- internal/delegate/cli_test.go | 16 ++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/internal/delegate/cli.go b/internal/delegate/cli.go index fb81afbd16..d6c6274d54 100644 --- a/internal/delegate/cli.go +++ b/internal/delegate/cli.go @@ -86,7 +86,7 @@ func Parse(program Delegate, line []string, out io.Writer) (*Invocation, error) if body == nil { return nil, fmt.Errorf("%s %s: %w", program.Name, command.Name, errNoBody) } - if err := fs.Parse(rest); err != nil { + if err := fs.Parse(interspersedFlags(fs, rest)); err != nil { if errors.Is(err, flag.ErrHelp) { commandHelp(program, command, fs, out) return nil, ErrHelp @@ -122,6 +122,51 @@ func Parse(program Delegate, line []string, out io.Writer) (*Invocation, error) }, nil } +// interspersedFlags moves declared flags and flag-shaped words ahead of the +// brief before flag.Parse sees them. The standard flag package stops at the +// first positional word; doing that here made a documented trailing ceiling +// become part of the brief, and made an unknown trailing flag look like valid +// prose. A brief that starts with a dash uses `--`, the same end marker the +// standard parser uses. +func interspersedFlags(flags *flag.FlagSet, args []string) []string { + var named, positional []string + endOfFlags := false + for index := 0; index < len(args); index++ { + argument := args[index] + if argument == "--" { + endOfFlags = true + continue + } + if endOfFlags { + positional = append(positional, argument) + continue + } + if !strings.HasPrefix(argument, "-") || argument == "-" { + positional = append(positional, argument) + continue + } + named = append(named, argument) + name := strings.TrimLeft(argument, "-") + if before, _, found := strings.Cut(name, "="); found { + name = before + } + declared := flags.Lookup(name) + if declared != nil && !isBoolFlag(declared) && !strings.Contains(argument, "=") && index+1 < len(args) { + index++ + named = append(named, args[index]) + } + } + if endOfFlags { + named = append(named, "--") + } + return append(named, positional...) +} + +func isBoolFlag(value *flag.Flag) bool { + boolFlag, ok := value.Value.(interface{ IsBoolFlag() bool }) + return ok && boolFlag.IsBoolFlag() +} + // ChildArgs is the line a host starts a program's process with, after // codeaf's own executable: the name, the default command, --json, the folder, // the ceilings that are set, and the brief after `--`, so no word of it can be diff --git a/internal/delegate/cli_test.go b/internal/delegate/cli_test.go index e992773d4f..3fff85c317 100644 --- a/internal/delegate/cli_test.go +++ b/internal/delegate/cli_test.go @@ -51,6 +51,22 @@ func TestParseRunsTheDefaultCommandOnABareBrief(t *testing.T) { } } +func TestParseReadsFlagsAfterTheBrief(t *testing.T) { + inv, err := Parse(testProgram(nil), []string{"brief", "--max-cost", "0.5"}, &bytes.Buffer{}) + if err != nil { + t.Fatal(err) + } + if inv.Brief() != "brief" || inv.Ceilings.CostUSD != 0.5 { + t.Fatalf("invocation = %+v, want brief and max-cost 0.5", inv) + } +} + +func TestParseRefusesAnUnknownFlagAfterTheBrief(t *testing.T) { + if _, err := Parse(testProgram(nil), []string{"brief", "--not-a-flag"}, &bytes.Buffer{}); err == nil { + t.Fatal("an unknown flag after the brief was folded into the brief") + } +} + func TestParseTakesANamedCommandAndItsOwnFlags(t *testing.T) { inv, err := Parse(testProgram(nil), []string{"run", "--variant", "high", "--dir", "/tmp", "--", "--not-a-flag"}, &bytes.Buffer{}) if err != nil { From 0e07534a3c6f2f05cbbabfaf9d7f8df6cdce98f1 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 00:01:29 -0400 Subject: [PATCH 07/76] headless: do --json carries the run engine's token counts The run engine's workers reported dollars but not tokens, so `do --json` printed zero tokens beside a non-zero spend. Workers, the supervisor and the summary now carry input and output tokens to the receipt. Part of #1558 (the token item; the other items stay open) Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/do.go | 2 ++ cmd/codeaf/do_engine_contract_test.go | 7 ++++ internal/run/bashworker.go | 4 +++ internal/run/delegateworker.go | 7 +++- internal/run/run.go | 48 ++++++++++++++++----------- internal/run/worker.go | 16 +++++---- 6 files changed, 56 insertions(+), 28 deletions(-) diff --git a/cmd/codeaf/do.go b/cmd/codeaf/do.go index 407037eb3d..5b82098734 100644 --- a/cmd/codeaf/do.go +++ b/cmd/codeaf/do.go @@ -3719,6 +3719,8 @@ func runErrand(request doRequest, seats config.Seats) (outcome headlessOutcome, Nodes: summary.Nodes, Seconds: summary.Seconds, Spend: summary.USD, + tokensIn: summary.TokensIn, + tokensOut: summary.TokensOut, } switch summary.Outcome { case runengine.OutcomeDone: diff --git a/cmd/codeaf/do_engine_contract_test.go b/cmd/codeaf/do_engine_contract_test.go index abe48ecabc..beec70ba78 100644 --- a/cmd/codeaf/do_engine_contract_test.go +++ b/cmd/codeaf/do_engine_contract_test.go @@ -36,6 +36,8 @@ func finishingSeat(usd float64) *beltSeat { if usd > 0 { cost := usd reply.Usage.Cost = &cost + reply.Usage.PromptTokens = 100 + reply.Usage.CompletionTokens = 20 } return reply } @@ -336,6 +338,11 @@ func TestDoOnTheRunEngineYesSpendRunsPastThePlanPrice(t *testing.T) { t.Fatalf("a run with --yes-spend left with %v, want 0\nstdout:\n%s\nstderr:\n%s", err, stdout.String(), stderr.String()) } + fields := doEnvelopeFields(t, stdout.String()) + tokens, ok := fields["tokens"].(map[string]any) + if !ok || tokens["in"] == float64(0) || tokens["out"] == float64(0) { + t.Fatalf("non-zero spend carried empty token counts: %v", fields["tokens"]) + } } // --db IS REFUSED IN WORDS. The run keeps its plan in a private folder, diff --git a/internal/run/bashworker.go b/internal/run/bashworker.go index 14ffd6bc7e..6f8a8d2823 100644 --- a/internal/run/bashworker.go +++ b/internal/run/bashworker.go @@ -121,6 +121,10 @@ func (w *BashWorker) Run(ctx context.Context, task plandb.Task) (rep Report, run inTok int outTok int ) + defer func() { + rep.TokensIn = inTok + rep.TokensOut = outTok + }() if len(past) > 0 { stepNumber = past[len(past)-1].Step } diff --git a/internal/run/delegateworker.go b/internal/run/delegateworker.go index 7efab78ec5..bcfa05bdcd 100644 --- a/internal/run/delegateworker.go +++ b/internal/run/delegateworker.go @@ -49,6 +49,7 @@ import ( "os" "path/filepath" "strings" + "sync/atomic" "time" "github.com/Agent-Field/agentfield/sdk/go/ai" @@ -309,6 +310,8 @@ type delegateMeter struct { // ledger row; onCharge folds each call into that conversation's books. conversation string onCharge func(session.RunCharge) + tokensIn atomic.Int64 + tokensOut atomic.Int64 } // bank books one charge in all four places. @@ -329,6 +332,8 @@ type delegateMeter struct { // the conversation's receipt and the spending page could not place — 94.9% of // one day's spend on 2026-09-23 was senior-dev calls filed under nobody. func (m *delegateMeter) bank(charge modelapi.Charge) { + m.tokensIn.Add(int64(charge.TokensIn)) + m.tokensOut.Add(int64(charge.TokensOut)) if m.onCharge != nil { m.onCharge(session.RunCharge{ Model: charge.Model, TokensIn: charge.TokensIn, TokensOut: charge.TokensOut, @@ -516,7 +521,7 @@ func (w *DelegateWorker) Run(ctx context.Context, task plandb.Task) (Report, err // about a present that is over. _ = w.store.ClearLive(task.ID) - report := Report{Steps: sink.steps, USD: api.Spent()} + report := Report{Steps: sink.steps, USD: api.Spent(), TokensIn: int(meter.tokensIn.Load()), TokensOut: int(meter.tokensOut.Load())} if sink.lastErr != nil { end(sink.steps, "the record failed: "+sink.lastErr.Error(), "") return report, sink.lastErr diff --git a/internal/run/run.go b/internal/run/run.go index e55268802e..24680ac059 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -132,9 +132,11 @@ type Supervisor struct { // and drain is its only reader: every road out of Run waits on it before // answering, so the store, the working copy and the process are the // caller's alone the moment the run is over. - workers sync.WaitGroup - inFlight int - spent float64 + workers sync.WaitGroup + inFlight int + spent float64 + tokensIn int + tokensOut int // onSpend receives the reconciled cumulative run spend whenever it rises. // It observes the same account Summary.USD reads, so live readings and the // final receipt can be folded by a caller without counting a dollar twice. @@ -765,6 +767,8 @@ func (s *Supervisor) settleSpend(ret workerReturn) { if counted := s.counted[ret.task.ID]; ret.report.USD > counted { s.spent += ret.report.USD - counted } + s.tokensIn += ret.report.TokensIn + s.tokensOut += ret.report.TokensOut s.forgetLive(ret.task.ID) // A RETURN THAT REACHES THE LIMIT ENDS ITS PEERS, the same as a live reading // that reaches it ([reachCostLimit] says why this is one place and not two). @@ -1769,8 +1773,8 @@ type Spec struct { // Summary is what a run came to, in the figures a headless caller prints // beside its exit code: the outcome word off the same ladder the envelope // speaks, the root's result where a deliverable goes, the run's size — every -// worker launched, every step its workers reported — what they cost, and the -// wall the run took. +// worker launched, every step its workers reported — what they cost and used, +// and the wall the run took. type Summary struct { Outcome Outcome // Result is the root's own result: what the run's last worker reported @@ -1794,11 +1798,13 @@ type Summary struct { // not here. This is the fact a surface draws those rows with, so a row the // person's bound took down is never read as a fault; it is carried typed // and never parsed out of a stored error sentence. - Cut []string - Nodes int - Steps int - USD float64 - Seconds float64 + Cut []string + Nodes int + Steps int + USD float64 + TokensIn int + TokensOut int + Seconds float64 } // endRootOn writes the run's own ending on its root task when the run ended on @@ -1894,15 +1900,17 @@ func Start(ctx context.Context, spec Spec) (Outcome, Summary) { result = root.Result } return outcome, Summary{ - Outcome: outcome, - Result: result, - Limit: supervisor.limitHit, - Program: supervisor.rootProgram, - Verdict: supervisor.rootVerdict, - Cut: supervisor.cutIDs(), - Nodes: supervisor.nodes, - Steps: supervisor.steps, - USD: supervisor.spent, - Seconds: time.Since(started).Seconds(), + Outcome: outcome, + Result: result, + Limit: supervisor.limitHit, + Program: supervisor.rootProgram, + Verdict: supervisor.rootVerdict, + Cut: supervisor.cutIDs(), + Nodes: supervisor.nodes, + Steps: supervisor.steps, + USD: supervisor.spent, + TokensIn: supervisor.tokensIn, + TokensOut: supervisor.tokensOut, + Seconds: time.Since(started).Seconds(), } } diff --git a/internal/run/worker.go b/internal/run/worker.go index 995fef5af0..571b905d7f 100644 --- a/internal/run/worker.go +++ b/internal/run/worker.go @@ -13,9 +13,9 @@ import ( ) // Report is what a worker hands back when its task ends well. Result is the -// task's own account of itself and lands in the store verbatim; Steps and USD -// feed the run's counters, and USD in particular feeds the shared cost -// counter the Limits govern. +// task's own account of itself and lands in the store verbatim; Steps, USD and +// token counts feed the run's receipt, and USD in particular feeds the shared +// cost counter the Limits govern. // // WAITING IS NOT A RESULT. A worker that called `plandb wait` has not finished // its task: it parked it, the store released its claim, and it is owed a wake @@ -23,10 +23,12 @@ import ( // set and no Result, and the supervisor leaves the task open rather than // writing a completion. type Report struct { - Result string - Steps int - USD float64 - Waiting bool + Result string + Steps int + USD float64 + TokensIn int + TokensOut int + Waiting bool // Verdict is a program's own word for the finished work it handed in — // senior-dev's `pass` or `pass-unverified` — when a delegated run's program // finished; empty for every other worker ([delegate.Terminal.Verdict]). From 6b107f883e348dd8042b48bc54c79c526755ec7a Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 00:03:26 -0400 Subject: [PATCH 08/76] chat: esc cancels a pending browser sign-in Browser sign-ins started from /connect or the Codex row live on the surface, with no turn for esc to interrupt, so the listener stayed pending. Esc now cancels every surface-owned browser flow through the existing abandon door. Fixes #1587 Co-Authored-By: Claude Opus 5.5 --- internal/manual/chat/models-and-cost.md | 3 +++ internal/tui3/connectpanel.go | 17 +++++++++++++++++ internal/tui3/input.go | 3 +++ internal/tui3/modelservices_test.go | 16 ++++++++++++++++ 4 files changed, 39 insertions(+) diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index a75f0bfff7..b6554aa275 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -85,6 +85,9 @@ the Codex CLI does; OpenAI's terms for a ChatGPT plan apply to what runs on it. service reads the model list belonging to that account, and a new connection moves this conversation to `codex/gpt-5.5`. Every model from it is qualified as `codex/`. +While the browser sign-in is waiting, `esc` cancels it and closes the waiting listener; +the conversation stays on its previous model and `enter` on Codex tries again. + A Codex model's context window is the one that account's model list gives it — `272k` on every model it lists today — so the status line reads `…/272k` on `codex/gpt-5.5` and compaction fires from that figure. When the list cannot be reached at sign-in, the four diff --git a/internal/tui3/connectpanel.go b/internal/tui3/connectpanel.go index 245c02331a..e1fa44c795 100644 --- a/internal/tui3/connectpanel.go +++ b/internal/tui3/connectpanel.go @@ -837,6 +837,23 @@ func (a *app) abandonConnects() { } } +// cancelBrowserSignIns releases browser flows that this idle surface owns. +// Session-owned browser waits are stopped by interrupt; these flows have no +// turn to interrupt, so escape must use the same cancellation door directly. +func (a *app) cancelBrowserSignIns() bool { + took := false + for service := range a.connFlows { + a.abandonConnect(service) + took = true + } + if a.codexFlow != nil { + a.codexFlow.Cancel() + a.codexFlow = nil + took = true + } + return took +} + // adoptConnectResult settles the block the browser — or the key — left open, and // tells the session what it now has. // diff --git a/internal/tui3/input.go b/internal/tui3/input.go index 663bbc33da..0e09c6836e 100644 --- a/internal/tui3/input.go +++ b/internal/tui3/input.go @@ -832,6 +832,9 @@ func (a *app) key(msg tea.KeyPressMsg) tea.Cmd { switch msg.String() { case "esc": + if a.cancelBrowserSignIns() { + return nil + } // esc during a recall is the recall's: it puts the person's own draft // back. A modal-ish state that could not be left by the dismiss key // would be a trap, and the turn is still interruptible the moment the diff --git a/internal/tui3/modelservices_test.go b/internal/tui3/modelservices_test.go index fc74ee0cce..5858338928 100644 --- a/internal/tui3/modelservices_test.go +++ b/internal/tui3/modelservices_test.go @@ -155,6 +155,22 @@ func TestC12C18ConnectCodexBrowserRowUsesTheRealPanelAndMovesToTheListedModel(t } } +func TestEscapeCancelsAPendingCodexBrowserSignIn(t *testing.T) { + a := modelServiceTestApp(t, t.TempDir(), "~deepseek/deepseek-v4-flash-latest", + modelsource.NewSet(testDefaultService("sk-default-1234567890")), nil) + flow := &panelCodexFlow{url: "https://auth.example/authorize?state=cancel"} + a.codexFlow = flow + + drive(t, a, key("esc")) + + if !flow.cancelled { + t.Fatal("esc did not cancel the pending browser sign-in") + } + if a.codexFlow != nil { + t.Fatal("esc left the cancelled browser flow installed") + } +} + func modelServiceTestApp(t *testing.T, dir string, model string, sources modelsource.Set, models []Model) *app { return modelServiceTestAppWithAgent(t, dir, model, sources, models, &fakeAgent{model: model}) } From 0c897b4b0d66e7f7c01f65c0e248b21e2612428b Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 00:09:47 -0400 Subject: [PATCH 09/76] session: a read hand-off helper is read-only and leaves no stopped card The read hand-off admitted an ordinary quick task with the full belt (bash, commit), guarded only by a prose line, so a manager's whole directive could be taken by a "reading" helper that wrote, committed and merged, and a request for a task on a named model became in-place edits with no branch. The quick node now carries a durable read-only mark and its worker gets the audit read-only belt at the one place quick workers are built; a request the reader cannot serve hands control back to the conversation. A failed hand-off retires a never-started helper instead of cancelling it into a stopped card, and still stops one that did start. Fixes #1568 Part of #1569 (the chat's own prose about model and cost is unchanged) Part of #1554 (item 3) Co-Authored-By: Claude Opus 5.5 --- internal/session/cancel.go | 36 ++++++++++++++++ internal/session/readhandoff.go | 21 +++++++--- internal/session/readhandoff_test.go | 63 ++++++++++++++++++++++++++++ internal/session/task_audit.go | 8 ++++ internal/session/task_quick.go | 13 ++++-- internal/session/task_run.go | 22 ++++++++++ internal/session/task_store.go | 21 ++++++---- internal/session/task_test.go | 36 ++++++++++++++++ 8 files changed, 203 insertions(+), 17 deletions(-) diff --git a/internal/session/cancel.go b/internal/session/cancel.go index b5ad644421..aaed6a4eaa 100644 --- a/internal/session/cancel.go +++ b/internal/session/cancel.go @@ -167,6 +167,42 @@ func (a *Agent) cancelTask(id uint64, why string) (string, error) { // slot back by hand ([TaskGraph.handBackSlotLocked]). func (g *TaskGraph) stop(id uint64) (string, error) { return g.stopFor(id, "") } +// retireUnstarted removes a hand-off that never acquired a worker. It is not a +// stop: the read sweep is an internal optimization, so a queued helper that +// falls back to the conversation must leave no person-visible history behind. +// The graph lock makes removal race-free with the frontier; a claimed node is +// left alone because its runner already owns the transition to settlement. +func (g *TaskGraph) retireUnstarted(id uint64) bool { + if g == nil { + return false + } + g.mu.Lock() + node := g.nodes[id] + if node == nil || node.state != TaskQueued || node.claimed { + g.mu.Unlock() + return false + } + delete(g.nodes, id) + for index, ordered := range g.order { + if ordered != id { + continue + } + copy(g.order[index:], g.order[index+1:]) + g.order = g.order[:len(g.order)-1] + break + } + g.releaseChildLocked(node.parent) + cut := node.cancel + close(node.done) + g.mu.Unlock() + if cut != nil { + cut() + } + g.checkpoint() + g.planPulse() + return true +} + // stopFor is [TaskGraph.stop] with the reason whoever pulled it gave, and it is // where that reason is written down: onto the node, so the landing this stop // causes carries it, and into the line, so the hand that pulled it reads back diff --git a/internal/session/readhandoff.go b/internal/session/readhandoff.go index 6b960763a6..0eeb23ae47 100644 --- a/internal/session/readhandoff.go +++ b/internal/session/readhandoff.go @@ -63,6 +63,12 @@ const readSweepWait = 4 * time.Minute // held node is a reason to run the batch inline now, not to freeze the chat. const readSweepStartWait = 30 * time.Second +// sweepNeedsConversation is the hand-off's return road when the person's +// request needs an action belt. It is a fixed protocol marker, not an English +// intent matcher: the reader has no task, team, writing, commit, merge, or +// model-selection tools, so it must give those requests back to the chat. +const sweepNeedsConversation = "[READ_HANDOFF_NEEDS_CONVERSATION]" + // readSweep is one turn's ledger of read-only calls. It lives beside the // turn's warmBatch: same lifetime, same owner, reset by the same events that // break a reading run — any call that is not one of the four readers. @@ -140,6 +146,8 @@ func (s *readSweep) due(calls []ai.ToolCall) bool { // as the first reader's result. Everything else in the batch — the wc that // sized the reading, the write the model already knew it wanted — runs the // ordinary way beside the hand-off, since it was emitted blind to the reads. +// The quick task is read-only; its action refusal returns control through the +// ordinary fallback path. // A nil return is every failure road at once — the caller then runs the whole // batch inline exactly as if the hook had not fired, and the sweep is disabled // so the failure is not re-tried round after round. @@ -154,8 +162,9 @@ func (a *Agent) handoffReadSweep(ctx context.Context, ep *episode, hub *eventHub } } id, _, refusal := a.admitQuick(quickAsk{ - line: sweepBrief(user, sweep.glosses, readers), - title: sweepTitle(user), + line: sweepBrief(user, sweep.glosses, readers), + title: sweepTitle(user), + readOnly: true, }) if refusal.said != "" { return nil @@ -189,10 +198,12 @@ func (a *Agent) handoffReadSweep(ctx context.Context, ep *episode, hub *eventHub restResults = a.runToolsWarm(ctx, ep, rest, hub, warm) } answer, ok := a.awaitQuickAnswer(ctx, id) - if !ok || strings.TrimSpace(answer) == "" { + if !ok || strings.TrimSpace(answer) == "" || strings.TrimSpace(answer) == sweepNeedsConversation { // FALLBACK: The quick task failed, timed out, or returned an empty answer. // Clean up the task node so it does not linger in the graph consuming resources. - a.cancelTask(id, "read handoff failed; running inline") + if !a.graph().retireUnstarted(id) { + a.cancelTask(id, "read handoff failed; running inline") + } // Any non-reader calls in the batch already ran in restResults and must NOT // be run a second time. Run the readers inline and stitch the results back together. @@ -315,7 +326,7 @@ func sweepBrief(user userMessage, glosses []string, calls []ai.ToolCall) string for _, call := range calls { b.WriteString("- " + gloss(call) + "\n") } - b.WriteString("\nReturn ONLY the distilled answer to the person's question — the answer itself, not a narration of what you read. Do not write or edit any file.") + b.WriteString("\nReturn ONLY the distilled answer to the person's question — the answer itself, not a narration of what you read. Do not write or edit any file. If the person asked for work, a team action, writing, a commit, a merge, or a named model, return exactly " + sweepNeedsConversation + " so the conversation can handle it.") return b.String() } diff --git a/internal/session/readhandoff_test.go b/internal/session/readhandoff_test.go index c56e7cd08e..df18b25e89 100644 --- a/internal/session/readhandoff_test.go +++ b/internal/session/readhandoff_test.go @@ -146,6 +146,69 @@ func TestReadSweepDisabledStaysDisabled(t *testing.T) { } } +func TestReadHandoffRetiresUnstartedTask(t *testing.T) { + graph := &TaskGraph{ + nodes: map[uint64]*TaskNode{}, + order: []uint64{7}, + claims: map[uint64]int{3: 1}, + } + node := &TaskNode{ + graph: graph, + id: 7, + parent: 3, + state: TaskQueued, + done: make(chan struct{}), + } + graph.nodes[node.id] = node + if !graph.retireUnstarted(node.id) { + t.Fatal("an unstarted hand-off was not retired") + } + if graph.node(node.id) != nil { + t.Fatal("the retired hand-off remains in the graph") + } + if len(graph.order) != 0 { + t.Fatalf("retired hand-off remains in order: %v", graph.order) + } + if graph.claims[3] != 0 { + t.Fatalf("parent claim was not released: %v", graph.claims) + } + if node.stopped { + t.Fatal("retiring a helper marked it stopped") + } +} + +func TestRetireUnstartedLeavesStartedTask(t *testing.T) { + for _, testCase := range []struct { + name string + state TaskState + claimed bool + }{ + {name: "claimed", state: TaskQueued, claimed: true}, + {name: "running", state: TaskRunning}, + } { + t.Run(testCase.name, func(t *testing.T) { + graph := &TaskGraph{ + nodes: map[uint64]*TaskNode{}, + order: []uint64{8}, + } + node := &TaskNode{ + graph: graph, + id: 8, + state: testCase.state, + claimed: testCase.claimed, + done: make(chan struct{}), + } + graph.nodes[node.id] = node + if graph.retireUnstarted(node.id) { + t.Fatal("a started hand-off was retired") + } + if graph.node(node.id) != node { + t.Fatal("a started hand-off left the graph") + } + }) + } +} + // keys is the test's window into the ledger. func (s *readSweep) keys() []string { keys := make([]string, 0, len(s.targets)) diff --git a/internal/session/task_audit.go b/internal/session/task_audit.go index 9b9da009ce..1df1541aa1 100644 --- a/internal/session/task_audit.go +++ b/internal/session/task_audit.go @@ -3285,6 +3285,14 @@ func auditBelt(dir string, door auditDoor, droppings Place) []bare.Tool { return belt } +// quickReadOnlyBelt is the existing audit belt applied to a read hand-off. It +// deliberately reuses the audit allowlist and bash guard: a reader must have +// the same hard refusal boundary as an auditor, not a prose instruction that +// can be ignored by the model. +func quickReadOnlyBelt(dir string, droppings Place) []bare.Tool { + return auditBelt(dir, plainDoor(auditReadCommands), droppings) +} + // boundedResult caps what one tool call may hand back. // // A cut result is filed through [writeStub], the same content-addressed, diff --git a/internal/session/task_quick.go b/internal/session/task_quick.go index 5195812b23..fadbdc9108 100644 --- a/internal/session/task_quick.go +++ b/internal/session/task_quick.go @@ -54,9 +54,9 @@ const ( quickItemsToolName = "items" ) -// quickTaskSpec is what a quick node is, and it is deliberately four fields: -// what to do, the list it works through, what it said it would write, and how -// far down the list it has got. +// quickTaskSpec is what a quick node is: what to do, the list it works through, +// what it said it would write, how far down the list it has got, and whether +// its worker is a reader rather than a writer. // // IT IS MUTATED WHILE THE NODE RUNS, which no other spec in this package is, // and that is the one thing to know about reading it. `items` and `done` grow @@ -78,6 +78,9 @@ type quickTaskSpec struct { // done is parallel to items: done[i] says item i+1 has been ticked. It is // written only by the `items` tool and read only under the graph's lock. done []bool + // readOnly is THE SAFETY BOUND OF A READ HAND-OFF. It is persisted with the + // quick body so a resumed helper cannot silently regain the ordinary belt. + readOnly bool // waits is WHY THIS NODE WAS QUEUED BEHIND ANOTHER, kept from admission so // the receipt the model reads can name the task and the path rather than // making it work the collision out for itself. It is a record of that one @@ -389,6 +392,9 @@ type quickAsk struct { // grow a quick node — the tool and the ceiling's carry-on — ask for it the // same way and neither can build a promotion the other could not. inherit bool + // readOnly is set only by the read hand-off. The ordinary quick-task tool + // never receives this capability from its wire arguments. + readOnly bool } // askOf is the wire form read as an ask. It is a function rather than a @@ -640,6 +646,7 @@ func (a *Agent) newQuickSpec(ask quickAsk) (taskSpec, string) { } else { quick = newQuickTaskSpec(line, kept, scope) } + quick.readOnly = ask.readOnly quick.waits = a.graph().quickClaimsOn(a.config.Workspace, scope) for _, claim := range quick.waits { dependsOn = append(dependsOn, claim.id) diff --git a/internal/session/task_run.go b/internal/session/task_run.go index a670700c42..9995d0c2e5 100644 --- a/internal/session/task_run.go +++ b/internal/session/task_run.go @@ -7510,6 +7510,16 @@ func (a *Agent) newTaskAgent(ctx context.Context, dir string, node *TaskNode, su return a.newTaskAgentOn(ctx, dir, node, suffix, "", false) } +// quickBelt narrows only a read hand-off worker. The ordinary quick task keeps +// the belt built from its Config, while a read helper must replace that belt +// with the existing hard read-only allowlist before its first model request. +func quickBelt(node *TaskNode, dir string, droppings Place) []bare.Tool { + if node == nil || node.spec.quick == nil || !node.spec.quick.readOnly { + return nil + } + return quickReadOnlyBelt(dir, droppings) +} + // newTaskAgentOn is [Agent.newTaskAgent] with the model said outright, and it // exists for exactly one caller: the repair round, whose model is the cascade's // answer rather than the node's (repair_role.go, task_audit.go's repairNode). @@ -7871,6 +7881,18 @@ func (a *Agent) newTaskAgentOn(ctx context.Context, dir string, node *TaskNode, if err != nil { return nil, err } + if tools := quickBelt(node, dir, parent.droppingsPlace()); tools != nil { + definitions, err := toolDefinitions(tools) + if err != nil { + _ = child.Close() + return nil, err + } + child.mu.Lock() + child.tools = tools + child.definitions = definitions + child.mu.Unlock() + child.clearShelf() + } // AND WHAT THE PERSON IS REMEMBERED TO WANT, HANDED OVER RATHER THAN WAITED // FOR. The node's brief is routed once, beside the work (memory.go's // [nodeMemory]), and this worker gets the answer now if it has come back and diff --git a/internal/session/task_store.go b/internal/session/task_store.go index 1158954a68..e79e6b3008 100644 --- a/internal/session/task_store.go +++ b/internal/session/task_store.go @@ -576,17 +576,18 @@ type taskRecord struct { Assignment *assignmentRecord `json:"assignment,omitempty"` } -// quickRecord is a quick node's body on disk: the four fields of +// quickRecord is a quick node's body on disk: the five fields of // [quickTaskSpec] that are facts about the work. `waits` is not among them — it // is a receipt for the moment of admission, and the edge it produced is already // on [taskRecord.DependsOn]. // // Done is parallel to Items, one tick per item, exactly as it is in memory. type quickRecord struct { - Line string `json:"line"` - Items []string `json:"items,omitempty"` - Done []bool `json:"done,omitempty"` - Files []string `json:"files,omitempty"` + Line string `json:"line"` + Items []string `json:"items,omitempty"` + Done []bool `json:"done,omitempty"` + Files []string `json:"files,omitempty"` + ReadOnly bool `json:"read_only,omitempty"` } // quickRecordLocked copies a quick body out, with the graph held — the lock the @@ -597,10 +598,11 @@ func quickRecordLocked(spec *quickTaskSpec) *quickRecord { return nil } return &quickRecord{ - Line: spec.line, - Items: append([]string(nil), spec.items...), - Done: append([]bool(nil), spec.done...), - Files: append([]string(nil), spec.files...), + Line: spec.line, + Items: append([]string(nil), spec.items...), + Done: append([]bool(nil), spec.done...), + Files: append([]string(nil), spec.files...), + ReadOnly: spec.readOnly, } } @@ -617,6 +619,7 @@ func (r *quickRecord) body() *quickTaskSpec { for index := range spec.done { spec.done[index] = index < len(r.Done) && r.Done[index] } + spec.readOnly = r.ReadOnly return spec } diff --git a/internal/session/task_test.go b/internal/session/task_test.go index aa14b6a933..6a17193670 100644 --- a/internal/session/task_test.go +++ b/internal/session/task_test.go @@ -1826,6 +1826,42 @@ func TestAuditBeltIsReadOnly(t *testing.T) { } } +func TestQuickWorkerCarriesReadOnlyBelt(t *testing.T) { + spec := newQuickTaskSpec("read the tree", nil, nil) + spec.readOnly = true + if restored := quickRecordLocked(spec).body(); restored == nil || !restored.readOnly { + t.Fatal("the read-only hand-off marker was not persisted") + } + + workspace := t.TempDir() + node := &TaskNode{spec: taskSpec{quick: spec}} + belt := quickBelt(node, workspace, Place{Dir: t.TempDir()}) + if belt == nil { + t.Fatal("a read-only quick worker got no belt") + } + byName := map[string]bare.Tool{} + for _, tool := range belt { + byName[tool.Name] = tool + } + for _, name := range []string{"read", "grep", "find", "ls", "bash"} { + if _, ok := byName[name]; !ok { + t.Fatalf("the reading helper cannot %q", name) + } + } + for _, name := range []string{"edit", "write", "commit", "merge", "propose_task", "team_start"} { + if _, ok := byName[name]; ok { + t.Fatalf("the reading helper carries %q", name) + } + } + text, isError, err := byName["bash"].Execute(context.Background(), json.RawMessage(`{"command":"touch forbidden.txt"}`)) + if err != nil { + t.Fatalf("bash: %v", err) + } + if !isError || !strings.HasPrefix(text, "refused:") { + t.Fatalf("write-capable bash was not refused: %q", text) + } +} + // A VERDICT NOBODY GAVE IS NOT A REFUTATION. Nothing merges without the word // VERIFIED — the frontier still fails closed — but an answer with neither word // in it is read as the NON-VERDICT it is, and it carries what the auditor From e284e0d048ebc311ce204331a02bf26b613cde4e Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 00:28:09 -0400 Subject: [PATCH 10/76] session: plan-born task workers carry the standing orders TaskGraph.briefLocked appended the project's standing orders to a task node's brief, but a run's workers are briefed from the plan store by BeltWorkerBrief, which never saw that tail, so no plan-born worker was told the rules. The run spec now carries the one rendered standing section and every worker the run seats gets it through the common brief composer. Fixes #1549 Co-Authored-By: Claude Opus 5.5 --- internal/run/bashworker.go | 12 ++++++++++-- internal/run/crew.go | 10 ++++++++-- internal/run/enginewire.go | 5 +++-- internal/run/run.go | 5 +++-- internal/session/bashbelt_worker.go | 12 ++++++++++-- internal/session/bashbelt_worker_test.go | 11 +++++++++++ internal/session/task_brief.go | 10 ++++++++++ internal/session/task_run_belt.go | 4 ++++ 8 files changed, 59 insertions(+), 10 deletions(-) diff --git a/internal/run/bashworker.go b/internal/run/bashworker.go index 6f8a8d2823..f6924733dc 100644 --- a/internal/run/bashworker.go +++ b/internal/run/bashworker.go @@ -56,6 +56,7 @@ type BashWorker struct { workspace string model string completer session.Completer + standing string } // NewBashWorker builds the seat the run's factory hands each claimed task to. @@ -65,7 +66,14 @@ type BashWorker struct { // completer is the seat's provider: a test scripts it, a run hands the door's // own. func NewBashWorker(store *plandb.Store, workspace, model string, completer session.Completer) *BashWorker { - return &BashWorker{store: store, workspace: workspace, model: model, completer: completer} + return NewBashWorkerWithStanding(store, workspace, model, completer, "") +} + +// NewBashWorkerWithStanding builds a worker with the standing section its run +// resolved for the project, while preserving the existing no-section helper +// for callers that do not own a session. +func NewBashWorkerWithStanding(store *plandb.Store, workspace, model string, completer session.Completer, standingSection string) *BashWorker { + return &BashWorker{store: store, workspace: workspace, model: model, completer: completer, standing: standingSection} } // Run hosts one agent's turn loop for the task until the agent ends its turn @@ -137,7 +145,7 @@ func (w *BashWorker) Run(ctx context.Context, task plandb.Task) (rep Report, run // THE BRIEF IS SAID ONCE, on the first round. Every round after it goes out // on the harness's own note, because a round only begins again when the last // one ended on words with no action. - brief := session.BeltWorkerBrief(w.store, &task, task.ID == w.store.RootID(), len(past) > 0, WakeClause(runCtx)) + brief := session.BeltWorkerBriefWithStanding(w.store, &task, task.ID == w.store.RootID(), len(past) > 0, WakeClause(runCtx), w.standing) // noAction counts replies in a row that carried no tool call. A reply that // did call a tool resets the run to one — its own trailing words are the // first of the new run — and the fourth in a row fails the task. diff --git a/internal/run/crew.go b/internal/run/crew.go index 36a1f4b596..96d14dbabb 100644 --- a/internal/run/crew.go +++ b/internal/run/crew.go @@ -123,12 +123,18 @@ type Seats struct { // reads empty, so a fallback means somebody emptied a row rather than that the // profile is old. func CrewFactory(store *plandb.Store, workspace, profileDir string, seats Seats, completerFor func(model string) session.Completer) WorkerFactory { + return CrewFactoryWithStanding(store, workspace, profileDir, seats, completerFor, "") +} + +// CrewFactoryWithStanding carries the session's one standing section into each +// worker the run seats, including workers launched after plan expansion. +func CrewFactoryWithStanding(store *plandb.Store, workspace, profileDir string, seats Seats, completerFor func(model string) session.Completer, standingSection string) WorkerFactory { return func(task plandb.Task) Worker { // UNDER `--one-model` THERE IS NO TIER TO READ. The door named one model // for every seat ([Seats.One]), so the role does not matter and neither // the profile nor the environment is asked. if seats.One != "" { - return NewBashWorker(store, workspace, seats.One, completerFor(seats.One)) + return NewBashWorkerWithStanding(store, workspace, seats.One, completerFor(seats.One), standingSection) } // A task the store cannot name — which the supervisor never hands over — // reads as the work seat, the same fallback SeatFor gives an unknown @@ -144,7 +150,7 @@ func CrewFactory(store *plandb.Store, workspace, profileDir string, seats Seats, // ceiling by seat, and a crew whose seats share one model would give it // nothing else to tell a check's call from a worker's. seat, _ := config.CrewTierSeat(tier) - return NewBashWorker(store, workspace, model, session.SeatCompleter(seat, completerFor(model))) + return NewBashWorkerWithStanding(store, workspace, model, session.SeatCompleter(seat, completerFor(model)), standingSection) } } diff --git a/internal/run/enginewire.go b/internal/run/enginewire.go index a4c7fe1be8..36aee8ee82 100644 --- a/internal/run/enginewire.go +++ b/internal/run/enginewire.go @@ -40,14 +40,14 @@ func (engine) Start(ctx context.Context, spec session.RunSpec) session.RunSummar // AND THE CHECK SEAT CLIMBS THE SAME LADDER `codeaf do` CLIMBS, minus // the flag no chat has ([chatCheckSeat]), so CODEAF_CHECK_MODEL reaches // a `/task` run the way the manual says it reaches a headless one. - factory := CrewFactory(spec.Store, spec.Workspace, spec.ProfileDir, Seats{ + factory := CrewFactoryWithStanding(spec.Store, spec.Workspace, spec.ProfileDir, Seats{ Work: spec.WorkModel, Plan: spec.PlanModel, Check: chatCheckSeat(spec.CheckModel), // AND UNDER `--one-model` ONE MODEL IS EVERY SEAT, the probe and the // check's environment rung included ([Seats.One]). One: spec.OneModel, - }, spec.CompleterFor) + }, spec.CompleterFor, spec.Standing) if spec.Delegate != nil { // A DELEGATED RUN SEATS THE PROGRAM ON ITS ROOT and has no review // round: a check seat is a bash-belt worker, which the belt switch may @@ -80,6 +80,7 @@ func (engine) Start(ctx context.Context, spec session.RunSpec) session.RunSummar Workspace: spec.Workspace, Title: spec.Title, Brief: spec.Brief, + Standing: spec.Standing, Slots: spec.Slots, Limits: limits, Factory: factory, diff --git a/internal/run/run.go b/internal/run/run.go index 24680ac059..17b50cfa5e 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -1752,8 +1752,9 @@ type Spec struct { // there; the brief is Start's to put down — on a root opened without a // description it becomes the root task's description, which is the // assignment the root worker reads. - Title string - Brief string + Title string + Brief string + Standing string // Slots bounds how many workers run at once, and 0 is no bound; Limits // bound the run's cost and its per-task steps. Both pass through to the // supervisor as given. diff --git a/internal/session/bashbelt_worker.go b/internal/session/bashbelt_worker.go index d965c758aa..e300698f61 100644 --- a/internal/session/bashbelt_worker.go +++ b/internal/session/bashbelt_worker.go @@ -167,15 +167,23 @@ func workerJournalName() string { // interrupted-predecessor sentence, which is a fact about a different worker // and not about this one. The resume flag still rides the trajectory's steps. func BeltWorkerBrief(store *plandb.Store, task *plandb.Task, root, resume bool, wake string) string { + return BeltWorkerBriefWithStanding(store, task, root, resume, wake, "") +} + +// BeltWorkerBriefWithStanding is [BeltWorkerBrief] with the standing section +// already resolved by the session that owns the run. The renderer remains at +// the common brief seam, so plan-born and direct workers use one section and +// one closing instruction. +func BeltWorkerBriefWithStanding(store *plandb.Store, task *plandb.Task, root, resume bool, wake, standingSection string) string { role := planIsTask if root { role = planIsRoot } - doc := composeBriefScoped(briefScopeFor(task.ID, true), briefPiece, "", + doc := composeBriefScopedWithStanding(briefScopeFor(task.ID, true), briefPiece, "", planBrief(task, task.ID, role), strings.Join(task.Deliverables, "\n"), task.Acceptance, - "", AdmissionContext{}, taskOrigin{}, taskCopy{}) + "", AdmissionContext{}, taskOrigin{}, taskCopy{}, standingSection) // THE ASK, FOR EVERY LEAF AND ONLY A LEAF. The section is absent on the // root's own document (its work order is the ask) and absent when the store // holds no root row to read it from, which is the emptiness law and not a diff --git a/internal/session/bashbelt_worker_test.go b/internal/session/bashbelt_worker_test.go index b955244e01..7547bd200a 100644 --- a/internal/session/bashbelt_worker_test.go +++ b/internal/session/bashbelt_worker_test.go @@ -116,6 +116,17 @@ func TestBeltWorkerBriefCarriesTheRunsAskToALeaf(t *testing.T) { } } +func TestBeltWorkerBriefCarriesStandingOrders(t *testing.T) { + store := askStore(t, "finish the release checklist") + standing := "Standing orders\n\n- Run the tests before you report done.\n\nIf you cannot honour one of these, say so in your report." + doc := BeltWorkerBriefWithStanding(store, store.Task("leaf"), false, false, "", standing) + for _, want := range []string{"Standing orders", "Run the tests before you report done.", "If you cannot honour one of these, say so in your report."} { + if !strings.Contains(doc, want) { + t.Fatalf("the plan-born worker brief is missing %q:\n%s", want, doc) + } + } +} + // TestBeltWorkerBriefLeavesTheRootsOwnDocumentAlone is the emptiness law: the // root's work order IS the ask, so its document gains no section and prints // its own words exactly once — under THE WORK it was composed into. diff --git a/internal/session/task_brief.go b/internal/session/task_brief.go index 977bdd8ce0..a6ce2368ba 100644 --- a/internal/session/task_brief.go +++ b/internal/session/task_brief.go @@ -203,6 +203,10 @@ func composeBrief(role briefRole, request, work, deliverable, acceptance, expect // message under a rule that labels it as background rather than as the thing // that wins. func composeBriefScoped(scope briefScope, role briefRole, request, work, deliverable, acceptance, expects string, heard AdmissionContext, origin taskOrigin, own taskCopy) string { + return composeBriefScopedWithStanding(scope, role, request, work, deliverable, acceptance, expects, heard, origin, own, "") +} + +func composeBriefScopedWithStanding(scope briefScope, role briefRole, request, work, deliverable, acceptance, expects string, heard AdmissionContext, origin taskOrigin, own taskCopy, standingSection string) string { request = briefAskText(request) work = briefWorkText(request, work) // THE COPY IS STATED ONLY WHERE THE GROUND WAS NAMED, and it is decided @@ -304,6 +308,12 @@ func composeBriefScoped(scope briefScope, role briefRole, request, work, deliver for _, part := range order { section(part.heading, part.rule, part.body) } + if standingSection = strings.TrimSpace(standingSection); standingSection != "" { + if out.Len() > 0 { + out.WriteString("\n\n") + } + out.WriteString(standingSection) + } return out.String() } diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index 8b0f92deaf..d2036138cd 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -101,6 +101,9 @@ type RunSpec struct { // and the brief is the assignment the root worker reads. Title string Brief string + // Standing is the one rendered standing-orders section every worker in this + // run must carry, resolved once from the conversation's project. + Standing string // Slots is how many workers run at once, and 0 is no limit, which is // the word `task.parallel` itself uses. CostUSD is what is left of the // smaller dollar limit the person set on the conversation, so the run and @@ -894,6 +897,7 @@ func (a *Agent) beltRunSpec(run *beltRun, brief string) RunSpec { Workspace: run.workspace, Title: run.title, Brief: brief, + Standing: a.graph().standingWorld(), Slots: a.config.TaskParallel, CostUSD: cost, Elapsed: wallLeft, From a6f91b823a63e57946d0fa4d01e781c04ee1c84f Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 00:28:09 -0400 Subject: [PATCH 11/76] standing: project orders match a cleaned path, cards quote the live allowance, task firings say task A project order stored with a trailing separator did not reach its own project and was listed under other projects; the standing card quoted the day allowance the session started with, not the one /budget set (the one the spend rail enforces); and a firing that ran a task was drawn as "said:". Fixes #1555 Co-Authored-By: Claude Opus 5.5 --- internal/manual/chat/keeping-an-eye.md | 3 ++ internal/session/standing_contract.go | 2 +- internal/session/standing_orders_test.go | 23 +++++++++++++ internal/session/standing_run.go | 22 +++++++++++-- internal/session/standing_test.go | 41 ++++++++++++++++++++++++ internal/session/tools_standing.go | 8 +++++ internal/standing/standing.go | 7 ++-- internal/tui3/standing.go | 10 ++++++ internal/tui3/standing_test.go | 15 +++++++++ 9 files changed, 126 insertions(+), 5 deletions(-) diff --git a/internal/manual/chat/keeping-an-eye.md b/internal/manual/chat/keeping-an-eye.md index faf713c293..8f328478bb 100644 --- a/internal/manual/chat/keeping-an-eye.md +++ b/internal/manual/chat/keeping-an-eye.md @@ -94,6 +94,9 @@ The row leads with your own sentence cut to its first six words, so `◦ remind me in 1 minute to · said: 💧 Time to drink water!`. The whole sentence is in the transcript, and on the item's own row on home. +When the firing runs a task rather than saying a sentence, its row uses `task:` +for the result, such as `◦ run the checks · task: the checks passed`. + There is no phone, no email and no desktop notification — see the last section of this page. diff --git a/internal/session/standing_contract.go b/internal/session/standing_contract.go index 8ee1589602..e0abe356f9 100644 --- a/internal/session/standing_contract.go +++ b/internal/session/standing_contract.go @@ -62,7 +62,7 @@ type StandingNotice struct { // a clock (tools_standing.go). The field stays because the event's shape // does. Deadline time.Time - // Update is set on EventStandingUpdate: "stood", "fired", "paused", + // Update is set on EventStandingUpdate: "stood", "said", "task", "paused", // "resumed", "stopped", "needs-you", "failed". Empty on a proposal. Update string // Text is the one line an update carries: what it said, what it landed, diff --git a/internal/session/standing_orders_test.go b/internal/session/standing_orders_test.go index 4dec5daae7..b1eb8461b7 100644 --- a/internal/session/standing_orders_test.go +++ b/internal/session/standing_orders_test.go @@ -100,6 +100,29 @@ func TestWhatStandsHereIsThisChatThenThisProjectThenTheMachine(t *testing.T) { } } +func TestProjectStandingOrdersMatchANormalizedWorkspace(t *testing.T) { + agent, store := ordersAgent(t) + workspace, sessionID := agent.standingPlace() + item := anOrder(t, store, "run the tests before you finish", workspace+string(filepath.Separator), standing.AltitudeProject, sessionID) + + got, err := store.Applicable(workspace, sessionID) + if err != nil { + t.Fatalf("Applicable: %v", err) + } + for _, found := range got { + if found.ID == item.ID { + stand, _ := agent.StandingHere() + for _, here := range stand { + if here.ID == item.ID { + return + } + } + t.Fatalf("Applicable found %q but StandingHere did not", item.ID) + } + } + t.Fatalf("project order %q did not reach normalized workspace %q", item.ID, workspace) +} + // A paused order must not vanish from the page on the pause keypress, or the // resume half of that one key becomes unreachable. The resolver itself still // answers active only — the seams that spend money never see a paused item. diff --git a/internal/session/standing_run.go b/internal/session/standing_run.go index 63e1851c2a..4ec302a5c9 100644 --- a/internal/session/standing_run.go +++ b/internal/session/standing_run.go @@ -446,7 +446,7 @@ func (r *standingRunner) deliver(item standing.Item, kind, text, run string) { // before the steering line goes on the queue, is what makes the order on // screen the order it happened in: the firing, then whatever is said // about it. - agent.emitStandingNews(standingUpdateWord(kind), item, text) + agent.emitStandingNews(standingFiringWord(kind, item.Does.Kind), item, text) agent.enqueueSteering(standingSteeringLine(item, text)) return } @@ -455,6 +455,7 @@ func (r *standingRunner) deliver(item standing.Item, kind, text, run string) { ItemID: item.ID, Words: item.Words, Kind: kind, + Action: string(item.Does.Kind), Text: text, Run: run, } @@ -492,6 +493,23 @@ func standingUpdateWord(kind string) string { return "fired" } +// standingFiringWord preserves the action a successful firing performed for +// the activity row; needs-you and failed remain outcome words because they say +// why the work stopped rather than what it delivered. +func standingFiringWord(kind string, action standing.ActionKind) string { + if word := standingUpdateWord(kind); word != "fired" { + return word + } + switch action { + case standing.ActionTask: + return "task" + case standing.ActionSay: + return "said" + default: + return "fired" + } +} + // standingSteeringLine is the shape a firing takes in a live conversation: the // glyph every surface leads a standing row with, the person's own words, and // what happened. It reads as one line of news and not as a machine reporting. @@ -1341,7 +1359,7 @@ func (a *Agent) queueStandingNews(notes []standing.Note) { // retired hours ago, so the row is built from the words and the id // the note kept rather than from a store lookup that can fail. Item: standing.Item{ID: note.ItemID, Words: note.Words}, - Update: standingUpdateWord(note.Kind), + Update: standingFiringWord(note.Kind, standing.ActionKind(note.Action)), Text: strings.TrimSpace(note.Text), }}) } diff --git a/internal/session/standing_test.go b/internal/session/standing_test.go index 127d53bdf3..f3f661f025 100644 --- a/internal/session/standing_test.go +++ b/internal/session/standing_test.go @@ -267,6 +267,47 @@ func TestStandingPersonNamedRailsSurviveAndTheCardQuotesThem(t *testing.T) { } } +func TestStandingCardReadsTheLiveDailyBudget(t *testing.T) { + store := newFakeStanding(t) + profile := t.TempDir() + if err := config.WriteDailyBudgetUSD(profile, 8); err != nil { + t.Fatalf("write budget: %v", err) + } + completer := &scriptedCompleter{steps: []step{standCall("s1", aReminder()), finalText("set up")}} + agent := standingAgent(t, completer, store, func(c *Config) { + c.ProfileDir = profile + c.Standing.DailyRailUSD = 50 + }) + events, err := agent.Submit(context.Background(), "remind me at 6 to leave") + if err != nil { + t.Fatalf("Submit: %v", err) + } + collected := drainAnsweringStanding(t, events, func(event Event) { + agent.ResolveStanding(event.Standing.ID, StandingAnswer{Approved: true}) + }) + card, found := firstOfKind(collected, EventStandingProposal) + if !found || card.Standing.CostWords != "shares the day's $8.00 allowance" { + t.Fatalf("card cost = %q, want the live $8.00 allowance", card.Standing.CostWords) + } +} + +func TestATaskFiringUsesTheTaskActivityWord(t *testing.T) { + workspace := t.TempDir() + room := standingLiveAgent(t, workspace, nil) + lane := room.TaskUpdates() + runner := &standingRunner{root: t.TempDir()} + item := standing.Item{ + ID: "task-item", Words: "run the checks", Workspace: workspace, + Origin: standing.Origin{SessionID: room.id, Transcript: room.config.SessionFile}, + Does: standing.Action{Kind: standing.ActionTask, Brief: "run the checks"}, + } + runner.deliver(item, "landed", "the checks passed", "") + event := standingNextUpdate(t, lane) + if event.Standing.Update != "task" { + t.Fatalf("task firing activity word = %q, want task", event.Standing.Update) + } +} + // A RULE STANDS UP WITH NOTHING BUT ITS SENTENCE AND ITS REACH. // // The three fields every other kind carries are all about waking — a cadence to diff --git a/internal/session/tools_standing.go b/internal/session/tools_standing.go index fc91dfe966..99e4c203c6 100644 --- a/internal/session/tools_standing.go +++ b/internal/session/tools_standing.go @@ -857,6 +857,14 @@ func (a *Agent) standingCostWords(item standing.Item, parsed standArguments) str if parsed.Rails.PerRunUSD != nil || parsed.Rails.MaxPerDay != nil { return strings.TrimSpace(parsed.CostWords) } + if a.config.ProfileDir != "" { + if daily, err := config.DailyBudgetUSDAt(a.config.ProfileDir); err == nil { + if daily > 0 { + return "shares the day's $" + strconv.FormatFloat(daily, 'f', 2, 64) + " allowance" + } + return "shares the day's allowance" + } + } if a.config.Standing != nil && a.config.Standing.DailyRailUSD > 0 { return "shares the day's $" + strconv.FormatFloat(a.config.Standing.DailyRailUSD, 'f', 2, 64) + " allowance" } diff --git a/internal/standing/standing.go b/internal/standing/standing.go index 02292e71f1..db8f5427c9 100644 --- a/internal/standing/standing.go +++ b/internal/standing/standing.go @@ -608,7 +608,8 @@ func (it Item) Reaches(workspace, sessionID string) bool { case AltitudeMachine: return true case AltitudeProject: - return workspace != "" && it.Workspace == workspace + return strings.TrimSpace(it.Workspace) != "" && strings.TrimSpace(workspace) != "" && + filepath.Clean(it.Workspace) == filepath.Clean(workspace) case AltitudeConversation: return sessionID != "" && it.Origin.SessionID == sessionID } @@ -880,7 +881,9 @@ type Note struct { Words string `json:"words"` // Kind is "said", "landed", "needs-you", or "failed". Kind string `json:"kind"` - Text string `json:"text"` + // Action preserves whether a delivered firing said something or ran a task. + Action string `json:"action,omitempty"` + Text string `json:"text"` // Run is the run folder a person can open for the whole story. Run string `json:"run,omitempty"` } diff --git a/internal/tui3/standing.go b/internal/tui3/standing.go index 0e91191dad..6ab55d9301 100644 --- a/internal/tui3/standing.go +++ b/internal/tui3/standing.go @@ -472,6 +472,16 @@ func standUpdateWord(update, text string) string { return "ran" } return "said: " + text + case "said": + if text == "" { + return "ran" + } + return "said: " + text + case "task": + if text == "" { + return "ran" + } + return "task: " + text case "needs-you": // ONE WORD FOR ONE READING, AND IT IS THE SURFACE'S ONE WORD. `needs your // look` was this file's own name for the fact every task row now calls diff --git a/internal/tui3/standing_test.go b/internal/tui3/standing_test.go index cfd388daf9..01a2c241af 100644 --- a/internal/tui3/standing_test.go +++ b/internal/tui3/standing_test.go @@ -751,6 +751,21 @@ func TestAFiringOffTheStandingLaneIsDrawnInTheConversation(t *testing.T) { } } +func TestATaskFiringIsDrawnAsATask(t *testing.T) { + a, agent, cmd := firingApp(t) + item := standItem() + item.Does.Kind = standing.ActionTask + agent.updates <- session.Event{Kind: session.EventStandingUpdate, Standing: &session.StandingNotice{ + Item: item, Update: "task", Text: "the checks passed", + }} + drive(t, a, runCmd(cmd)...) + + want := standWaitGlyph + " every Monday at 9, post the · task: the checks passed" + if body := standText(a); !strings.Contains(body, want) { + t.Fatalf("the task firing was drawn with the wrong activity word; want %q:\n%s", want, body) + } +} + // firingReplyAgent carries both lanes involved in a live firing: the standing // event that draws the news and the turn the session wakes to answer it. type firingReplyAgent struct { From 66475ea8e85605f9a1021e8118bf2e5093616d9c Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 00:03:26 -0400 Subject: [PATCH 12/76] chat: an empty /drafts or /skill never traps the keyboard /drafts claimed every key while its page had no overlay height or rows, so it drew nothing until esc. /skill on an empty shelf opened a picker with no choices and left `/skill ` in the box, so later commands became its path. The drafts page is drawn and pointed like every other place, and an empty skill catalog closes the picker and clears the box once both reads answer. Fixes #1557 Co-Authored-By: Claude Opus 5.5 --- .../manual/chat/putting-a-skill-in-front.md | 3 ++ internal/manual/chat/starting-codeaf.md | 2 + internal/tui3/app.go | 12 ++++++ internal/tui3/draftpage_test.go | 16 ++++++++ internal/tui3/palette.go | 4 ++ internal/tui3/skillpick.go | 37 +++++++++++++++++++ internal/tui3/skillpick_test.go | 32 ++++++++++++++++ 7 files changed, 106 insertions(+) diff --git a/internal/manual/chat/putting-a-skill-in-front.md b/internal/manual/chat/putting-a-skill-in-front.md index 76b5f60110..1e033897c2 100644 --- a/internal/manual/chat/putting-a-skill-in-front.md +++ b/internal/manual/chat/putting-a-skill-in-front.md @@ -9,6 +9,9 @@ it on. Enter does not close the list — three skills are three presses of it. Press `enter` on a skill that is already on to turn it off. `esc` closes the list and leaves your message exactly as you typed it. +If the shelf has no skills, `/skill` says `no skills are available here` and +clears itself from the message box, so the next command starts cleanly. + `/skills` is the same command. A skill that is on is marked with a filled dot on its row; one that is off diff --git a/internal/manual/chat/starting-codeaf.md b/internal/manual/chat/starting-codeaf.md index cb788ebb46..f0b558b830 100644 --- a/internal/manual/chat/starting-codeaf.md +++ b/internal/manual/chat/starting-codeaf.md @@ -797,3 +797,5 @@ will tell you so instead of inventing an answer. ## /drafts and the ↑ walk — cleared drafts come back too `ctrl+u`, a conversation switch, any clear that empties the whole box — codeaf pushes the draft onto a ring of ten (the kill ring, `draftring.go`). The usual `↑` walk, which used to answer about the sent lines, now visits the ring first, dim in front of the sent history. `/drafts` opens the same ring as its own page: a list with `enter` restorer over what is left in the box (that box, too, joins the ring before the restored line takes it), and `d` letting one go for good. A cleared draft is never lost and never keeps its place in the ring once it lands back in the box. + +Even with no cleared drafts, the page draws its heading and the line `no cleared draft is waiting`. diff --git a/internal/tui3/app.go b/internal/tui3/app.go index d63a807534..f063c12e94 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -1851,11 +1851,19 @@ type app struct { // (skillpick.go). It holds no attachment state of its own: the names live // in the session, and the tray chip reads them there. skillPick skillPick + // skillEmptyClose asks the picker to clear a bare command when its catalog + // answers empty; a typed picker must stay open so a path can become its next + // choice. + skillEmptyClose bool // skillShelfSeen is the session's shelf as its last reading answered, nil // until one has (skillpick.go's [app.readSkillShelf]). It outlives the // list, so a list opened again draws the last answer while the next read // is on its way. skillShelfSeen *skillShelfReading + // skillDiskRead distinguishes an empty disk scan from the scan that has not + // answered yet; the picker must not close before the asynchronous catalog is + // known to be empty. + skillDiskRead bool // skillDiskSeen is the last foreign folder scan. It outlives the picker // so reopening can draw those rows while a fresh scan is in flight. skillDiskSeen []skills.Skill @@ -4353,6 +4361,9 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { if a.connPanel.open { return a, a.connectPanelPress(msg.Mouse().Y) } + if a.draftPage.open { + return a, a.draftPagePress(msg.Mouse().Y) + } // AND THE HARNESS PICKER TAKES A PRESS ON ITS OWN ROWS AND NOTHING // ELSE, because it is not modal: it hangs under a draft somebody is // still typing, so a press anywhere else is a press on whatever is @@ -7686,6 +7697,7 @@ func (a *app) slash(line string) tea.Cmd { // the command and its query into the box rather than opening the list // from nowhere. A path typed on home must survive the new conversation // that home opens before this command reaches the picker. + a.skillEmptyClose = rest == "" a.input.reset() a.input.insert("/skill " + rest) cmd := a.edited() diff --git a/internal/tui3/draftpage_test.go b/internal/tui3/draftpage_test.go index 0771162b2f..235cc619c6 100644 --- a/internal/tui3/draftpage_test.go +++ b/internal/tui3/draftpage_test.go @@ -1,6 +1,7 @@ package tui3 import ( + "strings" "testing" ) @@ -36,3 +37,18 @@ func TestDraftRestorePushesTheLiveBox(t *testing.T) { t.Fatalf("the box that was live should have joined the ring: %v", list) } } + +func TestEmptyDraftPageDrawsItsOwnEmptyState(t *testing.T) { + a := &app{draftPage: draftPanel{}, width: 100, height: 24} + a.openDrafts() + + if got := a.overlayHeight(); got == 0 { + t.Fatal("an empty /drafts page has no overlay height") + } + screen := strings.Join(plainOverlay(a), "\n") + for _, want := range []string{draftHeading, draftEmptyWord} { + if !strings.Contains(screen, want) { + t.Fatalf("empty /drafts page is missing %q:\n%s", want, screen) + } + } +} diff --git a/internal/tui3/palette.go b/internal/tui3/palette.go index aa1884a4f2..32dbf196c0 100644 --- a/internal/tui3/palette.go +++ b/internal/tui3/palette.go @@ -3208,6 +3208,8 @@ func (a *app) overlayHeight() int { want = a.harnPick.height(width) case a.skillPick.open: want = a.skillPick.height(width) + case a.draftPage.open: + want = a.draftPage.height(width) case a.permPanel.open: want = a.permPanel.height(width) case a.subPage.open: @@ -3268,6 +3270,8 @@ func (a *app) overlayRows(width, n int) []string { return a.harnPick.draw(width, n, a.pal, hover) case a.skillPick.open: return a.skillPick.draw(width, n, a.pal, hover) + case a.draftPage.open: + return a.draftPage.draw(width, n, a.pal, hover) case a.permPanel.open: return a.permPanel.draw(width, n, a.pal, hover) case a.subPage.open: diff --git a/internal/tui3/skillpick.go b/internal/tui3/skillpick.go index 8b87b91710..f9f3576981 100644 --- a/internal/tui3/skillpick.go +++ b/internal/tui3/skillpick.go @@ -81,6 +81,7 @@ const ( // off has a shelf of its own, so the one case left is a conversation whose // door built none, or a far engine too old to be asked. skillNoShelfWarning = "this conversation has no skill shelf, so this cannot be attached" + skillEmptyWord = "no skills are available here" ) // skillHomeDir is where discovery looks beside the workspace: the same login @@ -132,6 +133,9 @@ func (r skillPickRow) note() string { // skillPick is the picker's whole state. The zero value is closed. type skillPick struct { open bool + // closeWhenEmpty is set only for a bare command, whose empty answer should + // clear the command line; a typed picker may still receive a folder path. + closeWhenEmpty bool // rows are the shelf as it was when the list opened, attached first. It // is resolved on the keystroke and not held from boot, for the harness // picker's reason: another window may have installed a skill a minute ago. @@ -267,6 +271,9 @@ func (p *skillPick) height(width int) int { if !p.open { return 0 } + if p.count() == 0 { + return 1 + } return overlayWindow(width, p.top, p.count(), harnessPickRows, p.note) } @@ -276,6 +283,12 @@ func (p *skillPick) draw(width, n int, pal palette, hover int) []string { } p.follow(overlayItems(n, width)) fill := newOverlayFill(width, n, pal, hover) + if p.count() == 0 { + fill.plain(pal.dim(fit(" "+skillEmptyWord, width))) + lines, owner := fill.done() + p.owner = owner + return lines + } for at := p.top; at < p.count() && fill.room(); at++ { if !fill.add(at, p.label(at, pal), p.note(at), at == p.cursor, false) { break @@ -340,6 +353,9 @@ func (a *app) syncSkillPick() (bool, tea.Cmd) { } if !a.skillPick.open { a.skillPick.start(a.skillPickList(), query) + a.skillPick.closeWhenEmpty = a.skillEmptyClose + a.skillEmptyClose = false + a.skillDiskRead = false return true, tea.Batch(a.readSkillShelf(), a.readSkillDisk()) } if query != a.skillPick.query { @@ -404,6 +420,7 @@ func (a *app) readSkillDisk() tea.Cmd { return nil } a.skillDiskSeen = found + a.skillDiskRead = true if a.skillPick.open { a.restartSkillPick() a.touch() @@ -417,12 +434,32 @@ func (a *app) readSkillDisk() tea.Cmd { // query and, where it still points at a row, the cursor. func (a *app) restartSkillPick() { cursor, query := a.skillPick.cursor, a.skillPick.query + closeWhenEmpty := a.skillPick.closeWhenEmpty a.skillPick.start(a.skillPickList(), query) + a.skillPick.closeWhenEmpty = closeWhenEmpty + if a.skillPick.closeWhenEmpty && len(a.skillPick.rows) == 0 && query == "" && a.skillPickReadsDone() { + a.skillPick.close() + a.input.reset() + a.note(skillEmptyWord) + a.touch() + return + } if cursor < a.skillPick.count() { a.skillPick.cursor = cursor } } +// skillPickReadsDone says the asynchronous catalog reads have answered, so an +// empty result is real rather than the brief state before a shelf or disk row +// arrives. Closing only at this seam keeps an empty picker from trapping the +// next command while preserving the picker’s useful early paint. +func (a *app) skillPickReadsDone() bool { + if _, ok := a.agent.(skillShelf); ok && a.skillShelfSeen == nil { + return false + } + return a.skillDiskRead +} + // skillPickList resolves the shelf into rows: the attached ones first, in // attachment order, then the rest by scope — project before user — and by // name. Two sources are merged and deduplicated by name: the active shelf the diff --git a/internal/tui3/skillpick_test.go b/internal/tui3/skillpick_test.go index 0e7dad30bb..7eaaf68abc 100644 --- a/internal/tui3/skillpick_test.go +++ b/internal/tui3/skillpick_test.go @@ -426,6 +426,25 @@ func TestAFolderWithNoSkillMDIsRefusedInOneLine(t *testing.T) { } } +// AN EMPTY CATALOG MUST NOT CLOSE BEFORE A PATH CHOICE. The folder row is a +// choice offered by the picker itself, even when the shelf has no skills. +func TestEmptySkillCatalogStaysOpenForAFolderChoice(t *testing.T) { + a, _, _, home := skillApp(t) + empty := filepath.Join(home, "not-a-skill") + if err := os.MkdirAll(empty, 0o755); err != nil { + t.Fatal(err) + } + + typeInto(t, a, "/skill "+empty) + if !a.skillPick.open { + t.Fatal("the empty catalog closed before the folder choice") + } + drive(t, a, key("enter")) + if said := strings.Join(plainRows(a), "\n"); !strings.Contains(said, "no SKILL.md in") { + t.Fatalf("the folder refusal was lost:\n%s", said) + } +} + // ── the chip ──────────────────────────────────────────────────────────────── // THE CHIP CARRIES THE NAME FOR ONE SKILL AND A COUNT FOR MORE, and one @@ -521,6 +540,19 @@ func TestBareSkillOpensThePickerOnTheWholeShelf(t *testing.T) { } } +func TestBareSkillOnAnEmptyShelfDoesNotLeaveAPathInTheBox(t *testing.T) { + a, _, _, _ := skillApp(t) + + cmd := a.slash("/skill") + spend(t, a, cmd) + if a.skillPick.open { + t.Fatal("empty /skill opened a picker with no choices") + } + if got := a.input.String(); got != "" { + t.Fatalf("empty /skill left %q in the box", got) + } +} + // THE COMMAND LIST CARRIES THE ROW, spelled the way the other query-bearing // commands are. func TestSkillIsOnTheCommandList(t *testing.T) { From 12b2f7d3753b17bcd544fca03e8758e7693d7373 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 00:38:08 -0400 Subject: [PATCH 13/76] teams: managers are addressable at once, sub-teams keep the posture, the cards and pages say what is true - A manager made with M had no handle until its first turn titled it, so team_send from the global manager could not reach it. SetManager now gives an untitled manager a unique fallback handle when it is registered. - A sub-team started by team_start did not inherit the starting manager's approval posture; the start entry now carries it and the child gets it before it runs. - The team_start consent card quoted the policy's fallback rule word "default" instead of the cost clause; approval names its fallback rules and the card treats them as no rule. - team_start's description told the model the person is always asked first, so a manager under an allowing posture reported an approval nobody gave. - After M the keyboard stayed on the teams page; hints said m; the key sheet omitted M m p r d u. - After Raise to $4 the header kept the recurring cap; it reads today's decided raise, the packet the cap gate uses. - A sub-team manager naming its own team in team_post got an unknown-team refusal; it is now told team_post is for the team above. Fixes #1551 Fixes #1552 Fixes #1576 Fixes #1585 Fixes #1591 Co-Authored-By: Claude Opus 5.5 --- internal/approval/approval.go | 19 ++++++++- internal/manual/chat/team-manager.md | 24 +++++++---- internal/manual/chat/teams-page.md | 2 + internal/session/consent.go | 9 ++-- internal/session/team_nest.go | 2 +- internal/session/team_nest_test.go | 42 ++++++++++++++++++- internal/session/team_test.go | 6 +++ internal/session/teamevent_test.go | 3 ++ internal/session/tools_team.go | 20 +++++++-- internal/teams/handle.go | 17 +++++++- internal/teams/handle_test.go | 25 ++++++++++++ internal/teams/teams.go | 19 ++++++++- internal/teams/traffic.go | 3 ++ internal/tui3/commands.go | 2 +- internal/tui3/teamsacts.go | 2 + internal/tui3/teamsopen.go | 2 +- internal/tui3/teamspage_test.go | 61 ++++++++++++++++++++++++++++ internal/tui3/teamspagedraw.go | 34 +++++++++++++--- internal/tui3/teamtraffic.go | 16 +++++++- 19 files changed, 276 insertions(+), 32 deletions(-) diff --git a/internal/approval/approval.go b/internal/approval/approval.go index 42f1ab0f23..3dea7f605e 100644 --- a/internal/approval/approval.go +++ b/internal/approval/approval.go @@ -229,13 +229,28 @@ func (p Policy) CheckBash(command string) Decision { // base is the answer before any bash-specific reasoning: the tool's own rule // if it has one, otherwise the default, otherwise ask — with the floor under a // blanket allow that [actsInThePersonsName] describes. +// RuleDefault and RuleUnset are the rule words of a decision no rule of the +// policy made: the policy's own default, or no default at all. They name +// machinery, not a reason a person chose, so a surface that has its own +// sentence for the call says that instead ([IsDefaultRule]). +const ( + RuleDefault = "default" + RuleUnset = "default (unset)" +) + +// IsDefaultRule reports whether a decision's rule is the fallback rather than +// a rule somebody wrote. +func IsDefaultRule(rule string) bool { + return rule == RuleDefault || rule == RuleUnset +} + func (p Policy) base(tool string, args json.RawMessage) Decision { if action, ok := p.Tools[tool]; ok && action.valid() { return Decision{Action: action, Rule: fmt.Sprintf("tool %q", tool)} } - decision := Decision{Action: ActionPrompt, Rule: "default (unset)"} + decision := Decision{Action: ActionPrompt, Rule: RuleUnset} if p.Default.valid() { - decision = Decision{Action: p.Default, Rule: "default"} + decision = Decision{Action: p.Default, Rule: RuleDefault} } if decision.Action == ActionAllow && ActsInThePersonsName(tool, args) { return Decision{Action: ActionPrompt, Rule: fmt.Sprintf("%s acts in your name outside this machine", tool)} diff --git a/internal/manual/chat/team-manager.md b/internal/manual/chat/team-manager.md index ae3ff57821..9da1bcbe03 100644 --- a/internal/manual/chat/team-manager.md +++ b/internal/manual/chat/team-manager.md @@ -64,6 +64,10 @@ Once there is a manager the place reads **`◆ Manager`**, and on the conversati comes first, titled `◆ Manager · `. Point at the tab to see the team and the title in the hint line. `alt+m` goes to the manager from any conversation in the team. +An untitled manager is addressable immediately: codeaf gives it a deterministic `@manager-...` +fallback when it joins the team, so another manager can use `team_send` before its first turn. +Once its title is made, the ordinary title-based handle choice may replace that fallback. + ## The manager's screen While the manager is in front, the message box says `to ◆ manager`, and keeps saying it on the @@ -147,9 +151,10 @@ the Traffic is the team's own and a manager set from the laptop is the one the f follows. Against a far machine running an older codeaf, `+ Manager` and the menus say managers are not available over `--host`, and the column has no Traffic word. -When the manager starts a member with `team_start`, you are asked first, on a card that reads -`◆ manager wants to start @lexer`, with the brief under it and the clause `a new conversation; -it spends until it stops`. When you allow it, this window opens the new conversation in the +When the manager starts a member with `team_start` under an approval posture that asks, you are +asked first, on a card that reads `◆ manager wants to start @lexer`, with the brief under it and +the clause `a new conversation; it spends until it stops`. Under an allowing posture it starts +without a card. When it starts, this window opens the new conversation in the team's folder **behind** the one you are in, never in front of it: what you were typing stays where it was. Its tab arrives at the end of the team's run, named `@lexer` until it has a title, and its working mark is the only thing that moves. With the team's auto-wake on, the member @@ -222,14 +227,15 @@ on one waits for you. | `team_read` | the end of one member's conversation, bounded; the member is not told | no | | `team_send` | a message to one member, to several (one message, every handle in `to`), or to everyone, as a note (information, which waits) or a directive (an instruction, which starts an idle member) | no | | `team_stop` | ends one member's current turn, the way your own Stop does, whether a window has it open or codeaf opened it in the background: nothing is deleted, and its background tasks and jobs keep running | no | -| `team_start` | a new member conversation with a handle and a brief; it opens in the team's folder and is handed the brief, marked as the manager's, on its first request. With kind `team` it starts a sub-team instead (see **Sub-teams**) | yes | +| `team_start` | a new member conversation with a handle and a brief; it opens in the team's folder and is handed the brief, marked as the manager's, on its first request. With kind `team` it starts a sub-team instead (see **Sub-teams**) | when approvals ask | | `team_decide` | answers a decision packet waiting on the manager, most often a member's question: an option, or its own words | no | | `team_escalate` | sends a packet waiting on the manager up, to its own manager or to you, with the reason it is not the manager's to decide | no | | `team_close_report` | brings you the team's closing report (done, left, where the files are) after you asked it to wrap up | no | | `team_raise` | raises a conflict to the manager above every party (see **Conflicts between members and teams**); members have it too | no | -`team_start` asks because a new conversation spends money for as long as it runs, and it is -refused while the team is at its daily cap. The others act only inside the team you made, and +`team_start` asks under an approval posture that asks because a new conversation spends money for +as long as it runs, and it is refused while the team is at its daily cap. Under an allowing +posture it starts without asking. The others act only inside the team you made, and every one of them is logged in the team's traffic. Questions, packets, caps and wrapping up are on the page **Team questions, decisions and caps**. Like any tool, each can be set to ask or allow in `/settings` under the tool approvals. @@ -264,9 +270,9 @@ move wakes it by itself, and a move that was refused writes no line. ## Sub-teams A manager can start a **sub-team**: `team_start` with kind `team`, a name for the new team, a -handle and a brief for its manager, and optionally members of its own team to move into it. You -are asked first, on the same card as any start, which then reads `a new team "backend" under -yours`. When you allow it: +handle and a brief for its manager, and optionally members of its own team to move into it. When +approvals ask, the same card as any start reads `a new team "backend" under yours`; under an +allowing posture it starts without a card. When it starts: - the new team is made under the manager's team, with its share of the pool: the parent's daily cap times `sub-team share` (`/settings`, **Teams**; 50% by default), written on the new team. diff --git a/internal/manual/chat/teams-page.md b/internal/manual/chat/teams-page.md index abc9d45a0c..c11c7011bb 100644 --- a/internal/manual/chat/teams-page.md +++ b/internal/manual/chat/teams-page.md @@ -262,6 +262,8 @@ On the page's buttons, and on a team with no manager in the pane: | `esc` | cancel a drag or a move's question, clear the picks, then back to the message box, or home when there is none | Any letter not in that list goes back to the message box and types there. +After `M` starts a manager, the new manager's message box receives the keyboard immediately; +letters you type are sent to that manager, not interpreted as page actions. ## A team's card: its settings, and where each value comes from diff --git a/internal/session/consent.go b/internal/session/consent.go index 0c25435ea4..f67a26a0b0 100644 --- a/internal/session/consent.go +++ b/internal/session/consent.go @@ -640,12 +640,15 @@ func ConsentHead(tool, args string) string { // consentRule is [Event.Rule] for a call: the policy's own words, and for a // start with none, what the person is agreeing to pay for ([teamStartCost]). func consentRule(call ai.ToolCall, decision approval.Decision) string { - if rule := strings.TrimSpace(decision.Rule); rule != "" { - return rule - } if call.Function.Name == teamStartToolName { + if rule := strings.TrimSpace(decision.Rule); rule != "" && !approval.IsDefaultRule(rule) { + return rule + } return teamStartCost } + if rule := strings.TrimSpace(decision.Rule); rule != "" { + return rule + } return "" } diff --git a/internal/session/team_nest.go b/internal/session/team_nest.go index 372cfae9e9..e28918c397 100644 --- a/internal/session/team_nest.go +++ b/internal/session/team_nest.go @@ -241,7 +241,7 @@ func (a *Agent) teamStartSubTeam(team teams.Team, role teamRole, handle, brief, // here, where the membership was written. A refusal returned above and // wrote nothing. _ = teams.WriteMoveNotices(profile, movedIn) - start := teams.Entry{Kind: teams.KindStart, From: teams.FromManager, To: handle, Text: brief, Team: childID} + start := teams.Entry{Kind: teams.KindStart, From: teams.FromManager, To: handle, Text: brief, Team: childID, Approval: a.ApprovalPosture()} if err := teams.AppendTraffic(profile, team.ID, start); err != nil { return "The team " + strconv.Quote(name) + " was made, but its manager's start could not be written to the traffic: " + err.Error(), true } diff --git a/internal/session/team_nest_test.go b/internal/session/team_nest_test.go index 7a4646a08e..e6cd79ff87 100644 --- a/internal/session/team_nest_test.go +++ b/internal/session/team_nest_test.go @@ -9,6 +9,7 @@ package session // teams.json and real Traffic, through internal/teams. import ( + "encoding/json" "os" "path/filepath" "strings" @@ -110,6 +111,7 @@ func TestTeamStartOfKindTeamMakesASubTeamWhoseManagerReportsUp(t *testing.T) { t.Fatal(err) } boss := n.agent(t, "boss") + boss.approvalPosture = PostureAsk boss.teamBoundary() said, failed := callTool(t, boss.teamStartTool, `{"handle":"api","brief":"Build the signup API.\nDone is a green handler test.","kind":"team","name":"backend","members":["parser"]}`) if failed || !strings.Contains(said, `Made the team "backend" under "harbor"`) || !strings.Contains(said, "$5.00 a day") || !strings.Contains(said, "Moved in: @parser") { @@ -130,7 +132,8 @@ func TestTeamStartOfKindTeamMakesASubTeamWhoseManagerReportsUp(t *testing.T) { } log, _ := teams.ReadTraffic(n.fixture.profile, harbor, "", 0) start := last(log) - if start.Kind != teams.KindStart || start.To != "api" || start.Team != backend.ID { + rawStart, _ := json.Marshal(start) + if start.Kind != teams.KindStart || start.To != "api" || start.Team != backend.ID || !strings.Contains(string(rawStart), `"approval":"ask"`) { t.Fatalf("harbor's start line: %+v", start) } here, _ := teams.ReadTraffic(n.fixture.profile, backend.ID, "", 0) @@ -186,6 +189,43 @@ func TestTeamStartOfKindTeamMakesASubTeamWhoseManagerReportsUp(t *testing.T) { } } +func TestSubTeamManagerNamingItsOwnTeamGetsTheRule(t *testing.T) { + n := newTeamTree(t, "boss", "api") + harbor := n.team(t, "harbor", "", "boss", n.member(t, "boss", "boss")) + boss := n.agent(t, "boss") + boss.teamBoundary() + if said, failed := callTool(t, boss.teamStartTool, `{"handle":"api","brief":"Run the API.","kind":"team","name":"backend"}`); failed { + t.Fatalf("team_start failed: %q", said) + } + f := n.file(t) + var backend teams.Team + for _, team := range f.Teams { + if team.Name == "backend" { + backend = team + break + } + } + if backend.ID == "" { + t.Fatal("the child team was not made") + } + if err := teams.Update(n.fixture.profile, func(f *teams.File) error { + m := n.member(t, "api", "api") + m.Started = true + if err := f.AddMember(harbor, m); err != nil { + return err + } + return f.SetManager(backend.ID, m.Key) + }); err != nil { + t.Fatal(err) + } + api := n.agent(t, "api") + api.teamBoundary() + said, failed := callTool(t, api.teamPostTool, `{"team":"backend","to":"manager","text":"status"}`) + if !failed || said != `You manage "backend"; team_post is for the team above you, "harbor".` { + t.Fatalf("the own-team refusal was %q (failed=%v)", said, failed) + } +} + // PAST THE DEPTH LIMIT, a sub-team is refused with the reason, and nothing is // made. func TestASubTeamPastTheDepthLimitIsRefusedWithTheReason(t *testing.T) { diff --git a/internal/session/team_test.go b/internal/session/team_test.go index 59d195fbb1..1dedca8396 100644 --- a/internal/session/team_test.go +++ b/internal/session/team_test.go @@ -31,6 +31,12 @@ type teamFixture struct { manager, web, parser string } +func TestTeamStartDescriptionMatchesTheConversationPosture(t *testing.T) { + if !strings.Contains(teamStartDescription, "when this conversation's approval posture asks") || strings.Contains(teamStartDescription, "The person is asked first. ") { + t.Fatalf("team_start description says %q", teamStartDescription) + } +} + // convKeyOf is the interface's key for a transcript (tui3's convKey): the path // cleaned, with its symlinks resolved once the file exists. func convKeyOf(t *testing.T, path string) string { diff --git a/internal/session/teamevent_test.go b/internal/session/teamevent_test.go index 0d6743683f..0cdb549ae5 100644 --- a/internal/session/teamevent_test.go +++ b/internal/session/teamevent_test.go @@ -258,6 +258,9 @@ func TestTheStartsCardSaysWhoIsStartedAndWhatItCosts(t *testing.T) { if rule := consentRule(call, approval.Decision{Rule: "a rule said so"}); rule != "a rule said so" { t.Errorf("a policy's own words were replaced: %q", rule) } + if rule := consentRule(call, approval.Decision{Rule: "default"}); rule != teamStartCost { + t.Errorf("the internal default leaked into the card: %q", rule) + } bash := ai.ToolCall{Function: ai.ToolCallFunction{Name: "bash", Arguments: `{"command":"ls"}`}} if head := ConsentHead("bash", bash.Function.Arguments); head != "needs your ok to run bash" { t.Errorf("an ordinary head changed: %q", head) diff --git a/internal/session/tools_team.go b/internal/session/tools_team.go index 09920c4ce5..d1642d913e 100644 --- a/internal/session/tools_team.go +++ b/internal/session/tools_team.go @@ -22,7 +22,7 @@ package session // new conversation that spends money are two different acts, and a person must // be able to allow one and be asked about the other. So the reads, the messages // and the stop sit on the builtin floor (cmd/codeaf's v3BuiltinApprovals) and -// `team_start` is left to the blanket mode, which asks. +// `team_start` is left to the blanket mode, so it follows the current posture. // // THE CHANNEL IS THE TRAFFIC LOG AND NOTHING ELSE. Every write here is one // [teams.AppendTraffic]: a message is a note or a directive, a stop is a @@ -104,7 +104,7 @@ const teamStopSchema = `{"type":"object","properties":{"handle":{"type":"string" `"reason":{"type":"string","description":"One line, shown in the traffic."},` + teamArgSchema + `},"required":["handle"],"additionalProperties":false}` -const teamStartDescription = "Start a new member conversation in this team with a handle and a brief. The person is asked first. " + +const teamStartDescription = "Start a new member conversation in this team with a handle and a brief. The person is asked first when this conversation's approval posture asks; otherwise it starts under the current approval posture. " + "The new conversation opens in the team's folder and its first message is your brief, marked as from the manager. " + "Write the brief as a complete assignment: the goal, what done looks like, and which files are its to touch. " + "kind team starts a sub-team instead: a new team under yours (name) whose manager is the new conversation, with its share of your pool; members you name move into it. " + @@ -198,6 +198,20 @@ func (a *Agent) teamTarget(want string, manager bool) (teams.Team, teamRole, str } } if len(chosen) != 1 { + for _, role := range rolesFor(file, keys, defaults) { + if !role.manager || !strings.EqualFold(role.name, want) { + continue + } + above := make([]teamRole, 0, len(fits)) + for _, candidate := range fits { + if candidate.id != role.id { + above = append(above, candidate) + } + } + if len(above) == 1 { + return teams.Team{}, teamRole{}, fmt.Sprintf("You manage %q; team_post is for the team above you, %q.", role.name, above[0].name) + } + } return teams.Team{}, teamRole{}, fmt.Sprintf("There is no one team called %q here. Yours are: %s.", want, strings.Join(sortedTeamNames(fits), ", ")) } fits = chosen @@ -668,7 +682,7 @@ func (a *Agent) teamStartTool(ctx context.Context, args json.RawMessage) (string if _, taken := team.ByHandle(handle); taken { return fmt.Sprintf("@%s is already a member of %q. Pick another handle, or team_send it the work.", handle, team.Name), true, nil } - entry := teams.Entry{Kind: teams.KindStart, From: teams.FromManager, To: handle, Text: brief} + entry := teams.Entry{Kind: teams.KindStart, From: teams.FromManager, To: handle, Text: brief, Approval: a.ApprovalPosture()} if err := teams.AppendTraffic(a.config.teamProfile(), team.ID, entry); err != nil { return "The start could not be written to the team's traffic: " + err.Error(), true, nil } diff --git a/internal/teams/handle.go b/internal/teams/handle.go index 992366a75e..cbabc52e23 100644 --- a/internal/teams/handle.go +++ b/internal/teams/handle.go @@ -5,6 +5,7 @@ import ( "fmt" "strconv" "strings" + "time" ) // A handle is how a member is named inside its team, in the Traffic log and to @@ -25,8 +26,8 @@ import ( // and a handle the model chose is not chosen again, so a line in the log keeps // meaning the member it meant. // -// A member that joins before its conversation has a title has no handle yet; it -// takes one the first time it is saved with a title. +// A member that joins before its conversation has a title has no handle yet; +// ordinary members take one the first time they are saved with a title. // Who chose a member's handle ([Member.HandleBy]). const ( @@ -223,6 +224,18 @@ func uniqueHandle(t Team, key, base string) string { } } +// managerFallbackHandle gives an untitled manager an address as soon as the +// team records it. The timestamp keeps the fallback useful before a title +// exists, while the team-level collision check keeps it valid when starts land +// in the same millisecond. +func managerFallbackHandle(at time.Time) string { + stamp := strconv.FormatInt(at.UnixNano()/int64(time.Millisecond), 36) + if len(stamp) > 4 { + stamp = stamp[len(stamp)-4:] + } + return "manager-" + stamp +} + // assignHandles clears every handle in t that is invalid or repeats one an // earlier member has, then gives each member with a title and no handle one, // in member order. It reports whether it changed anything. diff --git a/internal/teams/handle_test.go b/internal/teams/handle_test.go index 2ff9ce8192..908b07866f 100644 --- a/internal/teams/handle_test.go +++ b/internal/teams/handle_test.go @@ -3,6 +3,7 @@ package teams import ( "strings" "testing" + "time" ) func TestDeriveHandle(t *testing.T) { @@ -234,3 +235,27 @@ func TestChooseHandleClashesAndTypedHandles(t *testing.T) { t.Errorf("an unusable answer changed the member: %+v", after) } } + +func TestAnUntitledManagerGetsAHandleWhenRegistered(t *testing.T) { + made := time.Unix(1720000000, 123000000) + f := &File{Teams: []Team{{ID: "team", Made: made}}} + if err := f.SetManager("team", "manager-key"); err != nil { + t.Fatal(err) + } + member, _ := f.Teams[0].Member("manager-key") + if member.Handle == "" || ValidHandle(member.Handle) != nil || member.HandleBy != HandleByWords { + t.Fatalf("untitled manager registration made %+v", member) + } + if !strings.HasPrefix(member.Handle, "manager-") { + t.Fatalf("fallback handle %q does not name its role", member.Handle) + } + + other := &File{Teams: []Team{{ID: "team", Made: made}}} + if err := other.SetManager("team", "manager-key"); err != nil { + t.Fatal(err) + } + second, _ := other.Teams[0].Member("manager-key") + if second.Handle != member.Handle { + t.Fatalf("same creation time made different fallback handles: %q and %q", member.Handle, second.Handle) + } +} diff --git a/internal/teams/teams.go b/internal/teams/teams.go index 2696503522..80c574b92a 100644 --- a/internal/teams/teams.go +++ b/internal/teams/teams.go @@ -23,7 +23,8 @@ type Member struct { Where string `json:"where"` Word string `json:"word"` // Handle is the member's short name inside this team (handle.go). It is - // empty only while the member has no title to derive one from. + // empty only while an ordinary member has no title to derive one from; a + // manager gets a fallback when it is registered. Handle string `json:"handle,omitempty"` // Home marks the one membership, among all of this conversation's, that // names the manager it reports to (home.go): the nearest manager up this @@ -432,7 +433,21 @@ func (f *File) SetManager(id, key string) error { if err := f.AddMember(id, Member{Key: key}); err != nil { return err } - f.Teams[Index(f.Teams, id)].Manager = key + t := &f.Teams[Index(f.Teams, id)] + if member, ok := t.Member(key); ok && member.Handle == "" { + at := t.Made + if at.IsZero() { + at = time.Now() + } + for i := range t.Members { + if t.Members[i].Key == key { + t.Members[i].Handle = uniqueHandle(*t, key, managerFallbackHandle(at)) + t.Members[i].HandleBy = HandleByWords + break + } + } + } + t.Manager = key return nil } diff --git a/internal/teams/traffic.go b/internal/teams/traffic.go index 5bbdf6bfac..413e6d5dde 100644 --- a/internal/teams/traffic.go +++ b/internal/teams/traffic.go @@ -109,6 +109,9 @@ type Entry struct { // brief, makes itself that team's manager. It is empty on every other // start and every other kind. Team string `json:"team,omitempty"` + // Approval is the starting manager's conversation approval posture. It is + // carried on a start so the interface can apply it before the child runs. + Approval string `json:"approval,omitempty"` } // trafficRotateBytes is the size past which the log starts a new file. diff --git a/internal/tui3/commands.go b/internal/tui3/commands.go index 07b9c5a081..4616b9e632 100644 --- a/internal/tui3/commands.go +++ b/internal/tui3/commands.go @@ -1124,7 +1124,7 @@ func helpText(file string, chords chordSpelling) string { // THE TEAMS PAGE'S LETTERS, each the button of the same word on the // selected team, and the chord that puts the keyboard on those buttons // while the manager's conversation has the box (teamspagehost.go). - "s c w n o in /teams: settings · close · open on the wall · new team · organize", + "s c w n o M m p r d u in /teams: settings · close · wall · new team · organize · manager · move · members · reopen · delete · undo", helpKeyRow(chords.say("alt+↑↓"), "in /teams: onto the page's buttons while the manager has the box · esc back"), "ctrl+r ctrl+y in /files: reveal the folder it is in · copy it somewhere", ) diff --git a/internal/tui3/teamsacts.go b/internal/tui3/teamsacts.go index bd2c176872..3918c83835 100644 --- a/internal/tui3/teamsacts.go +++ b/internal/tui3/teamsacts.go @@ -215,6 +215,7 @@ func (a *app) teamsManagerStart(id string) tea.Cmd { a.touch() return nil } + a.tp.focus = false return a.teamsStartManager(a.teamWhere(t), func(tab chatTab) { if err := a.teamMakeManager(id, tab); err != nil { a.note("the manager is set for this window, but " + err.Error()) @@ -277,6 +278,7 @@ func (a *app) teamsRootManagerStart() tea.Cmd { func (a *app) teamsStartManager(where string, made func(chatTab)) tea.Cmd { take := func() { made(chatTab{key: a.convKey(a.file), file: a.file, where: a.workspace}) + a.tp.focus = false a.tp.top = teamsTopCache{} a.touch() } diff --git a/internal/tui3/teamsopen.go b/internal/tui3/teamsopen.go index 84351f8327..d03105fa41 100644 --- a/internal/tui3/teamsopen.go +++ b/internal/tui3/teamsopen.go @@ -418,7 +418,7 @@ func (a *app) teamsOpenButtons(d *teamsDraw, t team, y int, retry string) []stri x := 1 if a.teamsManagerMissing(t) { s, w := d.button(teamManagerSlotWord, teamsTarget{act: teamsActManager, id: t.ID, x0: x, y: y, - hint: "Start a new manager for " + t.Name + hintSegment + "m"}, pal.ink) + hint: "Start a new manager for " + t.Name + hintSegment + "M"}, pal.ink) row += s + " " x += w + 2 } diff --git a/internal/tui3/teamspage_test.go b/internal/tui3/teamspage_test.go index c17675b092..e6959adbb7 100644 --- a/internal/tui3/teamspage_test.go +++ b/internal/tui3/teamspage_test.go @@ -293,6 +293,25 @@ func TestTeamsHostsTheManagersRealConversation(t *testing.T) { } } +func TestStartingAManagerReturnsKeyboardToItsComposer(t *testing.T) { + a, harbor, _ := teamsPlaceLabIDs(t) + a.tp.focus = true + cmd := a.teamsManagerStart(harbor) + if cmd != nil { + drive(t, a, runCmd(cmd)...) + } + if a.tp.focus { + t.Fatal("manager creation left keyboard focus on the teams page") + } +} + +func TestTeamsActionKeysAreListedOnTheHelpSheet(t *testing.T) { + sheet := helpText("", chordSpelling{meta: chordAltWord}) + if !strings.Contains(sheet, "s c w n o M m p r d u") { + t.Fatalf("the teams action keys are missing from help:\n%s", sheet) + } +} + // ── the team's card ───────────────────────────────────────────────────────── // THE CARD SAYS WHERE EVERY VALUE COMES FROM: an inherited one dim with @@ -536,6 +555,48 @@ func TestTeamSurfacesSpellSubCentCapTheSameWay(t *testing.T) { } } +func TestTeamsHeaderUsesTodaysRaisedCap(t *testing.T) { + a, harbor, _ := teamsPlaceLabIDs(t) + capUSD := 5.0 + if err := a.teamEdit(func(f *teamstore.File) error { + return f.SetSettings(harbor, func(s *teamstore.Settings) { s.CapUSDDay = &capUSD }) + }); err != nil { + t.Fatal(err) + } + a.tp.defaultsOK = true + a.tp.spend = map[string]teamstore.Spend{harbor: {USD: 5.2}} + a.tp.packets = []teamstore.Packet{{Kind: teamstore.PacketCap, State: teamstore.PacketDecided, Decision: teamstore.OptionRaiseCap, + Cap: &teamstore.CapFacts{Team: harbor, Day: teamstore.Today(), CapUSD: capUSD, RaiseTo: 10}}} + team, ok := a.teamByID(harbor) + if !ok { + t.Fatal("no team") + } + if words := a.teamsSpendWords(team); !strings.Contains(words, "$5.20 of $10 today") { + t.Fatalf("header still uses the recurring ceiling: %q", words) + } +} + +func TestTeamsHeaderUsesPluralPossessiveForRootPool(t *testing.T) { + a, harbor, _ := teamsPlaceLabIDs(t) + rootID := "" + capUSD := 5.0 + if err := a.teamEdit(func(f *teamstore.File) error { + rootID = f.MakeRoot(a.now()) + return f.SetSettings(rootID, func(s *teamstore.Settings) { s.CapUSDDay = &capUSD }) + }); err != nil { + t.Fatal(err) + } + a.tp.defaultsOK = true + a.tp.spend = map[string]teamstore.Spend{rootID: {USD: 1}} + team, ok := a.teamByID(harbor) + if !ok { + t.Fatal("no team") + } + if words := a.teamsSpendWords(team); !strings.Contains(words, "All teams' cap") || strings.Contains(words, "All teams's cap") { + t.Fatalf("root pool possessive is %q", words) + } +} + // ── members ───────────────────────────────────────────────────────────────── // A MEMBER THIS WINDOW DOES NOT HOLD IS RESUMED BEHIND, in its own tab, and diff --git a/internal/tui3/teamspagedraw.go b/internal/tui3/teamspagedraw.go index 7f24e30d20..e16338e2dc 100644 --- a/internal/tui3/teamspagedraw.go +++ b/internal/tui3/teamspagedraw.go @@ -199,7 +199,7 @@ func (a *app) teamsRailAll(d *teamsDraw, width, y int) string { return teamsPad(name, width) } btn, _ := d.button(word, teamsTarget{act: teamsActRootManager, x0: left, y: y, - hint: "Start a manager over every team: you talk to it, it talks to theirs" + hintSegment + "m"}, pal.muted) + hint: "Start a manager over every team: you talk to it, it talks to theirs" + hintSegment + "M"}, pal.muted) return name + btn } @@ -414,19 +414,43 @@ func (a *app) teamsSpendWords(t team) string { } return teamsMoney(teamstore.RoundMoney(s.USD)) + " today" } - words := teamsMoney(teamstore.RoundMoney(s.USD)) + " of " + teamsMoney(e.CapUSDDay) + " today" + cap := a.teamsRaisedCap(owner, e.CapUSDDay) + words := teamsMoney(teamstore.RoundMoney(s.USD)) + " of " + teamsMoney(cap) + " today" if owner != t.ID { if o, ok := a.teamByID(owner); ok { name := o.Name if o.Root { name = teamstore.RootName } - words += " " + a.teamsDot() + " " + name + "'s cap" + words += " " + a.teamsDot() + " " + possessiveTeamName(name) + " cap" } } return words } +// teamsRaisedCap is today's ceiling after a person accepted a raise. The +// decision is deliberately not a team setting, so the header must read the +// same packet the cap gate uses rather than the stored recurring ceiling. +func (a *app) teamsRaisedCap(owner string, cap float64) float64 { + day := teamstore.Today() + for _, p := range a.tp.packets { + if p.Kind != teamstore.PacketCap || p.State != teamstore.PacketDecided || p.Decision != teamstore.OptionRaiseCap || p.Cap == nil { + continue + } + if p.Cap.Team == owner && p.Cap.Day == day && p.Cap.RaiseTo > cap { + cap = p.Cap.RaiseTo + } + } + return cap +} + +func possessiveTeamName(name string) string { + if strings.HasSuffix(name, "s") { + return name + "'" + } + return name + "'s" +} + // teamsClosedWords is a closed team's dates: when it was made and closed. func (a *app) teamsClosedWords(t team) string { words := "closed" @@ -826,7 +850,7 @@ func (a *app) teamsPaneRest(d *teamsDraw, width, y int) []string { case !ok && a.tp.sel == teamsAllRow: out = append(out, "", " "+pal.dim(fit("a manager over every team: you talk to it, and it talks to each team's own", width-2))) s, _ := d.button(teamManagerSlotWord, teamsTarget{act: teamsActRootManager, x0: 1, y: y + len(out) + 1, - hint: "Start the manager of every team" + hintSegment + "m"}, pal.ink) + hint: "Start the manager of every team" + hintSegment + "M"}, pal.ink) out = append(out, "", " "+s) case !ok: case t.Closed(): @@ -849,7 +873,7 @@ func (a *app) teamsNoManagerRows(d *teamsDraw, t team, width, y int) []string { } pal := a.pal s, w := d.button(teamManagerSlotWord, teamsTarget{act: teamsActManager, id: t.ID, x0: 1, y: y + 1, - hint: "Start " + t.Name + "'s manager: a conversation that runs the team for you" + hintSegment + "m"}, pal.ink) + hint: "Start " + t.Name + "'s manager: a conversation that runs the team for you" + hintSegment + "M"}, pal.ink) lead := 1 + w + 2 said := wrap(teamsNoManagerWord, max(width-lead, 8)) out := []string{""} diff --git a/internal/tui3/teamtraffic.go b/internal/tui3/teamtraffic.go index 6054c49ef2..84d668b290 100644 --- a/internal/tui3/teamtraffic.go +++ b/internal/tui3/teamtraffic.go @@ -468,7 +468,7 @@ func (a *app) trafficStart(t team, e teamstore.Entry) tea.Cmd { start, where, id := a.start, a.teamWhere(t), t.ID return a.besideLine(func() func(bool) tea.Cmd { conv, err := start(where) - return func(bool) tea.Cmd { return a.trafficStarted(id, handle, conv, err) } + return func(bool) tea.Cmd { return a.trafficStarted(id, handle, e.Approval, conv, err) } }) } @@ -479,7 +479,7 @@ func (a *app) trafficStartRefused(handle, why string) { // trafficStarted is the start's conversation back from the engine: held // behind, joined to the team under its handle, and said beside the manager. -func (a *app) trafficStarted(id, handle string, conv Conversation, err error) tea.Cmd { +func (a *app) trafficStarted(id, handle, approvalPosture string, conv Conversation, err error) tea.Cmd { if err != nil || conv.Agent == nil { why := "the conversation did not open" if err != nil { @@ -488,6 +488,18 @@ func (a *app) trafficStarted(id, handle string, conv Conversation, err error) te a.trafficStartRefused(handle, why) return nil } + if approvalPosture != "" { + if setter, ok := conv.Agent.(interface{ SetApprovalPosture(string) error }); ok { + if err := setter.SetApprovalPosture(approvalPosture); err != nil { + a.trafficStartRefused(handle, "could not carry over its approval posture: "+err.Error()) + _ = conv.Agent.Close() + return nil + } + if settler, ok := conv.Agent.(interface{ SettleWrites() }); ok { + settler.SettleWrites() + } + } + } t, ok := a.teamByID(id) if !ok { // The team went while the conversation was opening. It is held all the From 7e15975f7962530b6e5ca119d866d683d67d4783 Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Sun, 27 Sep 2026 00:43:58 -0400 Subject: [PATCH 14/76] jobs: bound the disk spool and let the footer admit the truncation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A background job's log used to grow without limit: every byte the job wrote went to one .log forever, a single huge Write grew the in-memory ring by its whole length before trimming, and a failed or short or unclosable spool write was swallowed whole. A watcher that prints for a week filled the disk at whatever rate it printed. The spool is now a window: at most jobSpoolChunks chunks of jobSpoolChunkBytes (4MB) on disk — .log live, .log.1 kept — rotated by rename and never rewritten per write, the chunk beyond the window deleted and counted. One huge Write spools in chunk-sized pieces and hands only its newest 64KB to the ring, so no temporary grows to match it. A failed, short or failed-to-close spool write records one notice, stops the retries, and never stops the drain or kills the job. Every footer a model reads — jobs output and the completion note alike — stays honest about the bound: full log while the file is everything the job wrote, the truncation or the failure named beside the path where it is not. The manual's promises of the whole log, the jobs tool description and PERF.md move with it. Retained output stays addressable by the read tool exactly as before. Aggregate retention across jobs and search exclusion are deliberately not in this change. Issue #1599. Assisted-by: CodeAF Co-Authored-By: CodeAF <267109073+agentfield-bot@users.noreply.github.com> --- PERF.md | 24 ++ .../unreleased/1599-bounded-job-logs.md | 22 ++ internal/manual/chat/how-tasks-run.md | 2 +- internal/manual/chat/what-i-can-do.md | 22 +- internal/session/jobs.go | 252 ++++++++++++++++-- internal/session/jobspool_test.go | 247 +++++++++++++++++ internal/session/tools_jobs.go | 11 +- 7 files changed, 546 insertions(+), 34 deletions(-) create mode 100644 docs/changes/unreleased/1599-bounded-job-logs.md create mode 100644 internal/session/jobspool_test.go diff --git a/PERF.md b/PERF.md index 2984ed7b3f..6f542a365a 100644 --- a/PERF.md +++ b/PERF.md @@ -1232,6 +1232,30 @@ only omit what the reader is already holding. On the inherited-brief road the address rides the prerequisite's HEADER, which the shared pot above does not clip. Pinned by `internal/session/task_result_e2e_test.go`. +## A background job's disk spool is bounded + +`internal/session/jobs.go` used to spool everything a background job wrote to +one `.log` with no ceiling: a watcher printing for a week filled the disk +at whatever rate it printed, and the in-memory ring appended one Write before +trimming, so a single multi-megabyte Write grew a temporary to match. + +The spool is now a window of at most **jobSpoolChunks (2) chunks of +jobSpoolChunkBytes (4MB)** — `.log` live and `.log.1` kept — rotated by +rename and never rewritten per write; the chunk that falls out of the window +is deleted and counted. One huge Write spools in chunk-sized pieces and hands +only its newest **64KB** (`jobRingBytes`) to the ring. The retained output +stays addressable by the read tool exactly as before, so no limit grows for +the reader. + +The honesty is the point, and it is pinned: a spool that has discarded +anything — or a spool write, short write or close that failed — sets the +sink's notice, and every footer a model reads stops saying `full log:` and +names the truncation or the failure beside the file instead +(`TestJobFooterNamesTruncationInsteadOfFullLog`); rotation, the discard, the +huge-Write tail and the injected failure are pinned by +`internal/session/jobspool_test.go`. The bound is a fact about the code, not +about the box: the window is fixed bytes per job, not a disk-filling rate. + ## Specialist tool discovery Chat starts with core tools and one local `load_capability` registry operation diff --git a/docs/changes/unreleased/1599-bounded-job-logs.md b/docs/changes/unreleased/1599-bounded-job-logs.md new file mode 100644 index 0000000000..3ab5b9d9c0 --- /dev/null +++ b/docs/changes/unreleased/1599-bounded-job-logs.md @@ -0,0 +1,22 @@ +--- +kind: fixed +title: a background job's log is a bounded spool that admits its truncation +pr: 1599 +surface: [chat, engine] +invalidates: + - "A background job's log was unbounded: everything the job ever wrote went to + one .log forever, so a watcher printing for a week filled the disk. The + spool is now a window of two 4MB chunks (.log and .log.1); older + output is discarded with a notice." + - "The manual and the jobs tool promised the whole log on disk. What a job + keeps is its most recent chunks, and when output has been discarded the + footer and the completion note name the truncation instead of saying full + log." + - "A failed, short or unclosable job-log write was swallowed silently. It is + still never fatal to the job, but the jobs footer now says the log stopped + and why." +--- + +A job's retained output stays addressable by the read tool exactly as before; +the window is what changed. The bound is fixed bytes per job, never a +wall-clock or a rate. diff --git a/internal/manual/chat/how-tasks-run.md b/internal/manual/chat/how-tasks-run.md index 3a50c1e6b0..d4dabdd630 100644 --- a/internal/manual/chat/how-tasks-run.md +++ b/internal/manual/chat/how-tasks-run.md @@ -1400,7 +1400,7 @@ and inside a task the work then *waits* for that command instead of asking what Nothing is asked over the wait, no step is counted, and no `[stuck]` note can be earned, because a task that is waiting makes no calls at all. What wakes it is the command's own ending, and that ending arrives whole: the exit line, the command's last lines, and the path -to the full log, all in the one turn. This is why a task does not `sleep` and `tail` its own +to the log, all in the one turn. This is why a task does not `sleep` and `tail` its own build or test run — the waiting is done for it, and those nine `sleep N && tail` steps above are what the counter catches when something is polled that nobody is waiting on. A command started with `background: true` is the other case: a server or a sweep the task deliberately diff --git a/internal/manual/chat/what-i-can-do.md b/internal/manual/chat/what-i-can-do.md index f429c79b0b..3e7e8fee55 100644 --- a/internal/manual/chat/what-i-can-do.md +++ b/internal/manual/chat/what-i-can-do.md @@ -237,7 +237,7 @@ your keyboard stays yours while the command runs. **Inside a task it does not.** A task has nobody to hand the keyboard back to, so a foreground command it started and is still waiting for is one the work simply waits for: nothing is asked of it, no step is counted, and the command's own ending — the exit line, -its last lines and the path to the full log — is the next thing the task reads. +its last lines and the path to the log — is the next thing the task reads. A command the task started with `background: true` is the other case and holds nothing up: a server or a sweep it deliberately left running is not something it is waiting on. @@ -288,7 +288,7 @@ turn with work out always carries it. A turn that made twenty tool calls with one job out used to pay for the same sentence twenty times. **When a job ends**, its exit code, last non-empty output line, output tail and -path to the full log arrive in the conversation on their own: +the log's path arrive in the conversation on their own: ``` while you worked: @@ -307,9 +307,11 @@ ended, the note starts a new one, exactly as a finished task does. Several session notes waiting at that boundary are one `while you worked:` message, not several synthetic user messages between tool calls. -The note carries the last 50 lines. The whole log stays on disk and the note -names its path, so an older line is one `jobs output` call away and the ending -itself never is. +The note carries the last 50 lines. The job's most recent output stays on disk — +a bounded spool of a few megabytes, older output discarded as it rolls — and the +note names its path, so a recent older line is one `jobs output` call away and +the ending itself never is. When output has been discarded, the note says so +instead of calling what remains full. So you should never see codeaf running `sleep 30 && tail …` to wait for something. That loop was real — it cost one benchmark worker two thirds of its @@ -376,12 +378,15 @@ job 3 started; log at ~/.codeaf/v3/projects/-you-work//logs/jobs/3.log ``` A background job never times out and is not tied to the turn that started it. -Everything it writes goes to that log file; the last **64KB** is also held in +Its most recent output goes to that log file — a bounded spool, about **8MB** in +chunks, the oldest discarded as it rolls — and the last **64KB** is also held in memory for quick reads. When the job exits, codeaf is told at the next step in one boundary batch. Its headline, e.g. `job 3 exited 1: make: *** [build] Error 1`, quotes the last non-empty log line, clipped to 120 characters. Under it the note carries the last 50 lines and the -path to the full log; use `jobs output` for anything older than that tail. +log's path; use `jobs output` for anything older than that tail. When the +spool has discarded output, the note names the truncation and the file instead +of promising a full log. The `jobs` tool looks at all of this. Its `action` is `list`, `output` or `kill`. @@ -391,7 +396,8 @@ The `jobs` tool looks at all of this. Its `action` is `list`, `output` or `kill` kept as a job — by the background-after clock, its timeout, or `ctrl+g` — has exactly this row, with no mark saying where it came from: it is a job like any - `output` — the last lines from the in-memory tail, **50 by default and 200 at - most**, with a footer naming the full log: + most**, with a footer naming the log: `full log:` while everything the job + wrote is still on disk, and the truncation named where it is not — `[job 1 · running · showing last 50 lines · full log: ]`. - `kill` — SIGTERM to the process group, SIGKILL after a **2-second** grace. Answers `job 1 killed`. diff --git a/internal/session/jobs.go b/internal/session/jobs.go index afe00f61aa..3607897fc3 100644 --- a/internal/session/jobs.go +++ b/internal/session/jobs.go @@ -10,14 +10,20 @@ package session // // Three choices here are worth the words: // -// - RING + DISK, not one or the other. Everything the job writes goes to a -// file, so the whole log is addressable by the read tool — paged, offset, -// grepped, the same way any other file is. Only the last 64KB is kept in -// memory, and only that tail is ever handed back through a tool result. A -// watcher that has printed 400MB must not be able to put 400MB in front of -// the model, and a watcher that printed the one line that matters must not -// lose it because nobody was polling. Disk answers the second, the ring -// answers the first. +// - RING + BOUNDED DISK, not one or the other. What a job writes goes to a +// disk spool bounded to its most recent jobSpoolChunks chunks of +// jobSpoolChunkBytes each — .log for the live chunk, .log.1 for the +// one before it, anything earlier discarded with a notice — so the recent +// log is addressable by the read tool — paged, offset, grepped, the same +// way any other file is — without a job that prints for a week filling the +// disk. A chunk is never rewritten: it fills, one rename keeps it, a fresh +// one opens. Only the last 64KB is kept in memory, and only that tail is +// ever handed back through a tool result. A watcher that has printed 400MB +// must not be able to put 400MB in front of the model, and a watcher that +// printed the one line that matters must not lose it because nobody was +// polling — the disk answers that for as long as the line is recent, and +// the notice every footer carries is what keeps the promise honest once it +// is not (issue #1599). // // WHERE that file is, is landing.go's answer and not this file's: a log is // a dropping, so once a session has a folder it lands in the folder rather @@ -28,8 +34,9 @@ package session // // - THE OWED LANE, not a tool and not an event. When a bash job ends, its // ending joins the session's boundary batch (agent.go), drained at the next -// step. The ending carries its output tail and names the whole log; anything -// older remains here behind `jobs output` and on disk. A completion is news, +// step. The ending carries its output tail and names the log; anything recent +// remains here behind `jobs output` and on disk, and the footer names the +// truncation when the beginning has been discarded. A completion is news, // not an answer to a question, and the alternative — the model polling // `jobs` on a hunch — costs a round trip per hunch and still misses the exit // it did not think to check for. @@ -65,6 +72,13 @@ const ( // and small enough that a hundred jobs cost megabytes, not gigabytes. jobRingBytes = 64 << 10 + // jobSpoolChunkBytes caps one chunk of the disk spool and jobSpoolChunks is + // how many of them a job keeps — the live chunk plus one older — so a + // hundred jobs cannot fill the disk either: 8MB each, and what falls out + // of the window is discarded, with [jobSink.notice] saying so. + jobSpoolChunkBytes = 4 << 20 + jobSpoolChunks = 2 + // jobTermGrace is how long a SIGTERM has to work before SIGKILL follows. // Two seconds is a server's shutdown hook, not a wait. jobTermGrace = 2 * time.Second @@ -582,7 +596,7 @@ func (r *jobRegistry) newJob(command string, kind jobKind) (*job, error) { // One sink for both streams, as bare's bash does: stdout and stderr // interleave in arrival order, which is the order a person reading the // log expects them in. - sink: &jobSink{file: logFile}, + sink: newJobSink(logFile, logPath), done: make(chan struct{}), }, nil } @@ -1011,8 +1025,16 @@ func (r *jobRegistry) settleExit(watched *job, code int) { // make one more call for what the note was already about. if watched.kind == jobKindBash { if tail := watched.sink.tail(jobExitTailLines); strings.TrimSpace(tail) != "" { + // AND THE FOOTER STAYS HONEST ABOUT THE BOUND: a log that has + // begun discarding is not a full one, and "full log" on a note + // whose beginning is gone would send the model reading a file + // that does not hold what it names. + ending := "full log: " + if notice := watched.sink.notice(); notice != "" { + ending = notice + " · log file: " + } note += "\n\n" + tail + "\n\n[job " + strconv.Itoa(watched.id) + " · last " + - strconv.Itoa(jobExitTailLines) + " lines · full log: " + watched.logPath + "]" + strconv.Itoa(jobExitTailLines) + " lines · " + ending + watched.logPath + "]" } } r.notify(note) @@ -1216,9 +1238,11 @@ func (r *jobRegistry) list() string { return rendered.String() } -// output renders one job's recent lines with a footer naming the full log. The +// output renders one job's recent lines with a footer naming the log. The // footer is the point of the whole design: it tells the model where the rest -// is, so the answer to "I need more" is a read call and not a bigger tail. +// is, so the answer to "I need more" is a read call and not a bigger tail — +// and while the spool has discarded anything, the footer says that instead of +// calling what remains full. func (r *jobRegistry) output(id, lines int) (string, bool) { target := r.find(id) if target == nil { @@ -1229,34 +1253,89 @@ func (r *jobRegistry) output(id, lines int) (string, bool) { if strings.TrimSpace(tail) == "" { tail = "(no output)" } - return fmt.Sprintf("%s\n\n[job %d · %s · showing last %d lines · full log: %s]", - tail, id, statusText(info), lines, target.logPath), false + // The footer is only honest while the rest IS there: a spool that has + // discarded anything names the truncation and the file instead of + // promising full. + ending := "full log: " + target.logPath + if notice := target.sink.notice(); notice != "" { + ending = notice + " · log file: " + target.logPath + } + return fmt.Sprintf("%s\n\n[job %d · %s · showing last %d lines · %s]", + tail, id, statusText(info), lines, ending), false } // ── the output sink: ring in memory, everything on disk ───────────────────── -// jobSink is one job's output: a rolling in-memory tail and the full spool. +// jobSink is one job's output: a rolling in-memory tail and a bounded disk +// spool. // // It is an io.Writer set as both Stdout and Stderr, so Go's exec package feeds -// it from two goroutines — the mutex is load-bearing, not decoration. +// it from two goroutines — the mutex is load-bearing, not decoration. Spool +// writes append whole pieces to the live chunk and never rewrite megabytes +// per line: a chunk fills, one rename keeps it, a fresh one opens, and the +// chunk beyond the window is discarded. type jobSink struct { mu sync.Mutex ring []byte file *os.File closed bool + // base is the live chunk's path (the newest chunk is always base+".1"). + // chunkBytes and chunks are the window; the tests set them tiny and inject + // hook instead of generating data anywhere near the real limits. + base string + chunkBytes int64 + chunks int + hook spoolHook + // spoolBytes is what the live chunk holds. spoolBroken says a spool write + // failed and retries stop; brokenText and noticeText are the two sentences + // [jobSink.notice] can carry — a failed spool and a discarded chunk — + // each set once and then left alone. + spoolBytes int64 + spoolBroken bool + brokenText string + noticeText string +} + +// spoolHook is the seam the tests inject spool failures through. It replaces +// the append into the live chunk, so a test can fail one chosen write or +// return a short count deterministically. Production leaves it nil. +type spoolHook func(data []byte) (int, error) + +// newJobSink opens a sink over one spool file at the production limits. +func newJobSink(file *os.File, base string) *jobSink { + return &jobSink{ + file: file, + base: base, + chunkBytes: jobSpoolChunkBytes, + chunks: jobSpoolChunks, + } } // Write always reports success. A write error here is a full disk or a removed // workspace, and the honest response to that is to keep the job running with // the in-memory tail intact: returning the error would make Go's copier close -// the pipe, and the job would die of a logging problem. +// the pipe, and the job would die of a logging problem. The failure is +// recorded once — [jobSink.notice] carries it — spool retries stop, and the +// drain and the ring go on. func (s *jobSink) Write(data []byte) (int, error) { s.mu.Lock() defer s.mu.Unlock() if s.file != nil && !s.closed { - _, _ = s.file.Write(data) + s.spoolLocked(data) + } + // A single Write may be megabytes, and no temporary grows to match it: + // only its newest jobRingBytes reaches the ring. + if len(data) > jobRingBytes { + start := len(data) - jobRingBytes + // Do not cut a rune in half: a tail starting mid-character renders as + // a replacement glyph in the model's context for no reason. + for start < len(data) && !utf8RuneStart(data[start]) { + start++ + } + s.ring = append(s.ring, data[start:]...) + } else { + s.ring = append(s.ring, data...) } - s.ring = append(s.ring, data...) // Trimming at twice the cap rather than at the cap makes this amortized: // trimming on every write would copy the whole ring per line of output. if len(s.ring) > jobRingBytes*2 { @@ -1265,6 +1344,130 @@ func (s *jobSink) Write(data []byte) (int, error) { return len(data), nil } +// spoolLocked appends data to the bounded spool, rotating the live chunk when +// it fills. A chunk is never rewritten and one call never copies its data: +// the pieces are slices of it, at most one rotation per call, and a Write of +// any size costs renames, not memory. +func (s *jobSink) spoolLocked(data []byte) { + if s.spoolBroken || s.base == "" { + return + } + for len(data) > 0 { + if s.spoolBytes >= s.chunkBytes { + if !s.rotateLocked() { + return + } + } + room := s.chunkBytes - s.spoolBytes + if int64(len(data)) < room { + room = int64(len(data)) + } + piece := data[:room] + written, err := s.spoolWriteLocked(piece) + if err != nil { + s.breakSpoolLocked(err) + return + } + if written < len(piece) { + s.breakSpoolLocked(fmt.Errorf("short write: %d of %d bytes", written, len(piece))) + return + } + s.spoolBytes += int64(written) + data = data[room:] + } +} + +// spoolWriteLocked is the one door the bytes leave through. hook is nil in +// production and set by tests to fail a chosen write deterministically. +func (s *jobSink) spoolWriteLocked(piece []byte) (int, error) { + if s.hook != nil { + return s.hook(piece) + } + return s.file.Write(piece) +} + +// rotateLocked keeps the live chunk and opens a fresh one, discarding the +// chunk beyond the window: the chain shifts up one place and the oldest +// numbered chunk is clobbered by the shift, which is the discard the notice +// records. Renames, not copies. Its false is the spool being over — a rename +// or an open that failed stops the spool, never the job. +func (s *jobSink) rotateLocked() bool { + discarded := false + if s.chunks > 1 { + if _, err := os.Stat(s.base + "." + strconv.Itoa(s.chunks-1)); err == nil { + discarded = true + } + } + _ = s.file.Close() + for index := s.chunks - 2; index >= 1; index-- { + _ = os.Rename(s.base+"."+strconv.Itoa(index), s.base+"."+strconv.Itoa(index+1)) + } + if err := os.Rename(s.base, s.base+".1"); err != nil { + s.breakSpoolLocked(err) + return false + } + file, err := os.OpenFile(s.base, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) + if err != nil { + s.file = nil + s.breakSpoolLocked(err) + return false + } + s.file = file + s.spoolBytes = 0 + if discarded && s.noticeText == "" { + s.noticeText = "log truncated: only the most recent " + spoolSizeText(s.chunkBytes*int64(s.chunks)) + " is kept" + } + return true +} + +// breakSpoolLocked records a spool failure once: retries stop, the file is +// left closed, the ring keeps draining, and the next footer names the failure +// instead of a log. +func (s *jobSink) breakSpoolLocked(err error) { + if s.spoolBroken { + return + } + s.spoolBroken = true + if s.file != nil { + _ = s.file.Close() + s.file = nil + } + what := "failed" + if err != nil { + what = err.Error() + } + s.brokenText = "job log stopped: " + what +} + +// notice is the one sentence about what the spool no longer holds, or "" +// while the log on disk is everything the job wrote. A surface quoting it +// names the file instead of calling the log full, which is what keeps the +// footer's promise honest. +func (s *jobSink) notice() string { + if s == nil { + return "" + } + s.mu.Lock() + defer s.mu.Unlock() + if s.spoolBroken { + return s.brokenText + } + return s.noticeText +} + +// spoolSizeText keeps the notice's figures readable: megabytes at the +// production limits, bytes at the ones the tests set. +func spoolSizeText(n int64) string { + switch { + case n >= 1<<20: + return fmt.Sprintf("%.1f MB", float64(n)/(1<<20)) + case n >= 1<<10: + return fmt.Sprintf("%d KB", n>>10) + default: + return fmt.Sprintf("%d bytes", n) + } +} + func (s *jobSink) trimLocked() { if len(s.ring) <= jobRingBytes { return @@ -1278,11 +1481,16 @@ func (s *jobSink) trimLocked() { s.ring = append([]byte(nil), s.ring[start:]...) } +// close settles the spool. A close that fails is surfaced the same way a +// failed write is — through [jobSink.notice] — and never turned into an error +// the job's ending would have to carry. func (s *jobSink) close() { s.mu.Lock() defer s.mu.Unlock() if s.file != nil && !s.closed { - _ = s.file.Close() + if err := s.file.Close(); err != nil && s.brokenText == "" && s.noticeText == "" { + s.brokenText = "job log did not close cleanly: " + err.Error() + } } s.closed = true } diff --git a/internal/session/jobspool_test.go b/internal/session/jobspool_test.go new file mode 100644 index 0000000000..dd08b3d217 --- /dev/null +++ b/internal/session/jobspool_test.go @@ -0,0 +1,247 @@ +package session + +// The disk spool's bound (issue #1599): a job that prints without end fills at +// most jobSpoolChunks chunks on disk, keeps the newest tail, says what it has +// discarded, and never lets one Write grow a temporary to match it. Every +// limit here is set tiny and every failure injected — no test generates data +// anywhere near the production figures. +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +// newSpool builds a sink over a real temp file at test-sized limits. +func newSpool(t *testing.T, chunkBytes int64, chunks int) *jobSink { + t.Helper() + path := filepath.Join(t.TempDir(), "3.log") + file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) + if err != nil { + t.Fatalf("open spool: %v", err) + } + sink := newJobSink(file, path) + sink.chunkBytes = chunkBytes + sink.chunks = chunks + t.Cleanup(func() { sink.close() }) + return sink +} + +// spoolFiles lists the chunks the spool is holding, with their sizes. +func spoolFiles(t *testing.T, sink *jobSink) map[string]int64 { + t.Helper() + entries, err := os.ReadDir(filepath.Dir(sink.base)) + if err != nil { + t.Fatalf("read dir: %v", err) + } + files := map[string]int64{} + for _, entry := range entries { + info, err := entry.Info() + if err != nil { + t.Fatalf("stat %s: %v", entry.Name(), err) + } + files[entry.Name()] = info.Size() + } + return files +} + +// A job that prints far more than the window holds leaves exactly the chunk +// files the window promises, the newest data in them, and nothing else — and +// says once that the beginning is gone. +func TestJobSpoolRotatesAndStaysBounded(t *testing.T) { + sink := newSpool(t, 1000, 2) + var wrote []byte + for index := 0; index < 120; index++ { + line := []byte(fmt.Sprintf("line%-20d\n", index)) + wrote = append(wrote, line...) + if _, err := sink.Write(line); err != nil { + t.Fatalf("write: %v", err) + } + } + // One live chunk at or under its cap, one kept chunk, nothing else: the + // disk holds the window and not the job's whole output. + files := spoolFiles(t, sink) + if len(files) != 2 { + t.Fatalf("spool holds %d files (%v), want the live chunk and one kept", len(files), files) + } + for name, size := range files { + if name != "3.log" && name != "3.log.1" { + t.Fatalf("unexpected chunk %q (%v)", name, files) + } + if size > 1000 { + t.Fatalf("chunk %s is %d bytes, over its 1000 cap", name, size) + } + } + // The live chunk is the NEWEST data: its first line follows the rotated + // data, and its last line is the last thing written. + live, err := os.ReadFile(sink.base) + if err != nil { + t.Fatalf("read live chunk: %v", err) + } + if !strings.HasSuffix(string(wrote), string(live)) { + t.Fatal("live chunk is not a suffix of what was written") + } + if got := sink.lastNonEmptyLine(); got != "line79" { + t.Fatalf("ring lost the newest line: %q", got) + } + // The kept chunk holds the middle of the stream, not the beginning: the + // first chunk (lines 0 to 39) was clobbered by the second rotation, which + // is the discard the notice is about. + kept, err := os.ReadFile(sink.base + ".1") + if err != nil { + t.Fatalf("read kept chunk: %v", err) + } + if !strings.Contains(string(kept), "line40") || strings.Contains(string(kept), "line20") { + t.Fatal("kept chunk is not the second chunk; the discard never happened") + } + // And the notice says the truncation out loud, once. + if notice := sink.notice(); !strings.Contains(notice, "log truncated") { + t.Fatalf("a bounded spool that discarded output says nothing: %q", notice) + } +} + +// The ring stays a tail under writes far larger than it: one huge Write must +// not grow a temporary to match, and the newest bytes must still arrive. +func TestJobSinkHugeSingleWriteKeepsOnlyTheTail(t *testing.T) { + sink := newSpool(t, 1<<20, 2) + // Well over the 64KB ring, nowhere near huge: the point is the tail path, + // not tonnage. + huge := make([]byte, 256<<10) + for index := range huge { + huge[index] = byte('a' + index%26) + } + copy(huge[len(huge)-8:], "THE-TAIL") + if _, err := sink.Write(huge); err != nil { + t.Fatalf("write: %v", err) + } + sink.mu.Lock() + size := len(sink.ring) + sink.mu.Unlock() + if size > jobRingBytes*2 { + t.Fatalf("ring grew to %d bytes for one huge write", size) + } + if got := sink.lastNonEmptyLine(); got != "THE-TAIL" { + t.Fatalf("ring lost the tail of a huge write: %q", got) + } + if !strings.Contains(sink.text(), "THE-TAIL") { + t.Fatal("text lost the tail") + } +} + +// A spool write that fails is recorded once, stops the retries, leaves the +// job's drain and ring alive, and is what the footer reports — never an error +// the job dies of. +func TestJobSpoolWriteFailureIsRecordedNotFatal(t *testing.T) { + sink := newSpool(t, 1<<20, 2) + calls := 0 + boom := errors.New("disk full") + sink.hook = func(data []byte) (int, error) { + calls++ + if calls == 3 { + return 0, boom + } + return len(data), nil + } + for index := 0; index < 5; index++ { + if _, err := sink.Write([]byte("hello\n")); err != nil { + t.Fatalf("Write reported %v; the drain must survive a spool failure", err) + } + } + if !sink.spoolBroken { + t.Fatal("a failed spool write left the spool unbroken") + } + if calls != 3 { + t.Fatalf("spool was attempted %d times after the failure stopped it", calls) + } + // The ring kept draining after the failure. + if got := sink.lastNonEmptyLine(); got != "hello" { + t.Fatalf("ring stopped draining after the spool failed: %q", got) + } + // The notice carries the failure, not a promise of a log. + if notice := sink.notice(); !strings.Contains(notice, "disk full") { + t.Fatalf("notice does not carry the failure: %q", notice) + } +} + +// A short write is the same recordable failure a failed write is. +func TestJobSpoolShortWriteIsRecorded(t *testing.T) { + sink := newSpool(t, 1<<20, 2) + sink.hook = func(data []byte) (int, error) { + if len(data) >= 4 { + return len(data) - 2, nil // two bytes short + } + return len(data), nil + } + if _, err := sink.Write([]byte("payload\n")); err != nil { + t.Fatalf("write: %v", err) + } + if !sink.spoolBroken { + t.Fatal("a short write left the spool healthy") + } + if notice := sink.notice(); !strings.Contains(notice, "short write") { + t.Fatalf("notice does not carry the short write: %q", notice) + } +} + +// A spool that was already broken never has its notice overwritten by the +// truncation, and a close that fails is surfaced through the same door. +func TestJobSpoolCloseErrorSurfacesInNotice(t *testing.T) { + sink := newSpool(t, 1<<20, 2) + if _, err := sink.Write([]byte("some output\n")); err != nil { + t.Fatalf("write: %v", err) + } + sink.close() + // Closing is idempotent and a second close records nothing new. + sink.close() + if notice := sink.notice(); notice != "" { + t.Fatalf("a clean close invented a notice: %q", notice) + } + // A close failure on a real file cannot be injected directly, so the + // broken-text door is asserted at the unit it runs through: the guard + // keeps the first notice and the close error cannot overwrite it. + sink2 := newSpool(t, 1<<20, 2) + sink2.brokenText = "job log stopped: injected earlier" + sink2.close() + if notice := sink2.notice(); notice != "job log stopped: injected earlier" { + t.Fatalf("close overwrote an earlier notice: %q", notice) + } +} + +// The footer a model reads is honest about the bound: `full log:` while the +// log on disk is everything the job wrote, and the truncation named where it +// is not — through `jobs output` and through the completion note alike. +func TestJobFooterNamesTruncationInsteadOfFullLog(t *testing.T) { + agent, _ := jobsAgent(t) + agent.mu.Lock() + agent.opened = false + agent.mu.Unlock() + + id := startJob(t, agent, "printf 'a\nb\nc\n'") + waitFor(t, "the completion note", func() bool { + return notesContain(agent, fmt.Sprintf("job %d exited 0", id)) + }) + queued := sessionNotes(agent) + if len(queued) != 1 { + t.Fatalf("want one note, got %v", queued) + } + job := agent.jobs.find(id) + footer := fmt.Sprintf("[job %d · last %d lines · full log: %s]", id, jobExitTailLines, job.logPath) + if !strings.Contains(queued[0], footer) { + t.Fatalf("a whole, untruncated log must still say full log: %q", queued[0]) + } + // Mark the same job's spool as having discarded output and read it again: + // the footer must stop promising full and name the truncation instead. + job.sink.noticeText = "log truncated: only the most recent 8.0 MB is kept" + text, isError := runTool(t, agent, "jobs", fmt.Sprintf(`{"action":"output","id":%d}`, id)) + if isError { + t.Fatalf("jobs output failed: %s", text) + } + if !strings.Contains(text, "log truncated") || strings.Contains(text, "full log:") { + t.Fatalf("footer still promises a full log after truncation: %q", text) + } + if !strings.Contains(text, "log file: "+job.logPath) { + t.Fatalf("footer lost the file path beside the truncation: %q", text) + } +} diff --git a/internal/session/tools_jobs.go b/internal/session/tools_jobs.go index 69e3dc2765..ba97d32255 100644 --- a/internal/session/tools_jobs.go +++ b/internal/session/tools_jobs.go @@ -264,8 +264,9 @@ const ( // each word here is paid dozens of times in one task and the prose around it is // paid never. Every rule the longer version stated is still stated, once. // -// AND EVERY FIGURE IS INTERPOLATED. The ring's size, the kill grace and the -// tail's bounds are all enforced somewhere else in this package ([jobRingBytes], +// AND EVERY FIGURE IS INTERPOLATED. The ring's size, the spool's chunk size, +// the kill grace and the tail's bounds are all enforced somewhere else in this +// package ([jobRingBytes], [jobSpoolChunkBytes], // [jobTermGrace], [jobsDefaultTail], [jobsMaxTail]); a digit typed here would be // the second copy, and the second copy is the one that goes stale. // AND IT DOES NOT OFFER POLLING AS A WAY TO WAIT. `output` is still here for an @@ -289,7 +290,7 @@ const ( // each op does, what comes back, and where the whole log lives. var jobsDescription = "Background work: bash background:true commands and watches. list: this session's jobs (id, kind, command, status, elapsed). output: the tail of one job's last " + strconv.Itoa(jobRingBytes>>10) + "KB (a watch's is its accumulated ticks). kill: SIGTERM the process group, SIGKILL " + - strconv.Itoa(int(jobTermGrace/time.Second)) + "s later; stops watches. Each job's whole log is a file on disk, named when it started; read it when the tail is short. A running job also shows on their screen." + strconv.Itoa(int(jobTermGrace/time.Second)) + "s later; stops watches. Each job's recent log is a bounded file on disk, named when it started; when older output has been discarded, its footer says so instead of promising a full log. A running job also shows on their screen." var jobsSchemaJSON = `{"type":"object","properties":{"action":{"type":"string","description":"The op.","enum":["list","output","kill"]},"id":{"type":"integer","description":"Job id (output and kill need one)"},"tail":{"type":"integer","description":"Lines returned (default: ` + strconv.Itoa(jobsDefaultTail) + `, max: ` + strconv.Itoa(jobsMaxTail) + `)"}},"required":["action"],"additionalProperties":false}` @@ -298,6 +299,10 @@ var jobsSchemaJSON = `{"type":"object","properties":{"action":{"type":"string"," // same Tool shape, same wire discipline — and it is deliberately the ONLY way // the model reaches a job: the registry is not addressable from the prompt, so // there is one vocabulary for background work and it is this one. +// +// THE SPOOL IS BOUNDED (issue #1599), and the description does not promise a +// full log: what a job keeps on disk is its most recent chunks, and the footer +// a model reads says the truncation is there when any output has fallen out. func (a *Agent) jobsTool() bare.Tool { return bare.Tool{ Name: "jobs", From ab6f4d055b6d85571233c23916b5b0f438cec052 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 00:50:59 -0400 Subject: [PATCH 15/76] session: a machine-held task owns no folder, stops at once, and a stop is not a failure - A task held by the busy-machine gate had already taken its folder lock and checked out its branch, and /stop could not end it until the driver woke. Admission is now checked first: an admitted run keeps the synchronous route, folder, store, copy order (so its refusals still leave no run behind); a held run seeds only its plan store, shows queued with the machine-busy reason, and prepares its folder and copy after admission. A stop on a held run settles it at once through one driver settlement. - After an engine restart an interrupted belt run's plan store stayed live, so its root read working forever with no worker; startup now archives it. - An explicit stop recorded the crew as not kept, so the crew line said failed and offered /redo stronger and the router learned a failure. A stop is now its own outcome with no learning signal and reads stopped. Fixes #1571 Fixes #1554 Co-Authored-By: Claude Opus 5.5 --- internal/manual/chat/models-and-cost.md | 4 + internal/manual/chat/tasks.md | 3 + internal/router/crew.go | 7 +- internal/session/run_lifecycle_test.go | 42 +++++ internal/session/stoplaw_test.go | 4 +- internal/session/task_run_belt.go | 216 +++++++++++++++++++++++- internal/session/task_run_belt_test.go | 62 ++++++- internal/session/task_store.go | 29 ++++ internal/tui3/crew.go | 6 + internal/tui3/crew_test.go | 13 ++ 10 files changed, 370 insertions(+), 16 deletions(-) diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index b6554aa275..5b46e90bec 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -774,6 +774,10 @@ names no money. `/task --best` that changes nothing says `best · already the strongest crew allowed`, and one that does names the rung (`worker glm-5.3-flash → kimi-k3`). +An explicit stop is different from a failure: its crew line starts with `stopped` and +does not offer `/redo stronger`, because stopping teaches the router nothing about the +crew. + **A seat whose model cannot start moves, inside the task.** When a seat's first call is refused, the seat goes down its ladder: the same model on its next route, then the next model for the seat at a similar cost, then the model the last good crew here used, then the diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index 19f753a8ec..41d5c4bc2a 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -4317,6 +4317,9 @@ to ask again: task 4 crew · bugfix · worker glm-5.3-flash (openrouter) · checker kimi-k3 · $0.021 (est $0.023) · not right? /redo stronger ``` +When you explicitly stop a task, its line says `stopped` and does not offer +`/redo stronger`; a stop is not a crew failure and teaches the router nothing. + A seat you pinned wears the pin mark `⌖` in front of its model. **`/redo stronger`** runs the last task this conversation started again, one rung stronger: diff --git a/internal/router/crew.go b/internal/router/crew.go index 1decbcad7d..6863c7e63e 100644 --- a/internal/router/crew.go +++ b/internal/router/crew.go @@ -39,8 +39,11 @@ const ( // crew. It is the learning signal: the class was under-served here. CrewRedone = "redo stronger" // CrewNotKept is a task that ended without a result anybody kept — it - // failed, or was stopped. It teaches nothing about the crew's strength. + // failed. It teaches nothing about the crew's strength. CrewNotKept = "not kept" + // CrewStopped is a person ending a task before it produced a result. It is + // recorded for the task's history but is not a failure signal for its crew. + CrewStopped = "stopped" ) // CrewRecord is the crew half of a row. @@ -233,6 +236,8 @@ func outcomeSignal(outcome string) float64 { return -1 case CrewNotKept: return -0.5 + case CrewStopped: + return 0 } return 0 } diff --git a/internal/session/run_lifecycle_test.go b/internal/session/run_lifecycle_test.go index 7bb530a456..7ad895790d 100644 --- a/internal/session/run_lifecycle_test.go +++ b/internal/session/run_lifecycle_test.go @@ -16,6 +16,7 @@ package session import ( "context" "errors" + "os" "path/filepath" "strconv" "strings" @@ -25,6 +26,47 @@ import ( "github.com/Agent-Field/codeaf/internal/plandb" ) +func TestRestartReconcilesAnUnfinishedBeltPlan(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + dir := t.TempDir() + place := Place{Dir: dir} + path := filepath.Join(dir, planStoreFilename) + store, err := plandb.Open(path, "held", "71", "held", "held brief", place.ID()) + if err != nil { + t.Fatal(err) + } + if err := store.Close(); err != nil { + t.Fatal(err) + } + checkpoint := place.Tasks() + writeCheckpoint(t, checkpoint, taskDocument{Type: taskDocumentType, Version: taskFileVersion, Seq: 71, + Runs: []runRecord{{ID: 71, Title: "held", State: TaskRunning, PlanTask: planStoreID("71")}}, + }) + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(cfg *Config) { + cfg.Workspace = newTestRepo(t) + cfg.Place = place + cfg.SessionFile = filepath.Join(dir, placeTranscript) + cfg.AskConsent = false + }) + defer agent.Close() + rows := agent.graph().runRows(71) + if len(rows) != 1 || rows[0].State != TaskInterrupted { + t.Fatalf("recovered belt row = %+v, want interrupted", rows) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("the interrupted plan is still live: stat err=%v", err) + } + archived, err := plandb.Open(path+".1", "", "", "", "") + if err != nil { + t.Fatal(err) + } + defer archived.Close() + root := archived.Task("71") + if root == nil || !terminalStoreStatus(root.Status) || root.Error != taskWordInterrupted { + t.Fatalf("reconciled root = %+v, want terminal interrupted", root) + } +} + // relayEngine is a run engine that can run more than once: every Start is // handed back on a channel, and each Start answers the summary the test queued // for it (a whole run when nothing is queued). A done run completes its root the diff --git a/internal/session/stoplaw_test.go b/internal/session/stoplaw_test.go index 9d7228a840..d28a125a8e 100644 --- a/internal/session/stoplaw_test.go +++ b/internal/session/stoplaw_test.go @@ -32,7 +32,9 @@ import ( // node's state, and the graph is the owner `task:N` has always reached. var stoppableRowPublishers = map[string]struct{ kind, proof string }{ "startOrJoinTaskRunVia": {CancelTask, "TestAStopOnARunsOwnRowEndsTheRun"}, - "setBeltRunMachineHold": {CancelTask, "TestAStopOnARunsOwnRowEndsTheRun"}, + "startHeldBeltRun": {CancelTask, "TestHeldBeltRunStopsWithoutPreparingRepository"}, + "startAdmittedBeltRun": {CancelTask, "TestAStopOnARunsOwnRowEndsTheRun"}, + "preparePendingBeltRun": {CancelTask, "TestAStopOnARunsOwnRowEndsTheRun"}, "ContinueRun": {CancelTask, "TestAStopReachesARunThatWasCarriedOn"}, "newOrchestrateFamily": {CancelRun, "TestCancelStopsAnAdaptiveRun"}, "sayForming": {CancelRun, "TestCancelStopsAnAdaptiveRun"}, diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index d2036138cd..42ed178bb4 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -308,6 +308,13 @@ type beltRun struct { root string row uint64 title string + brief string + stand taskStand + // pending is true until the run's machine admission has passed. A pending + // run has a plan store but no folder or working copy, so stopping it cannot + // leave a repository lock or branch behind. + pending bool + admission RunAdmission // workspace is the run's own copy, the directory every worker types in, and // ground is the folder that copy was cut from and comes home to. tree is the // copy as the ground ladder made it, kept so the run's landing is the ladder's @@ -548,6 +555,22 @@ func (a *Agent) startOrJoinTaskRunVia(ctx context.Context, id uint64, title, bri return true, nil } + // ADMISSION IS CHECKED BEFORE A FOLDER OR COPY IS TOUCHED. A held run seeds + // only its plan store so stopping it cannot leave a repository claim behind; + // the admitted road below keeps the original synchronous refusal order. + admission := NewRunAdmission(a.config.TaskMaxLoad, a.config.TaskMinFreeMB, a.graph().lanes) + held := admission != nil && !admission.MayStart() + if held { + return a.startHeldBeltRun(ctx, engine, g, path, storeID, id, title, brief, stand, question, via, asked, admission) + } + + return a.startAdmittedBeltRun(ctx, engine, g, path, storeID, id, title, brief, stand, question, via, asked, admission) +} + +// startAdmittedBeltRun is the original synchronous road after admission: route +// the crew, ready the folder, seed the store, prepare the copy, then publish +// the running row and hand the run to its engine. +func (a *Agent) startAdmittedBeltRun(ctx context.Context, engine RunEngine, g *TaskGraph, path, storeID string, id uint64, title, brief string, stand taskStand, question string, via *delegate.Delegate, asked []string, admission RunAdmission) (bool, error) { crew, folder, err := a.prepareBeltRunStart(ctx, id, title, brief, filepath.Dir(path), stand, via) if err != nil { return false, err @@ -587,8 +610,8 @@ func (a *Agent) startOrJoinTaskRunVia(ctx context.Context, id uint64, title, bri born := a.taskClockNow() run := &beltRun{ plan: plan, store: store, root: store.RootID(), row: id, title: title, - workspace: tree.dir, ground: ground, tree: tree, cut: cut, - born: born, over: make(chan struct{}), + workspace: tree.dir, ground: ground, tree: tree, admission: admission, + cut: cut, born: born, over: make(chan struct{}), delegate: via, folder: folder, asked: asked, crew: crew, } a.installBeltRun(g, run) @@ -613,7 +636,6 @@ func (a *Agent) startOrJoinTaskRunVia(ctx context.Context, id uint64, title, bri Crew: run.crewDecision(), Model: run.crewWorker(), }) - spec := a.beltRunSpec(run, brief) if programName(via) == "senior-dev" { run.costCeiling, run.timeCeiling = spec.CostUSD, spec.Elapsed.Hours() @@ -624,6 +646,53 @@ func (a *Agent) startOrJoinTaskRunVia(ctx context.Context, id uint64, title, bri return false, nil } +// startHeldBeltRun seeds the only state a machine-held run may own: its plan +// store and queued row. Crew routing remains synchronous, while folder and +// copy preparation stay behind the driver's later admission. +func (a *Agent) startHeldBeltRun(ctx context.Context, engine RunEngine, g *TaskGraph, path, storeID string, id uint64, title, brief string, stand taskStand, question string, via *delegate.Delegate, asked []string, admission RunAdmission) (bool, error) { + var crew *taskCrew + var err error + if via == nil { + crew, err = a.routeTaskCrew(ctx, id, title, brief) + if err != nil { + return false, err + } + } + plan, store, err := a.openBeltRunStore(g, path, storeID, title, brief, false) + if err != nil { + return false, err + } + if question = strings.TrimSpace(question); question != "" { + if _, err := store.Revise(store.RootID(), plandb.TaskPatch{Question: &question}); err != nil { + discardUnstartedRunStore(store) + return false, err + } + } + // THE RUN'S CONTEXT IS ONE A PERSON'S STOP CAN CUT. It outlives the turn that + // started it, which is the caller's business (task.go hands this door a + // context no turn's ending cancels); what it must not outlive is the person + // saying stop, and until this cancel was kept nothing could say it (stoprun.go). + runCtx, cut := context.WithCancel(ctx) + born := a.taskClockNow() + run := &beltRun{ + plan: plan, store: store, root: store.RootID(), row: id, title: title, brief: brief, + stand: stand, ground: canonicalPath(stand.dir), pending: true, admission: admission, + cut: cut, born: born, over: make(chan struct{}), + delegate: via, asked: asked, crew: crew, + } + a.installBeltRun(g, run) + // A HELD RUN IS QUEUED, not working: there is no worker and deliberately no + // copy yet. Its plan id is present from the first publish, which gives stop + // and recovery one persisted identity to reconcile. + a.publishRunRow(g, TaskNotice{ + ID: id, Title: title, State: TaskQueued, Program: programName(via), + PlanTask: planStoreID(storeID), Crew: run.crewDecision(), + Model: run.crewWorker(), Waiting: waitingMachineBusy, + }) + go a.driveBeltRun(runCtx, engine, run, RunSpec{}) + return false, nil +} + // prepareBeltRunStart settles admission before the store is seeded. An // ordinary task gets its per-task crew before its copy opens; a program keeps // its requested models and separate ceiling, and its folder is held before @@ -892,6 +961,10 @@ func (a *Agent) beltRunSpec(run *beltRun, brief string) RunSpec { ceilings := a.seniorDevCeilings(a.Usage().CostUSD) cost, wallLeft = ceilings.CostUSD, ceilings.Elapsed() } + admission := run.admission + if admission == nil { + admission = NewRunAdmission(a.config.TaskMaxLoad, a.config.TaskMinFreeMB, a.graph().lanes) + } return RunSpec{ Store: run.store, Workspace: run.workspace, @@ -906,7 +979,7 @@ func (a *Agent) beltRunSpec(run *beltRun, brief string) RunSpec { StepsPerTask: taskMaxSteps, // The graph already owns the fallback account when the door handed // none. Run and node workers must charge that same conversation. - Admission: NewRunAdmission(a.config.TaskMaxLoad, a.config.TaskMinFreeMB, a.graph().lanes), + Admission: admission, OnHold: func(ids []string) { a.setBeltRunMachineHold(run, ids) }, ProfileDir: a.config.ProfileDir, WorkModel: workSeat, @@ -1294,10 +1367,16 @@ func (a *Agent) setBeltRunMachineHold(run *beltRun, ids []string) { if len(held) != 0 { waiting = waitingMachineBusy } - a.publishRunRow(g, TaskNotice{ - ID: run.row, Title: run.title, State: TaskRunning, - StartedAt: run.born, PlanTask: planStoreID(run.root), Waiting: waiting, - }) + a.beltMu.Lock() + pending := run.pending + started := run.born + a.beltMu.Unlock() + state := TaskRunning + if pending { + state, started = TaskQueued, time.Time{} + } + a.publishRunRow(g, TaskNotice{ID: run.row, Title: run.title, State: state, + StartedAt: started, PlanTask: planStoreID(run.root), Waiting: waiting}) } } @@ -1557,12 +1636,131 @@ func (a *Agent) cutBeltRun() { } } +// waitForBeltAdmission keeps a newly seeded run queued until the machine gate +// admits its first worker. It is deliberately the run's existing goroutine: +// stopping the row cuts this wait directly, without a polling goroutine that +// could outlive the run. +func (a *Agent) waitForBeltAdmission(ctx context.Context, run *beltRun) bool { + for { + if run.admission == nil || run.admission.MayStart() { + return true + } + a.setBeltRunMachineHold(run, []string{run.root}) + timer := time.NewTimer(taskPressurePoll) + select { + case <-ctx.Done(): + if !timer.Stop() { + <-timer.C + } + return false + case <-timer.C: + } + } +} + +// preparePendingBeltRun is the only road from a held run to a repository. The +// admission check has passed before this function calls either folder or copy +// preparation, which is the lock-ownership seam for held tasks. +func (a *Agent) preparePendingBeltRun(ctx context.Context, run *beltRun) error { + folder, err := a.readyRunFolder(run.row, run.title, filepath.Dir(run.store.Path()), run.stand, run.delegate) + if err != nil { + return err + } + tree, ground := taskTree{}, canonicalPath(run.stand.dir) + if folder != nil { + ground = canonicalPath(folder.Dir) + tree = folder.tree() + } else { + tree, err = beltRunPrepare(ctx, a.config.Place, a.config.Workspace, a.journalID(), run.row, run.title, run.stand) + if err != nil { + return err + } + } + tree.bashBelt = true + a.beltMu.Lock() + stopped := run.stopped + run.folder, run.tree, run.ground = folder, tree, ground + run.workspace, run.pending = tree.dir, false + a.beltMu.Unlock() + if stopped || ctx.Err() != nil { + return ctx.Err() + } + if g := a.graph(); g != nil { + a.publishRunRow(g, TaskNotice{ + ID: run.row, Title: run.title, State: TaskRunning, StartedAt: run.born, + Copy: runCopyOf(tree), Program: programName(run.delegate), + PlanTask: planStoreID(run.root), Crew: run.crewDecision(), Model: run.crewWorker(), + }) + } + return nil +} + +// settlePendingBeltRun closes the two ways a held run can leave before its +// engine starts. A person stop uses the same stopped landing as an admitted +// run; a folder refusal keeps that refusal's words on both the store and row. +func (a *Agent) settlePendingBeltRun(run *beltRun, err error) { + a.beltMu.Lock() + stopped, why := run.stopped, run.stopReason + run.ending = true + a.beltMu.Unlock() + defer a.releaseBeltRun(run) + if stopped { + a.settleStoppedBeltRun(run, why, nil) + a.settleTaskCrew(run.row, router.CrewStopped, 0) + return + } + if err == nil { + return + } + reason := strings.TrimSpace(err.Error()) + _ = run.store.FailRoot(reason) + if g := a.graph(); g != nil { + a.publishRunRow(g, TaskNotice{ + ID: run.row, Title: run.title, State: TaskFailed, + Report: reason, EndedAt: a.taskClockNow(), + }) + } + a.settleTaskCrew(run.row, router.CrewNotKept, 0) +} + +// startPendingBeltRun admits a held run and prepares the repository only after +// admission. It returns false after settling a stop, close, or preparation +// refusal, so the driver has one short road for every pending exit. +func (a *Agent) startPendingBeltRun(ctx context.Context, run *beltRun) (RunSpec, bool) { + if !a.waitForBeltAdmission(ctx, run) { + a.settlePendingBeltRun(run, nil) + return RunSpec{}, false + } + // The successful admission supersedes the last held reading before the + // copy is prepared; otherwise the plan reader would keep drawing the old + // `machine busy` reason on a now-running root. + a.setBeltRunMachineHold(run, nil) + if err := a.preparePendingBeltRun(ctx, run); err != nil { + a.settlePendingBeltRun(run, err) + return RunSpec{}, false + } + spec := a.beltRunSpec(run, run.brief) + if programName(run.delegate) == "senior-dev" { + run.costCeiling, run.timeCeiling = spec.CostUSD, spec.Elapsed.Hours() + run.conversationCostLimit = a.railCap(0) > 0 && runCostLeft(a.railCap(0), a.Usage().CostUSD) <= delegate.DefaultSeniorDevCostUSD + run.conversationTimeLimit = a.seniorDevConversationTimeLimit() + } + return spec, true +} + // driveBeltRun runs one run to its outcome and writes the ending back where the // conversation reads it: the store's root carries the outcome and the landing, // the person's conversation is told with the same note a landed task sends, and // the row the run was published under settles. The store is closed and the run // cleared once the work is home, so the next `/task` seeds a fresh plan. func (a *Agent) driveBeltRun(ctx context.Context, engine RunEngine, run *beltRun, spec RunSpec) { + if run.pending { + var ok bool + spec, ok = a.startPendingBeltRun(ctx, run) + if !ok { + return + } + } // THE RUN'S MONEY REACHES THE CONVERSATION'S BOOKS THROUGH ONE FOLD // (task_run_money.go): each call whole as a program's model API meters it, // and whatever the run's running total holds beyond those — a bash @@ -1602,7 +1800,7 @@ func (a *Agent) driveBeltRun(ctx context.Context, engine RunEngine, run *beltRun // A RUN A PERSON STOPPED IS NOT LANDED. Its work is kept where the stop's // own sentence said it would be, and the ending is the stop's (stoprun.go). a.settleStoppedBeltRun(run, why, summary.Cut) - a.settleTaskCrew(run.row, router.CrewNotKept, summary.USD) + a.settleTaskCrew(run.row, router.CrewStopped, summary.USD) return } if closing && run.delegate == nil && summary.Outcome != beltRunOutcomeDone { diff --git a/internal/session/task_run_belt_test.go b/internal/session/task_run_belt_test.go index cac5f03a75..f814a60c8c 100644 --- a/internal/session/task_run_belt_test.go +++ b/internal/session/task_run_belt_test.go @@ -396,7 +396,7 @@ func TestBeltRunCarriesMachineGateAndShowsItsHold(t *testing.T) { cfg.SessionFile = filepath.Join(dir, placeTranscript) cfg.AskConsent = false cfg.TaskMaxLoad = 0 - cfg.TaskMinFreeMB = 1 << 40 + cfg.TaskMinFreeMB = 0 cfg.TaskLanes = lanes }) id, _, _, err := agent.StartTask(context.Background(), "fix the issue", false) @@ -411,9 +411,6 @@ func TestBeltRunCarriesMachineGateAndShowsItsHold(t *testing.T) { double.mu.Lock() spec := double.spec double.mu.Unlock() - if spec.Admission == nil || spec.Admission.MayStart() { - t.Fatal("conversation's memory floor did not hold the run gate") - } rootID := strconv.FormatUint(id, 10) spec.OnHold([]string{rootID}) rows := agent.graph().runRows(id) @@ -444,6 +441,61 @@ func TestBeltRunCarriesMachineGateAndShowsItsHold(t *testing.T) { endBeltRun(t, agent, double) } +func TestHeldBeltRunStopsWithoutPreparingRepository(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + double := newBeltRunDouble("done") + registerBeltRunEngine(t, double) + dir := t.TempDir() + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(cfg *Config) { + cfg.Workspace = newTestRepo(t) + cfg.Place = Place{Dir: dir} + cfg.SessionFile = filepath.Join(dir, placeTranscript) + cfg.AskConsent = false + cfg.TaskMaxLoad = 0 + cfg.TaskMinFreeMB = 1 << 40 + }) + id, _, _, err := agent.StartTask(context.Background(), "held work", false) + if err != nil { + t.Fatal(err) + } + beltRunWaitFor(t, "the held belt row", func() bool { + rows := agent.graph().runRows(id) + return len(rows) == 1 && rows[0].State == TaskQueued && rows[0].Waiting == waitingMachineBusy + }) + agent.beltMu.Lock() + run := agent.beltRun + pending, workspace, branch := run != nil, "", "" + if run != nil { + workspace, branch = run.workspace, run.tree.branch + } + agent.beltMu.Unlock() + if !pending || workspace != "" || branch != "" { + t.Fatalf("held run prepared repository state: live=%v workspace=%q branch=%q", pending, workspace, branch) + } + line, err := agent.Cancel(CancelTask + ":" + strconv.FormatUint(id, 10)) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(line, "stopping") { + t.Fatalf("stop line = %q, want stopping", line) + } + beltRunWaitFor(t, "the stopped held run", func() bool { return agent.beltRun == nil }) + rows := agent.graph().runRows(id) + if len(rows) != 1 || rows[0].State != TaskFailed || !rows[0].Stopped { + t.Fatalf("stopped held row = %+v", rows) + } + if !strings.HasPrefix(rows[0].Report, taskStoppedWord) { + t.Fatalf("stopped held row report = %q, want one stop settlement", rows[0].Report) + } + plan := planRowFor(agent.PlanTasks(), planStoreID(strconv.FormatUint(id, 10))) + if plan == nil || plan.Status != string(plandb.StatusCancelled) { + t.Fatalf("stopped held plan = %+v, want one stopped landing", plan) + } + if double.didRun() { + t.Fatal("a held run reached the engine") + } +} + func TestBeltRunSharesGraphLanesWhenConfigHasNone(t *testing.T) { t.Setenv("CODEAF_TASK_BELT", "bash") double := newBeltRunDouble("done") @@ -454,7 +506,7 @@ func TestBeltRunSharesGraphLanesWhenConfigHasNone(t *testing.T) { cfg.Place = Place{Dir: dir} cfg.SessionFile = filepath.Join(dir, placeTranscript) cfg.AskConsent = false - cfg.TaskMaxLoad = 1 + cfg.TaskMaxLoad = 1e9 cfg.TaskMinFreeMB = 0 cfg.TaskLanes = nil }) diff --git a/internal/session/task_store.go b/internal/session/task_store.go index e79e6b3008..6274722cf7 100644 --- a/internal/session/task_store.go +++ b/internal/session/task_store.go @@ -1743,6 +1743,19 @@ func (a *Agent) recoverTasks() { graph := a.graph() recovery := graph.rehydrate(document, a.config.Workspace, a.settlePolicy()) + // A belt run's plan is a second persisted state beside this checkpoint. If + // the process died before its driver settled, archive that live store now; + // otherwise PlanTasks would keep reading its root as working forever even + // though the restored run row already says nothing is driving it. + if graph.holdsInterruptedBeltRun() { + if path := graph.planPath(); path != "" { + if info, err := os.Stat(path); err == nil && !info.IsDir() { + if err := setAsideRunStore(path); err != nil { + graph.planNote("the interrupted run could not be reconciled: " + err.Error()) + } + } + } + } // The consume-once receipt reaches the disk BEFORE anything else happens: a // second crash between here and the first turn must not hand the same // interrupt to a second recovery. @@ -1783,6 +1796,22 @@ func (a *Agent) recoverTasks() { } } +// holdsInterruptedBeltRun distinguishes the bash-belt rows from adaptive +// rows. Both are restored as interrupted, but only the belt has a plan store +// whose live root must be archived on startup. +func (g *TaskGraph) holdsInterruptedBeltRun() bool { + g.mu.Lock() + defer g.mu.Unlock() + for _, rows := range g.runs { + for _, row := range rows { + if row.State == TaskInterrupted && row.Run == "" && row.PlanTask != "" { + return true + } + } + } + return false +} + // reconcile files ONE record and answers it as the graph is to hold it: a node // the close caught is turned into what it became ([interrupt]) and counted under // its own clause, and every other is counted as it stands ([taskRecovery.countSettled]). diff --git a/internal/tui3/crew.go b/internal/tui3/crew.go index c620d2cca6..72c3216ddc 100644 --- a/internal/tui3/crew.go +++ b/internal/tui3/crew.go @@ -301,6 +301,12 @@ func (a *app) sayTaskCrew(notice session.TaskNotice) { text = lead + a.crewLine(notice.Crew, -1) facts = []string{crewroute.ShortModel(notice.Crew.Seat(crewroute.Worker).Model)} case session.TaskFailed: + if notice.Stopped { + text = lead + "stopped · " + a.crewLine(notice.Crew, crewroute.Unspent) + facts = []string{"stopped"} + said.landed = true + break + } // A TASK THAT FAILED ASKS FOR THE NEXT STEP BY NAME: the stronger crew // is the one thing on this line a person can do about it. // AND THE FAILURE IS SAID FIRST, before any figure: a stopped seat's diff --git a/internal/tui3/crew_test.go b/internal/tui3/crew_test.go index ba51365216..e27326c061 100644 --- a/internal/tui3/crew_test.go +++ b/internal/tui3/crew_test.go @@ -182,6 +182,19 @@ func TestARoutedTaskSaysItsCrewTwice(t *testing.T) { } } +func TestAStoppedTaskCrewLineDoesNotOfferRedo(t *testing.T) { + a, _ := sheetApp(t) + crew := &crewroute.Decision{Class: crewroute.Bugfix, Crew: []crewroute.Pick{ + {Seat: crewroute.Worker, Model: "z-ai/glm-5.3-flash", Provider: "openrouter"}, + }} + a.sayTaskCrew(session.TaskNotice{ID: 8, State: session.TaskRunning, Crew: crew}) + a.sayTaskCrew(session.TaskNotice{ID: 8, State: session.TaskFailed, Stopped: true, Crew: crew}) + line := lastNote(t, a) + if !strings.Contains(line, "task 8 crew · stopped") || strings.Contains(line, "/redo stronger") { + t.Fatalf("stopped crew line = %q", line) + } +} + // crewEffortAgent is a task door that records the effort word it was handed. type crewEffortAgent struct { *fakeAgent From c55a77dc05e80ca05f8945773c695d68ee386ee1 Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Sun, 27 Sep 2026 01:05:00 -0400 Subject: [PATCH 16/76] fix(jobs): preserve spool identity and report incomplete output honestly --- PERF.md | 6 +- internal/manual/chat/what-i-can-do.md | 5 +- internal/session/joblog_feedback_test.go | 133 +++++++++++++++ internal/session/jobs.go | 198 +++++++++++++---------- internal/session/jobspool_test.go | 189 ++++++++++++++++------ 5 files changed, 386 insertions(+), 145 deletions(-) create mode 100644 internal/session/joblog_feedback_test.go diff --git a/PERF.md b/PERF.md index 6f542a365a..8a62499368 100644 --- a/PERF.md +++ b/PERF.md @@ -1241,8 +1241,10 @@ trimming, so a single multi-megabyte Write grew a temporary to match. The spool is now a window of at most **jobSpoolChunks (2) chunks of jobSpoolChunkBytes (4MB)** — `.log` live and `.log.1` kept — rotated by -rename and never rewritten per write; the chunk that falls out of the window -is deleted and counted. One huge Write spools in chunk-sized pieces and hands +copying a full chunk once at its boundary, then truncating and seeking the +same live inode. The previous backup is removed before copying, keeping even +transient usage within two chunks. No job ID or writer lock is released during +rotation. One huge Write spools in chunk-sized pieces and hands only its newest **64KB** (`jobRingBytes`) to the ring. The retained output stays addressable by the read tool exactly as before, so no limit grows for the reader. diff --git a/internal/manual/chat/what-i-can-do.md b/internal/manual/chat/what-i-can-do.md index 3e7e8fee55..14de575cbb 100644 --- a/internal/manual/chat/what-i-can-do.md +++ b/internal/manual/chat/what-i-can-do.md @@ -378,14 +378,15 @@ job 3 started; log at ~/.codeaf/v3/projects/-you-work//logs/jobs/3.log ``` A background job never times out and is not tied to the turn that started it. -Its most recent output goes to that log file — a bounded spool, about **8MB** in +Its most recent output goes to that log file — a bounded spool, at most **8MiB** in chunks, the oldest discarded as it rolls — and the last **64KB** is also held in memory for quick reads. When the job exits, codeaf is told at the next step in one boundary batch. Its headline, e.g. `job 3 exited 1: make: *** [build] Error 1`, quotes the last non-empty log line, clipped to 120 characters. Under it the note carries the last 50 lines and the log's path; use `jobs output` for anything older than that tail. When the -spool has discarded output, the note names the truncation and the file instead +spool has rotated, the note names both retained files; after older output is +discarded, it also names the truncation instead of promising a full log. The `jobs` tool looks at all of this. Its `action` is `list`, `output` or `kill`. diff --git a/internal/session/joblog_feedback_test.go b/internal/session/joblog_feedback_test.go new file mode 100644 index 0000000000..294d773c87 --- /dev/null +++ b/internal/session/joblog_feedback_test.go @@ -0,0 +1,133 @@ +package session + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/exec/bare" +) + +// Search output is written back into the same live spool it could encounter +// on its next pass. A stable answer proves the real tool cannot amplify it. +func TestJobLogSearchCannotFeedItsOwnSpool(t *testing.T) { + for _, layout := range []string{"legacy", "state-from-home", "state-from-inside"} { + t.Run(layout, func(t *testing.T) { + root := t.TempDir() + state := filepath.Join(root, "runtime") + t.Setenv("CODEAF_HOME", state) + place := Place{} + searchRoot := root + sourceDir := root + if layout != "legacy" { + place = Place{Dir: filepath.Join(state, "v3", "projects", "project", "session")} + if layout == "state-from-inside" { + searchRoot = state + sourceDir = filepath.Join(place.Dir, "work") + } + } + if err := os.MkdirAll(sourceDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sourceDir, "needle.go"), []byte("feedback_1599_needle\n"), 0o600); err != nil { + t.Fatal(err) + } + registry := newJobRegistry(root, place, nil) + job, err := registry.newJob("search feedback fixture", jobKindBash) + if err != nil { + t.Fatal(err) + } + t.Cleanup(job.sink.close) + job.sink.chunkBytes = 512 + var grep bare.Tool + for _, tool := range bare.AllTools(searchRoot) { + if tool.Name == "grep" { + grep = tool + } + } + if grep.Execute == nil { + t.Fatal("grep missing from real tool belt") + } + args, err := json.Marshal(map[string]any{"pattern": "feedback_1599_needle", "path": searchRoot, "limit": 10}) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + var first string + for iteration := 0; iteration < 40; iteration++ { + answer, failed, err := grep.Execute(ctx, args) + if failed || err != nil { + t.Fatalf("grep: %s, %v", answer, err) + } + if !strings.Contains(answer, "needle.go") || strings.Contains(answer, "jobs/") { + t.Fatalf("search included runtime output or missed source: %s", answer) + } + if iteration == 0 { + first = answer + } else if answer != first { + t.Fatalf("search output amplified on iteration %d: %s", iteration, answer) + } + if _, err := fmt.Fprintln(job.sink, answer); err != nil { + t.Fatal(err) + } + } + for _, path := range []string{job.logPath, job.logPath + ".1"} { + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Size() > 512 { + t.Fatalf("feedback spool escaped its chunk limit: %s has %d bytes", path, info.Size()) + } + } + if !strings.Contains(job.sink.notice(), "truncat") { + t.Fatal("feedback fixture must cross the spool window and report truncation") + } + }) + } +} + +// The actual background-command door drains sustained stdout and stderr to +// completion while a deliberately tiny disk window rotates many times. +func TestJobSpoolSustainedProcessExitsWithinDiskBound(t *testing.T) { + root := t.TempDir() + registry := newJobRegistry(root, Place{}, nil) + t.Cleanup(func() { registry.shutdown(25 * time.Millisecond) }) + job, err := registry.start("while [ ! -e spool-start ]; do sleep 0.01; done; i=0; while [ \"$i\" -lt 200 ]; do printf 'stdout %s\\n' \"$i\"; printf 'stderr %s\\n' \"$i\" >&2; i=$((i+1)); done; printf 'LAST-OUTPUT\\n'") + if err != nil { + t.Fatal(err) + } + job.sink.mu.Lock() + job.sink.chunkBytes = 128 + job.sink.mu.Unlock() + if err := os.WriteFile(filepath.Join(root, "spool-start"), nil, 0o600); err != nil { + t.Fatal(err) + } + select { + case <-job.done: + case <-time.After(10 * time.Second): + t.Fatal("bounded spool stopped draining the child") + } + if info := job.info(); info.state == jobRunning || info.code != 0 { + t.Fatalf("child did not exit successfully: %+v", info) + } + for _, path := range []string{job.logPath, job.logPath + ".1"} { + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Size() > 128 { + t.Fatalf("sustained output escaped bound: %s has %d bytes", path, info.Size()) + } + } + answer, failed := registry.output(job.id, 10) + if failed || !strings.Contains(answer, "LAST-OUTPUT") || !strings.Contains(answer, "truncat") { + t.Fatalf("job output lost its latest bytes or truncation notice: %s", answer) + } +} diff --git a/internal/session/jobs.go b/internal/session/jobs.go index 3607897fc3..b8b2487a63 100644 --- a/internal/session/jobs.go +++ b/internal/session/jobs.go @@ -16,8 +16,8 @@ package session // one before it, anything earlier discarded with a notice — so the recent // log is addressable by the read tool — paged, offset, grepped, the same // way any other file is — without a job that prints for a week filling the -// disk. A chunk is never rewritten: it fills, one rename keeps it, a fresh -// one opens. Only the last 64KB is kept in memory, and only that tail is +// disk. At a chunk boundary the live bytes are copied once to the backup +// before the same live inode is truncated; its identity never disappears. Only the last 64KB is kept in memory, and only that tail is // ever handed back through a tool result. A watcher that has printed 400MB // must not be able to put 400MB in front of the model, and a watcher that // printed the one line that matters must not lose it because nobody was @@ -53,6 +53,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -628,7 +629,7 @@ func (r *jobRegistry) claimJobLog(directory string) (int, string, *os.File, erro r.mu.Unlock() logPath := filepath.Join(directory, fmt.Sprintf("%d.log", id)) - logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) + logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o644) if err == nil { return id, logPath, logFile, nil } @@ -1024,17 +1025,13 @@ func (r *jobRegistry) settleExit(watched *job, code int) { // means something, and a note that withheld it would be an invitation to // make one more call for what the note was already about. if watched.kind == jobKindBash { - if tail := watched.sink.tail(jobExitTailLines); strings.TrimSpace(tail) != "" { - // AND THE FOOTER STAYS HONEST ABOUT THE BOUND: a log that has - // begun discarding is not a full one, and "full log" on a note - // whose beginning is gone would send the model reading a file - // that does not hold what it names. - ending := "full log: " - if notice := watched.sink.notice(); notice != "" { - ending = notice + " · log file: " + tail := watched.sink.tail(jobExitTailLines) + if strings.TrimSpace(tail) != "" || watched.sink.notice() != "" { + if strings.TrimSpace(tail) != "" { + note += "\n\n" + tail } - note += "\n\n" + tail + "\n\n[job " + strconv.Itoa(watched.id) + " · last " + - strconv.Itoa(jobExitTailLines) + " lines · " + ending + watched.logPath + "]" + note += "\n\n[job " + strconv.Itoa(watched.id) + " · last " + + strconv.Itoa(jobExitTailLines) + " lines · " + watched.sink.logFooter(watched.logPath) + "]" } } r.notify(note) @@ -1253,47 +1250,29 @@ func (r *jobRegistry) output(id, lines int) (string, bool) { if strings.TrimSpace(tail) == "" { tail = "(no output)" } - // The footer is only honest while the rest IS there: a spool that has - // discarded anything names the truncation and the file instead of - // promising full. - ending := "full log: " + target.logPath - if notice := target.sink.notice(); notice != "" { - ending = notice + " · log file: " + target.logPath - } + ending := target.sink.logFooter(target.logPath) return fmt.Sprintf("%s\n\n[job %d · %s · showing last %d lines · %s]", tail, id, statusText(info), lines, ending), false } // ── the output sink: ring in memory, everything on disk ───────────────────── -// jobSink is one job's output: a rolling in-memory tail and a bounded disk -// spool. -// -// It is an io.Writer set as both Stdout and Stderr, so Go's exec package feeds -// it from two goroutines — the mutex is load-bearing, not decoration. Spool -// writes append whole pieces to the live chunk and never rewrite megabytes -// per line: a chunk fills, one rename keeps it, a fresh one opens, and the -// chunk beyond the window is discarded. +// jobSink drains stdout and stderr into a bounded tail and two disk chunks. +// The live inode is never replaced, preserving both the job ID reservation and +// the writer lease held by retention. Disk failures stop spooling, not draining. type jobSink struct { - mu sync.Mutex - ring []byte - file *os.File - closed bool - // base is the live chunk's path (the newest chunk is always base+".1"). - // chunkBytes and chunks are the window; the tests set them tiny and inject - // hook instead of generating data anywhere near the real limits. - base string - chunkBytes int64 - chunks int - hook spoolHook - // spoolBytes is what the live chunk holds. spoolBroken says a spool write - // failed and retries stop; brokenText and noticeText are the two sentences - // [jobSink.notice] can carry — a failed spool and a discarded chunk — - // each set once and then left alone. + mu sync.Mutex + ring []byte + file *os.File + closed bool + base string + chunkBytes int64 spoolBytes int64 spoolBroken bool brokenText string noticeText string + backupInfo os.FileInfo + hook spoolHook } // spoolHook is the seam the tests inject spool failures through. It replaces @@ -1307,7 +1286,6 @@ func newJobSink(file *os.File, base string) *jobSink { file: file, base: base, chunkBytes: jobSpoolChunkBytes, - chunks: jobSpoolChunks, } } @@ -1332,7 +1310,7 @@ func (s *jobSink) Write(data []byte) (int, error) { for start < len(data) && !utf8RuneStart(data[start]) { start++ } - s.ring = append(s.ring, data[start:]...) + s.ring = append(s.ring[:0], data[start:]...) } else { s.ring = append(s.ring, data...) } @@ -1345,9 +1323,8 @@ func (s *jobSink) Write(data []byte) (int, error) { } // spoolLocked appends data to the bounded spool, rotating the live chunk when -// it fills. A chunk is never rewritten and one call never copies its data: -// the pieces are slices of it, at most one rotation per call, and a Write of -// any size costs renames, not memory. +// it fills. Input is sliced without copying; each full chunk is copied once +// during rotation with a fixed buffer, regardless of the size of a Write. func (s *jobSink) spoolLocked(data []byte) { if s.spoolBroken || s.base == "" { return @@ -1386,73 +1363,115 @@ func (s *jobSink) spoolWriteLocked(piece []byte) (int, error) { return s.file.Write(piece) } -// rotateLocked keeps the live chunk and opens a fresh one, discarding the -// chunk beyond the window: the chain shifts up one place and the oldest -// numbered chunk is clobbered by the shift, which is the discard the notice -// records. Renames, not copies. Its false is the spool being over — a rename -// or an open that failed stops the spool, never the job. +// rotateLocked preserves the live inode and copies at most one chunk. The +// previous backup is removed before the copy, so even during rotation there +// are at most two chunks. A backup is only removed if this sink created it. func (s *jobSink) rotateLocked() bool { - discarded := false - if s.chunks > 1 { - if _, err := os.Stat(s.base + "." + strconv.Itoa(s.chunks-1)); err == nil { - discarded = true - } + root, err := os.OpenRoot(filepath.Dir(s.base)) + if err != nil { + s.breakSpoolLocked(err) + return false } - _ = s.file.Close() - for index := s.chunks - 2; index >= 1; index-- { - _ = os.Rename(s.base+"."+strconv.Itoa(index), s.base+"."+strconv.Itoa(index+1)) + defer root.Close() + name := filepath.Base(s.base) + info, err := root.Lstat(name) + owned, ownErr := s.file.Stat() + if err != nil || ownErr != nil || !info.Mode().IsRegular() || !os.SameFile(info, owned) { + s.breakSpoolLocked(fmt.Errorf("live log identity changed")) + return false } - if err := os.Rename(s.base, s.base+".1"); err != nil { + backup := name + ".1" + if s.backupInfo != nil { + info, err := root.Lstat(backup) + if err != nil || !info.Mode().IsRegular() || !os.SameFile(info, s.backupInfo) { + s.breakSpoolLocked(fmt.Errorf("retained log identity changed")) + return false + } + if err := root.Remove(backup); err != nil { + s.breakSpoolLocked(err) + return false + } + s.backupInfo = nil + s.noticeText = "log truncated: only the most recent " + spoolSizeText(s.chunkBytes*jobSpoolChunks) + " is kept" + } + file, err := root.OpenFile(backup, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) + if err != nil { s.breakSpoolLocked(err) return false } - file, err := os.OpenFile(s.base, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) + s.backupInfo, err = file.Stat() if err != nil { - s.file = nil + _ = file.Close() s.breakSpoolLocked(err) return false } - s.file = file - s.spoolBytes = 0 - if discarded && s.noticeText == "" { - s.noticeText = "log truncated: only the most recent " + spoolSizeText(s.chunkBytes*int64(s.chunks)) + " is kept" + n, copyErr := io.CopyBuffer(file, io.NewSectionReader(s.file, 0, s.spoolBytes), make([]byte, 32<<10)) + closeErr := file.Close() + if copyErr == nil && n != s.spoolBytes { + copyErr = io.ErrShortWrite + } + if err := errors.Join(copyErr, closeErr); err != nil { + s.breakSpoolLocked(err) + return false + } + if err := s.file.Truncate(0); err != nil { + s.breakSpoolLocked(err) + return false } + if _, err := s.file.Seek(0, io.SeekStart); err != nil { + s.breakSpoolLocked(err) + return false + } + s.spoolBytes = 0 return true } -// breakSpoolLocked records a spool failure once: retries stop, the file is -// left closed, the ring keeps draining, and the next footer names the failure -// instead of a log. +// breakSpoolLocked leaves the fd and its writer lease held until close. A +// failed spool must not look like an abandoned log while its job is running. func (s *jobSink) breakSpoolLocked(err error) { if s.spoolBroken { return } s.spoolBroken = true - if s.file != nil { - _ = s.file.Close() - s.file = nil + s.brokenText = "job log stopped: " + err.Error() +} + +func (s *jobSink) noticeLocked() string { + parts := []string{} + if s.brokenText != "" { + parts = append(parts, s.brokenText) } - what := "failed" - if err != nil { - what = err.Error() + if s.noticeText != "" { + parts = append(parts, s.noticeText) + } else if s.backupInfo != nil { + parts = append(parts, "log rotated") } - s.brokenText = "job log stopped: " + what + return strings.Join(parts, "; ") } -// notice is the one sentence about what the spool no longer holds, or "" -// while the log on disk is everything the job wrote. A surface quoting it -// names the file instead of calling the log full, which is what keeps the -// footer's promise honest. +// notice records lost history and logging failures without stopping the job. func (s *jobSink) notice() string { if s == nil { return "" } s.mu.Lock() defer s.mu.Unlock() - if s.spoolBroken { - return s.brokenText + return s.noticeLocked() +} + +// logFooter names both chunks from the first rotation, even before any bytes +// are discarded. Calling only the live chunk the full log would be false. +func (s *jobSink) logFooter(path string) string { + s.mu.Lock() + defer s.mu.Unlock() + notice := s.noticeLocked() + if notice == "" { + return "full log: " + path + } + if s.backupInfo != nil { + return notice + " · log files: " + path + ".1, " + path } - return s.noticeText + return notice + " · log file: " + path } // spoolSizeText keeps the notice's figures readable: megabytes at the @@ -1487,9 +1506,12 @@ func (s *jobSink) trimLocked() { func (s *jobSink) close() { s.mu.Lock() defer s.mu.Unlock() - if s.file != nil && !s.closed { - if err := s.file.Close(); err != nil && s.brokenText == "" && s.noticeText == "" { - s.brokenText = "job log did not close cleanly: " + err.Error() + if s.closed { + return + } + if s.file != nil { + if err := s.file.Close(); err != nil { + s.breakSpoolLocked(fmt.Errorf("close: %w", err)) } } s.closed = true diff --git a/internal/session/jobspool_test.go b/internal/session/jobspool_test.go index dd08b3d217..c6d6601aec 100644 --- a/internal/session/jobspool_test.go +++ b/internal/session/jobspool_test.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" ) @@ -18,13 +19,15 @@ import ( func newSpool(t *testing.T, chunkBytes int64, chunks int) *jobSink { t.Helper() path := filepath.Join(t.TempDir(), "3.log") - file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) + file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o644) if err != nil { t.Fatalf("open spool: %v", err) } sink := newJobSink(file, path) sink.chunkBytes = chunkBytes - sink.chunks = chunks + if chunks != jobSpoolChunks { + t.Fatal("fixture must use production chunk count") + } t.Cleanup(func() { sink.close() }) return sink } @@ -83,7 +86,7 @@ func TestJobSpoolRotatesAndStaysBounded(t *testing.T) { if !strings.HasSuffix(string(wrote), string(live)) { t.Fatal("live chunk is not a suffix of what was written") } - if got := sink.lastNonEmptyLine(); got != "line79" { + if got := sink.lastNonEmptyLine(); got != "line119" { t.Fatalf("ring lost the newest line: %q", got) } // The kept chunk holds the middle of the stream, not the beginning: the @@ -122,7 +125,7 @@ func TestJobSinkHugeSingleWriteKeepsOnlyTheTail(t *testing.T) { if size > jobRingBytes*2 { t.Fatalf("ring grew to %d bytes for one huge write", size) } - if got := sink.lastNonEmptyLine(); got != "THE-TAIL" { + if got := sink.lastNonEmptyLine(); !strings.HasSuffix(got, "THE-TAIL") { t.Fatalf("ring lost the tail of a huge write: %q", got) } if !strings.Contains(sink.text(), "THE-TAIL") { @@ -185,63 +188,143 @@ func TestJobSpoolShortWriteIsRecorded(t *testing.T) { } } -// A spool that was already broken never has its notice overwritten by the -// truncation, and a close that fails is surfaced through the same door. +// Closing the actual fd behind the sink induces a real close failure. It +// must remain visible even after the disk window has discarded older bytes. func TestJobSpoolCloseErrorSurfacesInNotice(t *testing.T) { - sink := newSpool(t, 1<<20, 2) - if _, err := sink.Write([]byte("some output\n")); err != nil { - t.Fatalf("write: %v", err) + sink := newSpool(t, 4, 2) + _, _ = sink.Write([]byte("0123456789")) + if err := sink.file.Close(); err != nil { + t.Fatal(err) } sink.close() - // Closing is idempotent and a second close records nothing new. + first := sink.notice() + if !strings.Contains(first, "close:") || !strings.Contains(first, "truncated") { + t.Fatalf("close error hidden: %q", first) + } sink.close() - if notice := sink.notice(); notice != "" { - t.Fatalf("a clean close invented a notice: %q", notice) - } - // A close failure on a real file cannot be injected directly, so the - // broken-text door is asserted at the unit it runs through: the guard - // keeps the first notice and the close error cannot overwrite it. - sink2 := newSpool(t, 1<<20, 2) - sink2.brokenText = "job log stopped: injected earlier" - sink2.close() - if notice := sink2.notice(); notice != "job log stopped: injected earlier" { - t.Fatalf("close overwrote an earlier notice: %q", notice) + if sink.notice() != first { + t.Fatal("repeated close changed first failure") } } -// The footer a model reads is honest about the bound: `full log:` while the -// log on disk is everything the job wrote, and the truncation named where it -// is not — through `jobs output` and through the completion note alike. func TestJobFooterNamesTruncationInsteadOfFullLog(t *testing.T) { - agent, _ := jobsAgent(t) - agent.mu.Lock() - agent.opened = false - agent.mu.Unlock() + var note string + registry := newJobRegistry(t.TempDir(), Place{}, func(s string) { note = s }) + job, err := registry.newJob("fixture", jobKindBash) + if err != nil { + t.Fatal(err) + } + t.Cleanup(job.sink.close) + if err := registry.add(job); err != nil { + t.Fatal(err) + } + job.sink.chunkBytes = 4 + _, _ = job.sink.Write([]byte("a\nb\nc\nd\ne\n")) + registry.settleExit(job, 0) + output, failed := registry.output(job.id, 10) + if failed { + t.Fatal(output) + } + for _, text := range []string{note, output} { + if !strings.Contains(text, "log truncated") || strings.Contains(text, "full log:") || !strings.Contains(text, job.logPath+".1") { + t.Fatalf("dishonest footer: %s", text) + } + } +} + +func TestJobSpoolRotationPreservesIdentityAndNamesBothChunks(t *testing.T) { + sink := newSpool(t, 4, 2) + before, err := os.Stat(sink.base) + if err != nil { + t.Fatal(err) + } + _, _ = sink.Write([]byte("abcde")) + after, err := os.Stat(sink.base) + if err != nil { + t.Fatal(err) + } + if !os.SameFile(before, after) { + t.Fatal("rotation replaced the live job identity") + } + footer := sink.logFooter(sink.base) + if strings.Contains(footer, "full log:") || !strings.Contains(footer, sink.base+".1") { + t.Fatalf("first rotation footer omitted retained chunk: %s", footer) + } + contender, err := os.OpenFile(sink.base, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600) + if contender != nil { + contender.Close() + t.Fatal("a competing creator claimed the live ID") + } + if !os.IsExist(err) { + t.Fatalf("competing claim error: %v", err) + } +} - id := startJob(t, agent, "printf 'a\nb\nc\n'") - waitFor(t, "the completion note", func() bool { - return notesContain(agent, fmt.Sprintf("job %d exited 0", id)) - }) - queued := sessionNotes(agent) - if len(queued) != 1 { - t.Fatalf("want one note, got %v", queued) - } - job := agent.jobs.find(id) - footer := fmt.Sprintf("[job %d · last %d lines · full log: %s]", id, jobExitTailLines, job.logPath) - if !strings.Contains(queued[0], footer) { - t.Fatalf("a whole, untruncated log must still say full log: %q", queued[0]) - } - // Mark the same job's spool as having discarded output and read it again: - // the footer must stop promising full and name the truncation instead. - job.sink.noticeText = "log truncated: only the most recent 8.0 MB is kept" - text, isError := runTool(t, agent, "jobs", fmt.Sprintf(`{"action":"output","id":%d}`, id)) - if isError { - t.Fatalf("jobs output failed: %s", text) - } - if !strings.Contains(text, "log truncated") || strings.Contains(text, "full log:") { - t.Fatalf("footer still promises a full log after truncation: %q", text) - } - if !strings.Contains(text, "log file: "+job.logPath) { - t.Fatalf("footer lost the file path beside the truncation: %q", text) +func TestJobSinkHugeWriteReplacesStalePrefix(t *testing.T) { + sink := newSpool(t, 1<<20, 2) + _, _ = sink.Write([]byte("OLD\n")) + latest := strings.Repeat("new\n", jobRingBytes) + _, _ = sink.Write([]byte(latest)) + if got, want := sink.text(), latest[len(latest)-jobRingBytes:]; got != want { + t.Fatal("large write kept stale bytes before its newest suffix") + } +} + +func TestJobSpoolUnsafeBackupCannotTruncateAnotherFile(t *testing.T) { + sink := newSpool(t, 4, 2) + sentinel := filepath.Join(t.TempDir(), "sentinel") + if err := os.WriteFile(sentinel, []byte("untouched"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(sentinel, sink.base+".1"); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + _, _ = sink.Write([]byte("abcdefghij")) + data, err := os.ReadFile(sentinel) + if err != nil || string(data) != "untouched" { + t.Fatalf("rotation changed another file: %q, %v", data, err) + } + if !strings.Contains(sink.notice(), "job log stopped") || sink.text() != "abcdefghij" { + t.Fatal("unsafe backup must stop disk writes but keep draining") + } +} + +func TestJobSpoolEmptyCompletionStillReportsFailure(t *testing.T) { + var note string + registry := newJobRegistry(t.TempDir(), Place{}, func(s string) { note = s }) + job, err := registry.newJob("fixture", jobKindBash) + if err != nil { + t.Fatal(err) + } + t.Cleanup(job.sink.close) + job.sink.hook = func([]byte) (int, error) { return 0, errors.New("injected full disk") } + _, _ = job.sink.Write([]byte("\n")) + registry.settleExit(job, 0) + if !strings.Contains(note, "injected full disk") || strings.Contains(note, "full log:") { + t.Fatalf("blank output hid failure: %s", note) + } +} + +func TestJobSpoolConcurrentWritersAndCloseRemainBounded(t *testing.T) { + sink := newSpool(t, 64, 2) + var group sync.WaitGroup + for worker := 0; worker < 3; worker++ { + group.Add(1) + go func() { + defer group.Done() + for i := 0; i < 100; i++ { + _, _ = sink.Write([]byte("stdout stderr\n")) + } + sink.close() + }() + } + group.Wait() + for _, size := range spoolFiles(t, sink) { + if size > 64 { + t.Fatalf("concurrent spool exceeded cap: %d", size) + } + } + if sink.text() == "" { + t.Fatal("concurrent close stopped memory drain") } } From 0f5fcc48ed0bcf1403f67a35cd31747d8ec4977d Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 01:08:56 -0400 Subject: [PATCH 17/76] media: pictures are named by their bytes, and speech spend reaches the ledger and the call log - Both image doors chose a file suffix from the provider's declared type and kept a requested .png, so JPEG bytes were saved as .png. One helper in the provider now names an image from its sniffed bytes, falling back to the declared type, and both doors use it. - speak billed through the unnamed auxiliary usage path, so its spend had no role in usage.jsonl, and media HTTP calls wrote no call-log rows. Speech now records under its own role, and every media request writes one start/end call-log pair carrying the provider's cost. Fixes #1586 Fixes #1588 Co-Authored-By: Claude Opus 5.5 --- internal/exec/media.go | 20 +---- internal/exec/media_test.go | 27 ++++++ .../chat/making-pictures-audio-and-video.md | 4 + internal/provider/media.go | 80 +++++++++++++++-- internal/provider/media_call_log.go | 87 +++++++++++++++++++ internal/provider/media_test.go | 21 +++++ internal/session/tools_image.go | 24 ++--- internal/session/tools_image_test.go | 35 ++++++++ internal/session/tools_speak.go | 3 +- internal/session/tools_speak_test.go | 25 ++++++ 10 files changed, 284 insertions(+), 42 deletions(-) create mode 100644 internal/provider/media_call_log.go diff --git a/internal/exec/media.go b/internal/exec/media.go index c1ef3e65d7..ce2e26b36c 100644 --- a/internal/exec/media.go +++ b/internal/exec/media.go @@ -177,10 +177,7 @@ func (t *Toolbox) generateImage(ctx context.Context, args map[string]any) Result return errorf("image generation returned an unreadable image — try another image model") } decoded[index] = bytes - exts[index] = extensionForMediaType(image.MediaType) - if exts[index] == "" { - exts[index] = ".png" - } + exts[index] = provider.ImageExtension(bytes, image.MediaType) } paths := make([]string, 0, len(decoded)) for index, data := range decoded { @@ -575,21 +572,6 @@ func mediaUsage(usage *ai.Usage) Usage { return recorded } -func extensionForMediaType(mediaType string) string { - switch strings.ToLower(strings.TrimSpace(mediaType)) { - case "image/png": - return ".png" - case "image/jpeg": - return ".jpg" - case "image/webp": - return ".webp" - case "image/gif": - return ".gif" - default: - return "" - } -} - func oneLine(text string, limit int) string { text = strings.Join(strings.Fields(text), " ") if len(text) > limit { diff --git a/internal/exec/media_test.go b/internal/exec/media_test.go index 6b7969472d..d6578c43f2 100644 --- a/internal/exec/media_test.go +++ b/internal/exec/media_test.go @@ -1,11 +1,15 @@ package exec import ( + "bytes" "context" "encoding/base64" "encoding/json" "errors" "fmt" + "image" + "image/color" + "image/jpeg" "io" "net/http" "os" @@ -156,6 +160,29 @@ func TestGenerateImageWritesReadableNamesReferencesAndUsage(t *testing.T) { } } +func TestGenerateImageUsesTheSniffedExtensionWhenTheProviderLies(t *testing.T) { + var picture bytes.Buffer + canvas := image.NewRGBA(image.Rect(0, 0, 8, 8)) + canvas.Set(0, 0, color.RGBA{R: 40, G: 120, B: 200, A: 255}) + if err := jpeg.Encode(&picture, canvas, nil); err != nil { + t.Fatal(err) + } + fake := &fakeMediaProvider{imageResponse: &provider.ImageResponse{ + Data: []provider.GeneratedImage{{ + Base64: base64.StdEncoding.EncodeToString(picture.Bytes()), + MediaType: "image/png", + }}, + }} + tools, space := mediaToolbox(t, fake, fakeModalities{}) + result := tools.Execute(context.Background(), "generate_image", `{"prompt":"a plane"}`) + if result.IsError { + t.Fatal(result.Content) + } + if _, ok := space.Locate(filepath.Join("media", "a-plane-1.jpg")); !ok { + t.Fatalf("sniffed JPEG was not saved as .jpg: %q", result.Content) + } +} + func TestMediaToolsAreRegisteredAndHonorTheSpendGate(t *testing.T) { fake := &fakeMediaProvider{imageResponse: &provider.ImageResponse{ Data: []provider.GeneratedImage{{Base64: base64.StdEncoding.EncodeToString([]byte("png")), MediaType: "image/png"}}, diff --git a/internal/manual/chat/making-pictures-audio-and-video.md b/internal/manual/chat/making-pictures-audio-and-video.md index cf7bc15702..e959e8f2cd 100644 --- a/internal/manual/chat/making-pictures-audio-and-video.md +++ b/internal/manual/chat/making-pictures-audio-and-video.md @@ -30,6 +30,10 @@ it — **the whole path, absolute, from the root** — like: /home/you/work/.codeaf/images/20260817-142201-sunset-over-the-harbour.png — 1024×1024 png, 1.4MB, generated on ``` +The file suffix follows the image bytes. If a provider returns JPEG bytes for a +path ending in `.png`, codeaf changes the saved name to `.jpg` and reports that +actual path, so the name and the file agree. + The path is whole because that line is what you are shown in place of the picture on a terminal that cannot draw one, and a path relative to a directory you are not standing in is a path you cannot open. diff --git a/internal/provider/media.go b/internal/provider/media.go index d55b685ad4..1439771aa8 100644 --- a/internal/provider/media.go +++ b/internal/provider/media.go @@ -51,6 +51,35 @@ type GeneratedImage struct { MediaType string `json:"media_type"` } +// ImageExtension names an image from its bytes, using the provider's declared +// type only when the bytes do not identify a supported format. The saved suffix +// must describe what a file contains even when a provider mislabels its reply. +func ImageExtension(data []byte, declared string) string { + if extension, ok := imageExtensionForType(http.DetectContentType(data)); ok { + return extension + } + if extension, ok := imageExtensionForType(declared); ok { + return extension + } + return ".png" +} + +func imageExtensionForType(mediaType string) (string, bool) { + mediaType = strings.ToLower(strings.TrimSpace(strings.Split(mediaType, ";")[0])) + switch mediaType { + case "image/png": + return ".png", true + case "image/jpeg", "image/jpg": + return ".jpg", true + case "image/webp": + return ".webp", true + case "image/gif": + return ".gif", true + default: + return "", false + } +} + type ImageResponse struct { Data []GeneratedImage `json:"data"` Usage *ai.Usage `json:"usage,omitempty"` @@ -183,12 +212,17 @@ func (c *MediaClient) Speak(ctx context.Context, request SpeechRequest) (*Speech defer response.Body.Close() payload, err := io.ReadAll(io.LimitReader(response.Body, maxMediaResponseBytes)) if err != nil { + finishMediaResponse(response, response.StatusCode, err, nil) return nil, fmt.Errorf("read speech response: %w", err) } if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { - return nil, apiError(response.StatusCode, payload) + err := apiError(response.StatusCode, payload) + finishMediaResponse(response, response.StatusCode, err, nil) + return nil, err } - return &SpeechResponse{Audio: payload, Usage: usageFromHeaders(response.Header)}, nil + usage := usageFromHeaders(response.Header) + finishMediaResponse(response, response.StatusCode, nil, usage) + return &SpeechResponse{Audio: payload, Usage: usage}, nil } // GenerateVideo submits one asynchronous OpenRouter job, waits through its @@ -299,14 +333,20 @@ func (c *MediaClient) downloadVideo(ctx context.Context, job videoJob, model str defer response.Body.Close() payload, err := io.ReadAll(io.LimitReader(response.Body, maxVideoResponseBytes)) if err != nil { + finishMediaResponse(response, response.StatusCode, err, nil) return nil, fmt.Errorf("read video response: %w", err) } if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { - return nil, apiError(response.StatusCode, payload) + err := apiError(response.StatusCode, payload) + finishMediaResponse(response, response.StatusCode, err, nil) + return nil, err } if len(payload) == 0 { - return nil, fmt.Errorf("video download was empty") + err := fmt.Errorf("video download was empty") + finishMediaResponse(response, response.StatusCode, err, nil) + return nil, err } + finishMediaResponse(response, response.StatusCode, nil, nil) return &VideoResponse{Video: payload, Usage: job.Usage}, nil } @@ -322,14 +362,26 @@ func (c *MediaClient) postJSON(ctx context.Context, path string, request any, ta defer response.Body.Close() payload, err := io.ReadAll(io.LimitReader(response.Body, maxMediaResponseBytes)) if err != nil { + finishMediaResponse(response, response.StatusCode, err, nil) return nil, fmt.Errorf("read media response: %w", err) } if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { - return nil, apiError(response.StatusCode, payload) + err := apiError(response.StatusCode, payload) + finishMediaResponse(response, response.StatusCode, err, nil) + return nil, err + } + var envelope struct { + Usage *ai.Usage `json:"usage"` + } + if err := json.Unmarshal(payload, &envelope); err != nil { + finishMediaResponse(response, response.StatusCode, err, nil) + return nil, fmt.Errorf("decode media response: %w", err) } if err := json.Unmarshal(payload, target); err != nil { + finishMediaResponse(response, response.StatusCode, err, envelope.Usage) return nil, fmt.Errorf("decode media response: %w", err) } + finishMediaResponse(response, response.StatusCode, nil, envelope.Usage) return response.Header.Clone(), nil } @@ -341,14 +393,19 @@ func (c *MediaClient) getJSON(ctx context.Context, endpoint string, target any, defer response.Body.Close() payload, err := io.ReadAll(io.LimitReader(response.Body, maxMediaResponseBytes)) if err != nil { + finishMediaResponse(response, response.StatusCode, err, nil) return fmt.Errorf("read media response: %w", err) } if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { - return apiError(response.StatusCode, payload) + err := apiError(response.StatusCode, payload) + finishMediaResponse(response, response.StatusCode, err, nil) + return err } if err := json.Unmarshal(payload, target); err != nil { + finishMediaResponse(response, response.StatusCode, err, nil) return fmt.Errorf("decode media response: %w", err) } + finishMediaResponse(response, response.StatusCode, nil, nil) return nil } @@ -365,11 +422,13 @@ func (c *MediaClient) do(ctx context.Context, path string, body []byte, model st // belongs to nobody, and internal/tui3's PostPhaseNews drops it — which is how // a picture whose connection had gone drew nothing at all while it waited. func (c *MediaClient) doEndpoint(ctx context.Context, method, endpoint string, body []byte, authenticated bool, model string) (*http.Response, error) { + ctx = beginMediaCall(ctx, model) var recovery connectionRetry defer recovery.release() for { if c.connection != nil && authenticated { if _, err := c.connection.waitConnection(ctx, model, endpoint, false); err != nil { + finishMediaCall(ctx, 0, err, nil) return nil, err } } @@ -379,6 +438,7 @@ func (c *MediaClient) doEndpoint(ctx context.Context, method, endpoint string, b }) request, err := http.NewRequestWithContext(requestCtx, method, endpoint, bytes.NewReader(body)) if err != nil { + finishMediaCall(ctx, 0, err, nil) return nil, fmt.Errorf("create media request: %w", err) } if len(body) > 0 { @@ -395,11 +455,17 @@ func (c *MediaClient) doEndpoint(ctx context.Context, method, endpoint string, b if err != nil { if authenticated && c.connection != nil && connectionFailure(err) && !sent.Load() && ctx.Err() == nil { if err := c.connection.recoverBeforeSend(ctx, &recovery, model, endpoint); err != nil { + finishMediaCall(ctx, 0, err, nil) return nil, err } continue } - return nil, fmt.Errorf("execute media request: %w", err) + err := fmt.Errorf("execute media request: %w", err) + finishMediaCall(ctx, 0, err, nil) + return nil, err + } + if response.Request == nil { + response.Request = request } return response, nil } diff --git a/internal/provider/media_call_log.go b/internal/provider/media_call_log.go new file mode 100644 index 0000000000..db1e27ceb2 --- /dev/null +++ b/internal/provider/media_call_log.go @@ -0,0 +1,87 @@ +package provider + +import ( + "context" + "net/http" + "sync" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/calllog" + "github.com/Agent-Field/codeaf/internal/trace" +) + +// mediaCallKey keeps the one logical media call attached to the request context +// until the response path has the status and provider cost to close it. +type mediaCallKey struct{} + +// mediaCallLog pairs a media request's start row with the single end row every +// response or transport failure must produce. +type mediaCallLog struct { + id string + model string + start time.Time + once sync.Once +} + +// beginMediaCall writes the start row before waiting for a connection or sending +// bytes, so a paid media request is visible even while it is in flight. +func beginMediaCall(ctx context.Context, model string) context.Context { + if ctx == nil { + ctx = context.Background() + } + call := &mediaCallLog{id: calllog.NewID(), model: model, start: time.Now()} + calllog.Append(calllog.Record{ + Time: callLogTime(call.start), + ID: call.id, + Run: trace.RunFrom(ctx), + Phase: calllog.PhaseStart, + Tag: "media", + Model: model, + }) + return context.WithValue(ctx, mediaCallKey{}, call) +} + +// finishMediaCall writes the response row once, even if a response and a +// transport cleanup path both try to close the same request. +func finishMediaCall(ctx context.Context, status int, err error, usage *ai.Usage) { + if ctx == nil { + return + } + call, _ := ctx.Value(mediaCallKey{}).(*mediaCallLog) + if call == nil { + return + } + call.once.Do(func() { + record := calllog.Record{ + Time: callLogTime(time.Now()), + ID: call.id, + Run: trace.RunFrom(ctx), + Tag: "media", + Model: call.model, + Status: status, + Millis: time.Since(call.start).Milliseconds(), + } + if err != nil { + record.Error = calllog.ClipError(err.Error()) + } + if usage != nil && usage.Cost != nil { + record.Cost = *usage.Cost + } + calllog.Append(record) + }) +} + +// finishMediaResponse finds the request context that doEndpoint carried through +// the HTTP response and closes its media call log entry. +func finishMediaResponse(response *http.Response, status int, err error, usage *ai.Usage) { + if response == nil || response.Request == nil { + return + } + finishMediaCall(response.Request.Context(), status, err, usage) +} + +// callLogTime uses the millisecond timestamp format shared by model call rows. +func callLogTime(now time.Time) string { + return now.UTC().Format("2006-01-02T15:04:05.000Z07:00") +} diff --git a/internal/provider/media_test.go b/internal/provider/media_test.go index b9e102845f..757d4c411f 100644 --- a/internal/provider/media_test.go +++ b/internal/provider/media_test.go @@ -54,6 +54,27 @@ func TestMediaClientUsesVerifiedImageAndSpeechWireShapes(t *testing.T) { } } +func TestSpeechCallIsWrittenToTheCallLogWithItsCost(t *testing.T) { + read := loggingTo(t) + client := handlerClient(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.Header().Set("X-OpenRouter-Cost", "0.03") + _, _ = io.WriteString(writer, "mp3 bytes") + })) + media, err := NewMediaClient(Config{ + APIKey: "media-key", BaseURL: "https://openrouter.example/api/v1", HTTPClient: client, + }) + if err != nil { + t.Fatal(err) + } + if _, err := media.Speak(context.Background(), SpeechRequest{Model: "voice/model", Input: "hello"}); err != nil { + t.Fatal(err) + } + done := ended(read()) + if len(done) != 1 || done[0].Tag != "media" || done[0].Model != "voice/model" || done[0].Cost != 0.03 { + t.Fatalf("speech call log = %+v, want one media row with its cost", done) + } +} + // The image endpoint validates input_references as an array of OBJECTS, and it // says so by refusing the whole render: a bare data URL comes back as // `{"expected":"object","code":"invalid_type","path":["input_references",0]}` diff --git a/internal/session/tools_image.go b/internal/session/tools_image.go index 15d750d81e..2bca84fa40 100644 --- a/internal/session/tools_image.go +++ b/internal/session/tools_image.go @@ -238,8 +238,8 @@ func GenerateImage(ctx context.Context, gen ImageGen, parsed GenerateImageArgs) gen.Account(model, response.Usage) } - path, err := mediaDestinationFrom(gen.Workspace, parsed.Path, parsed.Prompt, - imageExtension(response.Data[0].MediaType), gen.Directory) + path, err := imageDestinationFrom(gen.Workspace, parsed.Path, parsed.Prompt, data, + response.Data[0].MediaType, gen.Directory) if err != nil { return "Could not save the generated image: " + err.Error(), true } @@ -255,20 +255,14 @@ func GenerateImage(ctx context.Context, gen ImageGen, parsed GenerateImageArgs) return describeGeneratedImage(path, data, model), false } -// imageExtension maps what the provider says it sent onto a file suffix. png is -// the default because png is what the request asked for: a provider that names -// no type sent the format it was told to. -func imageExtension(mediaType string) string { - switch strings.ToLower(strings.TrimSpace(mediaType)) { - case "image/jpeg", "image/jpg": - return ".jpg" - case "image/webp": - return ".webp" - case "image/gif": - return ".gif" - default: - return ".png" +func imageDestinationFrom(workspace, asked, prompt string, data []byte, declared, directory string) (string, error) { + extension := provider.ImageExtension(data, declared) + if trimmed := strings.TrimSpace(asked); trimmed != "" { + if ext := filepath.Ext(trimmed); ext != "" && ext != "." { + asked = strings.TrimSuffix(trimmed, ext) + extension + } } + return mediaDestinationFrom(workspace, asked, prompt, extension, directory) } // describeGeneratedImage is the whole result the model reads: WHERE and HOW BIG, diff --git a/internal/session/tools_image_test.go b/internal/session/tools_image_test.go index 65539e1052..5de5db81b3 100644 --- a/internal/session/tools_image_test.go +++ b/internal/session/tools_image_test.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "image" "image/color" + "image/jpeg" "image/png" "os" "path/filepath" @@ -286,6 +287,40 @@ func TestGenerateImageHonoursACustomPath(t *testing.T) { } } +func TestGenerateImageNamesTheFileByItsBytes(t *testing.T) { + picture := jpegOfSize(t, 8, 8) + painter := &scriptedMedia{ + base64: base64.StdEncoding.EncodeToString(picture), + mediaType: "image/png", + } + agent, workspace := newPainterAgent(t, painter, "paint/model") + + result, isError := runTool(t, agent, "generate_image", `{"prompt":"a plane","path":"art/plane.png"}`) + if isError { + t.Fatalf("generate_image failed: %s", result) + } + if _, err := os.Stat(filepath.Join(workspace, "art", "plane.jpg")); err != nil { + t.Fatalf("sniffed JPEG was not saved with .jpg: %v", err) + } + if _, err := os.Stat(filepath.Join(workspace, "art", "plane.png")); !os.IsNotExist(err) { + t.Fatalf("misleading .png path exists: %v", err) + } + if !strings.Contains(result, "art/plane.jpg") { + t.Fatalf("result %q does not name the sniffed path", result) + } +} + +func jpegOfSize(t *testing.T, width, height int) []byte { + t.Helper() + canvas := image.NewRGBA(image.Rect(0, 0, width, height)) + canvas.Set(0, 0, color.RGBA{R: 40, G: 120, B: 200, A: 255}) + var encoded bytes.Buffer + if err := jpeg.Encode(&encoded, canvas, nil); err != nil { + t.Fatalf("encode jpeg: %v", err) + } + return encoded.Bytes() +} + // A failure is the model's to act on, never the turn's to die of. func TestGenerateImageFailuresAreToolErrors(t *testing.T) { for _, testCase := range []struct { diff --git a/internal/session/tools_speak.go b/internal/session/tools_speak.go index d21511b473..6b06823f06 100644 --- a/internal/session/tools_speak.go +++ b/internal/session/tools_speak.go @@ -25,6 +25,7 @@ import ( "github.com/Agent-Field/agentfield/sdk/go/ai" "github.com/Agent-Field/codeaf/internal/exec/bare" "github.com/Agent-Field/codeaf/internal/provider" + "github.com/Agent-Field/codeaf/internal/roles" ) // audioDirectory is where a spoken file lands when the model does not say and @@ -107,7 +108,7 @@ func (a *Agent) speakTool(client MediaGenerator, defaultModel string) bare.Tool } // Paid for before it is saved, on the session's pocket and no // turn's — tools_image.go states the reason. - a.addAuxiliaryUsage(&ai.Response{Usage: response.Usage}, model, 1) + a.addAuxiliaryUsageAs(&ai.Response{Usage: response.Usage}, model, 1, string(roles.RoleSpeech)) path, err := a.mediaDestination(parsed.Path, text, speechExtension, AudioDir(a.config.Place, a.config.Workspace)) diff --git a/internal/session/tools_speak_test.go b/internal/session/tools_speak_test.go index 7c34f6d7b8..b93f15ac58 100644 --- a/internal/session/tools_speak_test.go +++ b/internal/session/tools_speak_test.go @@ -8,6 +8,7 @@ import ( "regexp" "strings" "testing" + "time" "github.com/Agent-Field/agentfield/sdk/go/ai" ) @@ -189,6 +190,30 @@ func TestSpeakUsageFoldsIntoTheSessionTotal(t *testing.T) { } } +func TestSpeakUsageLedgerNamesTheSpeechCall(t *testing.T) { + cost := 0.02 + ledger := filepath.Join(t.TempDir(), UsageLedgerName) + media := &scriptedMedia{ + audio: []byte("ID3 audio"), + speechCost: &ai.Usage{PromptTokens: 9, Cost: &cost}, + } + agent, _ := newMediaAgent(t, media, func(config *Config) { + config.usageLedger = ledger + }) + + if _, isError := runTool(t, agent, "speak", `{"text":"hello"}`); isError { + t.Fatal("speak failed") + } + FlushUsage() + rows, err := ReadUsage(ledger, time.Time{}) + if err != nil { + t.Fatal(err) + } + if len(rows) != 1 || rows[0].Role != "speech" || rows[0].Model != "talk/model" || rows[0].USD != cost { + t.Fatalf("speech ledger rows = %+v, want one named paid speech call", rows) + } +} + // The landing law is one law for every modality: a BORROWED session's audio // lands in the session's own artifacts/ and never in the person's repository. func TestSpokenAudioLandsInArtifactsForABorrowedSession(t *testing.T) { From fe03d005bd799fb00ffd0fdee3eebd77b5fa841b Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Sun, 27 Sep 2026 01:10:01 -0400 Subject: [PATCH 18/76] fix(jobs): retain bounded completed logs with cross-process ownership --- PERF.md | 26 + docs/changes/unreleased/1599-job-retention.md | 30 ++ internal/manual/chat/what-i-can-do.md | 12 + internal/session/jobretention.go | 489 +++++++++++++++++ internal/session/jobretention_test.go | 506 ++++++++++++++++++ internal/session/jobretention_unix.go | 25 + internal/session/jobretention_windows.go | 17 + internal/session/jobs.go | 119 ++-- internal/session/sweep.go | 9 + internal/session/task_close_test.go | 8 +- 10 files changed, 1180 insertions(+), 61 deletions(-) create mode 100644 docs/changes/unreleased/1599-job-retention.md create mode 100644 internal/session/jobretention.go create mode 100644 internal/session/jobretention_test.go create mode 100644 internal/session/jobretention_unix.go create mode 100644 internal/session/jobretention_windows.go diff --git a/PERF.md b/PERF.md index 8a62499368..ec739e0fcb 100644 --- a/PERF.md +++ b/PERF.md @@ -2641,3 +2641,29 @@ Rendering selects a phrase by elapsed ten-second interval and samples the existi decoding ripple with 240 ms letter steps and a 1.8-second pause per pass. The 28-column caption and nine-column mark have fixed widths. This uses the existing clock and one foreground span; it adds no timer, I/O, model call or per-frame randomness. + +## Completed job log retention + +`internal/session/jobretention.go` limits eligible completed managed spools in +one jobs directory to **128 MiB and 64 job groups**, counting the base and +rotation together. Active spools have separate per-job limits; unmarked legacy +logs and unsafe files remain outside the budget because older writers may not +hold leases. This is not a machine-wide bound. + +Maintenance runs at log creation, sink close, and the existing startup sweep, +never per output write. It +lists one jobs directory, sorts candidates by allocated ID, and takes +nonblocking independent file leases; a deletion holds its lease through unlink. +Directory locks serialize allocation and maintenance across processes. Counter +and ownership metadata reads are capped at 256 bytes. ID allocation persists a +high-water value before cleanup and keeps the writer lease before publishing +the marker, preventing both reused IDs and newborn-log eviction. The stable +lock file remembers initialization if a counter later disappears. + +A claim with damaged metadata fails explicitly. Cleanup failures remain +retryable and are surfaced in the sink notice. Existing journals, worktrees, +legacy logs, and unrelated directories are not retention candidates. Tests use +explicit byte/count budgets with small payloads, avoiding mutable global limits. + +The startup TTL sweeper delegates `logs/jobs/` to this retention instead of +expiring the stable allocation metadata or ownership markers by age. diff --git a/docs/changes/unreleased/1599-job-retention.md b/docs/changes/unreleased/1599-job-retention.md new file mode 100644 index 0000000000..6c4eff083e --- /dev/null +++ b/docs/changes/unreleased/1599-job-retention.md @@ -0,0 +1,30 @@ +--- +kind: added +title: completed job logs have a bounded retention budget +pr: 1599 +surface: [chat, engine] +invalidates: + - "Completed managed spools are retained under a per-directory limit of 128 MiB and 64 job groups, counting each base and rotation together. Active spools retain their independent per-job cap; legacy and unsafe files are preserved outside this budget." + - "Job IDs use durable high-water metadata under a stable directory lock. Allocation creates the file, takes its active lease, and publishes ownership within the same transaction. Missing or damaged metadata refuses allocation instead of resetting IDs." + - "A jobs footer checks for subsequent eviction and retains the in-memory output tail when the disk log is gone. Maintenance errors are reported without turning a completed job into a process failure." +--- + +Maintenance runs when a job claims its log, after its sink closes, and for +existing jobs directories during the startup sweep. It does not crawl the machine or run on every write. +Completed groups with the oldest allocated IDs are removed first. The cleaner +holds an independent file lease through removal; the writer must keep the same +main-log descriptor through rotation and spool failures until sink close. + +Directory-relative operations reject symlinked jobs directories and ancestors +(except the platform's canonical temporary-directory alias). Metadata reads are +limited to 256 bytes; counter replacement uses an exclusive random temporary. +Ownership markers remain if a payload removal fails. Unmarked legacy logs may +have older live writers without leases, so they are never automatically removed. + +The permanent lock records initialization, so a missing counter after all logs +have been evicted still refuses new allocation. Removing all allocation metadata +manually destroys that history and is not supported. On Windows the counter +file is flushed before rooted replacement; a directory flush is unavailable. + +The startup TTL sweeper delegates `logs/jobs/` to this retention instead of +expiring the stable allocation metadata or ownership markers by age. diff --git a/internal/manual/chat/what-i-can-do.md b/internal/manual/chat/what-i-can-do.md index 14de575cbb..b0c3fbd2c1 100644 --- a/internal/manual/chat/what-i-can-do.md +++ b/internal/manual/chat/what-i-can-do.md @@ -389,6 +389,18 @@ spool has rotated, the note names both retained files; after older output is discarded, it also names the truncation instead of promising a full log. +Finished logs do not pile up forever either. Each session's logs/jobs +directory keeps managed finished logs within **128 MiB and 64 jobs** +(a job's log and its one rotation count together), evicting the oldest job IDs first; +active jobs are never touched, and files codeaf did not create there — older +unmarked logs included — are left alone. If a log you were pointed at has +since been evicted, the jobs footer says so instead of naming the file, and +the last 64KB in memory is still readable. Cleanup runs when a job starts or +finishes and during the startup sweep of existing jobs directories; active jobs, legacy logs, and files whose ownership cannot be verified +are outside this completed-log budget. A cleanup failure is reported and may +leave the directory over budget. Damaged allocation metadata refuses new logs +rather than reusing previous job IDs. + The `jobs` tool looks at all of this. Its `action` is `list`, `output` or `kill`. - `list` — one row per job: `job 1 · exited(0) · 12.4s · go build ./...`. diff --git a/internal/session/jobretention.go b/internal/session/jobretention.go new file mode 100644 index 0000000000..71e58b9d14 --- /dev/null +++ b/internal/session/jobretention.go @@ -0,0 +1,489 @@ +package session + +// Retention bounds completed, managed spools in one jobs directory. Legacy +// logs may have unlocked old writers and are never pruned. Active spools have +// their own size cap and hold an inode lock until the sink closes. This is not +// a machine-wide or active-job aggregate budget. +import ( + "crypto/rand" + "errors" + "fmt" + "io" + "math" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/Agent-Field/codeaf/internal/filelock" +) + +const ( + jobRetentionLockName = ".retention.lock" + jobRetentionCounterName = ".retention.highwater" + jobRetentionMarkerSuffix = ".retention" + jobRetentionInitialized = "codeaf job retention v1\n" + jobRetentionMetadataLimit = 256 +) + +var errRetentionUnsafe = errors.New("job log retention: unsafe or damaged state; refusing allocation or cleanup") + +type jobRetentionBudget struct { + bytes int64 + groups int +} + +func defaultJobRetentionBudget() jobRetentionBudget { return jobRetentionBudget{128 << 20, 64} } + +// Traverse directory handles, rejecting symlinks, including the jobs directory. +// Resolve only the platform's trusted temporary-directory alias first (macOS +// exposes /var as /private/var); user-created links below it remain refused. +func openJobRetentionDir(directory string, create bool) (*os.Root, error) { + absolute, err := filepath.Abs(directory) + if err != nil { + return nil, err + } + temp := filepath.Clean(os.TempDir()) + if relative, e := filepath.Rel(temp, absolute); e == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(os.PathSeparator)) { + if canonical, e := filepath.EvalSymlinks(temp); e == nil { + absolute = filepath.Join(canonical, relative) + } + } + volume := filepath.VolumeName(absolute) + string(os.PathSeparator) + root, err := os.OpenRoot(volume) + if err != nil { + return nil, err + } + for _, component := range strings.Split(strings.TrimPrefix(absolute, volume), string(os.PathSeparator)) { + if component == "" { + continue + } + info, e := root.Lstat(component) + if os.IsNotExist(e) && create { + if e = root.Mkdir(component, 0o755); e != nil && !os.IsExist(e) { + root.Close() + return nil, e + } + info, e = root.Lstat(component) + } + if e != nil || !info.IsDir() { + root.Close() + return nil, errRetentionUnsafe + } + next, e := root.OpenRoot(component) + if e != nil { + root.Close() + return nil, e + } + opened, e := next.Stat(".") + root.Close() + if e != nil || !os.SameFile(info, opened) { + next.Close() + return nil, errRetentionUnsafe + } + root = next + } + return root, nil +} + +// Check pathname and opened descriptor before any write. New files use +// O_EXCL, never O_TRUNC. Root confines operations if a parent moves during I/O. +func retentionOpen(root *os.Root, name string, flags int) (*os.File, error) { + before, err := root.Lstat(name) + if err != nil || !before.Mode().IsRegular() { + return nil, errRetentionUnsafe + } + file, err := root.OpenFile(name, flags, 0) + if err != nil { + return nil, err + } + after, err := file.Stat() + current, pathErr := root.Lstat(name) + if err != nil || pathErr != nil || !current.Mode().IsRegular() || !os.SameFile(before, after) || !os.SameFile(after, current) || !jobRetentionSingleLink(file) { + file.Close() + return nil, errRetentionUnsafe + } + return file, nil +} +func retentionRead(root *os.Root, name string) ([]byte, error) { + file, err := retentionOpen(root, name, os.O_RDONLY) + if err != nil { + return nil, err + } + defer file.Close() + data, err := io.ReadAll(io.LimitReader(file, jobRetentionMetadataLimit+1)) + if err != nil || len(data) > jobRetentionMetadataLimit { + return nil, errRetentionUnsafe + } + return data, nil +} + +type jobRetentionDirectory struct { + root *os.Root + lock *os.File +} + +func (d *jobRetentionDirectory) close() { d.lock.Close(); d.root.Close() } +func lockJobRetention(directory string) (*jobRetentionDirectory, error) { + root, err := openJobRetentionDir(directory, false) + if err != nil { + return nil, err + } + file, err := root.OpenFile(jobRetentionLockName, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600) + if os.IsExist(err) { + file, err = retentionOpen(root, jobRetentionLockName, os.O_RDWR) + } + if err != nil { + root.Close() + return nil, err + } + if err = filelock.Lock(file, true, false); err != nil { + file.Close() + root.Close() + return nil, err + } + return &jobRetentionDirectory{root, file}, nil +} +func jobRetentionMarker(base string) string { return "codeaf job log retention v1\n" + base + "\n" } +func jobRetentionMarkerPath(directory string, id int64) string { + return filepath.Join(directory, fmt.Sprintf("%d.log%s", id, jobRetentionMarkerSuffix)) +} +func jobLogBaseID(name string) (int64, bool) { + name = strings.TrimSuffix(name, jobRetentionMarkerSuffix) + name = strings.TrimSuffix(name, ".1") + if !strings.HasSuffix(name, ".log") { + return 0, false + } + digits := strings.TrimSuffix(name, ".log") + id, err := strconv.ParseInt(digits, 10, 64) + return id, err == nil && id > 0 && strconv.FormatInt(id, 10) == digits +} +func (d *jobRetentionDirectory) entries() ([]os.DirEntry, error) { + file, err := d.root.Open(".") + if err != nil { + return nil, err + } + defer file.Close() + return file.ReadDir(-1) +} +func (d *jobRetentionDirectory) counter() (int64, error) { + if _, err := d.lock.Seek(0, 0); err != nil { + return 0, err + } + initialized, err := io.ReadAll(io.LimitReader(d.lock, jobRetentionMetadataLimit+1)) + if err != nil || (len(initialized) != 0 && string(initialized) != jobRetentionInitialized) { + return 0, errRetentionUnsafe + } + _, err = d.root.Lstat(jobRetentionCounterName) + counterExists := err == nil + var high int64 + if counterExists { + data, e := retentionRead(d.root, jobRetentionCounterName) + if e != nil { + return 0, e + } + high, e = strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64) + if e != nil || high < 0 { + return 0, errRetentionUnsafe + } + } else if !os.IsNotExist(err) || len(initialized) != 0 { + return 0, errRetentionUnsafe + } + entries, err := d.entries() + if err != nil { + return 0, err + } + for _, entry := range entries { + // Markers also prove previous initialization if the lock was removed. + if strings.HasSuffix(entry.Name(), jobRetentionMarkerSuffix) && !counterExists { + return 0, errRetentionUnsafe + } + if id, ok := jobLogBaseID(entry.Name()); ok && id > high { + high = id + } + } + return high, nil +} +func (d *jobRetentionDirectory) persist(high int64) error { + var nonce [16]byte + if _, err := rand.Read(nonce[:]); err != nil { + return err + } + temp := fmt.Sprintf(".retention-counter-%x.tmp", nonce) + file, err := d.root.OpenFile(temp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return err + } + defer d.root.Remove(temp) + _, err = fmt.Fprintf(file, "%d\n", high) + if err == nil { + err = file.Sync() + } + closeErr := file.Close() + if err == nil { + err = closeErr + } + if err != nil { + return err + } + if err = d.root.Rename(temp, jobRetentionCounterName); err != nil { + return err + } + if err = jobRetentionSyncDir(d.root); err != nil { + return err + } + // This permanent inode remembers initialization after all payload eviction. + // A missing counter then fails closed, rather than resetting the id history. + if _, err = d.lock.WriteAt([]byte(jobRetentionInitialized), 0); err != nil { + return err + } + return d.lock.Sync() +} + +// Counter, O_EXCL creation, writer lease and ownership marker publication are +// one transaction under the directory lock. Persist before pruning anything. +func jobRetentionClaim(directory string) (int, string, *os.File, error) { + d, err := lockJobRetention(directory) + if err != nil { + return 0, "", nil, err + } + defer d.close() + high, err := d.counter() + if err != nil { + return 0, "", nil, err + } + if high >= int64(math.MaxInt) { + return 0, "", nil, errRetentionUnsafe + } + id := high + 1 + if err = d.persist(id); err != nil { + return 0, "", nil, err + } + name := fmt.Sprintf("%d.log", id) + file, err := d.root.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o644) + if err != nil { + return 0, "", nil, err + } + if err = filelock.Lock(file, true, true); err != nil { + file.Close() + d.root.Remove(name) + return 0, "", nil, err + } + marker, err := d.root.OpenFile(name+jobRetentionMarkerSuffix, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err == nil { + _, err = io.WriteString(marker, jobRetentionMarker(name)) + closeErr := marker.Close() + if err == nil { + err = closeErr + } + if err != nil { + d.root.Remove(name + jobRetentionMarkerSuffix) + } + } + if err != nil { + file.Close() + d.root.Remove(name) + return 0, "", nil, err + } + if err = d.sweep(defaultJobRetentionBudget()); err != nil { + file.Close() + // Keep the marker if removing the payload failed, so cleanup can retry. + if e := d.root.Remove(name); e == nil { + d.root.Remove(name + jobRetentionMarkerSuffix) + } + return 0, "", nil, err + } + return int(id), filepath.Join(directory, name), file, nil +} + +type retainedJob struct { + id int64 + base string + bytes int64 + identity os.FileInfo +} + +// Acquire an independent lease; the caller holds it throughout deletion. +func (d *jobRetentionDirectory) inspect(base string) (*os.File, int64, error) { + marker, err := retentionRead(d.root, base+jobRetentionMarkerSuffix) + if err != nil || string(marker) != jobRetentionMarker(base) { + return nil, 0, errRetentionUnsafe + } + file, err := retentionOpen(d.root, base, os.O_RDWR) + if err != nil { + return nil, 0, err + } + if err = filelock.Lock(file, true, true); err != nil { + file.Close() + return nil, 0, err + } + info, err := file.Stat() + if err != nil { + file.Close() + return nil, 0, err + } + size := info.Size() + backup := base + ".1" + if _, err = d.root.Lstat(backup); err == nil { + other, e := retentionOpen(d.root, backup, os.O_RDONLY) + if e != nil { + file.Close() + return nil, 0, e + } + stat, e := other.Stat() + other.Close() + if e != nil { + file.Close() + return nil, 0, e + } + if stat.Size() > math.MaxInt64-size { + file.Close() + return nil, 0, errRetentionUnsafe + } + size += stat.Size() + } else if !os.IsNotExist(err) { + file.Close() + return nil, 0, err + } + return file, size, nil +} +func (d *jobRetentionDirectory) sweep(budget jobRetentionBudget) error { + if budget.bytes < 0 || budget.groups < 0 { + return errRetentionUnsafe + } + entries, err := d.entries() + if err != nil { + return err + } + var groups []retainedJob + var total int64 + for _, entry := range entries { + // A crash after payload unlink may leave only its small ownership + // marker. Under the directory lock no publisher is in flight. + if strings.HasSuffix(entry.Name(), jobRetentionMarkerSuffix) { + base := strings.TrimSuffix(entry.Name(), jobRetentionMarkerSuffix) + if _, ok := jobLogBaseID(base); ok { + if _, e := d.root.Lstat(base); os.IsNotExist(e) { + if _, e := d.root.Lstat(base + ".1"); os.IsNotExist(e) { + data, e := retentionRead(d.root, entry.Name()) + if e == nil && string(data) == jobRetentionMarker(base) { + if e := d.root.Remove(entry.Name()); e != nil { + return e + } + } + } + } + } + continue + } + id, ok := jobLogBaseID(entry.Name()) + if !ok || entry.Name() != fmt.Sprintf("%d.log", id) { + continue + } + file, size, err := d.inspect(entry.Name()) + if err != nil { + continue + } // live, legacy, unsafe: preserve without deletion + info, err := file.Stat() + file.Close() + if err != nil { + return err + } + if size > math.MaxInt64-total { + return errRetentionUnsafe + } + total += size + groups = append(groups, retainedJob{id, entry.Name(), size, info}) + } + sort.Slice(groups, func(i, j int) bool { return groups[i].id < groups[j].id }) + count := len(groups) + for _, group := range groups { + if total <= budget.bytes && count <= budget.groups { + break + } + file, _, e := d.inspect(group.base) + if e != nil { + return fmt.Errorf("job retention could not reclaim %s: %w", group.base, e) + } + info, e := file.Stat() + if e == nil && !os.SameFile(group.identity, info) { + e = errRetentionUnsafe + } + if e == nil { + e = d.remove(group.base) + } + file.Close() + if e != nil { + return fmt.Errorf("job retention could not reclaim %s: %w", group.base, e) + } + total -= group.bytes + count-- + } + return nil +} +func (d *jobRetentionDirectory) remove(base string) error { + // Do not remove ownership if any payload unlink fails: partial cleanup must + // remain retryable. All operations remain beneath the pinned jobs directory. + for _, name := range []string{base + ".1", base, base + jobRetentionMarkerSuffix} { + if _, err := d.root.Lstat(name); os.IsNotExist(err) { + continue + } else if err != nil { + return err + } + file, err := retentionOpen(d.root, name, os.O_RDONLY) + if err != nil { + return err + } + file.Close() + if err = d.root.Remove(name); err != nil { + return err + } + } + return nil +} +func jobRetentionSweep(directory string, budget jobRetentionBudget) error { + d, err := lockJobRetention(directory) + if err != nil { + return err + } + defer d.close() + high, err := d.counter() + if err != nil { + return err + } + if err = d.persist(high); err != nil { + return err + } + return d.sweep(budget) +} +func jobRetentionFinish(sink *jobSink) { + if sink == nil || sink.base == "" { + return + } + err := jobRetentionSweep(filepath.Dir(sink.base), defaultJobRetentionBudget()) + sink.mu.Lock() + defer sink.mu.Unlock() + if err != nil { + sink.retentionText = "job log retention deferred: " + err.Error() + } +} + +// A job refused at the registry's join boundary was never published. Remove +// its payload and marker without discarding the directory's durable id history. +func jobRetentionDiscard(path string) error { + d, err := lockJobRetention(filepath.Dir(path)) + if err != nil { + return err + } + defer d.close() + file, _, err := d.inspect(filepath.Base(path)) + if err != nil { + if _, statErr := d.root.Lstat(filepath.Base(path)); os.IsNotExist(statErr) { + return nil + } + return err + } + defer file.Close() + return d.remove(filepath.Base(path)) +} diff --git a/internal/session/jobretention_test.go b/internal/session/jobretention_test.go new file mode 100644 index 0000000000..4a16a7b1a8 --- /dev/null +++ b/internal/session/jobretention_test.go @@ -0,0 +1,506 @@ +package session + +import ( + "bufio" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/filelock" +) + +func retentionFixture(t *testing.T) string { + t.Helper() + directory := t.TempDir() + d, err := lockJobRetention(directory) + if err != nil { + t.Fatal(err) + } + defer d.close() + if err := d.persist(100); err != nil { + t.Fatal(err) + } + return directory +} +func writeManagedLog(t *testing.T, directory string, id int64, baseBytes, backupBytes int) { + t.Helper() + base := fmt.Sprintf("%d.log", id) + for name, data := range map[string][]byte{base: make([]byte, baseBytes), base + jobRetentionMarkerSuffix: []byte(jobRetentionMarker(base))} { + if err := os.WriteFile(filepath.Join(directory, name), data, 0o600); err != nil { + t.Fatal(err) + } + } + if backupBytes > 0 { + if err := os.WriteFile(filepath.Join(directory, base+".1"), make([]byte, backupBytes), 0o600); err != nil { + t.Fatal(err) + } + } +} +func retentionExists(t *testing.T, path string, want bool) { + t.Helper() + _, err := os.Lstat(path) + if want && err != nil { + t.Fatalf("missing %s: %v", path, err) + } + if !want && !os.IsNotExist(err) { + t.Fatalf("unexpected %s: %v", path, err) + } +} +func TestJobRetentionByteAndCountBudgets(t *testing.T) { + for _, budget := range []jobRetentionBudget{{25, 100}, {1 << 20, 2}} { + directory := retentionFixture(t) + writeManagedLog(t, directory, 1, 10, 0) + writeManagedLog(t, directory, 2, 10, 5) + writeManagedLog(t, directory, 3, 10, 0) + writeManagedLog(t, directory, 4, 10, 0) + if err := jobRetentionSweep(directory, budget); err != nil { + t.Fatal(err) + } + for _, id := range []int{1, 2, 3, 4} { + base := filepath.Join(directory, fmt.Sprintf("%d.log", id)) + retentionExists(t, base, id > 2) + retentionExists(t, base+jobRetentionMarkerSuffix, id > 2) + } + retentionExists(t, filepath.Join(directory, "2.log.1"), false) + } +} + +// The actual OS lease in a second process must protect an active spool. +func TestJobRetentionLeaseHelper(t *testing.T) { + path := os.Getenv("CODEAF_RETENTION_TEST_LEASE") + if path == "" { + return + } + file, err := os.OpenFile(path, os.O_RDWR, 0) + if err != nil { + t.Fatal(err) + } + defer file.Close() + if err = filelock.Lock(file, true, true); err != nil { + t.Fatal(err) + } + fmt.Println("locked") + _, _ = bufio.NewReader(os.Stdin).ReadByte() +} +func retentionSubprocessLease(t *testing.T, path string) func() { + t.Helper() + cmd := exec.Command(os.Args[0], "-test.run=^TestJobRetentionLeaseHelper$", "-test.timeout=30s") + cmd.Env = append(os.Environ(), "CODEAF_RETENTION_TEST_LEASE="+path) + input, err := cmd.StdinPipe() + if err != nil { + t.Fatal(err) + } + output, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err = cmd.Start(); err != nil { + t.Fatal(err) + } + var once sync.Once + stop := func() { once.Do(func() { input.Close(); cmd.Wait() }) } + t.Cleanup(stop) + ready := make(chan string, 1) + go func() { line, _ := bufio.NewReader(output).ReadString('\n'); ready <- line }() + select { + case line := <-ready: + if line != "locked\n" { + t.Fatalf("lease helper: %q", line) + } + case <-time.After(10 * time.Second): + cmd.Process.Kill() + t.Fatal("lease helper timed out") + } + return stop +} +func TestJobRetentionActiveLeaseAcrossProcesses(t *testing.T) { + directory := retentionFixture(t) + writeManagedLog(t, directory, 1, 5, 0) + writeManagedLog(t, directory, 2, 500, 0) + stop := retentionSubprocessLease(t, filepath.Join(directory, "2.log")) + if err := jobRetentionSweep(directory, jobRetentionBudget{0, 0}); err != nil { + t.Fatal(err) + } + retentionExists(t, filepath.Join(directory, "1.log"), false) + retentionExists(t, filepath.Join(directory, "2.log"), true) + stop() + if err := jobRetentionSweep(directory, jobRetentionBudget{0, 0}); err != nil { + t.Fatal(err) + } + retentionExists(t, filepath.Join(directory, "2.log"), false) +} +func TestJobRetentionClaimLeaseAndRestartIDs(t *testing.T) { + directory := t.TempDir() + if err := os.WriteFile(filepath.Join(directory, "41.log.1"), []byte("legacy"), 0o600); err != nil { + t.Fatal(err) + } + registry := newJobRegistry(t.TempDir(), Place{}, nil) + first, path, file, err := registry.claimJobLog(directory) + if err != nil { + t.Fatal(err) + } + if first != 42 { + t.Fatalf("rotated-only id not seeded: %d", first) + } + probe, err := os.OpenFile(path, os.O_RDWR, 0) + if err != nil { + t.Fatal(err) + } + if err := filelock.Lock(probe, true, true); !filelock.IsBusy(err) { + t.Fatalf("claim lacks live lease: %v", err) + } + probe.Close() + if err := jobRetentionSweep(directory, jobRetentionBudget{0, 0}); err != nil { + t.Fatal(err) + } + retentionExists(t, path, true) + file.Close() + if err := jobRetentionSweep(directory, jobRetentionBudget{0, 0}); err != nil { + t.Fatal(err) + } + retentionExists(t, path, false) + restarted := newJobRegistry(t.TempDir(), Place{}, nil) + second, _, next, err := restarted.claimJobLog(directory) + if err != nil { + t.Fatal(err) + } + next.Close() + if second <= first { + t.Fatalf("reused id %d after %d", second, first) + } +} +func TestJobRetentionDamagedCounterRefusesWithoutDeleting(t *testing.T) { + for _, damage := range []string{"missing", "corrupt", "oversized", "overflow"} { + t.Run(damage, func(t *testing.T) { + directory := retentionFixture(t) + writeManagedLog(t, directory, 1, 10, 0) + counter := filepath.Join(directory, jobRetentionCounterName) + switch damage { + case "missing": + if err := os.Remove(counter); err != nil { + t.Fatal(err) + } + case "corrupt": + if err := os.WriteFile(counter, []byte("bad\n"), 0o600); err != nil { + t.Fatal(err) + } + case "oversized": + if err := os.WriteFile(counter, []byte(strings.Repeat("1", 1024)), 0o600); err != nil { + t.Fatal(err) + } + case "overflow": + if err := os.WriteFile(counter, []byte("9223372036854775807\n"), 0o600); err != nil { + t.Fatal(err) + } + } + registry := newJobRegistry(t.TempDir(), Place{}, nil) + if _, _, file, err := registry.claimJobLog(directory); err == nil { + file.Close() + t.Fatal("unsafe counter allocated an id") + } + if damage != "overflow" { + if err := jobRetentionSweep(directory, jobRetentionBudget{0, 0}); err == nil { + t.Fatal("damaged counter pruned") + } + } + retentionExists(t, filepath.Join(directory, "1.log"), true) + }) + } + // Even with all completed payloads gone, losing the counter is not fresh. + directory := retentionFixture(t) + if err := os.Remove(filepath.Join(directory, jobRetentionCounterName)); err != nil { + t.Fatal(err) + } + if _, _, file, err := jobRetentionClaim(directory); err == nil { + file.Close() + t.Fatal("missing counter reset after empty-directory eviction") + } +} +func TestJobRetentionLegacyAndUnknownPreserved(t *testing.T) { + directory := retentionFixture(t) + for _, name := range []string{"9.log", "9.log.1", "notes.txt"} { + if err := os.WriteFile(filepath.Join(directory, name), make([]byte, 4096), 0o600); err != nil { + t.Fatal(err) + } + } + writeManagedLog(t, directory, 1, 5, 0) + if err := jobRetentionSweep(directory, jobRetentionBudget{5, 1}); err != nil { + t.Fatal(err) + } + for _, name := range []string{"9.log", "9.log.1", "notes.txt", "1.log"} { + retentionExists(t, filepath.Join(directory, name), true) + } +} +func TestJobRetentionUnsafePathsPreserved(t *testing.T) { + for _, kind := range []string{"symlink-base", "hardlink-base", "symlink-counter", "hardlink-counter", "symlink-lock", "parent", "leaf"} { + t.Run(kind, func(t *testing.T) { + directory := retentionFixture(t) + target := filepath.Join(t.TempDir(), "sentinel") + if err := os.WriteFile(target, []byte("untouched"), 0o600); err != nil { + t.Fatal(err) + } + writeManagedLog(t, directory, 1, 5, 0) + selected := "1.log" + if strings.Contains(kind, "counter") { + selected = jobRetentionCounterName + } + if strings.Contains(kind, "lock") { + selected = jobRetentionLockName + } + if kind == "parent" || kind == "leaf" { + holder := t.TempDir() + alias := filepath.Join(holder, "alias") + if kind == "parent" { + if err := os.Symlink(filepath.Dir(directory), alias); err != nil { + t.Skip(err) + } + directory = filepath.Join(alias, filepath.Base(directory)) + } else { + if err := os.Symlink(directory, alias); err != nil { + t.Skip(err) + } + directory = alias + } + } else { + path := filepath.Join(directory, selected) + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + var err error + if strings.HasPrefix(kind, "hardlink") { + err = os.Link(target, path) + } else { + err = os.Symlink(target, path) + } + if err != nil { + t.Skip(err) + } + } + _ = jobRetentionSweep(directory, jobRetentionBudget{0, 0}) + if kind != "symlink-base" && kind != "hardlink-base" { + if _, _, f, err := jobRetentionClaim(directory); err == nil { + f.Close() + t.Fatal("unsafe directory allocated") + } + } + data, err := os.ReadFile(target) + if err != nil || string(data) != "untouched" { + t.Fatalf("external sentinel changed: %q %v", data, err) + } + retentionExists(t, filepath.Join(directory, "1.log"), true) + }) + } +} +func TestJobRetentionOldFixedTempSymlinkCannotClobber(t *testing.T) { + directory := retentionFixture(t) + target := filepath.Join(t.TempDir(), "sentinel") + if err := os.WriteFile(target, []byte("untouched"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(directory, jobRetentionCounterName+".tmp")); err != nil { + t.Skip(err) + } + _, _, file, err := jobRetentionClaim(directory) + if err != nil { + t.Fatal(err) + } + file.Close() + data, err := os.ReadFile(target) + if err != nil || string(data) != "untouched" { + t.Fatalf("temp followed: %q %v", data, err) + } +} +func TestJobRetentionFailureKeepsOwnership(t *testing.T) { + directory := retentionFixture(t) + writeManagedLog(t, directory, 1, 5, 0) + d, err := lockJobRetention(directory) + if err != nil { + t.Fatal(err) + } + defer d.close() + // A rotation path that became a directory makes removal fail before ownership + // is lost. This is deterministic, including under a privileged test account. + if err := d.root.Mkdir("1.log.1", 0o700); err != nil { + t.Fatal(err) + } + if err := d.remove("1.log"); err == nil { + t.Fatal("unsafe removal reported success") + } + retentionExists(t, filepath.Join(directory, "1.log"), true) + retentionExists(t, jobRetentionMarkerPath(directory, 1), true) +} +func TestJobRetentionPersistFailureNeverPrunes(t *testing.T) { + directory := retentionFixture(t) + writeManagedLog(t, directory, 1, 5, 0) + if err := os.Remove(filepath.Join(directory, jobRetentionCounterName)); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(directory, jobRetentionCounterName), 0o700); err != nil { + t.Fatal(err) + } + if _, _, file, err := jobRetentionClaim(directory); err == nil { + file.Close() + t.Fatal("counter failure allocated") + } + retentionExists(t, filepath.Join(directory, "1.log"), true) +} +func TestJobRetentionConcurrentClaimsRemainLeased(t *testing.T) { + directory := t.TempDir() + var workers sync.WaitGroup + ids := make(chan int, 40) + errs := make(chan error, 120) + for worker := 0; worker < 4; worker++ { + workers.Add(1) + go func() { + defer workers.Done() + for n := 0; n < 10; n++ { + id, path, file, err := jobRetentionClaim(directory) + if err != nil { + errs <- err + return + } + if err := jobRetentionSweep(directory, jobRetentionBudget{0, 0}); err != nil { + errs <- err + } + if _, err := os.Stat(path); err != nil { + errs <- fmt.Errorf("active claim removed: %w", err) + } + file.Close() + ids <- id + } + }() + } + workers.Wait() + close(ids) + close(errs) + for err := range errs { + t.Error(err) + } + seen := map[int]bool{} + for id := range ids { + if seen[id] { + t.Errorf("duplicate id %d", id) + } + seen[id] = true + } + if len(seen) != 40 { + t.Fatalf("claimed %d jobs, want 40", len(seen)) + } +} +func TestJobRetentionLaterEvictionAndCloseOnce(t *testing.T) { + directory := t.TempDir() + _, path, file, err := jobRetentionClaim(directory) + if err != nil { + t.Fatal(err) + } + sink := newJobSink(file, path) + callbacks := 0 + sink.finishRetention = func() { callbacks++; jobRetentionFinish(sink) } + sink.Write([]byte("kept in memory\n")) + sink.close() + if sink.retentionLost() { + t.Fatal("premature eviction") + } + if err := jobRetentionSweep(directory, jobRetentionBudget{0, 0}); err != nil { + t.Fatal(err) + } + if !sink.retentionLost() { + t.Fatal("cached presence hid later eviction") + } + sink.close() + if callbacks != 1 { + t.Fatalf("close ran %d callbacks", callbacks) + } + if !strings.Contains(sink.tail(10), "kept in memory") { + t.Fatal("eviction lost in-memory tail") + } +} + +func TestJobRetentionPartialRemovalKeepsMarker(t *testing.T) { + directory := retentionFixture(t) + writeManagedLog(t, directory, 1, 5, 5) + target := filepath.Join(t.TempDir(), "sentinel") + if err := os.WriteFile(target, []byte("untouched"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(directory, "1.log")); err != nil { + t.Fatal(err) + } + if err := os.Link(target, filepath.Join(directory, "1.log")); err != nil { + t.Skip(err) + } + d, err := lockJobRetention(directory) + if err != nil { + t.Fatal(err) + } + defer d.close() + if err := d.remove("1.log"); err == nil { + t.Fatal("unsafe base removal succeeded") + } + retentionExists(t, filepath.Join(directory, "1.log.1"), false) + retentionExists(t, jobRetentionMarkerPath(directory, 1), true) + data, err := os.ReadFile(target) + if err != nil || string(data) != "untouched" { + t.Fatalf("sentinel changed: %q %v", data, err) + } +} +func TestJobRetentionInterruptedRemovalMarkerReclaimed(t *testing.T) { + directory := retentionFixture(t) + writeManagedLog(t, directory, 1, 5, 0) + if err := os.Remove(filepath.Join(directory, "1.log")); err != nil { + t.Fatal(err) + } + if err := jobRetentionSweep(directory, jobRetentionBudget{0, 0}); err != nil { + t.Fatal(err) + } + retentionExists(t, jobRetentionMarkerPath(directory, 1), false) +} + +func TestStartupSweepPreservesJobIDHistoryAndLegacyLogs(t *testing.T) { + session := t.TempDir() + directory := filepath.Join(session, placeLogs, droppingJobs) + if err := os.MkdirAll(directory, 0o700); err != nil { + t.Fatal(err) + } + id, path, file, err := jobRetentionClaim(directory) + if err != nil { + t.Fatal(err) + } + file.Close() + legacy := filepath.Join(directory, "999.log") + if err := os.WriteFile(legacy, []byte("older writer without a lease"), 0o600); err != nil { + t.Fatal(err) + } + entries, err := os.ReadDir(directory) + if err != nil { + t.Fatal(err) + } + old := time.Now().Add(-2 * sweepTTL) + for _, entry := range entries { + if err := os.Chtimes(filepath.Join(directory, entry.Name()), old, old); err != nil { + t.Fatal(err) + } + } + var notes []string + sweepLogs(context.Background(), session, time.Now(), func(note string) { notes = append(notes, note) }) + if len(notes) != 0 { + t.Fatalf("startup maintenance failed: %v", notes) + } + for _, name := range []string{path, legacy, filepath.Join(directory, jobRetentionLockName), filepath.Join(directory, jobRetentionCounterName), jobRetentionMarkerPath(directory, int64(id))} { + retentionExists(t, name, true) + } + next, _, file, err := jobRetentionClaim(directory) + if err != nil { + t.Fatal(err) + } + file.Close() + if next <= 999 { + t.Fatalf("startup lost seeded history: %d", next) + } +} diff --git a/internal/session/jobretention_unix.go b/internal/session/jobretention_unix.go new file mode 100644 index 0000000000..28a9b8b7ac --- /dev/null +++ b/internal/session/jobretention_unix.go @@ -0,0 +1,25 @@ +//go:build !windows + +package session + +import ( + "os" + "syscall" +) + +func jobRetentionSingleLink(file *os.File) bool { + info, err := file.Stat() + if err != nil { + return false + } + stat, ok := info.Sys().(*syscall.Stat_t) + return ok && stat.Nlink == 1 +} +func jobRetentionSyncDir(root *os.Root) error { + directory, err := root.Open(".") + if err != nil { + return err + } + defer directory.Close() + return directory.Sync() +} diff --git a/internal/session/jobretention_windows.go b/internal/session/jobretention_windows.go new file mode 100644 index 0000000000..3d35353cac --- /dev/null +++ b/internal/session/jobretention_windows.go @@ -0,0 +1,17 @@ +//go:build windows + +package session + +import ( + "golang.org/x/sys/windows" + "os" +) + +func jobRetentionSingleLink(file *os.File) bool { + var info windows.ByHandleFileInformation + return windows.GetFileInformationByHandle(windows.Handle(file.Fd()), &info) == nil && info.NumberOfLinks == 1 +} + +// Windows does not support FlushFileBuffers on a directory opened for reading. +// The counter file itself is flushed before atomic rooted replacement. +func jobRetentionSyncDir(root *os.Root) error { return nil } diff --git a/internal/session/jobs.go b/internal/session/jobs.go index b8b2487a63..0ecd9c8963 100644 --- a/internal/session/jobs.go +++ b/internal/session/jobs.go @@ -56,7 +56,6 @@ import ( "io" "os" "os/exec" - "path/filepath" "strconv" "strings" "sync" @@ -578,15 +577,17 @@ func (r *jobRegistry) newJob(command string, kind jobKind) (*job, error) { } directory := droppingsDir(r.place, workspace, droppingJobs) - if err := os.MkdirAll(directory, 0o755); err != nil { + root, err := openJobRetentionDir(directory, true) + if err != nil { return nil, fmt.Errorf("could not create the jobs directory: %w", err) } + root.Close() id, logPath, logFile, err := r.claimJobLog(directory) if err != nil { return nil, err } - return &job{ + started := &job{ epoch: epoch, id: id, command: command, @@ -594,50 +595,19 @@ func (r *jobRegistry) newJob(command string, kind jobKind) (*job, error) { dir: workspace, started: time.Now(), logPath: logPath, - // One sink for both streams, as bare's bash does: stdout and stderr - // interleave in arrival order, which is the order a person reading the - // log expects them in. - sink: newJobSink(logFile, logPath), - done: make(chan struct{}), - }, nil + done: make(chan struct{}), + } + started.sink = newJobSink(logFile, logPath) + // Retention maintenance (issue #1601) runs when the sink closes — the + // second of its two events — and only then, never per Write. + started.sink.finishRetention = func() { jobRetentionFinish(started.sink) } + return started, nil } -// claimJobLog takes the next id whose log file this session can CREATE, and -// returns it with the file already open. -// -// The name is claimed, not merely chosen. The counter behind it is this -// process's own and starts at one in every window, so `1.log` is a name two -// codeafs in one directory both pick within a minute of each other — and the -// open that used to be here truncated whatever was already at the name. Nothing -// visible went wrong: the older session's writer kept its own file offset, so -// its log became a hole where its first pages had been followed by two runs -// interleaved by byte position, and `jobs output` showed the person a mixture -// neither process had any idea it was in. -// -// So the create is O_EXCL and a taken name simply means take the next one. This -// is tools_image.go's collision loop, for its reason — a check that only ASKED -// whether the file existed would hand two racing openers the same answer — with -// the second race that two processes are also racing. The visible consequence is -// that a second window's job ids start above one rather than at it, which is the -// honest thing for them to do: the ids are what `jobs output` is addressed by, -// so two jobs may not share one. +// claimJobLog reserves the persistent id, creates the spool, and holds its +// writer lease before publishing the marker, under one directory lock. func (r *jobRegistry) claimJobLog(directory string) (int, string, *os.File, error) { - for attempt := 0; attempt < 1000; attempt++ { - r.mu.Lock() - r.seq++ - id := r.seq - r.mu.Unlock() - - logPath := filepath.Join(directory, fmt.Sprintf("%d.log", id)) - logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o644) - if err == nil { - return id, logPath, logFile, nil - } - if !os.IsExist(err) { - return 0, "", nil, fmt.Errorf("could not open the job log: %w", err) - } - } - return 0, "", nil, fmt.Errorf("could not open the job log: %s is full of them", directory) + return jobRetentionClaim(directory) } // join is the ONE door into the registry's slice, and the place the closed @@ -662,7 +632,7 @@ func (r *jobRegistry) join(started *job) error { r.mu.Unlock() started.sink.close() if started.logPath != "" { - _ = os.Remove(started.logPath) + _ = jobRetentionDiscard(started.logPath) } return errSessionClosed } @@ -1026,7 +996,7 @@ func (r *jobRegistry) settleExit(watched *job, code int) { // make one more call for what the note was already about. if watched.kind == jobKindBash { tail := watched.sink.tail(jobExitTailLines) - if strings.TrimSpace(tail) != "" || watched.sink.notice() != "" { + if strings.TrimSpace(tail) != "" || watched.sink.notice() != "" || watched.sink.retentionLost() { if strings.TrimSpace(tail) != "" { note += "\n\n" + tail } @@ -1261,18 +1231,20 @@ func (r *jobRegistry) output(id, lines int) (string, bool) { // The live inode is never replaced, preserving both the job ID reservation and // the writer lease held by retention. Disk failures stop spooling, not draining. type jobSink struct { - mu sync.Mutex - ring []byte - file *os.File - closed bool - base string - chunkBytes int64 - spoolBytes int64 - spoolBroken bool - brokenText string - noticeText string - backupInfo os.FileInfo - hook spoolHook + mu sync.Mutex + ring []byte + file *os.File + closed bool + base string + chunkBytes int64 + spoolBytes int64 + spoolBroken bool + brokenText string + noticeText string + backupInfo os.FileInfo + hook spoolHook + finishRetention func() + retentionText string } // spoolHook is the seam the tests inject spool failures through. It replaces @@ -1438,6 +1410,9 @@ func (s *jobSink) breakSpoolLocked(err error) { func (s *jobSink) noticeLocked() string { parts := []string{} + if s.retentionText != "" { + parts = append(parts, s.retentionText) + } if s.brokenText != "" { parts = append(parts, s.brokenText) } @@ -1462,6 +1437,13 @@ func (s *jobSink) notice() string { // logFooter names both chunks from the first rotation, even before any bytes // are discarded. Calling only the live chunk the full log would be false. func (s *jobSink) logFooter(path string) string { + if s.retentionLost() { + ending := "log evicted by the retention budget · the lines above are the in-memory tail" + if notice := s.notice(); notice != "" { + ending = notice + " · " + ending + } + return ending + } s.mu.Lock() defer s.mu.Unlock() notice := s.noticeLocked() @@ -1474,6 +1456,17 @@ func (s *jobSink) logFooter(path string) string { return notice + " · log file: " + path } +// A previous successful read does not prove a later retained log still exists. +func (s *jobSink) retentionLost() bool { + s.mu.Lock() + defer s.mu.Unlock() + if s.base == "" || !s.closed { + return false + } + _, err := os.Lstat(s.base) + return os.IsNotExist(err) +} + // spoolSizeText keeps the notice's figures readable: megabytes at the // production limits, bytes at the ones the tests set. func spoolSizeText(n int64) string { @@ -1505,8 +1498,8 @@ func (s *jobSink) trimLocked() { // the job's ending would have to carry. func (s *jobSink) close() { s.mu.Lock() - defer s.mu.Unlock() if s.closed { + s.mu.Unlock() return } if s.file != nil { @@ -1515,6 +1508,14 @@ func (s *jobSink) close() { } } s.closed = true + finish := s.finishRetention + s.finishRetention = nil + s.mu.Unlock() + // Maintenance takes the directory lock only after releasing the writer + // lease and sink mutex, so it cannot deadlock with another job's claim. + if finish != nil { + finish() + } } // tail returns the last n lines of the ring. diff --git a/internal/session/sweep.go b/internal/session/sweep.go index 6a60ead364..c953f30112 100644 --- a/internal/session/sweep.go +++ b/internal/session/sweep.go @@ -228,6 +228,15 @@ func sweepLogs(ctx context.Context, dir string, now time.Time, note func(string) if err != nil { return err } + if entry.IsDir() && path == filepath.Join(logs, droppingJobs) { + // Job ownership and durable id history must not expire by age. + // This existing-directory startup pass uses the same leased + // retention as claim/close and preserves unmarked legacy writers. + if err := jobRetentionSweep(path, defaultJobRetentionBudget()); err != nil { + note(fmt.Sprintf("sweep: job retention deferred for %s: %v", path, err)) + } + return fs.SkipDir + } if entry.IsDir() || !entry.Type().IsRegular() { return nil } diff --git a/internal/session/task_close_test.go b/internal/session/task_close_test.go index 506d6dcd19..fdfba875d1 100644 --- a/internal/session/task_close_test.go +++ b/internal/session/task_close_test.go @@ -266,8 +266,12 @@ func TestNoJobJoinsBehindTheShutdownWalk(t *testing.T) { if err != nil && !os.IsNotExist(err) { t.Fatalf("read the jobs directory: %v", err) } - if len(entries) != 0 { - t.Fatalf("the jobs folder outlived the quit with %d file(s) in it", len(entries)) + // The permanent allocation lock and counter survive: deleting them would + // allow a reopened registry to reuse a previously issued job id. + for _, entry := range entries { + if entry.Name() != jobRetentionLockName && entry.Name() != jobRetentionCounterName { + t.Fatalf("the refused job left %s behind", entry.Name()) + } } } From d16eabd9684440222058b641eec2845646f729c6 Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Sun, 27 Sep 2026 01:12:19 -0400 Subject: [PATCH 19/76] fix(jobs): finish integrating retention and task log diagnostics --- internal/session/jobs.go | 17 ++++++++++++----- internal/session/jobspool_test.go | 16 ++++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/internal/session/jobs.go b/internal/session/jobs.go index 0ecd9c8963..4ccbec71dc 100644 --- a/internal/session/jobs.go +++ b/internal/session/jobs.go @@ -56,6 +56,7 @@ import ( "io" "os" "os/exec" + "path/filepath" "strconv" "strings" "sync" @@ -518,7 +519,6 @@ type jobRegistry struct { paid func() mu sync.Mutex - seq int jobs []*job // watches is the number of watch slots CLAIMED, not the number of watch // jobs in the slice. Counting the slice would leave a window between the @@ -599,7 +599,7 @@ func (r *jobRegistry) newJob(command string, kind jobKind) (*job, error) { } started.sink = newJobSink(logFile, logPath) // Retention maintenance (issue #1601) runs when the sink closes — the - // second of its two events — and only then, never per Write. + // maintenance also runs at claim and startup, never per Write. started.sink.finishRetention = func() { jobRetentionFinish(started.sink) } return started, nil } @@ -859,11 +859,18 @@ func (r *jobRegistry) finish(done *job, code int, note string) { if requested := r.settled(done, code); requested { return } - if note == "" || r.notify == nil { + if r.notify == nil { + return + } + if done.sink.notice() != "" { + if note == "" { + note = fmt.Sprintf("job %d finished", done.id) + } + note += "\n\n[job " + strconv.Itoa(done.id) + " · " + done.sink.logFooter(done.logPath) + "]" + } + if note == "" { return } - // A goroutine's ending is one sentence its caller wrote, so that sentence is - // already the whole ending this file composed for it. r.notify(note) } diff --git a/internal/session/jobspool_test.go b/internal/session/jobspool_test.go index c6d6601aec..552746ec5a 100644 --- a/internal/session/jobspool_test.go +++ b/internal/session/jobspool_test.go @@ -328,3 +328,19 @@ func TestJobSpoolConcurrentWritersAndCloseRemainBounded(t *testing.T) { t.Fatal("concurrent close stopped memory drain") } } + +func TestJobSpoolGoroutineCompletionReportsLoggingFailure(t *testing.T) { + var note string + registry := newJobRegistry(t.TempDir(), Place{}, func(s string) { note = s }) + job, err := registry.newJob("fixture", jobKindTask) + if err != nil { + t.Fatal(err) + } + t.Cleanup(job.sink.close) + job.sink.hook = func([]byte) (int, error) { return 0, errors.New("injected task log failure") } + _, _ = job.sink.Write([]byte("\n")) + registry.finish(job, 0, "task finished") + if !strings.Contains(note, "injected task log failure") || strings.Contains(note, "full log:") { + t.Fatalf("task completion hid logging failure: %s", note) + } +} From 59eaa7ba3dbe45508168660cfaaf57296218bc12 Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Sun, 27 Sep 2026 01:14:52 -0400 Subject: [PATCH 20/76] fix(jobs): preserve registry identity across workspace changes --- internal/session/jobretention.go | 9 +++++ internal/session/jobretention_test.go | 48 +++++++++++++++++++++++++++ internal/session/jobs.go | 15 +++++++-- 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/internal/session/jobretention.go b/internal/session/jobretention.go index 71e58b9d14..03744c5948 100644 --- a/internal/session/jobretention.go +++ b/internal/session/jobretention.go @@ -244,6 +244,12 @@ func (d *jobRetentionDirectory) persist(high int64) error { // Counter, O_EXCL creation, writer lease and ownership marker publication are // one transaction under the directory lock. Persist before pruning anything. func jobRetentionClaim(directory string) (int, string, *os.File, error) { + return jobRetentionClaimAbove(directory, 0) +} + +// A registry may move its legacy jobs directory when the workspace changes; +// its already-issued ids remain reserved even in a fresh destination. +func jobRetentionClaimAbove(directory string, floor int64) (int, string, *os.File, error) { d, err := lockJobRetention(directory) if err != nil { return 0, "", nil, err @@ -253,6 +259,9 @@ func jobRetentionClaim(directory string) (int, string, *os.File, error) { if err != nil { return 0, "", nil, err } + if high < floor { + high = floor + } if high >= int64(math.MaxInt) { return 0, "", nil, errRetentionUnsafe } diff --git a/internal/session/jobretention_test.go b/internal/session/jobretention_test.go index 4a16a7b1a8..436df1d8bb 100644 --- a/internal/session/jobretention_test.go +++ b/internal/session/jobretention_test.go @@ -504,3 +504,51 @@ func TestStartupSweepPreservesJobIDHistoryAndLegacyLogs(t *testing.T) { t.Fatalf("startup lost seeded history: %d", next) } } + +func TestJobRetentionWorkspaceMoveDoesNotReuseRegistryIDs(t *testing.T) { + registry := newJobRegistry(t.TempDir(), Place{}, nil) + first, _, file, err := registry.claimJobLog(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer file.Close() + next, _, other, err := registry.claimJobLog(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer other.Close() + if next <= first { + t.Fatalf("workspace move reused id %d after %d", next, first) + } +} + +func TestJobRetentionWorkspaceSnapshotStaysConsistent(t *testing.T) { + first, second := t.TempDir(), t.TempDir() + registry := newJobRegistry(first, Place{Dir: first, Workspace: first}, nil) + var changes sync.WaitGroup + changes.Add(1) + go func() { + defer changes.Done() + for i := 0; i < 200; i++ { + workspace := first + if i%2 == 0 { + workspace = second + } + registry.mu.Lock() + registry.workspace = workspace + registry.place = Place{Dir: workspace, Workspace: workspace} + registry.mu.Unlock() + } + }() + defer changes.Wait() + for i := 0; i < 10; i++ { + job, err := registry.newJob("workspace fixture", jobKindTask) + if err != nil { + t.Fatal(err) + } + job.sink.close() + if filepath.Dir(job.logPath) != filepath.Join(job.dir, placeLogs, droppingJobs) { + t.Fatalf("mixed workspace and place: %s / %s", job.dir, job.logPath) + } + } +} diff --git a/internal/session/jobs.go b/internal/session/jobs.go index 4ccbec71dc..4df9d3b0dd 100644 --- a/internal/session/jobs.go +++ b/internal/session/jobs.go @@ -520,6 +520,9 @@ type jobRegistry struct { mu sync.Mutex jobs []*job + // Claims keep ids unique even when a legacy registry changes workspace. + claimMu sync.Mutex + seq int // watches is the number of watch slots CLAIMED, not the number of watch // jobs in the slice. Counting the slice would leave a window between the // limit check and the append in which two concurrent starts both pass, and @@ -570,13 +573,13 @@ func (r *jobRegistry) newJob(command string, kind jobKind) (*job, error) { // was taken away. Every caller of this already answers an error by carrying // on without a log, which is the honest shape for work that is ending. r.mu.Lock() - closed, epoch, workspace := r.closed, r.epoch, r.workspace + closed, epoch, workspace, place := r.closed, r.epoch, r.workspace, r.place r.mu.Unlock() if closed { return nil, errSessionClosed } - directory := droppingsDir(r.place, workspace, droppingJobs) + directory := droppingsDir(place, workspace, droppingJobs) root, err := openJobRetentionDir(directory, true) if err != nil { return nil, fmt.Errorf("could not create the jobs directory: %w", err) @@ -607,7 +610,13 @@ func (r *jobRegistry) newJob(command string, kind jobKind) (*job, error) { // claimJobLog reserves the persistent id, creates the spool, and holds its // writer lease before publishing the marker, under one directory lock. func (r *jobRegistry) claimJobLog(directory string) (int, string, *os.File, error) { - return jobRetentionClaim(directory) + r.claimMu.Lock() + defer r.claimMu.Unlock() + id, path, file, err := jobRetentionClaimAbove(directory, int64(r.seq)) + if err == nil { + r.seq = id + } + return id, path, file, err } // join is the ONE door into the registry's slice, and the place the closed From 224dd49632515d5f5b592cbfac7fcb8966d82115 Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Sun, 27 Sep 2026 01:17:14 -0400 Subject: [PATCH 21/76] docs(jobs): explain unsafe log storage refusal --- internal/manual/chat/what-i-can-do.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/internal/manual/chat/what-i-can-do.md b/internal/manual/chat/what-i-can-do.md index b0c3fbd2c1..b8042745d4 100644 --- a/internal/manual/chat/what-i-can-do.md +++ b/internal/manual/chat/what-i-can-do.md @@ -443,6 +443,16 @@ parts it has, and however long it takes — belongs to a task instead, which giv to watch and a report you can read. If a multi-part piece of work was started as a background command, say so: it can be handed to a task instead. +## Why can a job not create its log? + +Job-log setup refuses symlinked storage paths, including a symlinked codeaf home +or legacy workspace, because cleanup cannot establish ownership safely. Use the +resolved directory path. A damaged or missing established job-ID counter is also +refused with `job log retention: unsafe or damaged state; refusing allocation or +cleanup`. The counter and `.retention.lock` preserve job IDs across cleanup and +restarts; deleting them is not a supported reset. Existing unmarked logs are +preserved because an older process may still be writing them. + ## Can you keep an eye on something and tell me when it changes? Yes. The `watch` tool runs a command on a timer and speaks **only when there is From b174e627aab9a2c0ffd5c22ec8f3ca22757eaea3 Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Sun, 27 Sep 2026 01:20:50 -0400 Subject: [PATCH 22/76] fix(jobs): preserve safe startup expiry for completed log payloads --- PERF.md | 5 +- docs/changes/unreleased/1599-job-retention.md | 4 +- internal/manual/chat/what-i-can-do.md | 3 +- internal/session/jobretention.go | 65 +++++++++++++++++-- internal/session/jobretention_test.go | 40 +++++++++++- internal/session/sweep.go | 2 +- 6 files changed, 107 insertions(+), 12 deletions(-) diff --git a/PERF.md b/PERF.md index ec739e0fcb..9c0368abbb 100644 --- a/PERF.md +++ b/PERF.md @@ -2648,7 +2648,10 @@ foreground span; it adds no timer, I/O, model call or per-frame randomness. one jobs directory to **128 MiB and 64 job groups**, counting the base and rotation together. Active spools have separate per-job limits; unmarked legacy logs and unsafe files remain outside the budget because older writers may not -hold leases. This is not a machine-wide bound. +hold leases. This is not a machine-wide bound. Startup retains the existing +seven-day (`sweepTTL`) expiry for eligible inactive groups only: both chunks +must be older than the cutoff. Expired groups are removed before applying the +byte/count budget to fresh groups; metadata and active/legacy logs do not expire. Maintenance runs at log creation, sink close, and the existing startup sweep, never per output write. It diff --git a/docs/changes/unreleased/1599-job-retention.md b/docs/changes/unreleased/1599-job-retention.md index 6c4eff083e..4f1befbae3 100644 --- a/docs/changes/unreleased/1599-job-retention.md +++ b/docs/changes/unreleased/1599-job-retention.md @@ -11,7 +11,9 @@ invalidates: Maintenance runs when a job claims its log, after its sink closes, and for existing jobs directories during the startup sweep. It does not crawl the machine or run on every write. -Completed groups with the oldest allocated IDs are removed first. The cleaner +Startup expires managed inactive groups whose two chunks are older than seven +days, preserving the prior payload TTL without expiring allocation metadata. +Then completed groups with the oldest allocated IDs are removed first. The cleaner holds an independent file lease through removal; the writer must keep the same main-log descriptor through rotation and spool failures until sink close. diff --git a/internal/manual/chat/what-i-can-do.md b/internal/manual/chat/what-i-can-do.md index b8042745d4..806c147787 100644 --- a/internal/manual/chat/what-i-can-do.md +++ b/internal/manual/chat/what-i-can-do.md @@ -391,7 +391,8 @@ of promising a full log. Finished logs do not pile up forever either. Each session's logs/jobs directory keeps managed finished logs within **128 MiB and 64 jobs** -(a job's log and its one rotation count together), evicting the oldest job IDs first; +(a job's log and its one rotation count together), evicting the oldest job IDs first. Startup also expires inactive managed logs +whose base and rotated chunk have both been untouched for **7 days**; active jobs are never touched, and files codeaf did not create there — older unmarked logs included — are left alone. If a log you were pointed at has since been evicted, the jobs footer says so instead of naming the file, and diff --git a/internal/session/jobretention.go b/internal/session/jobretention.go index 03744c5948..8be481c1e2 100644 --- a/internal/session/jobretention.go +++ b/internal/session/jobretention.go @@ -15,6 +15,7 @@ import ( "sort" "strconv" "strings" + "time" "github.com/Agent-Field/codeaf/internal/filelock" ) @@ -311,6 +312,7 @@ type retainedJob struct { base string bytes int64 identity os.FileInfo + newest time.Time } // Acquire an independent lease; the caller holds it throughout deletion. @@ -357,7 +359,26 @@ func (d *jobRetentionDirectory) inspect(base string) (*os.File, int64, error) { } return file, size, nil } -func (d *jobRetentionDirectory) sweep(budget jobRetentionBudget) error { + +// latestPayloadTime keeps a fresh rotated chunk alive even if the base is old. +func (d *jobRetentionDirectory) latestPayloadTime(base string, info os.FileInfo) (time.Time, error) { + newest := info.ModTime() + backup, err := d.root.Lstat(base + ".1") + if err == nil { + if backup.ModTime().After(newest) { + newest = backup.ModTime() + } + } else if !os.IsNotExist(err) { + return time.Time{}, err + } + return newest, nil +} + +func (d *jobRetentionDirectory) sweep(budget jobRetentionBudget, expiredBefore ...time.Time) error { + var cutoff time.Time + if len(expiredBefore) > 0 { + cutoff = expiredBefore[0] + } if budget.bytes < 0 || budget.groups < 0 { return errRetentionUnsafe } @@ -395,6 +416,10 @@ func (d *jobRetentionDirectory) sweep(budget jobRetentionBudget) error { continue } // live, legacy, unsafe: preserve without deletion info, err := file.Stat() + var newest time.Time + if err == nil { + newest, err = d.latestPayloadTime(entry.Name(), info) + } file.Close() if err != nil { return err @@ -403,13 +428,24 @@ func (d *jobRetentionDirectory) sweep(budget jobRetentionBudget) error { return errRetentionUnsafe } total += size - groups = append(groups, retainedJob{id, entry.Name(), size, info}) - } - sort.Slice(groups, func(i, j int) bool { return groups[i].id < groups[j].id }) + groups = append(groups, retainedJob{id: id, base: entry.Name(), bytes: size, identity: info, newest: newest}) + } + // Expired payloads go first, so they do not displace fresh groups merely + // because a long-running fresh job has an older allocated id. + sort.Slice(groups, func(i, j int) bool { + oldI := !cutoff.IsZero() && groups[i].newest.Before(cutoff) + oldJ := !cutoff.IsZero() && groups[j].newest.Before(cutoff) + if oldI != oldJ { + return oldI + } + return groups[i].id < groups[j].id + }) count := len(groups) for _, group := range groups { - if total <= budget.bytes && count <= budget.groups { - break + overBudget := total > budget.bytes || count > budget.groups + expired := !cutoff.IsZero() && group.newest.Before(cutoff) + if !overBudget && !expired { + continue } file, _, e := d.inspect(group.base) if e != nil { @@ -419,6 +455,15 @@ func (d *jobRetentionDirectory) sweep(budget jobRetentionBudget) error { if e == nil && !os.SameFile(group.identity, info) { e = errRetentionUnsafe } + if e == nil && !overBudget { + newest, ageErr := d.latestPayloadTime(group.base, info) + if ageErr != nil { + e = ageErr + } else if !newest.Before(cutoff) { + file.Close() + continue + } + } if e == nil { e = d.remove(group.base) } @@ -452,6 +497,12 @@ func (d *jobRetentionDirectory) remove(base string) error { return nil } func jobRetentionSweep(directory string, budget jobRetentionBudget) error { + return jobRetentionSweepBefore(directory, budget, time.Time{}) +} + +// Startup retains the existing age policy for proven inactive managed logs, +// without applying age to permanent identity metadata or uncertain legacy logs. +func jobRetentionSweepBefore(directory string, budget jobRetentionBudget, cutoff time.Time) error { d, err := lockJobRetention(directory) if err != nil { return err @@ -464,7 +515,7 @@ func jobRetentionSweep(directory string, budget jobRetentionBudget) error { if err = d.persist(high); err != nil { return err } - return d.sweep(budget) + return d.sweep(budget, cutoff) } func jobRetentionFinish(sink *jobSink) { if sink == nil || sink.base == "" { diff --git a/internal/session/jobretention_test.go b/internal/session/jobretention_test.go index 436df1d8bb..bf38ec752f 100644 --- a/internal/session/jobretention_test.go +++ b/internal/session/jobretention_test.go @@ -492,9 +492,11 @@ func TestStartupSweepPreservesJobIDHistoryAndLegacyLogs(t *testing.T) { if len(notes) != 0 { t.Fatalf("startup maintenance failed: %v", notes) } - for _, name := range []string{path, legacy, filepath.Join(directory, jobRetentionLockName), filepath.Join(directory, jobRetentionCounterName), jobRetentionMarkerPath(directory, int64(id))} { + for _, name := range []string{legacy, filepath.Join(directory, jobRetentionLockName), filepath.Join(directory, jobRetentionCounterName)} { retentionExists(t, name, true) } + retentionExists(t, path, false) + retentionExists(t, jobRetentionMarkerPath(directory, int64(id)), false) next, _, file, err := jobRetentionClaim(directory) if err != nil { t.Fatal(err) @@ -552,3 +554,39 @@ func TestJobRetentionWorkspaceSnapshotStaysConsistent(t *testing.T) { } } } + +func TestJobRetentionAgeExpiryPreservesFreshChunksAndActiveLogs(t *testing.T) { + directory := retentionFixture(t) + now := time.Date(2026, 9, 27, 12, 0, 0, 0, time.UTC) + old, fresh := now.Add(-2*sweepTTL), now.Add(-time.Hour) + for id := int64(1); id <= 4; id++ { + writeManagedLog(t, directory, id, 5, 5) + for _, suffix := range []string{".log", ".log.1"} { + path := filepath.Join(directory, fmt.Sprintf("%d%s", id, suffix)) + if err := os.Chtimes(path, old, old); err != nil { + t.Fatal(err) + } + } + } + // The first id is fresh only through its backup; later ids still expire. + if err := os.Chtimes(filepath.Join(directory, "1.log.1"), fresh, fresh); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(filepath.Join(directory, "2.log"), fresh, fresh); err != nil { + t.Fatal(err) + } + active, err := os.OpenFile(filepath.Join(directory, "3.log"), os.O_RDWR, 0) + if err != nil { + t.Fatal(err) + } + defer active.Close() + if err := filelock.Lock(active, true, true); err != nil { + t.Fatal(err) + } + if err := jobRetentionSweepBefore(directory, jobRetentionBudget{1 << 20, 2}, now.Add(-sweepTTL)); err != nil { + t.Fatal(err) + } + for _, id := range []int{1, 2, 3, 4} { + retentionExists(t, filepath.Join(directory, fmt.Sprintf("%d.log", id)), id != 4) + } +} diff --git a/internal/session/sweep.go b/internal/session/sweep.go index c953f30112..1a08c908b1 100644 --- a/internal/session/sweep.go +++ b/internal/session/sweep.go @@ -232,7 +232,7 @@ func sweepLogs(ctx context.Context, dir string, now time.Time, note func(string) // Job ownership and durable id history must not expire by age. // This existing-directory startup pass uses the same leased // retention as claim/close and preserves unmarked legacy writers. - if err := jobRetentionSweep(path, defaultJobRetentionBudget()); err != nil { + if err := jobRetentionSweepBefore(path, defaultJobRetentionBudget(), cutoff); err != nil { note(fmt.Sprintf("sweep: job retention deferred for %s: %v", path, err)) } return fs.SkipDir From 62b294291551e0a0cf3ae8bb587dacc9445f7291 Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Sun, 27 Sep 2026 01:22:08 -0400 Subject: [PATCH 23/76] fix: exclude runtime output from recursive search and bound log reads --- PERF.md | 22 ++ .../changes/unreleased/1599-runtime-search.md | 23 ++ internal/exec/bare/grep_runtime.go | 125 ++++++++ internal/exec/bare/grep_runtime_test.go | 291 ++++++++++++++++++ internal/exec/bare/grep_stream.go | 94 ++++++ internal/exec/bare/grepfindls.go | 251 ++++++++------- internal/exec/bare/tools.go | 2 +- internal/manual/chat/what-i-can-do.md | 17 +- internal/session/prompts/bashworker.md | 18 +- 9 files changed, 729 insertions(+), 114 deletions(-) create mode 100644 docs/changes/unreleased/1599-runtime-search.md create mode 100644 internal/exec/bare/grep_runtime.go create mode 100644 internal/exec/bare/grep_runtime_test.go create mode 100644 internal/exec/bare/grep_stream.go diff --git a/PERF.md b/PERF.md index 9c0368abbb..c2b8864433 100644 --- a/PERF.md +++ b/PERF.md @@ -2670,3 +2670,25 @@ explicit byte/count budgets with small payloads, avoiding mutable global limits. The startup TTL sweeper delegates `logs/jobs/` to this retention instead of expiring the stable allocation metadata or ownership markers by age. +## Runtime-safe file search + +The structured `grep` tool excludes known codeaf runtime output for both engines, +including custom state homes and searches starting inside those directories. +The policy preserves source under `work/`, `trees/`, and ordinary user `logs/` +directories. Ripgrep receives exclusions after user globs and runs without user +config or symlink traversal. Shell commands do not inherit these protections. + +Recursive search skips files above **8 MiB**. The walking engine and explicit +single-file inspection read a snapshot bounded by `min(size-at-open, 8 MiB)`; +one **64 KiB** line buffer drains and skips oversized lines, with an incomplete +result notice. Stored matches are clipped to the existing 500-byte display cap, +with at most **1000 matches** and **20 context lines per side**. Context rendering +streams the same bounded snapshot reader and stops accumulating output once the +result byte budget has been reached. Directory traversal and total files searched +remain governed by the caller's context, rather than a machine-wide byte cap. + +Ripgrep JSON records are limited to **1 MiB**; scanner errors and match limits +kill and reap the child so `Wait` cannot hang behind a full stdout pipe. Stderr +capture retains at most **4 KiB** while continuing to drain. Regression fixtures +cover both engines, direct runtime roots, aliases, broad globs, subprocess +termination, source worktrees, and growth during a snapshot read. diff --git a/docs/changes/unreleased/1599-runtime-search.md b/docs/changes/unreleased/1599-runtime-search.md new file mode 100644 index 0000000000..20b3f4bd9d --- /dev/null +++ b/docs/changes/unreleased/1599-runtime-search.md @@ -0,0 +1,23 @@ +--- +kind: fixed +title: recursive searches skip runtime output and bound log inspection +pr: 1599 +surface: [chat, engine] +invalidates: + - "Recursive grep could read codeaf's own job output and repeat earlier searches. Both the ripgrep and walking engines now exclude known runtime logs and transcripts, even when the requested directory is inside the state home, while keeping source worktrees searchable." + - "The walking fallback and context renderer loaded entire files. They now stream a bounded initial snapshot, skipping oversized lines; naming one log file still permits bounded inspection." + - "Shell search guidance recommended recursive grep without runtime exclusions. Shell commands are not rewritten, so the guidance now asks for narrow source roots, explicit runtime exclusions, and byte-limited log reads." +--- + +The state root comes from `home.Dir()`, including custom `CODEAF_HOME` and +resolved aliases. Exclusions are applied after user globs and symlink directory +traversal is disabled. Ordinary source directories named `logs` remain visible; +only known runtime locations and legacy `.codeaf` output directories are skipped. + +Recursive file search skips files over 8 MiB. Explicit single-file inspection +reads at most the first 8 MiB present at open; append activity cannot extend that +snapshot. The walking reader skips lines over 64 KiB and reports incomplete +results, and context rendering uses that same bounded reader. Requests allow at +most 1000 matches and 20 context lines per side. A ripgrep JSON response over its +1 MiB reader limit terminates and reaps the child, returning an explicit error +instead of waiting with an undrained pipe. diff --git a/internal/exec/bare/grep_runtime.go b/internal/exec/bare/grep_runtime.go new file mode 100644 index 0000000000..8e2fd97505 --- /dev/null +++ b/internal/exec/bare/grep_runtime.go @@ -0,0 +1,125 @@ +package bare + +import ( + "path/filepath" + "strings" + + "github.com/Agent-Field/codeaf/internal/home" +) + +// These are output locations, not a blanket exclusion of the state home: +// work/, trees/ and artifacts/ may contain the source the user wants searched. +var grepStateOutputs = grepRuntimePatterns() + +func grepRuntimePatterns() []string { + patterns := []string{ + "logs", "jobs", "stubs", "trace", "runs/*/tasks", "runs/*/logs", "runs/*/trace", + "runs/*/transcript.jsonl", "v3/runs/*/*/*.jsonl", "v3/runs/*/*/logs", + "v3/runs/*/*/tasks", "v3/tasks/*/*.jsonl", "v3/usage.jsonl", "v3/fixes.json", + } + for _, place := range []string{"v3/projects/*/*", "v3/standing/*/runs/*", "v3/standing/exchanges/*"} { + for _, output := range []string{"logs", "tasks", "transcript.jsonl", "state.json", "tasks.json"} { + patterns = append(patterns, place+"/"+output) + } + } + return patterns +} + +var grepLegacyOutputs = []string{"jobs", "logs", "stubs", "trace"} + +func grepCanonical(path string) string { + absolute, err := filepath.Abs(path) + if err != nil { + return filepath.Clean(path) + } + if resolved, err := filepath.EvalSymlinks(absolute); err == nil { + return resolved + } + return filepath.Clean(absolute) +} +func grepWithin(parent, child string) (string, bool) { + relative, err := filepath.Rel(parent, child) + return filepath.ToSlash(relative), err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) +} + +type grepRuntimePolicy struct{ state string } + +func newGrepRuntimePolicy() grepRuntimePolicy { return grepRuntimePolicy{grepCanonical(home.Dir())} } +func grepPatternPrefix(pattern, path string) bool { + expected, actual := strings.Split(pattern, "/"), strings.Split(path, "/") + if len(actual) < len(expected) { + return false + } + for i, part := range expected { + if ok, _ := filepath.Match(part, actual[i]); !ok { + return false + } + } + return true +} +func (p grepRuntimePolicy) excludes(path string) bool { + if relative, inside := grepWithin(p.state, path); inside { + for _, pattern := range grepStateOutputs { + if grepPatternPrefix(pattern, relative) { + return true + } + } + } + parts := strings.Split(filepath.ToSlash(path), "/") + for i, part := range parts { + if part != ".codeaf" || i+1 >= len(parts) { + continue + } + for _, output := range grepLegacyOutputs { + if parts[i+1] == output { + return true + } + } + } + return false +} +func grepEscapeGlob(text string) string { + return strings.NewReplacer("\\", "\\\\", "*", "\\*", "?", "\\?", "[", "\\[", "]", "\\]", "{", "\\{", "}", "\\}").Replace(text) +} + +// Exclusions are rooted at rg's working directory and appended AFTER the +// user's glob. Starting within a runtime directory is rejected separately; +// starting within the state root simply shortens these same patterns. +func (p grepRuntimePolicy) rgGlobs(root string) []string { + var globs []string + add := func(pattern string) { globs = append(globs, "!"+pattern, "!"+pattern+"/**") } + for _, output := range grepLegacyOutputs { + add("**/.codeaf/" + output) + if filepath.Base(root) == ".codeaf" { + add("/" + output) + } + } + if relative, inside := grepWithin(root, p.state); inside { + prefix := "" + if relative != "." { + prefix = grepEscapeGlob(relative) + "/" + } + for _, pattern := range grepStateOutputs { + add("/" + prefix + pattern) + } + } else if relative, inside := grepWithin(p.state, root); inside { + actual := strings.Split(relative, "/") + for _, pattern := range grepStateOutputs { + expected := strings.Split(pattern, "/") + if len(actual) >= len(expected) { + continue + } + match := true + for i, part := range actual { + if ok, _ := filepath.Match(expected[i], part); !ok { + match = false + break + } + } + if match { + add("/" + strings.Join(expected[len(actual):], "/")) + } + } + } + return globs +} diff --git a/internal/exec/bare/grep_runtime_test.go b/internal/exec/bare/grep_runtime_test.go new file mode 100644 index 0000000000..1a430d44e4 --- /dev/null +++ b/internal/exec/bare/grep_runtime_test.go @@ -0,0 +1,291 @@ +package bare + +import ( + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/home" +) + +func runtimeSearchWrite(t *testing.T, path, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} +func runtimeSearchTool(t *testing.T, engine, root string) Tool { + t.Helper() + path, err := exec.LookPath("rg") + if engine == "rg" && err != nil { + t.Skip("ripgrep required for the native-engine parity case") + } + return newGrepToolUsing(root, DefaultCaps(), path, engine == "rg") +} +func runtimeSearchCall(t *testing.T, tool Tool, path, glob string) string { + t.Helper() + args, _ := json.Marshal(map[string]any{"pattern": "needle", "path": path, "glob": glob}) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + text, bad, err := tool.Execute(ctx, args) + if err != nil || bad { + t.Fatalf("grep(%s,%s): %s %v", path, glob, text, err) + } + return text +} + +func TestRuntimeSearchExclusionsBothEngines(t *testing.T) { + for _, engine := range []string{"walk", "rg"} { + t.Run(engine, func(t *testing.T) { + root := t.TempDir() + state := filepath.Join(root, "state[custom]") + t.Setenv(home.EnvVar, state) + source := []string{ + "ordinary/logs/source-generic.go", + ".codeaf/work/source-legacy-work.go", + "state[custom]/work/source-root-work.go", + "state[custom]/trees/source-root-tree.go", + "state[custom]/v3/projects/p/s/work/source-session-work.go", + "state[custom]/v3/projects/p/s/trees/1/logs/source-tree-log.go", + "state[custom]/runs/codeaf-do-test/work/source-do-work.go", + "state[custom]/v3/standing/task/runs/run/trees/1/source-standing.go", + "state[custom]/v3/standing/exchanges/chat/work/source-exchange.go", + } + runtime := []string{ + ".codeaf/jobs/runtime-legacy.log", ".codeaf/stubs/runtime-legacy.txt", + "state[custom]/logs/runtime-daemon.log", + "state[custom]/v3/projects/p/s/logs/jobs/runtime-job.log", + "state[custom]/v3/projects/p/s/tasks/runtime-task.jsonl", + "state[custom]/v3/projects/p/s/transcript.jsonl", + "state[custom]/runs/codeaf-do-test/tasks/root/runtime-do.jsonl", + "state[custom]/v3/standing/task/runs/run/logs/runtime-standing.log", + "state[custom]/v3/standing/exchanges/chat/tasks/runtime-exchange.jsonl", + } + for _, name := range append(append([]string{}, source...), runtime...) { + runtimeSearchWrite(t, filepath.Join(root, name), "needle\n") + } + tool := runtimeSearchTool(t, engine, root) + for _, glob := range []string{"", "**/*"} { + text := runtimeSearchCall(t, tool, root, glob) + for _, name := range source { + if !strings.Contains(text, filepath.Base(name)) { + t.Fatalf("source omitted: %s\n%s", name, text) + } + } + for _, name := range runtime { + if strings.Contains(text, filepath.Base(name)+":") { + t.Fatalf("runtime output re-included: %s\n%s", name, text) + } + } + } + // Starting at or below the state root must not switch off exclusions. + for _, path := range []string{state, filepath.Join(state, "v3"), filepath.Join(state, "v3/projects/p/s")} { + text := runtimeSearchCall(t, tool, path, "**/*") + if strings.Contains(text, "runtime-") || strings.Contains(text, "transcript.jsonl:") { + t.Fatalf("state-relative search leaked logs: %s", text) + } + if !strings.Contains(text, "source-session-work.go") || !strings.Contains(text, "source-tree-log.go") { + t.Fatalf("state source omitted: %s", text) + } + } + for _, path := range []string{filepath.Join(state, "logs"), filepath.Join(state, "v3/projects/p/s/logs/jobs"), filepath.Join(root, ".codeaf/jobs")} { + text := runtimeSearchCall(t, tool, path, "**/*") + if !strings.Contains(text, "excluded from recursive search") { + t.Fatalf("runtime-root search was not excluded: %s", text) + } + } + legacyRoot := filepath.Join(root, ".codeaf") + text := runtimeSearchCall(t, tool, legacyRoot, "**/*") + if strings.Contains(text, "runtime-legacy") || !strings.Contains(text, "source-legacy-work.go:") { + t.Fatalf("search rooted at legacy .codeaf leaked logs or hid source: %s", text) + } + file := filepath.Join(state, "v3/projects/p/s/logs/jobs/runtime-job.log") + if text := runtimeSearchCall(t, tool, file, ""); !strings.Contains(text, "needle") { + t.Fatalf("explicit inspection denied: %s", text) + } + }) + } +} +func TestRuntimeSearchHomeAndAliases(t *testing.T) { + for _, engine := range []string{"walk", "rg"} { + t.Run(engine, func(t *testing.T) { + root := t.TempDir() + t.Setenv("HOME", root) + t.Setenv(home.EnvVar, filepath.Join(root, ".codeaf")) + state := home.Dir() + runtime := filepath.Join(state, "v3/projects/p/s/logs/jobs/runtime.log") + source := filepath.Join(state, "v3/projects/p/s/trees/1/source.go") + runtimeSearchWrite(t, runtime, "needle\n") + runtimeSearchWrite(t, source, "needle\n") + alias := filepath.Join(root, "alias") + if err := os.Symlink(state, alias); err != nil { + t.Skip(err) + } + tool := runtimeSearchTool(t, engine, root) + if text := runtimeSearchCall(t, tool, alias, "**/*"); strings.Contains(text, "runtime.log:") || !strings.Contains(text, "source.go:") { + t.Fatalf("state alias escaped policy: %s", text) + } + explicitAlias := filepath.Join(alias, "v3/projects/p/s/logs/jobs") + if text := runtimeSearchCall(t, tool, explicitAlias, "**/*"); !strings.Contains(text, "excluded from recursive search") { + t.Fatalf("nested alias escaped policy: %s", text) + } + nested := filepath.Join(root, "ordinary", "linked-runtime") + if err := os.MkdirAll(filepath.Dir(nested), 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Dir(runtime), nested); err != nil { + t.Skip(err) + } + if text := runtimeSearchCall(t, tool, filepath.Dir(nested), "**/*"); strings.Contains(text, "runtime.log:") { + t.Fatalf("nested symlink followed: %s", text) + } + if text := runtimeSearchCall(t, tool, filepath.Join(".codeaf", "v3", "projects", "p", "s", "logs"), "**/*"); !strings.Contains(text, "excluded from recursive search") { + t.Fatalf("relative root escaped: %s", text) + } + }) + } +} +func TestRuntimeSearchIgnoresRipgrepConfig(t *testing.T) { + root := t.TempDir() + state := filepath.Join(root, "state") + t.Setenv(home.EnvVar, state) + runtimeSearchWrite(t, filepath.Join(state, "v3/projects/p/s/logs/runtime.log"), "needle\n") + runtimeSearchWrite(t, filepath.Join(root, "source.go"), "needle\n") + config := filepath.Join(root, "rg-config") + runtimeSearchWrite(t, config, "--follow\n--glob=**/*\n") + t.Setenv("RIPGREP_CONFIG_PATH", config) + text := runtimeSearchCall(t, runtimeSearchTool(t, "rg", root), root, "**/*") + if strings.Contains(text, "runtime.log:") || !strings.Contains(text, "source.go:") { + t.Fatalf("config changed search policy: %s", text) + } +} +func TestBoundedGrepSnapshotDoesNotChaseGrowth(t *testing.T) { + path := filepath.Join(t.TempDir(), "growing.log") + runtimeSearchWrite(t, path, "first\n") + var lines []string + limited, err := scanGrepFile(context.Background(), path, func(_ int, line string) error { + lines = append(lines, line) + file, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0) + if err != nil { + return err + } + defer file.Close() + _, err = file.WriteString("second\n") + return err + }) + if err != nil || limited || len(lines) != 1 || lines[0] != "first\n" { + t.Fatalf("snapshot chased growth: %q %v %v", lines, limited, err) + } +} +func TestBoundedGrepSkipsLongLineAndKeepsLineNumbers(t *testing.T) { + root := t.TempDir() + t.Setenv(home.EnvVar, filepath.Join(root, "state")) + path := filepath.Join(root, "source.go") + runtimeSearchWrite(t, path, strings.Repeat("x", grepLineBytes+100)+"\nneedle\n") + matches, hit, bounded, err := grepByWalkingBounded(context.Background(), "needle", root, "", false, false, 100) + if err != nil || hit || !bounded || len(matches) != 1 || matches[0].lineNumber != 2 { + t.Fatalf("long line hid later match: %+v %v %v %v", matches, hit, bounded, err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := scanGrepFile(ctx, path, func(int, string) error { return nil }); !errors.Is(err, context.Canceled) { + t.Fatalf("cancel ignored: %v", err) + } +} +func TestBoundedGrepExplicitLargeFileAndContext(t *testing.T) { + root := t.TempDir() + t.Setenv(home.EnvVar, filepath.Join(root, "state")) + path := filepath.Join(root, "state/logs/large.log") + runtimeSearchWrite(t, path, "before\nneedle\nafter\n"+strings.Repeat("x", 8192)+"\n") + file, err := os.OpenFile(path, os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + if err := file.Truncate(grepFileBytes + 1); err != nil { + t.Fatal(err) + } + file.Close() + for _, engine := range []string{"walk", "rg"} { + tool := newGrepToolUsing(root, DefaultCaps(), "must-not-execute-for-a-file", engine == "rg") + text := runtimeSearchCall(t, tool, path, "") + if !strings.Contains(text, "needle") { + t.Fatalf("specific large log not inspectable: %s", text) + } + args, _ := json.Marshal(map[string]any{"pattern": "needle", "path": path, "context": 2}) + text, bad, err := tool.Execute(context.Background(), args) + if err != nil || bad || !strings.Contains(text, "before") || !strings.Contains(text, "after") { + t.Fatalf("bounded context failed: %s %v", text, err) + } + args, _ = json.Marshal(map[string]any{"pattern": "[unclosed", "path": path}) + _, bad, err = tool.Execute(context.Background(), args) + if err != nil || !bad { + t.Fatalf("explicit-file invalid regex not surfaced: %v %v", bad, err) + } + } +} +func TestRipgrepLongJSONLineIsKilledAndReaped(t *testing.T) { + root := t.TempDir() + t.Setenv(home.EnvVar, filepath.Join(root, "state")) + runtimeSearchWrite(t, filepath.Join(root, "long.txt"), "needle"+strings.Repeat("x", 2<<20)+"\n") + tool := runtimeSearchTool(t, "rg", root) + args, _ := json.Marshal(map[string]any{"pattern": "needle", "path": root}) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + text, bad, err := tool.Execute(ctx, args) + if ctx.Err() != nil { + t.Fatalf("scanner stopped without reaping rg: %v", ctx.Err()) + } + if err != nil || !bad || !strings.Contains(text, "bounded reader") { + t.Fatalf("oversized JSON was not reported: %s %v %v", text, bad, err) + } +} +func TestRuntimeGlobEscaping(t *testing.T) { + policy := grepRuntimePolicy{state: "/home/user/state[one]"} + globs := policy.rgGlobs("/home/user") + found := false + for _, glob := range globs { + if glob == "!/state\\[one\\]/v3/projects/*/*/logs" { + found = true + } + } + if !found { + t.Fatalf("custom home treated as glob metacharacters: %v", globs) + } + if policy.excludes("/home/user/state[one]/v3/projects/p/s/work/logs/source.go") { + t.Fatal("source logs directory hidden") + } +} + +func TestRecursiveSearchSkipsOversizedFiles(t *testing.T) { + for _, engine := range []string{"walk", "rg"} { + t.Run(engine, func(t *testing.T) { + root := t.TempDir() + t.Setenv(home.EnvVar, filepath.Join(root, "state")) + runtimeSearchWrite(t, filepath.Join(root, "small.go"), "needle\n") + large := filepath.Join(root, "oversized.go") + runtimeSearchWrite(t, large, "needle\n"+strings.Repeat("x", 8192)) + file, err := os.OpenFile(large, os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + if err = file.Truncate(grepFileBytes + 1); err != nil { + file.Close() + t.Fatal(err) + } + file.Close() + text := runtimeSearchCall(t, runtimeSearchTool(t, engine, root), root, "**/*") + if !strings.Contains(text, "small.go:") || strings.Contains(text, "oversized.go:") { + t.Fatalf("recursive size cap failed: %s", text) + } + }) + } +} diff --git a/internal/exec/bare/grep_stream.go b/internal/exec/bare/grep_stream.go new file mode 100644 index 0000000000..4fd15579c5 --- /dev/null +++ b/internal/exec/bare/grep_stream.go @@ -0,0 +1,94 @@ +package bare + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os" +) + +const ( + grepFileBytes = 8 << 20 + grepLineBytes = 64 << 10 + grepMatchCeiling = 1000 + grepContextCeiling = 20 +) + +func grepSafetyDescription() string { + return fmt.Sprintf(" Skips runtime output and files >%d MiB. Named files allow bounded log inspection.", grepFileBytes>>20) +} + +// Read only the size observed at open, capped independently of file growth. +// One fixed-size line buffer suffices; a long line is drained and skipped, +// rather than accumulating arbitrary JSON/log output in memory. +func scanGrepFile(ctx context.Context, path string, visit func(int, string) error) (limited bool, err error) { + file, err := os.Open(path) + if err != nil { + return false, err + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return false, err + } + if !info.Mode().IsRegular() { + return false, fmt.Errorf("not a regular file: %s", path) + } + size := info.Size() + capped := size > grepFileBytes + if capped { + size = grepFileBytes + limited = true + } + reader := bufio.NewReaderSize(io.LimitReader(file, size), grepLineBytes) + head, _ := reader.Peek(min(8192, int(size))) + if looksBinary(head) { + return limited, nil + } + lineNumber := 0 + discarding := false + for { + if err := ctx.Err(); err != nil { + return limited, err + } + line, readErr := reader.ReadSlice('\n') + if errors.Is(readErr, bufio.ErrBufferFull) { + discarding = true + limited = true + continue + } + // The byte cap may cut a line in half. Do not create a false regex + // end-of-line match at that artificial boundary. + if capped && errors.Is(readErr, io.EOF) && len(line) > 0 && line[len(line)-1] != '\n' { + return true, nil + } + if len(line) > 0 || discarding { + lineNumber++ + if !discarding { + if err := visit(lineNumber, string(line)); err != nil { + return limited, err + } + } + discarding = false + } + if errors.Is(readErr, io.EOF) { + return limited, nil + } + if readErr != nil { + return limited, readErr + } + } +} + +// exec.Cmd drains stderr into this writer without retaining unbounded errors. +type grepErrorBuffer struct{ data []byte } + +func (b *grepErrorBuffer) Write(data []byte) (int, error) { + n := len(data) + if room := 4096 - len(b.data); room > 0 { + b.data = append(b.data, data[:min(room, len(data))]...) + } + return n, nil +} diff --git a/internal/exec/bare/grepfindls.go b/internal/exec/bare/grepfindls.go index 6665de6e46..b478cfd03f 100644 --- a/internal/exec/bare/grepfindls.go +++ b/internal/exec/bare/grepfindls.go @@ -18,9 +18,6 @@ import ( "sort" "strings" "sync" - "syscall" - - "github.com/Agent-Field/codeaf/internal/guard" ) // grepMaxLineLength mirrors pi's truncate.js:GREP_MAX_LINE_LENGTH. @@ -58,7 +55,7 @@ var ripgrepPath = sync.OnceValues(func() (string, bool) { // two sentences that differ are the two facts that differ, and they are stated // rather than left for the model to discover by being surprised. func grepFallbackDescription(caps Caps) string { - return fmt.Sprintf("Search file contents for a pattern. Returns matching lines with file paths and line numbers. Walks the tree itself (ripgrep is not on this machine), so it does NOT read .gitignore — it skips .git, node_modules, vendor and files that look binary. Output is truncated to 100 matches or %s (whichever is hit first). Long lines are truncated to 500 chars.", sizeWord(caps.MaxBytes)) + return fmt.Sprintf("Search file contents for a pattern. Returns matching lines with file paths and line numbers. Walks the tree itself (ripgrep is not on this machine), so it does NOT read .gitignore — it skips .git, node_modules, vendor and files that look binary. Output is truncated to 100 matches or %s (whichever is hit first). Long lines are truncated to 500 chars.", sizeWord(caps.MaxBytes)) + grepSafetyDescription() } // grepToolDescription is the description this machine's `grep` actually carries. @@ -109,6 +106,11 @@ type grepMatch struct { } func newGrepTool(cwd string, caps Caps) Tool { + path, present := ripgrepPath() + return newGrepToolUsing(cwd, caps, path, present) +} + +func newGrepToolUsing(cwd string, caps Caps, rgPath string, haveRipgrep bool) Tool { caps = caps.resolve() return Tool{ Name: "grep", @@ -128,13 +130,11 @@ func newGrepTool(cwd string, caps Caps) Tool { return "Invalid arguments: " + err.Error(), true, nil } - rgPath, haveRipgrep := ripgrepPath() - searchDir := "." if p.Path != nil { searchDir = *p.Path } - searchPath := resolveToCwd(searchDir, cwd) + searchPath := grepCanonical(resolveToCwd(searchDir, cwd)) // Check if path exists and is a directory. info, err := os.Stat(searchPath) @@ -142,14 +142,21 @@ func newGrepTool(cwd string, caps Caps) Tool { return fmt.Sprintf("Path not found: %s", searchPath), true, nil } isDirectory := info.IsDir() + if !isDirectory && !info.Mode().IsRegular() { + return "Search requires a regular file or directory", true, nil + } + policy := newGrepRuntimePolicy() + if isDirectory && policy.excludes(searchPath) { + return "Runtime output is excluded from recursive search. Inspect a specific file for a bounded snapshot.", false, nil + } contextValue := 0 if p.Context != nil && *p.Context > 0 { - contextValue = *p.Context + contextValue = min(*p.Context, grepContextCeiling) } effectiveLimit := 100 if p.Limit != nil && *p.Limit >= 1 { - effectiveLimit = *p.Limit + effectiveLimit = min(*p.Limit, grepMatchCeiling) } var ( @@ -158,12 +165,12 @@ func newGrepTool(cwd string, caps Caps) Tool { matchLimitReached bool linesTruncated bool ) - if !haveRipgrep { + if !haveRipgrep || !isDirectory { // THE FALLBACK ANSWERS IN THE SAME SHAPE, which is the whole // point of it: the same matches, formatted by the same code // below, so nothing downstream — the model, the person's screen, // the fix-recall lane — can tell which engine ran. - found, limitHit, walkErr := grepByWalking(ctx, p.Pattern, searchPath, globOr(p.Glob), boolOr(p.IgnoreCase), boolOr(p.Literal), effectiveLimit) + found, limitHit, bounded, walkErr := grepByWalkingBounded(ctx, p.Pattern, searchPath, globOr(p.Glob), boolOr(p.IgnoreCase), boolOr(p.Literal), effectiveLimit) if walkErr != nil { return walkErr.Error(), true, nil } @@ -171,11 +178,15 @@ func newGrepTool(cwd string, caps Caps) Tool { return "Operation aborted", true, nil } matches, matchCount, matchLimitReached = found, len(found), limitHit - return grepRender(caps, matches, matchCount, matchLimitReached, linesTruncated, contextValue, searchPath, isDirectory, effectiveLimit) + text, failed, renderErr := grepRender(caps, matches, matchCount, matchLimitReached, linesTruncated, contextValue, searchPath, isDirectory, effectiveLimit) + if bounded { + text += fmt.Sprintf("\n[Search incomplete: files over %d MiB or lines over %d KiB were bounded/skipped; a specific file reads its initial snapshot only.]", grepFileBytes>>20, grepLineBytes>>10) + } + return text, failed, renderErr } // Build rg args. - rgArgs := []string{"--json", "--line-number", "--color=never", "--hidden"} + rgArgs := []string{"--no-config", "--no-follow", "--json", "--line-number", "--color=never", "--hidden", fmt.Sprintf("--max-filesize=%d", grepFileBytes)} if p.IgnoreCase != nil && *p.IgnoreCase { rgArgs = append(rgArgs, "--ignore-case") } @@ -185,37 +196,24 @@ func newGrepTool(cwd string, caps Caps) Tool { if p.Glob != nil && *p.Glob != "" { rgArgs = append(rgArgs, "--glob", *p.Glob) } - rgArgs = append(rgArgs, "--", p.Pattern, searchPath) + for _, glob := range policy.rgGlobs(searchPath) { + rgArgs = append(rgArgs, "--glob", glob) + } + rgArgs = append(rgArgs, "--", p.Pattern, ".") cmd := exec.CommandContext(ctx, rgPath, rgArgs...) - cmd.Stderr = nil + cmd.Dir = searchPath + var stderr grepErrorBuffer + cmd.Stderr = &stderr stdout, err := cmd.StdoutPipe() if err != nil { return fmt.Sprintf("Failed to run ripgrep: %s", err.Error()), true, nil } - stderrPipe, _ := cmd.StderrPipe() if err := cmd.Start(); err != nil { return fmt.Sprintf("Failed to run ripgrep: %s", err.Error()), true, nil } - // Collect stderr. - var stderrStr strings.Builder - if stderrPipe != nil { - guard.Go("exec/bare grep stderr", func() { - buf := make([]byte, 4096) - for { - n, err := stderrPipe.Read(buf) - if n > 0 { - stderrStr.Write(buf[:n]) - } - if err != nil { - break - } - } - }) - } - // Parse rg --json output: collect match events. scanner := bufio.NewScanner(stdout) scanner.Buffer(make([]byte, 1024*1024), 1024*1024) @@ -253,23 +251,41 @@ func newGrepTool(cwd string, caps Caps) Tool { } } if filePath != "" && lineNumber > 0 { + if !filepath.IsAbs(filePath) { + filePath = filepath.Join(searchPath, filePath) + } + lineText, truncated := truncateLine(lineText) + linesTruncated = linesTruncated || truncated matches = append(matches, grepMatch{filePath, lineNumber, lineText}) } if matchCount >= effectiveLimit { matchLimitReached = true // Kill the child process to stop it. - _ = cmd.Process.Signal(syscall.SIGTERM) + _ = cmd.Process.Kill() break } } - cmd.Wait() - + scanErr := scanner.Err() + if scanErr != nil { + _ = cmd.Process.Kill() + } + waitErr := cmd.Wait() if ctx.Err() != nil { return "Operation aborted", true, nil } - return grepRender(caps, matches, matchCount, matchLimitReached, linesTruncated, contextValue, searchPath, isDirectory, effectiveLimit) + if scanErr != nil { + return "Search stopped: a ripgrep response exceeded its bounded reader. Inspect a specific file instead: " + scanErr.Error(), true, nil + } + if waitErr != nil && !matchLimitReached { + var exit *exec.ExitError + if !errors.As(waitErr, &exit) || exit.ExitCode() != 1 { + return "Search failed: " + strings.TrimSpace(string(stderr.data)), true, nil + } + } + text, failed, renderErr := grepRender(caps, matches, matchCount, matchLimitReached, linesTruncated, contextValue, searchPath, isDirectory, effectiveLimit) + return text, failed, renderErr }, } } @@ -284,7 +300,12 @@ func grepRender(caps Caps, matches []grepMatch, matchCount int, matchLimitReache } var outputLines []string + renderedBytes := 0 for _, m := range matches { + if renderedBytes > caps.resolve().MaxBytes { + break + } + before := len(outputLines) if contextValue == 0 && m.lineText != "" { relativePath := grepFormatPath(m.filePath, searchPath, isDirectory) sanitized := m.lineText @@ -301,6 +322,9 @@ func grepRender(caps Caps, matches []grepMatch, matchCount int, matchLimitReache block := grepFormatBlock(m.filePath, m.lineNumber, contextValue, searchPath, isDirectory) outputLines = append(outputLines, block...) } + for _, line := range outputLines[before:] { + renderedBytes += len(line) + 1 + } } rawOutput := strings.Join(outputLines, "\n") @@ -309,7 +333,11 @@ func grepRender(caps Caps, matches []grepMatch, matchCount int, matchLimitReache var notices []string if matchLimitReached { - notices = append(notices, fmt.Sprintf("%d matches limit reached. Use limit=%d for more, or refine pattern", effectiveLimit, effectiveLimit*2)) + if effectiveLimit >= grepMatchCeiling { + notices = append(notices, fmt.Sprintf("%d matches limit reached. Refine the pattern or search a narrower path", effectiveLimit)) + } else { + notices = append(notices, fmt.Sprintf("%d matches limit reached. Use limit=%d for more, or refine pattern", effectiveLimit, min(effectiveLimit*2, grepMatchCeiling))) + } } if truncation.truncated { notices = append(notices, fmt.Sprintf("%s limit reached", formatSize(caps.MaxBytes))) @@ -350,6 +378,10 @@ var grepSkippedDirs = map[string]bool{ // dialect ripgrep uses for the patterns models actually write, and it is // guaranteed present because it is compiled in. func grepByWalking(ctx context.Context, pattern, searchPath, glob string, ignoreCase, literal bool, limit int) ([]grepMatch, bool, error) { + found, hit, _, err := grepByWalkingBounded(ctx, pattern, searchPath, glob, ignoreCase, literal, limit) + return found, hit, err +} +func grepByWalkingBounded(ctx context.Context, pattern, searchPath, glob string, ignoreCase, literal bool, limit int) ([]grepMatch, bool, bool, error) { expression := pattern if literal { expression = regexp.QuoteMeta(pattern) @@ -359,54 +391,58 @@ func grepByWalking(ctx context.Context, pattern, searchPath, glob string, ignore } compiled, err := regexp.Compile(expression) if err != nil { - return nil, false, fmt.Errorf("Invalid pattern: %s", err.Error()) + return nil, false, false, fmt.Errorf("Invalid pattern: %s", err) } - - var ( - matches []grepMatch - limitHit bool - stopWalk = errors.New("enough") - globMatch = grepGlobMatcher(glob) - searchInfo os.FileInfo - ) - if searchInfo, err = os.Stat(searchPath); err != nil { - return nil, false, fmt.Errorf("Path not found: %s", searchPath) + searchPath = grepCanonical(searchPath) + searchInfo, err := os.Stat(searchPath) + if err != nil { + return nil, false, false, fmt.Errorf("Path not found: %s", searchPath) } - + policy := newGrepRuntimePolicy() + if searchInfo.IsDir() && policy.excludes(searchPath) { + return nil, false, false, nil + } + limit = min(max(1, limit), grepMatchCeiling) + var matches []grepMatch + hit, bounded := false, false + stop := errors.New("enough matches") + globMatch := grepGlobMatcher(glob) scan := func(path string) error { - data, readErr := os.ReadFile(path) - if readErr != nil || looksBinary(data) { - return nil - } - for index, line := range strings.Split(string(data), "\n") { - if !compiled.MatchString(line) { - continue + clipped, scanErr := scanGrepFile(ctx, path, func(number int, line string) error { + if !compiled.MatchString(strings.TrimSuffix(line, "\n")) { + return nil } - matches = append(matches, grepMatch{filePath: path, lineNumber: index + 1, lineText: line}) + text, _ := truncateLine(strings.TrimSuffix(line, "\n")) + matches = append(matches, grepMatch{path, number, text}) if len(matches) >= limit { - limitHit = true - return stopWalk + hit = true + return stop } - } - return nil + return nil + }) + bounded = bounded || clipped + return scanErr } - if !searchInfo.IsDir() { - if err := scan(searchPath); err != nil && err != stopWalk { - return nil, false, err + err = scan(searchPath) + if err == stop { + err = nil } - return matches, limitHit, nil + return matches, hit, bounded, err } - err = filepath.WalkDir(searchPath, func(path string, entry os.DirEntry, walkErr error) error { + if err := ctx.Err(); err != nil { + return err + } if walkErr != nil { - // An unreadable directory is skipped rather than fatal: a search - // that dies on one permission-denied folder answers nothing about - // the thousand folders it could have read. + bounded = true return nil } - if ctx.Err() != nil { - return ctx.Err() + if policy.excludes(path) { + if entry.IsDir() { + return filepath.SkipDir + } + return nil } if entry.IsDir() { if path != searchPath && grepSkippedDirs[entry.Name()] { @@ -420,15 +456,24 @@ func grepByWalking(ctx context.Context, pattern, searchPath, glob string, ignore if globMatch != nil && !globMatch(path, searchPath) { return nil } - return scan(path) - }) - if err != nil && err != stopWalk { - if ctx.Err() != nil { - return nil, false, nil + info, err := entry.Info() + if err != nil || info.Size() > grepFileBytes { + bounded = true + return nil } - return nil, false, err + err = scan(path) + if err == stop || ctx.Err() != nil { + return err + } + if err != nil { + bounded = true + } + return nil + }) + if err == stop { + err = nil } - return matches, limitHit, nil + return matches, hit, bounded, err } // grepGlobMatcher turns the tool's `glob` argument into a per-file test, or nil @@ -513,39 +558,29 @@ func grepFormatPath(filePath, searchPath string, isDirectory bool) string { // line, mirroring pi's formatBlock. func grepFormatBlock(filePath string, lineNumber, contextValue int, searchPath string, isDirectory bool) []string { relativePath := grepFormatPath(filePath, searchPath, isDirectory) - data, err := os.ReadFile(filePath) - if err != nil { - return []string{fmt.Sprintf("%s:%d: (unable to read file)", relativePath, lineNumber)} - } - lines := strings.Split(strings.ReplaceAll(strings.ReplaceAll(string(data), "\r\n", "\n"), "\r", "\n"), "\n") - if len(lines) == 0 { - return []string{fmt.Sprintf("%s:%d: (unable to read file)", relativePath, lineNumber)} - } + contextValue = min(max(contextValue, 0), grepContextCeiling) + start, end := max(1, lineNumber-contextValue), lineNumber+contextValue var block []string - start := lineNumber - end := lineNumber - if contextValue > 0 { - start = lineNumber - contextValue - if start < 1 { - start = 1 + stop := errors.New("context complete") + _, err := scanGrepFile(context.Background(), filePath, func(current int, line string) error { + if current > end { + return stop } - end = lineNumber + contextValue - if end > len(lines) { - end = len(lines) + if current >= start { + text, _ := truncateLine(strings.TrimRight(strings.ReplaceAll(line, "\r", ""), "\n")) + if current == lineNumber { + block = append(block, fmt.Sprintf("%s:%d: %s", relativePath, current, text)) + } else { + block = append(block, fmt.Sprintf("%s-%d- %s", relativePath, current, text)) + } } - } - for current := start; current <= end; current++ { - lineText := "" - if current-1 < len(lines) { - lineText = strings.ReplaceAll(lines[current-1], "\r", "") - } - isMatchLine := current == lineNumber - truncatedText, _ := truncateLine(lineText) - if isMatchLine { - block = append(block, fmt.Sprintf("%s:%d: %s", relativePath, current, truncatedText)) - } else { - block = append(block, fmt.Sprintf("%s-%d- %s", relativePath, current, truncatedText)) + if current >= end { + return stop } + return nil + }) + if err != nil && err != stop { + return []string{fmt.Sprintf("%s:%d: (unable to read bounded context)", relativePath, lineNumber)} } return block } diff --git a/internal/exec/bare/tools.go b/internal/exec/bare/tools.go index 95f32a196b..e297fe8bbe 100644 --- a/internal/exec/bare/tools.go +++ b/internal/exec/bare/tools.go @@ -181,7 +181,7 @@ const editDescription = "Edit a single file using exact text replacement. Every const writeDescription = "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories." func grepDescription(caps Caps) string { - return fmt.Sprintf("Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to 100 matches or %s (whichever is hit first). Long lines are truncated to 500 chars.", sizeWord(caps.MaxBytes)) + return fmt.Sprintf("Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to 100 matches or %s (whichever is hit first). Long lines are truncated to 500 chars.", sizeWord(caps.MaxBytes)) + grepSafetyDescription() } func findDescription(caps Caps) string { diff --git a/internal/manual/chat/what-i-can-do.md b/internal/manual/chat/what-i-can-do.md index 806c147787..bd777500ee 100644 --- a/internal/manual/chat/what-i-can-do.md +++ b/internal/manual/chat/what-i-can-do.md @@ -132,7 +132,7 @@ Any single line longer than 500 characters is cut and marked `... [truncated]`. **It works whether or not the machine has ripgrep.** With ripgrep it shells out to it and respects `.gitignore`. Without ripgrep it walks the tree itself, with -the same arguments, the same caps and the same output — it just does not read +the same arguments and output format — it does not read `.gitignore`, and skips `.git`, `node_modules`, `vendor` and files that look binary instead. The tool description says which of the two you have. It never answers `ripgrep (rg) is not available and could not be downloaded` any more: @@ -152,6 +152,21 @@ directory. Default **500 entries**, and at the cap: All three are capped at the same size as `read` — the model's own cap, 50KB by default — and all three are pure reads, so none of them asks your permission. +## Searching runtime logs and growing files + +Recursive `grep` skips codeaf runtime logs and saved transcripts, including +custom `CODEAF_HOME`, even when the requested directory is inside that state +home. Source under `work/` and `trees/`, and ordinary source directories named +`logs`, remain searchable. A `glob` cannot re-include runtime output; recursive +search does not follow symlink directories. A named log file can still be +inspected: it reads at most the first **8 MiB present when opened**, so a growing +log cannot keep the search running indefinitely. Recursive search skips files +larger than **8 MiB**. The walking reader skips lines over **64 KiB**, reports +incomplete results, and uses the same streaming reader for context. Requests are +capped at **1000 matches** and **20 context lines per side**. If a ripgrep JSON +response exceeds **1 MiB**, the tool stops it and asks for single-file inspection. +Shell `rg` and `grep` commands do not inherit the structured tool's exclusions. + ## Can you run tests for me or start a dev server? Yes. The `bash` tool runs tests, builds and development servers through `/bin/bash -c` diff --git a/internal/session/prompts/bashworker.md b/internal/session/prompts/bashworker.md index 9400b321ab..eac52c5087 100644 --- a/internal/session/prompts/bashworker.md +++ b/internal/session/prompts/bashworker.md @@ -125,7 +125,7 @@ Parallelism lives in the shell, not in the batch: ``` cmd1 & cmd2 & wait # two commands at once, both waited for -find . -type f -name '' | xargs -P 4 grep -l +rg -l -g '' # one search instead of find | grep git grep -n "theSymbol" # one search instead of three ``` @@ -152,9 +152,19 @@ The idioms, in place of the tools other belts carry: through a `sed -i` aimed at one region. Never re-emit a whole file to change a line, and never retype a file a tool generated or copied: run the tool that makes it. -- Search inside a repository with git grep -n pattern — it respects - .gitignore the way a search tool would. Outside a repository, grep -rn - --exclude-dir=.git pattern. +- Search tracked repository source with `git grep -n -- pattern`. Otherwise + point `rg --no-config --no-follow -n -- pattern path/to/source` at a narrow + source directory. Exclude legacy output with + `-g '!**/.codeaf/{jobs,logs,stubs,trace}/**'`. For a broader search, also + exclude the actual runtime logs/tasks/transcripts beneath + `${CODEAF_HOME:-$HOME/.codeaf}`; custom state roots need their own exclusions. + Source under that home's `work` or `trees` remains a legitimate target. + Neither a shell `rg` nor `grep -rn` inherits the structured grep tool's + exclusions: no command is rewritten for you. Never recursively search a + directory of live job logs. Inspect a named log with a byte limit, e.g. + `tail -c 65536 -- /path/to/job.log`, or the structured grep tool's bounded + single-file inspection when available. + A big result is cut to its first half and its last half, and the WHOLE output is filed beside this node's own log; the result names that file with a line From 97b04eeea5e12a6868fa14bda071fa2afd55e61d Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Sun, 27 Sep 2026 01:24:57 -0400 Subject: [PATCH 24/76] docs: consolidate bounded runtime logs change entry --- .../unreleased/1599-bounded-job-logs.md | 32 ++++++++++--------- docs/changes/unreleased/1599-job-retention.md | 32 ------------------- .../changes/unreleased/1599-runtime-search.md | 23 ------------- 3 files changed, 17 insertions(+), 70 deletions(-) delete mode 100644 docs/changes/unreleased/1599-job-retention.md delete mode 100644 docs/changes/unreleased/1599-runtime-search.md diff --git a/docs/changes/unreleased/1599-bounded-job-logs.md b/docs/changes/unreleased/1599-bounded-job-logs.md index 3ab5b9d9c0..7b84692603 100644 --- a/docs/changes/unreleased/1599-bounded-job-logs.md +++ b/docs/changes/unreleased/1599-bounded-job-logs.md @@ -1,22 +1,24 @@ --- kind: fixed -title: a background job's log is a bounded spool that admits its truncation +title: bound background logs and keep recursive searches out of runtime output pr: 1599 surface: [chat, engine] invalidates: - - "A background job's log was unbounded: everything the job ever wrote went to - one .log forever, so a watcher printing for a week filled the disk. The - spool is now a window of two 4MB chunks (.log and .log.1); older - output is discarded with a notice." - - "The manual and the jobs tool promised the whole log on disk. What a job - keeps is its most recent chunks, and when output has been discarded the - footer and the completion note name the truncation instead of saying full - log." - - "A failed, short or unclosable job-log write was swallowed silently. It is - still never fatal to the job, but the jobs footer now says the log stopped - and why." + - "Background jobs wrote unbounded logs and promised full output. Each job now retains two 4 MiB chunks, reports discarded output and disk failures, and continues draining the child process." + - "Completed job logs could accumulate without an aggregate budget. Eligible completed managed logs now share a 128 MiB and 64-job budget per directory, while startup preserves seven-day expiry and durable job ID history. Active and unmarked legacy logs are protected from cleanup." + - "Recursive grep could read its own runtime output and the fallback loaded whole files. Both engines now exclude known runtime output, while explicit file inspection and fallback reads use bounded snapshots. Shell search guidance now requires explicit exclusions and byte-limited log reads." --- -A job's retained output stays addressable by the read tool exactly as before; -the window is what changed. The bound is fixed bytes per job, never a -wall-clock or a rate. +The spool retains the same active file and lease through rotation, so recursive +readers cannot chase an ever-growing old inode. Write, rotation and close errors +remain visible without failing the child process. Retention uses durable ID +allocation, ownership markers and independent file leases. Unsafe linked files +or storage ancestors are refused; legacy output remains outside the budget. +The bounds apply per job and per jobs directory, not across the machine. + +Structured search preserves source worktrees, including those inside custom +state homes. Recursive searches skip files above 8 MiB. Explicit inspection reads +at most the first 8 MiB present at open; oversized 64 KiB lines are skipped with +an incomplete-result notice. Requests allow at most 1000 matches and 20 context +lines per side. Oversized ripgrep records terminate and reap the child with an +explicit error. Arbitrary shell commands do not inherit structured exclusions. diff --git a/docs/changes/unreleased/1599-job-retention.md b/docs/changes/unreleased/1599-job-retention.md deleted file mode 100644 index 4f1befbae3..0000000000 --- a/docs/changes/unreleased/1599-job-retention.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -kind: added -title: completed job logs have a bounded retention budget -pr: 1599 -surface: [chat, engine] -invalidates: - - "Completed managed spools are retained under a per-directory limit of 128 MiB and 64 job groups, counting each base and rotation together. Active spools retain their independent per-job cap; legacy and unsafe files are preserved outside this budget." - - "Job IDs use durable high-water metadata under a stable directory lock. Allocation creates the file, takes its active lease, and publishes ownership within the same transaction. Missing or damaged metadata refuses allocation instead of resetting IDs." - - "A jobs footer checks for subsequent eviction and retains the in-memory output tail when the disk log is gone. Maintenance errors are reported without turning a completed job into a process failure." ---- - -Maintenance runs when a job claims its log, after its sink closes, and for -existing jobs directories during the startup sweep. It does not crawl the machine or run on every write. -Startup expires managed inactive groups whose two chunks are older than seven -days, preserving the prior payload TTL without expiring allocation metadata. -Then completed groups with the oldest allocated IDs are removed first. The cleaner -holds an independent file lease through removal; the writer must keep the same -main-log descriptor through rotation and spool failures until sink close. - -Directory-relative operations reject symlinked jobs directories and ancestors -(except the platform's canonical temporary-directory alias). Metadata reads are -limited to 256 bytes; counter replacement uses an exclusive random temporary. -Ownership markers remain if a payload removal fails. Unmarked legacy logs may -have older live writers without leases, so they are never automatically removed. - -The permanent lock records initialization, so a missing counter after all logs -have been evicted still refuses new allocation. Removing all allocation metadata -manually destroys that history and is not supported. On Windows the counter -file is flushed before rooted replacement; a directory flush is unavailable. - -The startup TTL sweeper delegates `logs/jobs/` to this retention instead of -expiring the stable allocation metadata or ownership markers by age. diff --git a/docs/changes/unreleased/1599-runtime-search.md b/docs/changes/unreleased/1599-runtime-search.md deleted file mode 100644 index 20b3f4bd9d..0000000000 --- a/docs/changes/unreleased/1599-runtime-search.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -kind: fixed -title: recursive searches skip runtime output and bound log inspection -pr: 1599 -surface: [chat, engine] -invalidates: - - "Recursive grep could read codeaf's own job output and repeat earlier searches. Both the ripgrep and walking engines now exclude known runtime logs and transcripts, even when the requested directory is inside the state home, while keeping source worktrees searchable." - - "The walking fallback and context renderer loaded entire files. They now stream a bounded initial snapshot, skipping oversized lines; naming one log file still permits bounded inspection." - - "Shell search guidance recommended recursive grep without runtime exclusions. Shell commands are not rewritten, so the guidance now asks for narrow source roots, explicit runtime exclusions, and byte-limited log reads." ---- - -The state root comes from `home.Dir()`, including custom `CODEAF_HOME` and -resolved aliases. Exclusions are applied after user globs and symlink directory -traversal is disabled. Ordinary source directories named `logs` remain visible; -only known runtime locations and legacy `.codeaf` output directories are skipped. - -Recursive file search skips files over 8 MiB. Explicit single-file inspection -reads at most the first 8 MiB present at open; append activity cannot extend that -snapshot. The walking reader skips lines over 64 KiB and reports incomplete -results, and context rendering uses that same bounded reader. Requests allow at -most 1000 matches and 20 context lines per side. A ripgrep JSON response over its -1 MiB reader limit terminates and reaps the child, returning an explicit error -instead of waiting with an undrained pipe. From 8690d9b2c47acd5cfc66c5930d75b6e5ed344dc2 Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Sun, 27 Sep 2026 01:27:04 -0400 Subject: [PATCH 25/76] docs: use plain language for retained log ownership --- internal/manual/chat/what-i-can-do.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/manual/chat/what-i-can-do.md b/internal/manual/chat/what-i-can-do.md index bd777500ee..7226142b6d 100644 --- a/internal/manual/chat/what-i-can-do.md +++ b/internal/manual/chat/what-i-can-do.md @@ -412,7 +412,7 @@ active jobs are never touched, and files codeaf did not create there — older unmarked logs included — are left alone. If a log you were pointed at has since been evicted, the jobs footer says so instead of naming the file, and the last 64KB in memory is still readable. Cleanup runs when a job starts or -finishes and during the startup sweep of existing jobs directories; active jobs, legacy logs, and files whose ownership cannot be verified +finishes and during the startup sweep of existing jobs directories; active jobs, legacy logs, and files without safe ownership records are outside this completed-log budget. A cleanup failure is reported and may leave the directory over budget. Damaged allocation metadata refuses new logs rather than reusing previous job IDs. From 16a4644e5ac42bee884f69f533a42eb691c4de7b Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Sun, 27 Sep 2026 01:27:22 -0400 Subject: [PATCH 26/76] docs: make completed log retention separately searchable --- internal/manual/chat/what-i-can-do.md | 29 +++++++++++++++------------ 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/internal/manual/chat/what-i-can-do.md b/internal/manual/chat/what-i-can-do.md index 7226142b6d..ef847006a4 100644 --- a/internal/manual/chat/what-i-can-do.md +++ b/internal/manual/chat/what-i-can-do.md @@ -404,19 +404,6 @@ spool has rotated, the note names both retained files; after older output is discarded, it also names the truncation instead of promising a full log. -Finished logs do not pile up forever either. Each session's logs/jobs -directory keeps managed finished logs within **128 MiB and 64 jobs** -(a job's log and its one rotation count together), evicting the oldest job IDs first. Startup also expires inactive managed logs -whose base and rotated chunk have both been untouched for **7 days**; -active jobs are never touched, and files codeaf did not create there — older -unmarked logs included — are left alone. If a log you were pointed at has -since been evicted, the jobs footer says so instead of naming the file, and -the last 64KB in memory is still readable. Cleanup runs when a job starts or -finishes and during the startup sweep of existing jobs directories; active jobs, legacy logs, and files without safe ownership records -are outside this completed-log budget. A cleanup failure is reported and may -leave the directory over budget. Damaged allocation metadata refuses new logs -rather than reusing previous job IDs. - The `jobs` tool looks at all of this. Its `action` is `list`, `output` or `kill`. - `list` — one row per job: `job 1 · exited(0) · 12.4s · go build ./...`. @@ -459,6 +446,22 @@ parts it has, and however long it takes — belongs to a task instead, which giv to watch and a report you can read. If a multi-part piece of work was started as a background command, say so: it can be handed to a task instead. +## How much disk space do finished job logs keep? + +Finished logs do not pile up forever either. Each session's logs/jobs +directory keeps managed finished logs within **128 MiB and 64 jobs** +(a job's log and its one rotation count together), evicting the oldest job IDs first. Startup also expires inactive managed logs +whose base and rotated chunk have both been untouched for **7 days**; +active jobs are never touched, and files codeaf did not create there — older +unmarked logs included — are left alone. If a log you were pointed at has +since been evicted, the jobs footer says so instead of naming the file, and +the last 64KB in memory is still readable. Cleanup runs when a job starts or +finishes and during the startup sweep of existing jobs directories; active jobs, legacy logs, and files without safe ownership records +are outside this completed-log budget. A cleanup failure is reported and may +leave the directory over budget. Damaged allocation metadata refuses new logs +rather than reusing previous job IDs. + + ## Why can a job not create its log? Job-log setup refuses symlinked storage paths, including a symlinked codeaf home From 659383dbdda4048d8f4995269a2095adcbe30bb4 Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Sun, 27 Sep 2026 01:28:04 -0400 Subject: [PATCH 27/76] fix: preserve grep matches when bounded context skips long lines --- internal/exec/bare/grep_runtime_test.go | 30 +++++++++++++++++++ internal/exec/bare/grepfindls.go | 39 ++++++++++++++++--------- 2 files changed, 55 insertions(+), 14 deletions(-) diff --git a/internal/exec/bare/grep_runtime_test.go b/internal/exec/bare/grep_runtime_test.go index 1a430d44e4..6a32397fb4 100644 --- a/internal/exec/bare/grep_runtime_test.go +++ b/internal/exec/bare/grep_runtime_test.go @@ -289,3 +289,33 @@ func TestRecursiveSearchSkipsOversizedFiles(t *testing.T) { }) } } + +func TestGrepOversizedLineWithContextBothEngines(t *testing.T) { + for _, engine := range []string{"walk", "rg"} { + t.Run(engine, func(t *testing.T) { + root := t.TempDir() + t.Setenv(home.EnvVar, filepath.Join(root, "state")) + runtimeSearchWrite(t, filepath.Join(root, "long.txt"), "before\nneedle-long"+strings.Repeat("x", 80<<10)+"\nneedle-short\nafter\n") + tool := runtimeSearchTool(t, engine, root) + args, _ := json.Marshal(map[string]any{"pattern": "needle", "path": root, "context": 1}) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + text, bad, err := tool.Execute(ctx, args) + if err != nil || bad || !strings.Contains(text, "long.txt:3: needle-short") || !strings.Contains(text, "long.txt-4- after") || !strings.Contains(text, "Context incomplete") { + t.Fatalf("bounded context lost its match or notice: %q %v %v", text, bad, err) + } + if engine == "rg" && (!strings.Contains(text, "long.txt:2: needle-long") || !strings.Contains(text, "long.txt-1- before")) { + t.Fatalf("native long-line match disappeared during context rendering: %q", text) + } + }) + } +} + +func TestGrepContextPreservesMatchAfterFileDisappears(t *testing.T) { + root := t.TempDir() + match := grepMatch{filePath: filepath.Join(root, "gone.txt"), lineNumber: 2, lineText: "needle"} + text, bad, err := grepRender(DefaultCaps(), []grepMatch{match}, 1, false, false, 1, root, true, 100) + if err != nil || bad || !strings.Contains(text, "gone.txt:2: needle") || !strings.Contains(text, "Context incomplete") { + t.Fatalf("unavailable context erased original match: %q %v %v", text, bad, err) + } +} diff --git a/internal/exec/bare/grepfindls.go b/internal/exec/bare/grepfindls.go index b478cfd03f..b19a40d545 100644 --- a/internal/exec/bare/grepfindls.go +++ b/internal/exec/bare/grepfindls.go @@ -301,6 +301,7 @@ func grepRender(caps Caps, matches []grepMatch, matchCount int, matchLimitReache var outputLines []string renderedBytes := 0 + contextIncomplete := false for _, m := range matches { if renderedBytes > caps.resolve().MaxBytes { break @@ -319,7 +320,8 @@ func grepRender(caps Caps, matches []grepMatch, matchCount int, matchLimitReache outputLines = append(outputLines, fmt.Sprintf("%s:%d: %s", relativePath, m.lineNumber, truncatedText)) } else { // Context mode: read the file and format a block. - block := grepFormatBlock(m.filePath, m.lineNumber, contextValue, searchPath, isDirectory) + block, incomplete := grepFormatBlock(m, contextValue, searchPath, isDirectory) + contextIncomplete = contextIncomplete || incomplete outputLines = append(outputLines, block...) } for _, line := range outputLines[before:] { @@ -332,6 +334,9 @@ func grepRender(caps Caps, matches []grepMatch, matchCount int, matchLimitReache output := truncation.content var notices []string + if contextIncomplete { + notices = append(notices, "Context incomplete: bounded or unavailable lines were skipped; original matches are preserved") + } if matchLimitReached { if effectiveLimit >= grepMatchCeiling { notices = append(notices, fmt.Sprintf("%d matches limit reached. Refine the pattern or search a narrower path", effectiveLimit)) @@ -556,22 +561,26 @@ func grepFormatPath(filePath, searchPath string, isDirectory bool) string { // grepFormatBlock reads a file and formats a context block around a match // line, mirroring pi's formatBlock. -func grepFormatBlock(filePath string, lineNumber, contextValue int, searchPath string, isDirectory bool) []string { - relativePath := grepFormatPath(filePath, searchPath, isDirectory) +func grepFormatBlock(m grepMatch, contextValue int, searchPath string, isDirectory bool) ([]string, bool) { + relativePath := grepFormatPath(m.filePath, searchPath, isDirectory) contextValue = min(max(contextValue, 0), grepContextCeiling) - start, end := max(1, lineNumber-contextValue), lineNumber+contextValue - var block []string + start, end := max(1, m.lineNumber-contextValue), m.lineNumber+contextValue + var before, after []string + seenMatch := false stop := errors.New("context complete") - _, err := scanGrepFile(context.Background(), filePath, func(current int, line string) error { + limited, err := scanGrepFile(context.Background(), m.filePath, func(current int, line string) error { if current > end { return stop } - if current >= start { + if current == m.lineNumber { + seenMatch = true + } else if current >= start { text, _ := truncateLine(strings.TrimRight(strings.ReplaceAll(line, "\r", ""), "\n")) - if current == lineNumber { - block = append(block, fmt.Sprintf("%s:%d: %s", relativePath, current, text)) + formatted := fmt.Sprintf("%s-%d- %s", relativePath, current, text) + if current < m.lineNumber { + before = append(before, formatted) } else { - block = append(block, fmt.Sprintf("%s-%d- %s", relativePath, current, text)) + after = append(after, formatted) } } if current >= end { @@ -579,10 +588,12 @@ func grepFormatBlock(filePath string, lineNumber, contextValue int, searchPath s } return nil }) - if err != nil && err != stop { - return []string{fmt.Sprintf("%s:%d: (unable to read bounded context)", relativePath, lineNumber)} - } - return block + // Context is a second, bounded read. Preserve the engine's original match + // even if that line is too long for this reader or the file has changed. + text, _ := truncateLine(strings.TrimRight(strings.ReplaceAll(m.lineText, "\r", ""), "\n")) + block := append(before, fmt.Sprintf("%s:%d: %s", relativePath, m.lineNumber, text)) + block = append(block, after...) + return block, limited || !seenMatch || (err != nil && err != stop) } // truncateHeadNoLineLimit applies truncateHead with effectively no line limit From f732247b6ced09c6b060ce58b190b11f1d6da2b1 Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Sun, 27 Sep 2026 01:38:04 -0400 Subject: [PATCH 28/76] docs: associate bounded logs change with PR 1603 --- .../{1599-bounded-job-logs.md => 1603-bounded-job-logs.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename docs/changes/unreleased/{1599-bounded-job-logs.md => 1603-bounded-job-logs.md} (99%) diff --git a/docs/changes/unreleased/1599-bounded-job-logs.md b/docs/changes/unreleased/1603-bounded-job-logs.md similarity index 99% rename from docs/changes/unreleased/1599-bounded-job-logs.md rename to docs/changes/unreleased/1603-bounded-job-logs.md index 7b84692603..4e4f603bee 100644 --- a/docs/changes/unreleased/1599-bounded-job-logs.md +++ b/docs/changes/unreleased/1603-bounded-job-logs.md @@ -1,7 +1,7 @@ --- kind: fixed title: bound background logs and keep recursive searches out of runtime output -pr: 1599 +pr: 1603 surface: [chat, engine] invalidates: - "Background jobs wrote unbounded logs and promised full output. Each job now retains two 4 MiB chunks, reports discarded output and disk failures, and continues draining the child process." From b22359d0711133e2ebf413b25c4c5e8778c4d7dd Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Sun, 27 Sep 2026 01:43:49 -0400 Subject: [PATCH 29/76] fix: keep runtime search guidance within prompt budgets --- internal/exec/bare/grep_stream.go | 2 +- internal/exec/bare/grepfindls.go | 2 +- internal/exec/bare/tools.go | 2 +- internal/session/prompts/bashworker.md | 37 +++++++++----------------- 4 files changed, 16 insertions(+), 27 deletions(-) diff --git a/internal/exec/bare/grep_stream.go b/internal/exec/bare/grep_stream.go index 4fd15579c5..9dea843136 100644 --- a/internal/exec/bare/grep_stream.go +++ b/internal/exec/bare/grep_stream.go @@ -17,7 +17,7 @@ const ( ) func grepSafetyDescription() string { - return fmt.Sprintf(" Skips runtime output and files >%d MiB. Named files allow bounded log inspection.", grepFileBytes>>20) + return fmt.Sprintf(" Skips runtime output and files >%d MiB; named logs get bounded reads.", grepFileBytes>>20) } // Read only the size observed at open, capped independently of file growth. diff --git a/internal/exec/bare/grepfindls.go b/internal/exec/bare/grepfindls.go index b19a40d545..11ff008949 100644 --- a/internal/exec/bare/grepfindls.go +++ b/internal/exec/bare/grepfindls.go @@ -55,7 +55,7 @@ var ripgrepPath = sync.OnceValues(func() (string, bool) { // two sentences that differ are the two facts that differ, and they are stated // rather than left for the model to discover by being surprised. func grepFallbackDescription(caps Caps) string { - return fmt.Sprintf("Search file contents for a pattern. Returns matching lines with file paths and line numbers. Walks the tree itself (ripgrep is not on this machine), so it does NOT read .gitignore — it skips .git, node_modules, vendor and files that look binary. Output is truncated to 100 matches or %s (whichever is hit first). Long lines are truncated to 500 chars.", sizeWord(caps.MaxBytes)) + grepSafetyDescription() + return fmt.Sprintf("Search contents; returns path:line:match. No rg: ignores .gitignore, skips .git/node_modules/vendor and binary files. Default cap: 100 matches or %s; lines clipped to 500 chars.", sizeWord(caps.MaxBytes)) + grepSafetyDescription() } // grepToolDescription is the description this machine's `grep` actually carries. diff --git a/internal/exec/bare/tools.go b/internal/exec/bare/tools.go index e297fe8bbe..50a86997b8 100644 --- a/internal/exec/bare/tools.go +++ b/internal/exec/bare/tools.go @@ -181,7 +181,7 @@ const editDescription = "Edit a single file using exact text replacement. Every const writeDescription = "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories." func grepDescription(caps Caps) string { - return fmt.Sprintf("Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to 100 matches or %s (whichever is hit first). Long lines are truncated to 500 chars.", sizeWord(caps.MaxBytes)) + grepSafetyDescription() + return fmt.Sprintf("Search contents; returns path:line:match. Respects .gitignore. Default cap: 100 matches or %s; lines clipped to 500 chars.", sizeWord(caps.MaxBytes)) + grepSafetyDescription() } func findDescription(caps Caps) string { diff --git a/internal/session/prompts/bashworker.md b/internal/session/prompts/bashworker.md index eac52c5087..260626f92e 100644 --- a/internal/session/prompts/bashworker.md +++ b/internal/session/prompts/bashworker.md @@ -1,14 +1,10 @@ ## Working through bash -This belt carries ONE tool: `bash`. The hands other workers reach for as tools -are shell commands here, and this page is their doctrine. Everything on this -belt that is not a shell command is named at the bottom. +This belt carries ONE tool: `bash`. Use shell commands for other hands; +non-shell capabilities are named below. -A response that carries two calls, or a call for a hand that is not here, or -arguments that do not parse, runs NOTHING: what comes back instead is a line -beginning `[not run]` saying what was wrong, and nothing has entered the world. -Fix the shape and send the command again — a good call was never the problem, -so the step before a rejection is simply the corrected call. +Two calls, an unavailable hand, or invalid arguments run NOTHING. The +`[not run]` response explains why. Correct the call and send it again. Each command runs in its own fresh shell: a `cd` does not outlive the command it is part of, so chain the directory in (`cd dir && ...`) or use @@ -16,10 +12,8 @@ the path. ## Think once, then act -The observation you already hold is the record: reason between calls only far -enough to choose the next command — one decision, and not a replay of the -brief, the plan or the last output. Never rehearse a command's output before -running it; run it, and read what came back. +Reason between calls only to choose the next command. Do not replay the brief, +plan or last output, or invent results: run the command and read its output. ## The plan @@ -152,18 +146,13 @@ The idioms, in place of the tools other belts carry: through a `sed -i` aimed at one region. Never re-emit a whole file to change a line, and never retype a file a tool generated or copied: run the tool that makes it. -- Search tracked repository source with `git grep -n -- pattern`. Otherwise - point `rg --no-config --no-follow -n -- pattern path/to/source` at a narrow - source directory. Exclude legacy output with - `-g '!**/.codeaf/{jobs,logs,stubs,trace}/**'`. For a broader search, also - exclude the actual runtime logs/tasks/transcripts beneath - `${CODEAF_HOME:-$HOME/.codeaf}`; custom state roots need their own exclusions. - Source under that home's `work` or `trees` remains a legitimate target. - Neither a shell `rg` nor `grep -rn` inherits the structured grep tool's - exclusions: no command is rewritten for you. Never recursively search a - directory of live job logs. Inspect a named log with a byte limit, e.g. - `tail -c 65536 -- /path/to/job.log`, or the structured grep tool's bounded - single-file inspection when available. +- Search source with `git grep -n -- pattern` or narrow + `rg --no-config --no-follow -n -- pattern path/to/source`. + Shell searches get no automatic exclusions. Exclude + `-g '!**/.codeaf/{jobs,logs,stubs,trace}/**'` and runtime logs/tasks/transcripts + under `${CODEAF_HOME:-$HOME/.codeaf}`, including custom roots; `work`/`trees` + source remains searchable. Never recurse into live logs. Inspect one log + with `tail -c 65536 -- /path/to/job.log`. A big result is cut to its first half and its last half, and the WHOLE output From c847d9e8438b0ee826f75e00761dca73f62bc47b Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 01:16:00 -0400 Subject: [PATCH 30/76] remote: a redial's ssh stderr stays out of the full-screen frame Every launch of the ssh link teed its stderr to the terminal, so a redial while the TUI held the alternate screen painted ssh's diagnostics over it. Only the first launch writes to the terminal now; a redial's stderr goes to the session's diagnostic tail. Part of #1553 (the stderr item; the keyboard-focus item stays open) Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/chatv3_host.go | 15 ++++++++++++++- cmd/codeaf/chatv3_host_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/cmd/codeaf/chatv3_host.go b/cmd/codeaf/chatv3_host.go index 194255e9c7..5bfc061e28 100644 --- a/cmd/codeaf/chatv3_host.go +++ b/cmd/codeaf/chatv3_host.go @@ -200,7 +200,7 @@ func (l *engineLink) spawn() (io.ReadWriteCloser, error) { // is how a passphrase prompt and a host-key question reach the person — and // the tail is kept so that a handshake failure can name the likely cause. tail := &tailWriter{} - process.Stderr = io.MultiWriter(os.Stderr, tail) + process.Stderr = l.stderrWriter(tail, os.Stderr) if err := process.Start(); err != nil { if strings.Contains(err.Error(), "executable file not found") { return nil, fmt.Errorf("this machine has no ssh on its path, and --host is ssh") @@ -211,6 +211,19 @@ func (l *engineLink) spawn() (io.ReadWriteCloser, error) { return pipePair{r: stdout, w: stdin}, nil } +// stderrWriter keeps the launch-time prompts visible but keeps redial output +// inside the session's diagnostic tail. A reconnect happens while Bubble Tea +// owns the terminal's alternate screen, so writing ssh's transient errors to +// os.Stderr would paint over the frame instead of becoming a status detail. +func (l *engineLink) stderrWriter(tail *tailWriter, terminal io.Writer) io.Writer { + l.mu.Lock() + defer l.mu.Unlock() + if l.process == nil { + return io.MultiWriter(terminal, tail) + } + return tail +} + // sshTransportArgs keeps the carrier's latency policy in one place. -T remains // first because a pseudo-terminal changes bytes; the other options keep a warm // connection available for redials, notice a machine that stopped answering, diff --git a/cmd/codeaf/chatv3_host_test.go b/cmd/codeaf/chatv3_host_test.go index 152a66626a..d16f486fba 100644 --- a/cmd/codeaf/chatv3_host_test.go +++ b/cmd/codeaf/chatv3_host_test.go @@ -1,9 +1,11 @@ package main import ( + "bytes" "context" "errors" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -20,6 +22,30 @@ import ( // of a remote session. var _ tui3.Agent = (*remote.Agent)(nil) +func TestRedialSSHStderrStaysOutOfTheTerminal(t *testing.T) { + link := &engineLink{} + terminal := new(bytes.Buffer) + first := &tailWriter{} + if _, err := link.stderrWriter(first, terminal).Write([]byte("host-key prompt\n")); err != nil { + t.Fatal(err) + } + if terminal.String() != "host-key prompt\n" { + t.Fatalf("the first ssh prompt was not shown: %q", terminal.String()) + } + + link.process = &exec.Cmd{} + redial := &tailWriter{} + if _, err := link.stderrWriter(redial, terminal).Write([]byte("connection refused\n")); err != nil { + t.Fatal(err) + } + if terminal.String() != "host-key prompt\n" { + t.Fatalf("redial stderr painted the terminal: %q", terminal.String()) + } + if got := redial.String(); got != "connection refused\n" { + t.Fatalf("redial stderr was not retained: %q", got) + } +} + func TestParseHostTarget(t *testing.T) { cases := []struct { raw string From a6b558c7fb0c38f74ba5d6bfe0cd0de3490febc0 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 01:16:00 -0400 Subject: [PATCH 31/76] chat: the / list names every argument form, /compact speaks plainly, task titles end on a whole phrase - The / list and /help omitted /land now, /crew cap task and /cache clean now, which dispatch and the manual already support. - /compact on a short chat printed the engine's raw sentinel error; it now says nothing to compact yet. - The fallback task title could end on a dangling word ("... each of"); it now stops at the nearest phrase boundary. Part of #1559 (the /new title, key-sheet and /status items stay open) Co-Authored-By: Claude Opus 5.5 --- internal/manual/chat/commands.md | 8 ++++++-- internal/manual/chat/models-and-cost.md | 4 ++-- internal/session/task_person.go | 12 ++++++++++++ internal/session/taskname_test.go | 7 +++++++ internal/tui3/app.go | 7 ++++++- internal/tui3/commandorder_test.go | 21 ++++++++++++++++++++- internal/tui3/commands.go | 11 +++++++---- internal/tui3/compact_refused_test.go | 9 +++++++++ 8 files changed, 69 insertions(+), 10 deletions(-) diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index c730e3d391..926bbc1860 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -210,6 +210,7 @@ Canonical word, the other words it answers to, its argument form, and what it do | `/budget` | `/limits` | ` ` | sets one by name: `day`, `conversation`, `plan`, `practice` | | `/cache` | — | — | how big the shared build cache is, and where | | `/cache` | — | `clean` | asks first, then deletes the cache to free disk — confirm with `/cache clean now` | +| `/cache` | — | `clean now` | deletes the shared build cache now | | `/debug` | — | — | keeps the full record of **this conversation** from here on, and says which folder it goes to | | `/update` | `/upgrade` | — | installs the newest stable release and restarts this conversation on it | | `/update` | `/upgrade` | `` | installs that channel's newest release or one exact tag, then restarts this conversation on it | @@ -365,6 +366,9 @@ A failure comes back as: compact failed: ``` +If the conversation is still short, the note says `nothing to compact yet` rather +than exposing the engine's internal error wording. + `/compact` has no argument form and no alias. **A compaction costs nothing and asks no model.** It is two mechanical passes over @@ -704,8 +708,8 @@ Over `--host` the `place` and `file` values are written in full as `machine:/pat ## Is the asking on — what `/status` says under `approvals`, and where the YOLO badge went `/status` carries the tool gate's posture on a line of its own, labelled `approvals`, in -the engine's posture words: `ask` (it asks you), `guardian` (a small model answers the -plainly safe ones first), `allow` (it runs things without asking) or `deny` (it refuses). +the person's words: `asks` (it asks you), `guardian` (a small model answers the plainly +safe ones first), `YOLO` (it runs things without asking) or `refuses` (it refuses). It is **this conversation's** posture — the one the `◇` cell on the legend shows — whichever setting decided it. `/status --json` carries the same fact under the `approvals` key, and the phone's status sheet has the same row. diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 5b46e90bec..0cec6f5c8c 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -2823,8 +2823,8 @@ What the model is handed instead of a summary is the **state card** — what `tr after each one. So what the conversation is about is never paraphrased, because it was never written as prose in the first place. -A pass can decline: `session: nothing to compact` (everything already fits in the tail), or -`session: a compaction pass is already running`. +A pass can decline when everything already fits in the tail, which `/compact` answers in plain +words: `nothing to compact yet`. It can also decline with `session: a compaction pass is already running`. ## What happens when the conversation gets too long — when compaction happens by itself diff --git a/internal/session/task_person.go b/internal/session/task_person.go index 4ac9b88239..bc7768679f 100644 --- a/internal/session/task_person.go +++ b/internal/session/task_person.go @@ -179,9 +179,21 @@ func taskPersonTitle(brief string) string { if len(words) > 8 { words = words[:8] } + for len(words) > 1 && taskTitleGlue[strings.ToLower(words[len(words)-1])] { + words = words[:len(words)-1] + } return clip(strings.Join(words, " "), titleLimit) } +// taskTitleGlue keeps the mechanical fallback from ending on a word that +// promises a missing complement. The complete brief remains beside the title; +// this only makes the short name stop at the nearest readable phrase boundary. +var taskTitleGlue = map[string]bool{ + "a": true, "an": true, "and": true, "by": true, "for": true, + "from": true, "in": true, "of": true, "on": true, "or": true, + "the": true, "to": true, "with": true, +} + // judgeDecomposable asks one bounded auxiliary question. Every failure is a no: // a no leaves the one worker that is already running exactly as it was. func (a *Agent) judgeDecomposable(ctx context.Context, brief string) (bool, []string, string) { diff --git a/internal/session/taskname_test.go b/internal/session/taskname_test.go index ef7c19a6c5..8c99daba05 100644 --- a/internal/session/taskname_test.go +++ b/internal/session/taskname_test.go @@ -440,6 +440,13 @@ func TestAPersonsTaskIsNamedTheMomentItExists(t *testing.T) { } } +func TestTaskPersonTitleStopsAtAPhraseBoundary(t *testing.T) { + brief := "write a pytest test file for each of cart.py, pricing.py and orders.py" + if got := taskPersonTitle(brief); got == "" || strings.HasSuffix(got, " of") { + t.Fatalf("task fallback ends mid-phrase: %q", got) + } +} + // answeringCompleter answers a request from the request itself, which is the // only thing a provider shared by several agents at once can safely be scripted // on. A list indexed by call number is a script for one caller, and this diff --git a/internal/tui3/app.go b/internal/tui3/app.go index f063c12e94..03776bf848 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -2,6 +2,7 @@ package tui3 import ( "context" + "errors" "fmt" "os" "os/exec" @@ -5115,7 +5116,11 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { case compactedMsg: if msg.err != nil { - a.note("compact failed: " + msg.err.Error()) + if errors.Is(msg.err, session.ErrNothingToCompact) { + a.note("nothing to compact yet") + } else { + a.note("compact failed: " + msg.err.Error()) + } } else { a.noticeEvent(eventCompacted) } diff --git a/internal/tui3/commandorder_test.go b/internal/tui3/commandorder_test.go index 4e1636f42b..bf62dc2be4 100644 --- a/internal/tui3/commandorder_test.go +++ b/internal/tui3/commandorder_test.go @@ -1,6 +1,25 @@ package tui3 -import "testing" +import ( + "strings" + "testing" +) + +func TestCommandCatalogueIncludesTheCompleteArgumentForms(t *testing.T) { + wants := []string{"/crew cap task ", "/land now", "/cache clean now"} + for _, want := range wants { + found := false + for _, command := range commands { + if strings.TrimSpace(command.typed()) == want { + found = true + break + } + } + if !found { + t.Fatalf("command catalogue does not offer %s", want) + } + } +} // THE WORDS FORM LEADS THE PAIR, AND THE FINISHED WORD STILL GETS ITS PAGE. // /standing has two rows on purpose: the one that makes an order (the act the diff --git a/internal/tui3/commands.go b/internal/tui3/commands.go index 4616b9e632..a761449efb 100644 --- a/internal/tui3/commands.go +++ b/internal/tui3/commands.go @@ -121,6 +121,7 @@ var commands = []command{ // (landcmd.go), so the list is the second way of finding it and not the // first. {name: "land", desc: "put the changes for another folder into it · says what changed first"}, + {name: "land", args: "now", desc: "…put the waiting changes into their folder"}, {name: "land", args: "", desc: "…that folder, when more than one is waiting"}, // IT BELONGS BESIDE /resume AND SITS UNDER /compact, and the gap is the // frequency law this table is ordered by. /resume is "which conversation, @@ -275,6 +276,7 @@ var commands = []command{ {name: "crew", args: "unpin ", desc: "…put a seat back on auto"}, {name: "crew", args: "models ", desc: "…which models a seat may be picked from · all, open, ≤in/out, ids"}, {name: "crew", args: "cap ", desc: "…the most tasks' crews may spend in a day"}, + {name: "crew", args: "cap task ", desc: "…the most one task may spend"}, // AND HOW HARD THE ONE YOU TALK TO THINKS, under the two rows about WHICH // models it thinks with, because that is the order the two questions arrive // in: a person picks the model and then decides how much of it to spend. @@ -370,6 +372,7 @@ var commands = []command{ // be the most expensive pun on the surface. {name: "cache", desc: "the shared build cache — how big, and where"}, {name: "cache", args: "clean", desc: "…delete it to free disk · asks before anything is removed"}, + {name: "cache", args: "clean now", desc: "…delete the cache now"}, // THE THREE DOORS ONTO GETTING TEXT OUT, and they sit beside /help because // that is where a person goes with the question they answer. The keys behind // the first two are the least discoverable on the surface — nothing on the @@ -1083,10 +1086,10 @@ func helpText(file string, chords chordSpelling) string { // answer to the question that test asks). The card is named by what it is // instead. helpKeyRow(closeTabChord, "close this tab · select the last open chat · keep your draft"), - // THE TEAM'S TWO CHORDS (teamrail.go). They do something only in a team - // with a manager, and the rows say so rather than leaving a person to - // find out by pressing them anywhere else. - helpKeyRow(chords.say(trafficKey), "with a team's manager in front: show or hide its Traffic"), + // THE COLUMN CHORD AND THE MANAGER CHORD (teamrail.go). The first works in + // every chat; the second needs a team with a manager, and the rows say so + // rather than leaving a person to find out by pressing them elsewhere. + helpKeyRow(chords.say(trafficKey), "show or hide the right column · Tasks or Traffic"), helpKeyRow(chords.say(teamManagerKey), "in a team with a manager: go to the manager"), helpKeyRow(reopenTabChord, "reopen the last closed tab · when the terminal sends this distinct chord"), helpKeyRow(chords.say(railHoldChord), "the task roster · ↑↓ move · ←→ tasks/traffic · enter opens · esc back"), diff --git a/internal/tui3/compact_refused_test.go b/internal/tui3/compact_refused_test.go index d260c4d004..cc1ee80a19 100644 --- a/internal/tui3/compact_refused_test.go +++ b/internal/tui3/compact_refused_test.go @@ -64,6 +64,15 @@ func TestAPassThatCompactedNothingLeavesTheConversationReachable(t *testing.T) { } } +func TestAnEmptyCompactPassUsesPlainWords(t *testing.T) { + a := newTestApp(&fakeAgent{model: "m"}) + a.Update(compactedMsg{err: session.ErrNothingToCompact}) + got := plain(lastNote(t, a)) + if got != "nothing to compact yet" { + t.Fatalf("empty compaction note = %q, want a plain explanation", got) + } +} + // AND A PASS THAT HAPPENED STILL HANDS THE BOOKKEEPING OVER, which is what // keeps the test above from passing on a build that simply stopped rebasing. func TestAPassThatHappenedStillCarriesThePlaceIntoTheRegion(t *testing.T) { From 121be161cccad72c1ee35f9fd559d1a597c59b48 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 01:25:30 -0400 Subject: [PATCH 32/76] remote: a redialing window takes its own keyboard back, and only its own A --host redial arrives before the old pipe has detached, so the engine still recorded that dead pipe as the driver and made the returning window a watcher ("another window"). Each surface now carries a stable client id across its redials, and a returning window replaces the recorded driver only when that driver is its own previous connection; another live window keeps the keyboard. Fixes #1553 Co-Authored-By: Claude Opus 5.5 --- internal/remote/client.go | 21 ++++++++ internal/remote/modelsource_wire_law_test.go | 2 + internal/remote/moved_test.go | 50 ++++++++++++++++++++ internal/remote/server.go | 14 ++++-- internal/remote/wire.go | 4 ++ 5 files changed, 88 insertions(+), 3 deletions(-) diff --git a/internal/remote/client.go b/internal/remote/client.go index 76607ef0b7..946b4a07bd 100644 --- a/internal/remote/client.go +++ b/internal/remote/client.go @@ -2,6 +2,8 @@ package remote import ( "context" + "crypto/rand" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -19,6 +21,8 @@ import ( "github.com/Agent-Field/codeaf/internal/store" ) +var fallbackClientID atomic.Uint64 + // ── THE SURFACE HALF ──────────────────────────────────────────────────────── // // This file is everything the local half of `codeaf chat --host devbox` needs: @@ -264,6 +268,9 @@ func newClient(host string, hello Hello) *Client { if strings.TrimSpace(hello.Surface) == "" { hello.Surface = MachineName() } + if strings.TrimSpace(hello.ClientID) == "" { + hello.ClientID = newClientID() + } hello.Encodings = []string{frameEncodingGzip} return &Client{ host: strings.TrimSpace(host), @@ -279,6 +286,20 @@ func newClient(host string, hello Hello) *Client { } } +// newClientID gives one surface a stable identity to carry through every +// redial. It is not an authorization token; it only lets the engine tell a +// returning window from another window using the same machine label. +func newClientID() string { + raw := make([]byte, 16) + if _, err := rand.Read(raw); err == nil { + return hex.EncodeToString(raw) + } + // A failed system random source must not make the connection unusable. The + // clock and process-local sequence still distinguish the clients this + // process creates, which is enough for the in-memory room's live identity. + return fmt.Sprintf("%x-%x", time.Now().UnixNano(), fallbackClientID.Add(1)) +} + // attach says hello on one pipe and reads the welcome back. It is the handshake // for BOTH doors: the first one and every redial, because a returning surface // says exactly what an arriving one says plus how far it got. diff --git a/internal/remote/modelsource_wire_law_test.go b/internal/remote/modelsource_wire_law_test.go index d3c1e51cb1..ecb7751420 100644 --- a/internal/remote/modelsource_wire_law_test.go +++ b/internal/remote/modelsource_wire_law_test.go @@ -18,6 +18,8 @@ var helloFields = map[string]bool{ "Headless": true, "Version": true, "Workspace": true, "Session": true, "Model": true, "Level": true, "Launch": true, "Encodings": true, "Resume": true, "Surface": true, "Back": true, "Join": true, "New": true, "Watch": true, + // ClientID is a random per-window id for redial focus (#1553), not a credential. + "ClientID": true, } // TestNoServiceKeyOrAddressCrossesTheWire refuses model-service credential diff --git a/internal/remote/moved_test.go b/internal/remote/moved_test.go index 46aa61aa09..eb471998a7 100644 --- a/internal/remote/moved_test.go +++ b/internal/remote/moved_test.go @@ -156,6 +156,56 @@ func TestARedialMovesNobody(t *testing.T) { } } +// A REDIAL CAN BE WELCOMED BEFORE THE OLD PIPE LEAVES THE ROOM. The returning +// window may reclaim the keyboard only when the driver still recorded in the +// room is that same window, not merely because one other surface is attached. +func TestARedialReclaimsTheKeyboardBeforeTheOldPipeLeaves(t *testing.T) { + agent := &fakeAgent{} + sess := heldSession(agent) + + first := dialSession(t, sess) + first.hello(Hello{Version: Version, Surface: "laptop", ClientID: "window-x"}) + + back := dialSession(t, sess) + welcome := decode[Welcome](t, back.hello(Hello{ + Version: Version, + Surface: "laptop", + ClientID: "window-x", + Back: true, + }).Payload) + if !welcome.Driver.Yours { + t.Fatalf("the returning window did not reclaim the keyboard: %+v", welcome.Driver) + } +} + +// A REDIAL DOES NOT RECLAIM A KEYBOARD THAT MOVED TO ANOTHER WINDOW. The old +// connection's identity is not the live driver's identity, so the returning +// window remains a watcher even while the stale pipe is still attached. +func TestARedialKeepsADifferentLiveDriverInControl(t *testing.T) { + agent := &fakeAgent{} + sess := heldSession(agent) + + first := dialSession(t, sess) + first.hello(Hello{Version: Version, Surface: "laptop", ClientID: "window-x"}) + + driver := dialSession(t, sess) + driver.hello(Hello{Version: Version, Surface: "desktop", ClientID: "window-y"}) + + back := dialSession(t, sess) + welcome := decode[Welcome](t, back.hello(Hello{ + Version: Version, + Surface: "laptop", + ClientID: "window-x", + Back: true, + }).Payload) + if welcome.Driver.Yours { + t.Fatalf("the returning window took a different live driver's keyboard: %+v", welcome.Driver) + } + if welcome.Driver.Machine != "desktop" { + t.Fatalf("the returning window was told the keyboard is on %q, want desktop", welcome.Driver.Machine) + } +} + // AND THE FRAME REACHES A SURFACE AS THE ONE EVENT IT ACTS ON, on the standing // task lane it is already reading (tasklane.go's [Client.movedFrame]). func TestTheMoveArrivesOnTheStandingTaskLane(t *testing.T) { diff --git a/internal/remote/server.go b/internal/remote/server.go index 7b161a7f98..ec862ae942 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -1022,6 +1022,7 @@ func (sess *Session) attach(s *server, hello Hello) error { // says which questions THIS surface is owed and the number is how a card // names the surfaces that have already drawn it (held.go). s.name = machineLabel(hello.Surface) + s.clientID = strings.TrimSpace(hello.ClientID) // THE ARRIVAL IS AN ACT. A window that has just been welcomed is // watching, and a pipe that tears on the way in — or a getter that // no longer crosses the wire — must not read as nobody having been @@ -1051,7 +1052,13 @@ func (sess *Session) attach(s *server, hello Hello) error { // is against that field and one spelling saves a clean on every call. s.joined = sess.engine.SessionFile } - if !s.watching && (!hello.Back || sess.driver == nil) { + // A REDIAL MAY ARRIVE BEFORE ITS OLD PIPE HAS DETACHED. Only the same + // window may replace that stale driver, because Attached counts every live + // surface and cannot distinguish the returning window from another one. + // Replacing the driver pointer makes the old pipe a non-driver immediately; + // its later detach therefore cannot take the keyboard away again. + sameReturningWindow := hello.Back && s.clientID != "" && sess.driver != nil && sess.driver.clientID == s.clientID + if !s.watching && (!hello.Back || sess.driver == nil || sameReturningWindow) { sess.takeLocked(s) } welcome.Driver = sess.driverForLocked(s) @@ -1597,8 +1604,9 @@ type server struct { // name is the machine this surface is running on, as its hello said and // [machineLabel] made it safe to draw. arrived is its place in the order the // room filled up, which is how "the newest" is decided (driver.go). - name string - arrived uint64 + name string + clientID string + arrived uint64 // watching is [Hello.Watch]: this surface reads and never drives. It is kept // on the connection because the decision is made in three places — arrival, // the hand-on when a driver leaves, and the guard in front of every door that diff --git a/internal/remote/wire.go b/internal/remote/wire.go index 58020d1c5e..75f4585e45 100644 --- a/internal/remote/wire.go +++ b/internal/remote/wire.go @@ -875,6 +875,10 @@ type Hello struct { // text one machine sends for another machine's screen. Surface string `json:"surface,omitempty"` + // ClientID is this window's stable identity across its redials. Surface is + // only a machine label, so it cannot distinguish two windows on one machine. + ClientID string `json:"clientID,omitempty"` + // Back says this surface has been in this conversation before and is coming // back from a link that dropped, rather than arriving for the first time. // From 01ba6f907a997cb66a77e486b50cb95285dfe01f Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 01:29:03 -0400 Subject: [PATCH 33/76] runs: each check has its own ceiling, unfinished checks fail the run, bad check paths are refused, kept branches reach /land - Every checker in a run drew from one checker-seat tally, so after the first few checks the rest stopped "at its spend ceiling" having spent almost nothing. The ceiling is now kept per check task (the run's task cap is still shared), and a run whose checks did not finish no longer ends done; its landing names the unfinished checks. - A planner-declared per-part check was stored verbatim even when it named a path that does not exist and that the part does not produce (issue_.go). Such a check is now refused at division admission with the check and path named, so the planner rewrites it; nothing guesses a filename. - A run kept on a protected or moved checkout was invisible to /land. Kept, conflicted and aborted run branches are now listed and landable by /land, and editor .orig backups are classified as droppings so they never reach a task branch. Fixes #1572 Fixes #1573 Fixes #1574 Co-Authored-By: Claude Opus 5.5 --- internal/manual/chat/tasks.md | 12 +++-- internal/run/crew.go | 6 ++- internal/run/crew_context_test.go | 53 +++++++++++++++++++- internal/run/enginewire.go | 1 + internal/run/review_test.go | 31 ++++++++++++ internal/run/run.go | 25 ++++++++++ internal/session/land_run_tree_test.go | 21 ++++++++ internal/session/seatcompleter.go | 34 +++++++++++++ internal/session/spendguard.go | 36 +++++++++----- internal/session/standingtree.go | 69 ++++++++++++++++++++++++++ internal/session/standingtree_test.go | 41 +++++++++++++++ internal/session/task_divide_scope.go | 65 ++++++++++++++++++++++++ internal/session/task_divide_test.go | 15 ++++++ internal/session/task_run.go | 6 +++ internal/session/task_run_belt.go | 5 ++ internal/session/task_run_belt_test.go | 16 ++++++ 16 files changed, 419 insertions(+), 17 deletions(-) diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index 41d5c4bc2a..2e6ef193aa 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -1698,6 +1698,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: ` | +| a fan-out check did not finish | `incomplete · unfinished checks: ` | | something broke | `incomplete · a fault: ` | **`incomplete` is not `stopped`.** `stopped` is *you* ending the work and means nothing else @@ -1805,7 +1806,9 @@ on a protected branch, was on a different branch than when the work was cut, mov 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. 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. A retained run is the +exception: `/land` lists its waiting folder and merges that named run branch when +you ask. 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 @@ -4089,6 +4092,8 @@ done-condition, and ask again — it is not a finding that the work cannot be sp checks that name different things — a package each, a file each — are two checks and are admitted; nothing here reads which program is being run or how long it takes. +A part check is stored exactly as the planner wrote it. If it names a path that is not in the workspace and is not that part's own target, the division is refused with the check and missing path named; rewrite the check for the part and ask again. codeaf does not guess or expand filename stems such as `issue_.go`. + **And you are only told once.** If the same task asks again with the same shared check still in every part — which is what a worker does when it cannot rewrite three done-conditions — the division is **taken** rather than refused a second time, with that check **removed from every @@ -4628,8 +4633,9 @@ Whenever a task stops for any reason it wears its own word — `stopped` when yo `incomplete · ` 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. For a +retained run, `/land` is the explicit merge door; an ordinary branch-kept task still +waits for you, because only work that was checked reaches your branch. ## Continue task N — keep going on a failed or finished task, No task 1 in this project diff --git a/internal/run/crew.go b/internal/run/crew.go index 96d14dbabb..e394779c7b 100644 --- a/internal/run/crew.go +++ b/internal/run/crew.go @@ -150,7 +150,11 @@ func CrewFactoryWithStanding(store *plandb.Store, workspace, profileDir string, // ceiling by seat, and a crew whose seats share one model would give it // nothing else to tell a check's call from a worker's. seat, _ := config.CrewTierSeat(tier) - return NewBashWorkerWithStanding(store, workspace, model, session.SeatCompleter(seat, completerFor(model)), standingSection) + completer := session.SeatCompleter(seat, completerFor(model)) + if role == plandb.RoleCheck { + completer = session.SpendScope(task.ID, completer) + } + return NewBashWorkerWithStanding(store, workspace, model, completer, standingSection) } } diff --git a/internal/run/crew_context_test.go b/internal/run/crew_context_test.go index 0b8978d9db..a361692373 100644 --- a/internal/run/crew_context_test.go +++ b/internal/run/crew_context_test.go @@ -16,10 +16,16 @@ import ( ) // crewCallProbe is a provider that answers nothing and counts what it was asked. -type crewCallProbe struct{ calls int } +type crewCallProbe struct { + calls int + cost float64 +} func (p *crewCallProbe) CompleteWithMessages(context.Context, []ai.Message, ...ai.Option) (*ai.Response, error) { p.calls++ + if p.cost > 0 { + return &ai.Response{Usage: &ai.Usage{Cost: &p.cost}}, nil + } return &ai.Response{}, nil } @@ -72,3 +78,48 @@ func TestCrewFactoryCarriesTheRoleSeatToTheSpendGuard(t *testing.T) { t.Fatalf("checker crossed its line: %v, probes %+v", err, probes) } } + +func TestCrewFactoryGivesEachCheckerItsOwnSpendCeiling(t *testing.T) { + dir := t.TempDir() + profile := t.TempDir() + rows, _ := json.Marshal(map[string]string{config.KeyTierWorkerModel: "vendor/shared", config.KeyTierHighModel: "vendor/shared"}) + if err := os.WriteFile(config.BudgetConfigPath(profile), rows, 0o600); err != nil { + t.Fatal(err) + } + store, err := plandb.Open(filepath.Join(dir, "plan.db"), "seat-test", "root", "Root", "check the seat") + if err != nil { + t.Fatal(err) + } + defer store.Close() + if _, err := store.AddMany([]plandb.TaskSpec{ + {ID: "review-one", Title: "Review one", Role: plandb.RoleCheck}, + {ID: "review-two", Title: "Review two", Role: plandb.RoleCheck}, + }); err != nil { + t.Fatal(err) + } + guard := &session.SpendGuard{ + Price: func(string) (float64, float64, float64, bool) { return 0, 1e-6, 0, true }, + SeatCeilings: map[crewroute.Seat]float64{crewroute.Checker: 0.01}, + CeilingAction: "checker ceiling $%.2f", + } + probes := []*crewCallProbe{} + factory := CrewFactory(store, dir, profile, Seats{}, func(model string) session.Completer { + probe := &crewCallProbe{cost: 0.01} + probes = append(probes, probe) + return guard.Wrap(model, probe) + }) + call := func(id string) error { + worker := factory(*store.Task(id)).(*BashWorker) + _, err := worker.completer.CompleteWithMessages(t.Context(), []ai.Message{{Role: "user"}}) + return err + } + if err := call("review-one"); err != nil { + t.Fatalf("first checker call: %v", err) + } + if err := call("review-one"); err == nil { + t.Fatal("second call on one checker crossed no ceiling") + } + if err := call("review-two"); err != nil { + t.Fatalf("first call on a second checker: %v", err) + } +} diff --git a/internal/run/enginewire.go b/internal/run/enginewire.go index 36aee8ee82..8b5a59915b 100644 --- a/internal/run/enginewire.go +++ b/internal/run/enginewire.go @@ -91,6 +91,7 @@ func (engine) Start(ctx context.Context, spec session.RunSpec) session.RunSummar return session.RunSummary{ Outcome: string(outcome), Result: summary.Result, + Failure: summary.Failure, // WHICH LIMIT FIRED IS A FACT AND NOT A WORD IN THE OUTCOME SENTENCE: // the run's own typed answer crosses the seam here, mapped one for one, // so the session draws the ending out of the fact and never parses the diff --git a/internal/run/review_test.go b/internal/run/review_test.go index aa52505437..f3b8206637 100644 --- a/internal/run/review_test.go +++ b/internal/run/review_test.go @@ -213,6 +213,37 @@ func TestSupervisorLeavesADoesNotHoldFindingAsANoteOnTheLeaf(t *testing.T) { } } +func TestSupervisorDoesNotFinishWhenACheckDoesNotFinish(t *testing.T) { + store := runOpenStore(t) + ctx := runContext(t) + seat := newFakeSeat() + seat.actions["root"] = splitRoot(t, store, leafDone("l1")) + factory := func(task plandb.Task) run.Worker { + if task.Role == plandb.RoleCheck { + return funcWorker(func(context.Context, plandb.Task) (run.Report, error) { + return run.Report{}, fmt.Errorf("checker lost its provider") + }) + } + return seat.workerFor(task) + } + outcome, summary := run.Start(ctx, run.Spec{Store: store, Workspace: t.TempDir(), Slots: 2, + Limits: run.Limits{ReviewRound: true}, Factory: factory}) + if outcome != run.OutcomeIncomplete { + t.Fatalf("outcome = %q, want incomplete", outcome) + } + if !strings.Contains(summary.Failure, "unfinished checks: check: leaf") { + t.Fatalf("failure = %q, want the unfinished check named", summary.Failure) + } + check := tasksWithRole(store, plandb.RoleCheck) + if len(check) != 1 || check[0].Status != plandb.StatusFailed { + t.Fatalf("checks = %+v, want one failed check", check) + } + root := store.Task(store.RootID()) + if root.Status != plandb.StatusFailed || !strings.Contains(root.Error, "unfinished checks: check: leaf") { + t.Fatalf("root = %+v, want failed with the unfinished check named", root) + } +} + // TestSupervisorRootWaitsOnAnOpenCheckTask proves the waiting the round relies // on: the store's own CanFinish refuses the root while a check stands open, // naming the check, which is the shape completeTree reads to hold the root's diff --git a/internal/run/run.go b/internal/run/run.go index 17b50cfa5e..61eaf7a5d2 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -1130,10 +1130,31 @@ func (s *Supervisor) completeTree() { return } if s.treeTerminal() { + if unfinished := unfinishedCheckNames(s.store.Tasks()); len(unfinished) > 0 { + s.rootFailed = true + s.rootFailure = "unfinished checks: " + strings.Join(unfinished, ", ") + return + } _ = s.store.CompleteRoot(s.rootResult) } } +func unfinishedCheckNames(tasks []*plandb.Task) []string { + var names []string + for _, task := range tasks { + if task.Role != plandb.RoleCheck || task.Status == plandb.StatusDone { + continue + } + name := strings.TrimSpace(task.Title) + if name == "" { + name = task.ID + } + names = append(names, name) + } + sort.Strings(names) + return names +} + // rootAwaitingWake answers whether the root still owes a wake: a landing of its // children it has not been given, or a waking worker of its own still running. func (s *Supervisor) rootAwaitingWake() bool { @@ -1781,6 +1802,9 @@ type Summary struct { // Result is the root's own result: what the run's last worker reported // when the tree finished whole, and empty whenever it did not. Result string + // Failure is the run's own account when it did not finish, including a + // checker that ended without completing its proof. + Failure string // Limit is which bound a person set ended the run, and empty on every // run that did not end on one. The outcome word is the same sentence for // both limits; this is what tells them apart. @@ -1903,6 +1927,7 @@ func Start(ctx context.Context, spec Spec) (Outcome, Summary) { return outcome, Summary{ Outcome: outcome, Result: result, + Failure: supervisor.rootFailure, Limit: supervisor.limitHit, Program: supervisor.rootProgram, Verdict: supervisor.rootVerdict, diff --git a/internal/session/land_run_tree_test.go b/internal/session/land_run_tree_test.go index ad21e1d951..913bfea56c 100644 --- a/internal/session/land_run_tree_test.go +++ b/internal/session/land_run_tree_test.go @@ -43,6 +43,27 @@ func TestLandRunTreeCommitsTheTreesOwnWorkOntoItsBranch(t *testing.T) { } } +func TestLandRunTreeDoesNotCarryOrigBackupsOntoTheTaskBranch(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + repo := newTestRepo(t) + writeFile(t, filepath.Join(repo, "split", "textkit.go.orig"), "editor backup\n") + writeFile(t, filepath.Join(repo, "split", "textkit.go"), "package split\n") + + branch, changed, refusal, err := LandRunTree(repo, "", "split the text kit", "") + if err != nil || refusal != "" { + t.Fatalf("LandRunTree = branch %q changed %v refusal %q error %v", branch, changed, refusal, err) + } + if strings.Contains(strings.Join(changed, "\n"), ".orig") { + t.Fatalf("changed paths include an editor backup: %v", changed) + } + if got := gitOut(t, repo, "ls-tree", "-r", "--name-only", branch); strings.Contains(got, ".orig") { + t.Fatalf("task branch carries an editor backup:\n%s", got) + } + if !strings.Contains(gitOut(t, repo, "show", "--name-only", "--format=", "HEAD"), "split/textkit.go") { + t.Fatal("task branch omitted the real source file") + } +} + // A TREE WITH NOTHING TO LAND IS A REFUSAL, NOT A FAULT: the branch would // carry what it always carried, so the door names no branch and says nothing // happened. diff --git a/internal/session/seatcompleter.go b/internal/session/seatcompleter.go index 0b4effa5e2..fd23977eeb 100644 --- a/internal/session/seatcompleter.go +++ b/internal/session/seatcompleter.go @@ -23,6 +23,8 @@ import ( // makes, fallbacks included. type crewSeatContextKey struct{} +type spendScopeContextKey struct{} + // crewSeatOf is the seat a call was made for, empty for a call no crew seat // made (an auxiliary call, a probe), which no seat's ceiling holds. func crewSeatOf(ctx context.Context) crewroute.Seat { @@ -30,6 +32,11 @@ func crewSeatOf(ctx context.Context) crewroute.Seat { return seat } +func spendScopeOf(ctx context.Context) string { + scope, _ := ctx.Value(spendScopeContextKey{}).(string) + return scope +} + // SeatCompleter is next with every call marked as the seat's, so a guard // attributes the call's spend to that seat even when another seat runs the // same model or a fallback moves the seat to another. It keeps next's model @@ -43,6 +50,17 @@ func SeatCompleter(seat crewroute.Seat, next Completer) Completer { return marked } +// SpendScope marks a completer with the concrete task whose seat spend it +// owns. The checker ceiling is per checker task, while the run's task cap stays +// shared by the guard that wraps all of its workers. +func SpendScope(scope string, next Completer) Completer { + marked := spendScopeCompleter{scope: scope, next: next} + if chain, ok := next.(modelChain); ok { + return spendScopeChain{spendScopeCompleter: marked, chain: chain} + } + return marked +} + // seatCompleter is one seat's completer with its calls marked. type seatCompleter struct { seat crewroute.Seat @@ -60,3 +78,19 @@ type seatChain struct { } func (c seatChain) FallbackModels(model string) []string { return c.chain.FallbackModels(model) } + +type spendScopeCompleter struct { + scope string + next Completer +} + +func (c spendScopeCompleter) CompleteWithMessages(ctx context.Context, messages []ai.Message, options ...ai.Option) (*ai.Response, error) { + return c.next.CompleteWithMessages(context.WithValue(ctx, spendScopeContextKey{}, c.scope), messages, options...) +} + +type spendScopeChain struct { + spendScopeCompleter + chain modelChain +} + +func (c spendScopeChain) FallbackModels(model string) []string { return c.chain.FallbackModels(model) } diff --git a/internal/session/spendguard.go b/internal/session/spendguard.go index e57f0e1ce4..7b623d654e 100644 --- a/internal/session/spendguard.go +++ b/internal/session/spendguard.go @@ -141,7 +141,12 @@ type SpendGuard struct { mu sync.Mutex modelSpent map[string]float64 - seatSpent map[crewroute.Seat]*SpendTask + seatSpent map[spendTallyKey]*SpendTask +} + +type spendTallyKey struct { + seat crewroute.Seat + scope string } // SpendTask is what one task has spent and holds in flight, across every @@ -196,17 +201,24 @@ func (g *SpendGuard) tally() *SpendTask { return g.Task } -// seatTally holds one seat's spend and in-flight estimates across model changes. +// seatTally preserves the direct guard test and auxiliary-call API: no scope +// means the guard's historical one-tally-per-seat behavior. func (g *SpendGuard) seatTally(seat crewroute.Seat) *SpendTask { + return g.seatTallyFor(context.Background(), seat) +} + +// seatTallyFor holds one seat's spend within the marked task scope. +func (g *SpendGuard) seatTallyFor(ctx context.Context, seat crewroute.Seat) *SpendTask { g.mu.Lock() defer g.mu.Unlock() if g.seatSpent == nil { - g.seatSpent = make(map[crewroute.Seat]*SpendTask) + g.seatSpent = make(map[spendTallyKey]*SpendTask) } - if g.seatSpent[seat] == nil { - g.seatSpent[seat] = &SpendTask{} + key := spendTallyKey{seat: seat, scope: spendScopeOf(ctx)} + if g.seatSpent[key] == nil { + g.seatSpent[key] = &SpendTask{} } - return g.seatSpent[seat] + return g.seatSpent[key] } // ErrSpendStopped is a call the guard did not make. Its text is the one @@ -254,7 +266,7 @@ func (g *SpendGuard) before(ctx context.Context, model string, messages []ai.Mes if g.TaskCap > 0 && g.tally().Total() >= g.TaskCap { return 0, ErrSpendStopped{Action: g.TaskAction} } - if seat := crewSeatOf(ctx); g.SeatCeilings[seat] > 0 && g.seatTally(seat).Total() >= g.SeatCeilings[seat] { + if seat := crewSeatOf(ctx); g.SeatCeilings[seat] > 0 && g.seatTallyFor(ctx, seat).Total() >= g.SeatCeilings[seat] { ceiling := g.SeatCeilings[seat] return 0, ErrSpendStopped{Action: fmt.Sprintf(g.CeilingAction, ceiling)} } @@ -276,13 +288,13 @@ func (g *SpendGuard) before(ctx context.Context, model string, messages []ai.Mes } seat := crewSeatOf(ctx) ceiling := g.SeatCeilings[seat] - if ceiling > 0 && !g.seatTally(seat).hold(est, ceiling) { + if ceiling > 0 && !g.seatTallyFor(ctx, seat).hold(est, ceiling) { return 0, ErrSpendStopped{Action: fmt.Sprintf(g.CeilingAction, ceiling)} } task := g.tally() if !task.hold(est, g.TaskCap) { if ceiling > 0 { - g.seatTally(seat).settle(est, 0) + g.seatTallyFor(ctx, seat).settle(est, 0) } return 0, ErrSpendStopped{Action: g.TaskAction} } @@ -290,7 +302,7 @@ func (g *SpendGuard) before(ctx context.Context, model string, messages []ai.Mes if !fits { task.settle(est, 0) if ceiling > 0 { - g.seatTally(seat).settle(est, 0) + g.seatTallyFor(ctx, seat).settle(est, 0) } return 0, ErrSpendStopped{Action: g.CapAction} } @@ -329,7 +341,7 @@ func (g *SpendGuard) after(ctx context.Context, model string, response *ai.Respo g.Day.settle(model, held, 0) task.settle(held, 0) if seat := crewSeatOf(ctx); g.SeatCeilings[seat] > 0 { - g.seatTally(seat).settle(held, 0) + g.seatTallyFor(ctx, seat).settle(held, 0) } return } @@ -346,7 +358,7 @@ func (g *SpendGuard) after(ctx context.Context, model string, response *ai.Respo g.Day.settle(model, held, usd) task.settle(held, usd) if seat := crewSeatOf(ctx); g.SeatCeilings[seat] > 0 { - g.seatTally(seat).settle(held, usd) + g.seatTallyFor(ctx, seat).settle(held, usd) } if usd <= 0 { return diff --git a/internal/session/standingtree.go b/internal/session/standingtree.go index 9437f45435..4c5992ba37 100644 --- a/internal/session/standingtree.go +++ b/internal/session/standingtree.go @@ -295,6 +295,7 @@ func (a *Agent) StandingTrees() []StandingTree { // chip appears exactly when there is something to land. func (a *Agent) UnlandedChanges() []StandingChange { var out []StandingChange + seen := map[string]bool{} for _, tree := range a.StandingTrees() { if len(tree.Wrote) == 0 { continue @@ -304,11 +305,53 @@ func (a *Agent) UnlandedChanges() []StandingChange { Name: filepath.Base(tree.Folder), Files: len(tree.Wrote), }) + seen[tree.Folder] = true + } + for _, row := range a.keptRunRows() { + if row.Copy == nil || seen[row.Copy.Root] { + continue + } + out = append(out, StandingChange{ + Folder: row.Copy.Root, + Name: filepath.Base(row.Copy.Root), + Files: len(row.Changed), + }) + seen[row.Copy.Root] = true } sort.SliceStable(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out } +func (a *Agent) keptRunRows() []TaskNotice { + g := a.tasker() + if g == nil { + return nil + } + g.mu.Lock() + defer g.mu.Unlock() + var out []TaskNotice + for _, row := range g.runRowsLocked() { + if !row.State.settled() || row.Copy == nil || strings.TrimSpace(row.Copy.Root) == "" || row.Branch == "" || len(row.Changed) == 0 { + continue + } + switch row.Merge { + case mergeKept, mergeConflicted, mergeAborted: + out = append(out, row) + } + } + return out +} + +func (a *Agent) keptRunFor(folder string) (TaskNotice, bool) { + folder = canonicalPath(strings.TrimSpace(folder)) + for _, row := range a.keptRunRows() { + if canonicalPath(row.Copy.Root) == folder { + return row, true + } + } + return TaskNotice{}, false +} + // standingTreeFor is the copy this conversation holds of one folder, and // whether it holds one at all. func (a *Agent) standingTreeFor(folder string) (StandingTree, bool) { @@ -691,6 +734,9 @@ func (a *Agent) Land(folder string) (FolderLanding, error) { if name == "" { return FolderLanding{}, errors.New("nothing is waiting to go into a folder") } + if row, ok := a.keptRunFor(name); ok { + return a.landKeptRun(row) + } tree, ok := a.standingTreeFor(name) if !ok { return FolderLanding{}, fmt.Errorf("nothing is waiting for %s", filepath.Base(name)) @@ -746,6 +792,29 @@ func (a *Agent) Land(folder string) (FolderLanding, error) { return landing, nil } +// landKeptRun is the explicit /land road for a run branch that automatic +// landing deliberately left alone. The person has named this action, so the +// protected-branch guard no longer applies; git still refuses dirty or +// conflicting ground and the retained row remains the recovery path then. +func (a *Agent) landKeptRun(row TaskNotice) (FolderLanding, error) { + root, branch := canonicalPath(row.Copy.Root), strings.TrimSpace(row.Branch) + landing := FolderLanding{Folder: root, Name: filepath.Base(root), Files: append([]string(nil), row.Changed...)} + unlock := lockGitRoot(a.placeHere(), root) + defer unlock() + if _, err := git(root, "merge", "--no-edit", branch); err != nil { + _, _ = git(root, "merge", "--abort") + landing.Merged = mergeConflicted + landing.Note = "its branch " + branch + " did not merge cleanly and was kept — inspect the retained branch before deciding what to do next" + return landing, nil + } + landing.Merged = mergeMerged + _, _ = git(root, "branch", "-d", branch) + if graph := a.tasker(); graph != nil { + graph.keepRunRows(row.ID, nil) + } + return landing, nil +} + // retireUntouchedTree takes back a working copy NOTHING HAS BEEN WRITTEN INTO. // // It exists for one moment, and the order of that moment cannot be otherwise: a diff --git a/internal/session/standingtree_test.go b/internal/session/standingtree_test.go index 08acff42b6..0cdf3480af 100644 --- a/internal/session/standingtree_test.go +++ b/internal/session/standingtree_test.go @@ -104,6 +104,47 @@ func TestTheFirstWriteIntoAReferredFolderCutsOneCopyAndTheSecondReusesIt(t *test } } +// A KEPT RUN IS STILL A LANDING, not a private branch somebody must discover +// with git. The standing list is the one road /land reads, so a run row left +// by automatic landing has to enter that list and use the same explicit door. +func TestLandFindsAndMergesAKeptRunBranch(t *testing.T) { + repo := newTestRepo(t) + agent, _, _ := standingLab(t, repo) + mustGit(t, repo, "checkout", "-b", "task/retained") + writeFile(t, filepath.Join(repo, "landed.txt"), "from the kept branch\n") + mustGit(t, repo, "add", "landed.txt") + mustGit(t, repo, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-m", "retained") + mustGit(t, repo, "checkout", "work") + + agent.graph().keepRunRows(71, []TaskNotice{{ + ID: 71, + Title: "retained run", + State: TaskDone, + Changed: []string{"landed.txt"}, + Branch: "task/retained", + Merge: mergeKept, + Copy: &TaskCopyRecord{Root: repo, Branch: "task/retained"}, + }}) + + waiting := agent.UnlandedChanges() + if len(waiting) != 1 || waiting[0].Folder != repo { + t.Fatalf("kept run is not waiting to land: %+v", waiting) + } + landing, err := agent.Land("") + if err != nil { + t.Fatalf("Land: %v", err) + } + if landing.Merged != mergeMerged { + t.Fatalf("kept run landing = %+v, want merged", landing) + } + if got := readFile(t, filepath.Join(repo, "landed.txt")); got != "from the kept branch\n" { + t.Fatalf("landed file = %q", got) + } + if got := gitOut(t, repo, "branch", "--list", "task/retained"); strings.TrimSpace(got) != "" { + t.Fatalf("kept branch survived landing: %q", got) + } +} + // AND THE MODEL SEES ITS OWN WORK. A write followed by a read of the same path // must answer what was written — the copy is that folder's truth for this // conversation — while a file the conversation never touched is still read diff --git a/internal/session/task_divide_scope.go b/internal/session/task_divide_scope.go index 28fa6bda8d..0aeeca896a 100644 --- a/internal/session/task_divide_scope.go +++ b/internal/session/task_divide_scope.go @@ -32,6 +32,9 @@ package session // sentence that was always about the finished tree: the DONE-CONDITION. import ( + "fmt" + "os" + "path/filepath" "sort" "strings" @@ -119,6 +122,11 @@ func scopeCollisions(parts []dividePart, tree string) []string { // WHAT DIFFERS BETWEEN THE TWO IS THE ENDING AND NOTHING ELSE, because what // differs is what has already been spent by the time the refusal is written. func (a *Agent) scopeRefusal(parts []dividePart, ending string) string { + // A CHECK THAT NAMES A PATH NOBODY HAS PROVES NO SLICE AT ALL, so it is + // refused on the same two roads as an overlapping scope (#1573). + if said := partCheckRefusal(parts, a.config.Workspace, ending); said != "" { + return said + } shared := scopeCollisions(parts, a.config.Workspace) if len(shared) == 0 { return "" @@ -126,6 +134,63 @@ func (a *Agent) scopeRefusal(parts []dividePart, ending string) string { return divisionScopesOverlap(shared, ending) } +// missingPartCheck is the admission seam for a declared check that names a +// path. The planner writes checks verbatim, so a path that is neither present +// in the workspace nor one of the part's own done-condition targets is a +// planner error, not a filename template the harness may invent. +func missingPartCheck(parts []dividePart, workspace string) string { + for index, part := range parts { + targets := make(map[string]bool) + for _, token := range pathTokens(part.Acceptance) { + targets[partCheckPathKey(workspace, token)] = true + } + for _, check := range part.Checks { + for _, token := range pathTokens(check) { + if strings.Contains(token, "...") { + continue + } + if targets[partCheckPathKey(workspace, token)] || partCheckExists(workspace, token) { + continue + } + return fmtPartCheckRefusal(index+1, check, token) + } + } + } + return "" +} + +func partCheckRefusal(parts []dividePart, workspace, ending string) string { + if missing := missingPartCheck(parts, workspace); missing != "" { + return "not split: " + missing + "; " + ending + } + return "" +} + +func partCheckPathKey(workspace, token string) string { + if strings.TrimSpace(workspace) == "" && !filepath.IsAbs(token) { + return filepath.ToSlash(filepath.Clean(token)) + } + return canonicalPath(resolvePath(workspace, token)) +} + +func partCheckExists(workspace, token string) bool { + workspace = canonicalPath(strings.TrimSpace(workspace)) + if workspace == "" { + return false + } + path := resolvePath(workspace, token) + relative, ok := insideWorkspace(workspace, path) + if !ok { + return false + } + _, err := os.Stat(filepath.Join(workspace, filepath.FromSlash(relative))) + return err == nil +} + +func fmtPartCheckRefusal(part int, check, path string) string { + return fmt.Sprintf("part %d check %q names %q, which does not exist in the workspace and is not that part's target; rewrite the check with a real path", part, check, path) +} + // The two endings a scope refusal can have, and they are two because THE // REFUSAL MUST NOT CLAIM A COST THAT WAS ALREADY PAID. A division refused // before the reviewer is read cost nothing, and saying so is the whole of what diff --git a/internal/session/task_divide_test.go b/internal/session/task_divide_test.go index 7d560a8e32..1648a94a15 100644 --- a/internal/session/task_divide_test.go +++ b/internal/session/task_divide_test.go @@ -2008,6 +2008,21 @@ func divideArgsFor(evidence string, parts ...dividePart) json.RawMessage { return raw } +func TestDeclaredPartChecksRefuseMissingUnclaimedPaths(t *testing.T) { + nest := newDivideNest(t, wideBrief, 0) + answer := nest.divide(t, divideArgsFor(wideEvidence, + dividePart{Title: "issue_01.go", Summary: "s", Brief: "fix issue_01.go", Acceptance: "issue_01.go is fixed", Checks: []string{"gofmt -l issue_.go"}}, + dividePart{Title: "issue_02.go", Summary: "s", Brief: "fix issue_02.go", Acceptance: "issue_02.go is fixed", Checks: []string{"gofmt -l issue_.go"}})) + if !strings.HasPrefix(answer, "not split:") || !strings.Contains(answer, "gofmt -l issue_.go") || + !strings.Contains(answer, "does not exist in the workspace") || + !strings.Contains(answer, "not that part's target") { + t.Fatalf("division answer = %q, want a refusal naming the invalid check and path", answer) + } + if kids := nest.graph.children(nest.parent.id); len(kids) != 0 { + t.Fatalf("admitted parts = %d, want none after check refusal", len(kids)) + } +} + // THE MEASURED DATA-LOSS CASE. The ledger is the contract of what ships and it // is staged once, so two parts writing one file is one version silently over the // other — with no conflict for anybody to notice. It is refused where it is still diff --git a/internal/session/task_run.go b/internal/session/task_run.go index 9995d0c2e5..6b026c0892 100644 --- a/internal/session/task_run.go +++ b/internal/session/task_run.go @@ -7024,6 +7024,9 @@ func readTaskDropping(dir, name string) ([]byte, error) { func isTaskDropping(path string) bool { clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(path))) + if strings.HasSuffix(clean, ".orig") { + return true + } for _, name := range taskDroppingNames() { if clean == name || strings.HasPrefix(clean, name+"/") { return true @@ -9416,6 +9419,9 @@ func beltTreeWork(dir string) []string { if harnessWrote(path) { continue } + if isTaskDropping(path) { + continue + } // AN UNTRACKED BUILD CACHE IS NOT THE WORK EITHER, and it is a // separate, narrower question ([buildCache]): exact cache names, and // only for a file git has never been told about. A tracked cache that diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index 42ed178bb4..d5c80272a2 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -230,6 +230,8 @@ const ( type RunSummary struct { Outcome string Result string + // Failure is the run's own account when it did not finish. + Failure string // Limit is empty on every run that did not end on a bound its person set. Limit RunLimit // Program is how a delegated run's program ended when it did not finish, @@ -2233,6 +2235,9 @@ func runEndingWords(summary RunSummary) (string, string) { if ended := summary.Program; ended != nil && summary.Outcome != beltRunOutcomeDone { return strings.TrimSpace(ended.Reason), strings.TrimSpace(ended.Result) } + if summary.Outcome != beltRunOutcomeDone && strings.TrimSpace(summary.Failure) != "" { + return summary.Outcome, strings.TrimSpace(summary.Failure) + } return summary.Outcome, strings.TrimSpace(summary.Result) } diff --git a/internal/session/task_run_belt_test.go b/internal/session/task_run_belt_test.go index f814a60c8c..65dd04342b 100644 --- a/internal/session/task_run_belt_test.go +++ b/internal/session/task_run_belt_test.go @@ -765,6 +765,22 @@ func TestLandingDigestIsUnchangedWithoutAStoredSummary(t *testing.T) { } } +func TestLandingDigestNamesUnfinishedChecks(t *testing.T) { + store, err := plandb.Open(filepath.Join(t.TempDir(), planStoreFilename), "run", planRootID, "The run", "person ask") + if err != nil { + t.Fatal(err) + } + defer store.Close() + got := beltRunOutcomeNote(store, planRootID, RunSummary{ + Outcome: "incomplete", + Failure: "unfinished checks: check: leaf", + }, RunLanding{}, 0) + want := "incomplete · unfinished checks: check: leaf" + if got != want { + t.Fatalf("landing digest = %q, want %q", got, want) + } +} + // landingRunDouble ends synchronously so the test can observe the exact order: // the summary refresh must have stored its sentence before the outcome note is // composed. From 27f8511374b012eede9c5e33eb38d7e1b18e6e63 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 01:30:38 -0400 Subject: [PATCH 34/76] home: a conversation whose engine answers for another project is refused, never used MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A /task typed on Home in project B's window could open on another window's live engine for project A and commit there, carrying A's uncommitted edits. The keeper, the one place every new conversation is bound, now holds an absolute ask to the workspace the engine answers for and refuses a mismatch visibly ("could not open the conversation in · the engine answered for ") before anything is sent. Plain messages and /task are covered as one target path. Part of #1466 (the road that reaches the wrong engine is not yet found; this stops it from running anything there) Co-Authored-By: Claude Opus 5.5 --- internal/manual/chat/home.md | 7 ++++ internal/tui3/homeslash_test.go | 74 +++++++++++++++++++++++++++++++++ internal/tui3/keeper.go | 10 +++++ internal/tui3/teams_test.go | 2 +- 4 files changed, 92 insertions(+), 1 deletion(-) diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index f8a214b474..44963764ed 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -58,6 +58,13 @@ whole map over the cells you are already reading. There is no argument form. The screen is how you name what you want; a command that took a project name would be asking you to type out the very thing home exists to show you. +## Starting a conversation from Home — the project is checked before anything is sent + +The project named by Home's `project:` line belongs to the new conversation, whether you send +a plain message or `/task `. Home checks the engine's answer before it sends the words. +If the engine answers for another project, Home stays up and says `could not open the conversation +in · the engine answered for `; nothing is sent to that other project. + Home is the **first place on the top line**, right after the `codeaf` wordmark: `home teams chats sessions spend settings`. The **teams page**, right after it, is where your teams and their managers live. diff --git a/internal/tui3/homeslash_test.go b/internal/tui3/homeslash_test.go index d59b7d68dc..44ce72fe91 100644 --- a/internal/tui3/homeslash_test.go +++ b/internal/tui3/homeslash_test.go @@ -168,6 +168,80 @@ func TestHomePlainSentenceStillStarts(t *testing.T) { } } +// HOME DOES NOT DISPATCH INTO A CONVERSATION THAT MISSED ITS TARGET. Both a +// plain sentence and a task command use the same opening door, so a hosted +// engine that answers for another project must be refused before either can +// reach that agent. +func TestHomeTargetBindingIsCheckedBeforePlainOrTaskDispatch(t *testing.T) { + for _, line := range []string{"hello there", "/task write h0.txt containing h0"} { + t.Run(strings.TrimPrefix(strings.ReplaceAll(line, " ", "-"), "/"), func(t *testing.T) { + lab := newHomeLab(t) + mine := lab.workspace("alpha") + theirs := lab.workspace("beta") + row := lab.session("-tmp-alpha", "aaaa000000000001", "porting the resume picker", mine, time.Now()) + a := lab.app(row) + runCmd(a.openHome()) + a.target.where = theirs + old := a.agent + nextAgent := &fakeAgent{model: "m"} + next := &taskCommandFake{Agent: nextAgent} + a.start = func(workspace string) (Conversation, error) { + return Conversation{Agent: next, SessionFile: workspace + "/next/transcript.jsonl", Workspace: mine}, nil + } + + if strings.HasPrefix(line, "/") { + runCmd(a.homeSlash(line)) + } else { + runCmd(a.homeStart(line)) + } + if !strings.Contains(a.home.msg, "could not open the conversation") { + t.Fatalf("target mismatch was not visible: %q; sent=%q task calls=%d", a.home.msg, nextAgent.sent, next.singleCalls) + } + if a.agent != old || len(nextAgent.sent) != 0 || next.singleCalls != 0 { + t.Fatalf("dispatch reached the wrong conversation: agent changed=%v sent=%q task calls=%d", a.agent != old, nextAgent.sent, next.singleCalls) + } + }) + } +} + +// HOME'S PLAIN AND TASK DOORS ASK FOR THE SAME PROJECT. The command road may +// start a task asynchronously, but it must first open the same target +// conversation as the ordinary message road. +func TestHomeTaskAndPlainMessageUseTheSameTarget(t *testing.T) { + for _, line := range []string{"hello there", "/task write h0.txt containing h0"} { + t.Run(strings.TrimPrefix(strings.ReplaceAll(line, " ", "-"), "/"), func(t *testing.T) { + lab := newHomeLab(t) + mine := lab.workspace("alpha") + target := lab.workspace("beta") + row := lab.session("-tmp-alpha", "aaaa000000000001", "current", mine, time.Now()) + a := lab.app(row) + runCmd(a.openHome()) + a.target.where = target + var asked string + nextAgent := &fakeAgent{model: "m"} + next := &taskCommandFake{Agent: nextAgent} + a.start = func(workspace string) (Conversation, error) { + asked = workspace + return Conversation{Agent: next, SessionFile: workspace + "/next.jsonl", Workspace: workspace}, nil + } + if strings.HasPrefix(line, "/") { + runCmd(a.homeSlash(line)) + } else { + runCmd(a.homeStart(line)) + } + if asked != target { + t.Fatalf("Home asked for %q, want target %q", asked, target) + } + if strings.HasPrefix(line, "/") && next.singleCalls != 1 { + t.Fatalf("task was not handed to the target conversation: %d calls", next.singleCalls) + } + if !strings.HasPrefix(line, "/") && len(nextAgent.sent) != 1 { + t.Fatalf("plain message was not handed to the target conversation: %q", nextAgent.sent) + } + }) + } +} + func TestHomeSkillPathOpensAConversationWithThePathStillInThePicker(t *testing.T) { lab := newHomeLab(t) mine := lab.session("-tmp-alpha", "aaaa000000000001", "reading the skill shelf", "/tmp/alpha", time.Now()) diff --git a/internal/tui3/keeper.go b/internal/tui3/keeper.go index 7c90119d68..0bb9d5e9e3 100644 --- a/internal/tui3/keeper.go +++ b/internal/tui3/keeper.go @@ -2,6 +2,7 @@ package tui3 import ( "errors" + "path/filepath" "strings" "sync" "sync/atomic" @@ -1068,6 +1069,15 @@ func (a *app) startBeside(workspace string) (tea.Cmd, string) { if err != nil { return nil, err.Error() } + // ONLY AN ABSOLUTE ASK CAN BE HELD TO THE ANSWER: a far machine's ask may be + // home-relative, and the engine answers with the path it resolved it to. + if got := strings.TrimSpace(conv.Workspace); got != "" && filepath.IsAbs(workspace) && !sameFolder(got, workspace) { + return nil, "could not open the conversation in " + workspace + " · the engine answered for " + got + } + // A legacy start door may omit the workspace even though the requested + // project is known. Carrying the target into the bundle keeps the surface's + // draft, transcript and engine selection bound to the same project. + conv.Workspace = workspace cmd := a.takeBeside(conv) // A conversation started while a team is shown is one of that team // (teams.go's [app.teamJoinFront]). diff --git a/internal/tui3/teams_test.go b/internal/tui3/teams_test.go index 81e74b7fc2..74f2c6d826 100644 --- a/internal/tui3/teams_test.go +++ b/internal/tui3/teams_test.go @@ -415,7 +415,7 @@ func TestTeamNewConversationJoinsTheShownTeam(t *testing.T) { n := 0 a.start = func(workspace string) (Conversation, error) { n++ - return Conversation{Agent: &fakeAgent{model: "m"}, SessionFile: fmt.Sprintf("/tmp/lab/new-%d.jsonl", n), Workspace: "/tmp/lab"}, nil + return Conversation{Agent: &fakeAgent{model: "m"}, SessionFile: fmt.Sprintf("%s/new-%d.jsonl", workspace, n), Workspace: workspace}, nil } before := a.frontTabKey() tabs := a.tabList() From 8d6c4c5a169a6e9d9c7695d0bd5dc132a5dca9d5 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 01:30:39 -0400 Subject: [PATCH 35/76] wall: shows only this window's conversations, and a tile opened from Teams lands on it - The wall built its tiles from the tab strip, which remembers visited and other windows' conversations, so a new window showed another window's conversation as open here. It now draws the front tab and the ones this window holds. - Opening a tile closed the wall but did not leave the page it was opened from, so from Teams it landed back on Teams. Tile opens now go through the shared landing seam. Fixes #1583 Fixes #1577 Co-Authored-By: Claude Opus 5.5 --- internal/tui3/sharedagent_test.go | 18 ++++++++++++++++++ internal/tui3/wall.go | 7 +++++-- internal/tui3/wallclick_test.go | 23 +++++++++++++++++++++++ internal/tui3/walltail.go | 6 ++++++ 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/internal/tui3/sharedagent_test.go b/internal/tui3/sharedagent_test.go index eafa97ab89..e863bb25d8 100644 --- a/internal/tui3/sharedagent_test.go +++ b/internal/tui3/sharedagent_test.go @@ -309,6 +309,24 @@ func TestSharedChatRoundTripRestoresEachDraftAndCaret(t *testing.T) { } } +// A SHARED ENGINE'S WALL SHOWS ONLY ITS CURRENT CONVERSATION. Swapping the +// single remote handle ends the old conversation; keeping its navigation name +// must not make that ended conversation look open in this window. +func TestSharedEngineWallDoesNotKeepTheSwappedConversationOpen(t *testing.T) { + a, _, _ := sharedSurface(t) + a.width, a.height = 160, 40 + _ = a.tabsRow(a.width) + cmd, refusal := a.openBeside("/srv/app", "/srv/app/b.jsonl") + if refusal != "" { + t.Fatal(refusal) + } + drain(t, a, cmd) + tiles := a.wallTiles(a.now()) + if len(tiles) != 1 || tiles[0].tab.key != "/srv/app/b.jsonl" { + t.Fatalf("shared wall has %d tiles: %+v", len(tiles), tiles) + } +} + // C5: a shared handle cannot keep the old turn alive, so its waiting messages // return to the composer. The fallback keeps every attachment too, with the // draft's tray first and each waiting message following in queue order. diff --git a/internal/tui3/wall.go b/internal/tui3/wall.go index 053aabd51a..bd72f862db 100644 --- a/internal/tui3/wall.go +++ b/internal/tui3/wall.go @@ -587,11 +587,14 @@ func (a *app) wallOpen(tiles []wallTile, i int) tea.Cmd { tab := tiles[i].tab from, ok := a.wallTileRect(i) a.closeWall() + var cmd tea.Cmd if ok && a.wallMotionOK() { a.wall.zoomFrom, a.wall.zoomAt = from, a.now() - return tea.Batch(a.tabGo(tab), a.wake()) + cmd = tea.Batch(a.tabGo(tab), a.wake()) + } else { + cmd = a.tabGo(tab) } - return a.tabGo(tab) + return a.hopLand(cmd) } // wallToggle marks tile i, or unmarks it. Any tile marked is the selection diff --git a/internal/tui3/wallclick_test.go b/internal/tui3/wallclick_test.go index a58de9f56f..899bd9c3ca 100644 --- a/internal/tui3/wallclick_test.go +++ b/internal/tui3/wallclick_test.go @@ -117,6 +117,29 @@ func TestWallClickPicksTilesAndMakesATeam(t *testing.T) { } } +// A WALL TILE LEAVES THE PLACE IT WAS OPENED FROM. The teams page can raise +// the wall just as chats can, and choosing a conversation must put that +// conversation in front rather than leaving the page over the switch. +func TestWallTileFromTeamsPageLandsOnConversation(t *testing.T) { + a, _, _ := tabApp(t) + runCmd(a.showPage(pageTeams)) + tiles := a.wallShown(a.now()) + if len(tiles) < 2 { + t.Fatalf("the teams lab has %d wall tiles, want a tile behind the front", len(tiles)) + } + want := tiles[1].tab.key + runCmd(a.wallOpen(tiles, 1)) + if a.at(pageTeams) { + t.Fatal("opening a wall tile left the teams page in front") + } + if a.wall.on { + t.Fatal("opening a wall tile left the wall open") + } + if got := a.frontTabKey(); got != want { + t.Fatalf("opening a wall tile put %q in front, want %q", got, want) + } +} + // A CONVERSATION'S TEAMS ARE A CLICK AWAY: its ●+ opens the popover, a box // puts it in a team and takes it out again, and a team's dot opens its // settings, where it is renamed, recoloured and deleted, the last only once diff --git a/internal/tui3/walltail.go b/internal/tui3/walltail.go index 6d33f95f48..45303bc267 100644 --- a/internal/tui3/walltail.go +++ b/internal/tui3/walltail.go @@ -498,6 +498,12 @@ func (a *app) wallTiles(now time.Time) []wallTile { if tab.start || tab.work { continue } + // THE WALL IS THE OPEN SET, not the strip's remembered history. A tab + // without the front or a keeper entry belongs to another window now, or + // was merely visited; drawing it here would let this wall act on it. + if !tab.here && a.behind[tab.key] == nil { + continue + } if len(terms) > 0 { if _, ok := fuzzy.ScoreFields([]string{tab.word, tab.full}, terms); !ok { continue From 2e7a862f707d79e011100b456665261080fe8f2c Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 01:32:00 -0400 Subject: [PATCH 36/76] chat: angle brackets survive, team names paint, Lyria composes, hosted memory works, long homes dial, check rows show - Model prose placeholders like were parsed as raw HTML and dropped; the shared inline renderer now keeps their source text. - The team naming card's async answer marked the wall dirty but did not wake the paint clock, so it drew only on the next key; it now wakes it. - The composing model list required a music modality; audio-output rows with the music id mark (Lyria) are admitted, plain speech rows are not. - /memory, /remember, /memories and /forget said memory is off on the default hosted engine road because only an in-process agent was consulted. The welcome now says whether the engine remembers, and the hosted surface reaches the engine's memory commands over the wire. - --host under a long codeaf home failed because the ssh control-path check ignored OpenSSH's 17-character temporary suffix; both forms must now fit, or multiplexing is left off. - A plan's check row that repeats its worker's title was deduplicated away from the Tasks column; check rows are never hidden by title. Fixes #1578 Fixes #1581 Fixes #1589 Fixes #1535 Fixes #1580 Part of #1556 (the check-row item; the landing-race item stays open) Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/chatv3_host.go | 32 ++++++++++++---- cmd/codeaf/sshcontrol_regression_test.go | 26 +++++++++++++ internal/manual/chat/places.md | 14 +++---- .../manual/chat/running-on-another-machine.md | 28 ++++++++------ internal/remote/callclass.go | 1 + internal/remote/client.go | 30 +++++++++++++++ internal/remote/places.go | 36 ++++++++++++++++++ internal/remote/server.go | 6 +++ internal/remote/wire.go | 5 +++ internal/remote/wire_places.go | 15 ++++++++ internal/tui2/prose/angle_regression_test.go | 19 ++++++++++ internal/tui2/prose/inline.go | 8 ++-- internal/tui3/check_task_regression_test.go | 38 +++++++++++++++++++ internal/tui3/memory.go | 21 +++------- internal/tui3/memory_host_regression_test.go | 21 ++++++++++ internal/tui3/modelmedia_regression_test.go | 12 ++++++ internal/tui3/models.go | 10 ++++- internal/tui3/place_memory.go | 2 +- internal/tui3/taskplan.go | 10 ++++- internal/tui3/tasksplace.go | 2 +- internal/tui3/team_repaint_regression_test.go | 20 ++++++++++ internal/tui3/teamorganize.go | 2 +- internal/tui3/wall.go | 2 +- 23 files changed, 309 insertions(+), 51 deletions(-) create mode 100644 cmd/codeaf/sshcontrol_regression_test.go create mode 100644 internal/tui2/prose/angle_regression_test.go create mode 100644 internal/tui3/check_task_regression_test.go create mode 100644 internal/tui3/memory_host_regression_test.go create mode 100644 internal/tui3/modelmedia_regression_test.go create mode 100644 internal/tui3/team_repaint_regression_test.go diff --git a/cmd/codeaf/chatv3_host.go b/cmd/codeaf/chatv3_host.go index 5bfc061e28..06ca2a6a74 100644 --- a/cmd/codeaf/chatv3_host.go +++ b/cmd/codeaf/chatv3_host.go @@ -263,15 +263,22 @@ func sshControlPath() string { return "" } path := filepath.Join(dir, "ctl-%C") - // OpenSSH expands %C to a 40-character SHA-1 digest before bind(2), so the - // expanded path is the one that must fit the shared macOS/Linux ceiling. - expanded := strings.Replace(path, "%C", strings.Repeat("0", 40), 1) - if !enginehost.SocketPathFits(expanded) { + if !sshControlPathFits(path) { return "" } return path } +// sshControlPathFits accounts for OpenSSH's temporary control-master name as +// well as the final hashed path, so a path accepted here cannot fail at bind. +func sshControlPathFits(path string) bool { + // OpenSSH expands %C to a 40-character SHA-1 digest and briefly appends a + // 17-character suffix before bind(2), so both forms must fit the shared + // macOS/Linux ceiling. + expanded := strings.Replace(path, "%C", strings.Repeat("0", 40), 1) + return enginehost.SocketPathFits(expanded) && enginehost.SocketPathFits(expanded+strings.Repeat("0", 17)) +} + // hold takes the new child and lets go of the old one. THE PREVIOUS SSH IS // REAPED IN THE BACKGROUND, because a redial happens after its pipe died and a // child nobody waits on is a zombie for as long as this terminal is open — five @@ -660,7 +667,7 @@ func hostOptions(fleet *engineFleet, welcome remote.Welcome, pick bool) (tui3.Op world.prime() ledger := newHostLedger(far) ledger.prime() - memory := newHostMemory(far) + memory := newHostMemory(far, welcome.Memory) memory.prime() // The counting gate is here and not on the roads: a session this window @@ -1570,11 +1577,12 @@ type hostMemory struct { mu sync.Mutex shelves store.MemoryShelves learned, letGo int + enabled bool known bool } -func newHostMemory(far hostFar) *hostMemory { - h := &hostMemory{client: far.client} +func newHostMemory(far hostFar, enabled bool) *hostMemory { + h := &hostMemory{client: far.client, enabled: enabled, known: true} far.arm(&h.duty, "reading what is remembered") return h } @@ -1631,3 +1639,13 @@ func (h *hostMemory) RestoreMemory(id string) error { func (h *hostMemory) MemoryProvenance(id string) (string, string, time.Time, error) { return h.client.MemoryProvenance(id) } +func (h *hostMemory) Remembers() bool { return h.enabled } +func (h *hostMemory) Remember(text string) (string, error) { + return h.client.Remember(text) +} +func (h *hostMemory) Forget(query string) (string, error) { + return h.client.ForgetQuery(query) +} +func (h *hostMemory) Memories(query string) ([]session.MemoryLine, error) { + return h.client.Memories(query) +} diff --git a/cmd/codeaf/sshcontrol_regression_test.go b/cmd/codeaf/sshcontrol_regression_test.go new file mode 100644 index 0000000000..9c0ef2ec01 --- /dev/null +++ b/cmd/codeaf/sshcontrol_regression_test.go @@ -0,0 +1,26 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/enginehost" +) + +func TestSSHControlPathFitsOpenSSHTemporarySuffix(t *testing.T) { + const hashLength = 40 + const temporarySuffix = 17 + prefix, err := os.MkdirTemp("/tmp", "s") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(prefix) }) + rootLength := enginehost.SocketLimit - len("/v3/ssh/ctl-") - hashLength + root := filepath.Join(prefix, strings.Repeat("a", rootLength-len(prefix)-1)) + t.Setenv("CODEAF_HOME", root) + if got := sshControlPath(); got != "" { + t.Fatalf("control path %q was accepted without room for OpenSSH's %d-byte temporary suffix", got, temporarySuffix) + } +} diff --git a/internal/manual/chat/places.md b/internal/manual/chat/places.md index 63ba119665..f75746157d 100644 --- a/internal/manual/chat/places.md +++ b/internal/manual/chat/places.md @@ -820,9 +820,9 @@ an hour ago, `alt+4`, `alt+7` and `alt+8` all open: On a narrow window the line wraps onto a second or third dim line under the first; it is never cut, and never ends in `…`. The moment the first thing arrives the line goes and the list begins under the same heading; nothing above it moves. -- **spend**, **search** and **memory** over `--host` each say one dim line where their rows - would be — see *The places over --host* below for the exact words and why three of the - eight still say them. +- **spend** and **search** over `--host` each say one dim line where their rows would be + when their older engine has no matching door. Memory follows the connected engine's + store and setting; see *The places over --host* below. There is no "coming soon", no greyed-out list and no empty table with headings over it. A page that draws the furniture of a feature it does not have looks like a bug rather than like @@ -889,10 +889,10 @@ their readings have not crossed: | **memory** | the far machine's memories; fixing and forgetting a line write there too | **No place silently substitutes this laptop's rows for the far machine's.** Settings names -the split as it opens. Spend, search and memory draw no local rows at all over `--host` and -say why. A screen full of the wrong machine's work is a confident lie, and one honest -sentence is better than eight rows and a total in dollars that belong to somebody else's -afternoon. +the split as it opens. Spend and search draw no local rows over `--host` when their remote +door is unavailable; memory reads and writes the far store when its door is present. A +screen full of the wrong machine's work is a confident lie, and one honest sentence is +better than eight rows and a total in dollars that belong to somebody else's afternoon. **The top line says whose machine it is: `on spark` at the end of the tab bar.** Over a connection the far end of the top line, where the places are, reads `on `, just diff --git a/internal/manual/chat/running-on-another-machine.md b/internal/manual/chat/running-on-another-machine.md index 95d06deb46..69be15b4db 100644 --- a/internal/manual/chat/running-on-another-machine.md +++ b/internal/manual/chat/running-on-another-machine.md @@ -467,14 +467,17 @@ exact sentence each one says. The second half of the list, with the exact sentence each one says. -`/cache`, `/permissions`, `/crew`, `/memory `, `/memories`, `/remember`, -`/forget`, `/subharness` and `/harness` describe stores or settings belonging to the -machine that runs the session, but this build has no wire door for them. They do not read -or change this machine's copy. The cache, permissions, crew and harness commands name the -connected machine and say `change it on that machine`; the memory commands say `memory -shows what this machine has learned, and this session is on another`. In particular, -`/cache clean now` deletes nothing here, `/crew pin` writes nothing here, and -`/subharness` does not claim the far registry is empty. +`/cache`, `/permissions`, `/crew`, `/subharness` and `/harness` describe stores or settings +belonging to the machine that runs the session, but this build has no wire door for them. +They do not read or change this machine's copy. The cache, permissions, crew and harness +commands name the connected machine and say `change it on that machine`; in particular, +`/cache clean now` deletes nothing here, `/crew pin` writes nothing here, and `/subharness` +does not claim the far registry is empty. + +The memory commands are different: this build carries the connected engine's memory state +and memory doors. `/memory ` and `/memories` read that machine's store, while +`/remember` and `/forget` write it. When memory is really off there, they say exactly: +`memory is off for this session · turn it on under /settings`. ## Did cache clean delete the laptop cache or the remote machine's cache? @@ -489,11 +492,12 @@ answer names the connected machine and says to change it there. name the connected machine, and say to change the crew there: `devbox owns the crew · change it on that machine`. -## Why does remember over host not say whether memory is off? +## How does remember over host know whether memory is on? -The surface has not asked the connected machine whether memory is enabled. `/remember`, -`/forget`, `/memories`, and `/memory ` therefore say only that this session is on -another machine; they neither claim memory is off there nor read this machine's memories. +The connection's welcome carries the connected engine's memory setting, and the memory +commands use that engine's store. `/remember` and `/forget` write there; `/memories` and +`/memory ` read there. With memory off, the commands and memory place say: +`memory is off for this session · turn it on under /settings`. ## Does subharness know whether the remote machine has saved programs? diff --git a/internal/remote/callclass.go b/internal/remote/callclass.go index c48380e0f8..cda5bcf438 100644 --- a/internal/remote/callclass.go +++ b/internal/remote/callclass.go @@ -156,6 +156,7 @@ func classify(method string) callClass { MethodConsent, MethodConsentRemember, MethodStandingResolve, MethodHarness, MethodConnect, MethodConnectKey, MethodNoteConnected, + MethodMemoryRemember, MethodMemoryForgetQuery, MethodMemoryMemories, MethodTake, MethodAnswerLaneOffer, MethodInterrupt, MethodPlanNote, MethodPlanPause, MethodPlanResume, MethodPlanCancel, MethodPlanAmend, MethodPlanPriority, MethodTyping: diff --git a/internal/remote/client.go b/internal/remote/client.go index 946b4a07bd..e22306674c 100644 --- a/internal/remote/client.go +++ b/internal/remote/client.go @@ -1329,6 +1329,36 @@ func (c *Client) MemoryProvenance(id string) (string, string, time.Time, error) return out.Session, out.Title, out.At, nil } +func (c *Client) Remember(text string) (string, error) { + payload, err := c.call(nil, MethodMemoryRemember, text) + if err != nil { + return "", err + } + var title string + err = json.Unmarshal(payload, &title) + return title, err +} + +func (c *Client) ForgetQuery(query string) (string, error) { + payload, err := c.call(nil, MethodMemoryForgetQuery, query) + if err != nil { + return "", err + } + var title string + err = json.Unmarshal(payload, &title) + return title, err +} + +func (c *Client) Memories(query string) ([]session.MemoryLine, error) { + payload, err := c.call(nil, MethodMemoryMemories, query) + if err != nil { + return nil, err + } + var lines []session.MemoryLine + err = json.Unmarshal(payload, &lines) + return lines, err +} + func (c *Client) StandingItems(workspace string) ([]standing.Item, error) { payload, err := c.call(nil, MethodStandingItems, workspace) if err != nil { diff --git a/internal/remote/places.go b/internal/remote/places.go index da249ac246..7f1fd7bf06 100644 --- a/internal/remote/places.go +++ b/internal/remote/places.go @@ -139,6 +139,13 @@ func (s *server) placesCall(call Frame) (json.RawMessage, bool, error) { } payload, err := memoryCall(engine.Memory, call) return payload, true, err + case MethodMemoryRemember, MethodMemoryForgetQuery, MethodMemoryMemories: + commands, ok := sess.current().(MemoryCommands) + if !ok || !commands.Remembers() { + return nil, true, errors.New(engineOffWord + memoryOffWord) + } + payload, err := memoryCommandCall(commands, call) + return payload, true, err } return nil, false, nil } @@ -309,3 +316,32 @@ func memoryCall(mem EngineMemory, call Frame) (json.RawMessage, error) { } return nil, errors.New(engineOffWord + "no such memory door") } + +func memoryCommandCall(commands MemoryCommands, call Frame) (json.RawMessage, error) { + text, err := arg[string](call) + if err != nil { + return nil, err + } + switch call.Method { + case MethodMemoryRemember: + title, err := commands.Remember(text) + if err != nil { + return nil, err + } + return json.Marshal(title) + case MethodMemoryForgetQuery: + title, err := commands.Forget(text) + if err != nil { + return nil, err + } + return json.Marshal(title) + case MethodMemoryMemories: + lines, err := commands.Memories(text) + if err != nil { + return nil, err + } + return json.Marshal(lines) + default: + return nil, errors.New(engineOffWord + "no such memory command") + } +} diff --git a/internal/remote/server.go b/internal/remote/server.go index ec862ae942..81194036fd 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -1156,6 +1156,11 @@ func steerRepeatKnown(agent any) bool { return ok && door.SteerRepeatKnown() } +func memoryCommandsKnown(agent any) bool { + door, ok := agent.(MemoryCommands) + return ok && door.Remembers() +} + func (sess *Session) welcomeLocked(s *server) Welcome { // A HOSTED START MUST READ THE ENGINE'S FILE, not the surface's. Carrying // this reading in the welcome is what makes an old persistent engine say @@ -1191,6 +1196,7 @@ func (sess *Session) welcomeLocked(s *server) Welcome { // open — for [Welcome.Folders]'s stated reason: the surface's own type // assertion cannot see across the wire. Folders: keepsFolders(sess.agent), + Memory: memoryCommandsKnown(sess.agent), // Every engine of this build answers the teams doors from its own // profile (teams.go), so the flag is about the build, not the agent. Teams: true, diff --git a/internal/remote/wire.go b/internal/remote/wire.go index 75f4585e45..8b00a61f16 100644 --- a/internal/remote/wire.go +++ b/internal/remote/wire.go @@ -1182,6 +1182,11 @@ type Welcome struct { // would open a picker whose every row ends in an error. Folders bool `json:"folders,omitempty"` + // Memory says the conversation's engine has its memory row enabled. It is + // carried once so a surface can distinguish an empty store from memory off + // without asking from its paint loop. + Memory bool `json:"memory,omitempty"` + // Teams says this engine ANSWERS THE TEAMS DOORS ([MethodTeamsRead], // [MethodTeamsUpdate], [MethodTeamsTraffic]) from its own profile, which // is where its team tools keep the teams and their Traffic. diff --git a/internal/remote/wire_places.go b/internal/remote/wire_places.go index 91d23930bc..b23b33b124 100644 --- a/internal/remote/wire_places.go +++ b/internal/remote/wire_places.go @@ -109,6 +109,11 @@ const ( // that takes it back. Both carry the id and nothing else. MethodMemoryForget = "Memory.Forget" // string (id) → nothing MethodMemoryRestore = "Memory.Restore" // string (id) → nothing + // These three are the transcript commands, which operate on the session's + // memory engine rather than the store editor behind the memory place. + MethodMemoryRemember = "Memory.Remember" // string → title + MethodMemoryForgetQuery = "Memory.ForgetQuery" // string → title + MethodMemoryMemories = "Memory.Memories" // string → []session.MemoryLine // MethodMemoryProvenance is where and when one memory was learned, asked for // the ONE id whose card is open rather than for every row on the page. MethodMemoryProvenance = "Memory.Provenance" // string (id) → MemoryOrigin @@ -214,3 +219,13 @@ type EngineMemory interface { RestoreMemory(id string) error MemoryProvenance(id string) (sessionID, sessionTitle string, writtenAt time.Time, err error) } + +// MemoryCommands is the transcript-facing memory door carried by a hosted +// session. The place editor above remains a separate interface because it +// operates on memory rows and cards rather than command text. +type MemoryCommands interface { + Remembers() bool + Remember(text string) (string, error) + Forget(query string) (string, error) + Memories(query string) ([]session.MemoryLine, error) +} diff --git a/internal/tui2/prose/angle_regression_test.go b/internal/tui2/prose/angle_regression_test.go new file mode 100644 index 0000000000..ac2060e772 --- /dev/null +++ b/internal/tui2/prose/angle_regression_test.go @@ -0,0 +1,19 @@ +package prose + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" +) + +func TestInlineAngleBracketPlaceholdersStayInModelProse(t *testing.T) { + rows := Render("GET /tasks/ and DELETE /tasks/", Options{Width: 80}) + var got strings.Builder + for _, row := range rows { + got.WriteString(ansi.Strip(row)) + } + if got.String() != "GET /tasks/ and DELETE /tasks/" { + t.Fatalf("angle brackets rendered as %q", got.String()) + } +} diff --git a/internal/tui2/prose/inline.go b/internal/tui2/prose/inline.go index a9bb0bad8b..b8665ed336 100644 --- a/internal/tui2/prose/inline.go +++ b/internal/tui2/prose/inline.go @@ -62,9 +62,10 @@ func (r *renderer) inline(n ast.Node, w *wrapper, st style) { url := scrub(string(c.URL(r.src))) r.link(c, w, st, scrub(string(c.Label(r.src))), url) case *ast.RawHTML: - // A tag is markup, not content. Its text has already been emitted - // as siblings, so drawing `` here would show the reader the - // author's punctuation twice. + // Model prose is content even when CommonMark classifies an angle- + // bracketed word as raw HTML. Keeping the source here preserves + // placeholders such as `` instead of silently dropping them. + w.push(scrub(string(c.Segments.Value(r.src))), st) default: r.inline(c, w, st) } @@ -94,6 +95,7 @@ func (r *renderer) collect(n ast.Node, b *strings.Builder) { case *ast.AutoLink: b.Write(c.Label(r.src)) case *ast.RawHTML: + b.Write(c.Segments.Value(r.src)) default: r.collect(c, b) } diff --git a/internal/tui3/check_task_regression_test.go b/internal/tui3/check_task_regression_test.go new file mode 100644 index 0000000000..23f5c525cd --- /dev/null +++ b/internal/tui3/check_task_regression_test.go @@ -0,0 +1,38 @@ +package tui3 + +import ( + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/session" +) + +func TestPlanRailKeepsCheckRowsUnderTheirRun(t *testing.T) { + rows := []session.PlanTaskRow{ + {ID: "run", Title: "run", Status: "done"}, + {ID: "check", Parent: "run", Title: "check: store.py", Status: "done"}, + } + forest := (tasksReading{items: c253PlanItems(rows, "chat"), now: taskFixtureNow}).planRailForest(rows) + if len(forest) != 1 || len(forest[0].kids) != 1 || forest[0].kids[0].row.Title != "check: store.py" { + t.Fatalf("check row was not kept under its run: %+v", forest) + } +} + +func TestPlanCheckIsNotHiddenByAWorkerWithTheSameTitle(t *testing.T) { + now := taskFixtureNow + chat := session.SessionRow{ID: "chat", Title: "chat", Open: true} + chat.Tasks.Rows = []session.TaskIndexEntry{{ID: "worker", Title: "test cart.py", SessionID: chat.ID, Status: string(session.TaskDone)}} + mine := tasksMine{ + row: chat, + rows: []tasksMineRow{{entry: chat.Tasks.Rows[0]}}, + plan: []session.PlanTaskRow{{ID: "t-check", Title: "test cart.py", Seat: "check", Status: "done"}}, + } + reading := readTasks(session.World{Projects: []session.Project{{Name: "project", Sessions: []session.SessionRow{chat}}}}, mine, + session.LastDays(now, 10), tasksSort{}, time.Time{}, now) + for _, item := range reading.items { + if item.plan != nil && item.plan.Seat == "check" { + return + } + } + t.Fatal("the check row was hidden by the worker row with the same title") +} diff --git a/internal/tui3/memory.go b/internal/tui3/memory.go index f72d3d1a7b..0a105ac4d3 100644 --- a/internal/tui3/memory.go +++ b/internal/tui3/memory.go @@ -43,10 +43,13 @@ type memoryAgent interface { // brain is the agent under this surface, when it has one at all. func (a *app) brain() (memoryAgent, bool) { agent, ok := a.agent.(memoryAgent) - if !ok || !agent.Remembers() { - return nil, false + if ok && agent.Remembers() { + return agent, true } - return agent, true + if memory, ok := a.memory.(memoryAgent); ok && memory.Remembers() { + return memory, true + } + return nil, false } // memoryOffNote is the one line every one of the three prints when this build @@ -58,10 +61,6 @@ const memoryOffNote = "memory is off for this session · turn it on under /setti // runRemember is /remember: keep one thing across conversations. func (a *app) runRemember(text string) { a.noticeEvent(eventRemembered) - if a.hosted() { - a.note(memoryRemoteWord) - return - } agent, ok := a.brain() if !ok { a.note(memoryOffNote) @@ -86,10 +85,6 @@ func (a *app) runRemember(text string) { // the recovery — the store keeps a tombstone, not the row's contents in any // place a surface can reach — is a database question rather than a keystroke. func (a *app) runForget(query string) { - if a.hosted() { - a.note(memoryRemoteWord) - return - } agent, ok := a.brain() if !ok { a.note(memoryOffNote) @@ -113,10 +108,6 @@ func (a *app) runForget(query string) { // runMemories is /memories: the whole list, or the ones matching a word. func (a *app) runMemories(query string) { - if a.hosted() { - a.note(memoryRemoteWord) - return - } agent, ok := a.brain() if !ok { a.note(memoryOffNote) diff --git a/internal/tui3/memory_host_regression_test.go b/internal/tui3/memory_host_regression_test.go new file mode 100644 index 0000000000..84b866d871 --- /dev/null +++ b/internal/tui3/memory_host_regression_test.go @@ -0,0 +1,21 @@ +package tui3 + +import "testing" + +func TestMemoryCommandsUseTheHostedStoreWhenTheAgentHasNoLocalBrain(t *testing.T) { + a, _, _ := tabApp(t) + memory := &struct { + panelMemoryStore + *rememberingAgent + }{rememberingAgent: &rememberingAgent{}} + a.memory = memory + + brain, ok := a.brain() + if !ok { + t.Fatalf("hosted memory was not selected: brain=%T", brain) + } + title, err := brain.Remember("the build uses make") + if err != nil || title == "" { + t.Fatalf("hosted memory command failed: title=%q err=%v", title, err) + } +} diff --git a/internal/tui3/modelmedia_regression_test.go b/internal/tui3/modelmedia_regression_test.go new file mode 100644 index 0000000000..fa679ed737 --- /dev/null +++ b/internal/tui3/modelmedia_regression_test.go @@ -0,0 +1,12 @@ +package tui3 + +import "testing" + +func TestComposingPickerKeepsAudioRowsMarkedAsMusic(t *testing.T) { + if !composesMusic(Model{ID: "google/lyria-3-clip-preview", Output: []string{"text", "audio"}}) { + t.Fatal("the composing picker rejected Lyria's audio output row") + } + if composesMusic(Model{ID: "openai/gpt-4o-mini-tts", Output: []string{"audio"}}) { + t.Fatal("the composing picker admitted an unmarked speech row") + } +} diff --git a/internal/tui3/models.go b/internal/tui3/models.go index cf59b7b2a0..e219b6e2db 100644 --- a/internal/tui3/models.go +++ b/internal/tui3/models.go @@ -557,7 +557,15 @@ func speaksAloud(model Model) bool { return makesModality(model, "speech", speec // catalog files speech under music; the marks below are narrow enough that a // silent TTS row cannot reach it, and a row that PUBLISHED "music" is taken at // its word the way every other row on this surface is. -func composesMusic(model Model) bool { return makesModality(model, "music", musicMarks) } +func composesMusic(model Model) bool { + if len(model.Output) > 0 { + if hasModality(model.Output, "music") { + return true + } + return hasModality(model.Output, "audio") && markedID(model.ID, musicMarks) + } + return markedID(model.ID, musicMarks) +} // filmsVideo is the "filming" slot's question. func filmsVideo(model Model) bool { return makesModality(model, "video", videoMarks) } diff --git a/internal/tui3/place_memory.go b/internal/tui3/place_memory.go index dc19e16fe1..e813d48ecc 100644 --- a/internal/tui3/place_memory.go +++ b/internal/tui3/place_memory.go @@ -519,7 +519,7 @@ func (a *app) openMemory() tea.Cmd { // (styles.go's THE EMPTINESS LAW covers the figures; this covers the reason). func (a *app) memorySnapshot() (store.MemoryShelves, string) { if !a.memoryReady() { - if a.hosted() { + if a.hosted() && a.memory == nil { return store.MemoryShelves{}, memoryRemoteWord } return store.MemoryShelves{}, memoryOffNote diff --git a/internal/tui3/taskplan.go b/internal/tui3/taskplan.go index 1317167551..2f47fa9d4a 100644 --- a/internal/tui3/taskplan.go +++ b/internal/tui3/taskplan.go @@ -589,11 +589,17 @@ func planTitleFor(title string) string { return strings.ToLower(strings.TrimSpac // task of the store that row is ([session.TaskNotice.PlanTask]) — so those rows // are taken out by identity before this runs ([planStoreDraws]), and the title // guess is left to the road that has nothing better. -func planRowShown(names map[string]bool, title string) bool { +func planRowShown(names map[string]bool, row session.PlanTaskRow) bool { if len(names) == 0 { return false } - return names[planTitleFor(title)] + // A CHECK ROW IS ITS OWN PIECE OF WORK, even when the checker repeats the + // worker's title. Title matching is only the fallback for plan-born worker + // nodes; letting it hide a check removes the proof row from the roster. + if strings.TrimSpace(row.Seat) == "check" { + return false + } + return names[planTitleFor(row.Title)] } // planNamesOf is the set of titles THIS conversation's own node rows wear, which diff --git a/internal/tui3/tasksplace.go b/internal/tui3/tasksplace.go index 3a073a4274..36b9b32a6b 100644 --- a/internal/tui3/tasksplace.go +++ b/internal/tui3/tasksplace.go @@ -315,7 +315,7 @@ func readTasks(world session.World, mine tasksMine, win session.UsageWindow, by // the name is another row's title (taskplan.go's [planWaits]). kin := planKinOf(mine.plan) for _, task := range mine.plan { - if planRowShown(names, task.Title) { + if planRowShown(names, task) { continue } item := planItem(task, mine.row.ID, kin) diff --git a/internal/tui3/team_repaint_regression_test.go b/internal/tui3/team_repaint_regression_test.go new file mode 100644 index 0000000000..26222c23c0 --- /dev/null +++ b/internal/tui3/team_repaint_regression_test.go @@ -0,0 +1,20 @@ +package tui3 + +import "testing" + +func TestTeamNameAnswerWakesAnIdlePaintClock(t *testing.T) { + a, namer, tiles := namingApp(t) + namer.name = "textkit" + cmd := a.wallStartNaming(tiles) + if cmd == nil { + t.Fatal("team naming did not start") + } + a.painting = false + namerDoors(t, a, cmd) + if !a.wall.nameAsking && a.wall.name != "textkit" { + t.Fatalf("team name did not land: asking=%v name=%q", a.wall.nameAsking, a.wall.name) + } + if !a.painting { + t.Fatal("team name answer did not wake the idle paint clock") + } +} diff --git a/internal/tui3/teamorganize.go b/internal/tui3/teamorganize.go index 4eeef2bd8d..dd9e83e5c8 100644 --- a/internal/tui3/teamorganize.go +++ b/internal/tui3/teamorganize.go @@ -455,7 +455,7 @@ func (a *app) wallOrganizeOpen() tea.Cmd { return func(bool) tea.Cmd { a.wallOrganizeQuietTake(gen, quiet) a.wallOrganized(gen, res, err) - return nil + return a.wake() } }) } diff --git a/internal/tui3/wall.go b/internal/tui3/wall.go index bd72f862db..f75c7eb8da 100644 --- a/internal/tui3/wall.go +++ b/internal/tui3/wall.go @@ -710,7 +710,7 @@ func (a *app) wallAskName(marked []chatTab) tea.Cmd { name, err := namer.NameTeam(ctx, titles) return func(bool) tea.Cmd { a.wallTeamNamed(gen, name, err) - return nil + return a.wake() } }) wait := tea.Tick(teamNameWait, func(time.Time) tea.Msg { return wallNameTimeMsg{gen: gen} }) From 3fb3b042c2efe12cf5308bc741f1b2c1f6f9578b Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 02:07:43 -0400 Subject: [PATCH 37/76] changes: the docs-audit batch's change entry Co-Authored-By: Claude Opus 5.5 --- .../unreleased/1604-docs-audit-batch.md | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 docs/changes/unreleased/1604-docs-audit-batch.md diff --git a/docs/changes/unreleased/1604-docs-audit-batch.md b/docs/changes/unreleased/1604-docs-audit-batch.md new file mode 100644 index 0000000000..bd0c494eda --- /dev/null +++ b/docs/changes/unreleased/1604-docs-audit-batch.md @@ -0,0 +1,28 @@ +--- +kind: fixed +title: docs-audit bug batch — headless brief and flags, read-only reading helpers, held tasks, standing orders in workers, teams, fan-out checks, media, remote focus +pr: 1604 +surface: [chat, engine, remote, docs] +invalidates: + - "`codeaf do \"\"` (or an all-blank brief) ran a paid job on a goal the planner invented. It is refused with the same usage as `codeaf do` with no argument, before anything is spent." + - "Flags after the brief (`codeaf senior-dev \"\" --max-cost 0.5`) were folded into the brief and the default ceiling applied. They are parsed wherever they sit for every delegate program, an unknown flag is refused, and `--` still ends flags." + - "`codeaf do --json` on the run engine reported zero tokens beside a non-zero spend. The run engine's workers now carry input and output tokens to the receipt." + - "A read hand-off (`◆ reading: …`) ran as an ordinary quick task with bash and commit, so it could write, commit and merge. It now gets the audit read-only belt, hands anything it cannot do back to the conversation, and a failed hand-off leaves no stopped card." + - "A task held by the busy-machine gate had already taken its folder lock and branch, and `/stop` could not end it. A held task owns only its plan row (`queued · machine busy`), stops at once, and prepares its folder only after admission; an admitted task's refusals are unchanged." + - "After an engine restart an interrupted run's plan store stayed live and its row read working forever. Startup archives it." + - "An explicit stop was recorded as a failed crew, so the crew line said `failed … /redo stronger` and the router learned a failure. A stop reads `stopped`, offers no redo and teaches the router nothing." + - "Plan-born task workers were briefed without the project's standing orders. Every worker a run seats carries the one standing section." + - "A project standing order stored with a trailing separator was listed under other projects; the standing card quoted the allowance the session started with; a firing that ran a task read `said:`. Paths are compared cleaned, the card quotes the live allowance the rail enforces, and task firings read `task:`." + - "A manager started with `M` had no handle until its first turn, so `team_send` could not reach it. It gets a unique fallback handle when it is registered." + - "A sub-team started by `team_start` did not inherit the starting manager's approval posture, and the start card showed the rule word `default`. The posture is carried to the child, and a fallback rule shows the cost clause." + - "`team_start`'s description said the person is always asked first, so a manager under an allowing posture reported an approval nobody gave. It now says approval follows the posture." + - "Every check in a fan-out run drew from one checker-seat tally, so later checks stopped at a ceiling other checks had spent, and the run still said done. Each check has its own ceiling, and a run with unfinished checks does not end done." + - "A per-part check naming a path that does not exist and that the part does not produce (`issue_.go`) was stored and run. Division refuses it with the check and path named." + - "A run branch kept on a protected or moved checkout was invisible to `/land`, and `.orig` backups could reach a task branch. `/land` lists and lands kept run branches, and `.orig` files are droppings." + - "A Home conversation could be opened on another project's live engine. A conversation whose engine answers for a different workspace is refused visibly before anything is sent." + - "The wall showed other windows' conversations as open here, and a tile opened from Teams landed back on Teams. It shows only this window's conversations, and a tile lands on its conversation." + - "`/memory`, `/remember`, `/memories` and `/forget` said memory is off on the hosted engine road. They reach the engine's memory over the wire." + - "Generated JPEG bytes were saved under a `.png` name. Images are named by their sniffed bytes." + - "Speech spend had no role in usage.jsonl and media calls wrote no call-log rows. Speech records under its own role, and media requests write a call-log pair with the provider's cost." + - "After a `--host` redial the window became a watcher of its own dead pipe, and ssh stderr painted over the frame. The same window takes its keyboard back by client id, and a redial's stderr goes to the diagnostic tail." + - "`/drafts` drew nothing while it held the keyboard, `/skill` on an empty shelf left `/skill ` in the box, and esc could not cancel a pending browser sign-in. All three are fixed." From 5c0512cfeed06390524ed7a9f99e094c56d7e532 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 02:08:03 -0400 Subject: [PATCH 38/76] changes: close the batch entry's frontmatter and shorten its title Co-Authored-By: Claude Opus 5.5 --- docs/changes/unreleased/1604-docs-audit-batch.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/changes/unreleased/1604-docs-audit-batch.md b/docs/changes/unreleased/1604-docs-audit-batch.md index bd0c494eda..c0ebbe9d39 100644 --- a/docs/changes/unreleased/1604-docs-audit-batch.md +++ b/docs/changes/unreleased/1604-docs-audit-batch.md @@ -1,6 +1,6 @@ --- kind: fixed -title: docs-audit bug batch — headless brief and flags, read-only reading helpers, held tasks, standing orders in workers, teams, fan-out checks, media, remote focus +title: A batch of fixes from the docs audit — headless, tasks, standing, teams, runs, media, remote pr: 1604 surface: [chat, engine, remote, docs] invalidates: @@ -26,3 +26,6 @@ invalidates: - "Speech spend had no role in usage.jsonl and media calls wrote no call-log rows. Speech records under its own role, and media requests write a call-log pair with the provider's cost." - "After a `--host` redial the window became a watcher of its own dead pipe, and ssh stderr painted over the frame. The same window takes its keyboard back by client id, and a redial's stderr goes to the diagnostic tail." - "`/drafts` drew nothing while it held the keyboard, `/skill` on an empty shelf left `/skill ` in the box, and esc could not cancel a pending browser sign-in. All three are fixed." +--- + +One entry for the whole batch. The issues it closes, the ones it only narrows, and the ones left for a ruling are listed in the pull request. From 8254cb07dbd5a68ed96a3d0449c0edab321e6350 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 02:25:11 -0400 Subject: [PATCH 39/76] session: take back the restart reconcile of an interrupted run's plan The real-binary check showed that archiving an interrupted run's plan store on startup made a task that never ran read done after a restart. A done that nothing earned is worse than the working row it replaced, so the reconcile is removed and #1554 item 1 stays open for a fix that says interrupted. Refs #1554 Co-Authored-By: Claude Opus 5.5 --- internal/session/run_lifecycle_test.go | 42 -------------------------- internal/session/task_store.go | 29 ------------------ 2 files changed, 71 deletions(-) diff --git a/internal/session/run_lifecycle_test.go b/internal/session/run_lifecycle_test.go index 7ad895790d..7bb530a456 100644 --- a/internal/session/run_lifecycle_test.go +++ b/internal/session/run_lifecycle_test.go @@ -16,7 +16,6 @@ package session import ( "context" "errors" - "os" "path/filepath" "strconv" "strings" @@ -26,47 +25,6 @@ import ( "github.com/Agent-Field/codeaf/internal/plandb" ) -func TestRestartReconcilesAnUnfinishedBeltPlan(t *testing.T) { - t.Setenv("CODEAF_TASK_BELT", "bash") - dir := t.TempDir() - place := Place{Dir: dir} - path := filepath.Join(dir, planStoreFilename) - store, err := plandb.Open(path, "held", "71", "held", "held brief", place.ID()) - if err != nil { - t.Fatal(err) - } - if err := store.Close(); err != nil { - t.Fatal(err) - } - checkpoint := place.Tasks() - writeCheckpoint(t, checkpoint, taskDocument{Type: taskDocumentType, Version: taskFileVersion, Seq: 71, - Runs: []runRecord{{ID: 71, Title: "held", State: TaskRunning, PlanTask: planStoreID("71")}}, - }) - agent, _ := newTestAgent(t, &scriptedCompleter{}, func(cfg *Config) { - cfg.Workspace = newTestRepo(t) - cfg.Place = place - cfg.SessionFile = filepath.Join(dir, placeTranscript) - cfg.AskConsent = false - }) - defer agent.Close() - rows := agent.graph().runRows(71) - if len(rows) != 1 || rows[0].State != TaskInterrupted { - t.Fatalf("recovered belt row = %+v, want interrupted", rows) - } - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Fatalf("the interrupted plan is still live: stat err=%v", err) - } - archived, err := plandb.Open(path+".1", "", "", "", "") - if err != nil { - t.Fatal(err) - } - defer archived.Close() - root := archived.Task("71") - if root == nil || !terminalStoreStatus(root.Status) || root.Error != taskWordInterrupted { - t.Fatalf("reconciled root = %+v, want terminal interrupted", root) - } -} - // relayEngine is a run engine that can run more than once: every Start is // handed back on a channel, and each Start answers the summary the test queued // for it (a whole run when nothing is queued). A done run completes its root the diff --git a/internal/session/task_store.go b/internal/session/task_store.go index 6274722cf7..e79e6b3008 100644 --- a/internal/session/task_store.go +++ b/internal/session/task_store.go @@ -1743,19 +1743,6 @@ func (a *Agent) recoverTasks() { graph := a.graph() recovery := graph.rehydrate(document, a.config.Workspace, a.settlePolicy()) - // A belt run's plan is a second persisted state beside this checkpoint. If - // the process died before its driver settled, archive that live store now; - // otherwise PlanTasks would keep reading its root as working forever even - // though the restored run row already says nothing is driving it. - if graph.holdsInterruptedBeltRun() { - if path := graph.planPath(); path != "" { - if info, err := os.Stat(path); err == nil && !info.IsDir() { - if err := setAsideRunStore(path); err != nil { - graph.planNote("the interrupted run could not be reconciled: " + err.Error()) - } - } - } - } // The consume-once receipt reaches the disk BEFORE anything else happens: a // second crash between here and the first turn must not hand the same // interrupt to a second recovery. @@ -1796,22 +1783,6 @@ func (a *Agent) recoverTasks() { } } -// holdsInterruptedBeltRun distinguishes the bash-belt rows from adaptive -// rows. Both are restored as interrupted, but only the belt has a plan store -// whose live root must be archived on startup. -func (g *TaskGraph) holdsInterruptedBeltRun() bool { - g.mu.Lock() - defer g.mu.Unlock() - for _, rows := range g.runs { - for _, row := range rows { - if row.State == TaskInterrupted && row.Run == "" && row.PlanTask != "" { - return true - } - } - } - return false -} - // reconcile files ONE record and answers it as the graph is to hold it: a node // the close caught is turned into what it became ([interrupt]) and counted under // its own clause, and every other is counted as it stands ([taskRecovery.countSettled]). From 0b86a5f7c2c434acf5ee428032465cc5cfdfe0f0 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 02:31:00 -0400 Subject: [PATCH 40/76] session: take back the part-check path refusal The full session suite showed the refusal is too eager: a declared check that names a package a part will create (go test ./internal/rank) was refused as a missing path, which broke the family-check lift. Guessing which paths a part will produce is not a sound seam, so the refusal is removed and #1573 stays open. Refs #1573 Co-Authored-By: Claude Opus 5.5 --- internal/manual/chat/tasks.md | 2 - internal/session/task_divide_scope.go | 65 --------------------------- internal/session/task_divide_test.go | 15 ------- 3 files changed, 82 deletions(-) diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index 2e6ef193aa..86db9941b8 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -4092,8 +4092,6 @@ done-condition, and ask again — it is not a finding that the work cannot be sp checks that name different things — a package each, a file each — are two checks and are admitted; nothing here reads which program is being run or how long it takes. -A part check is stored exactly as the planner wrote it. If it names a path that is not in the workspace and is not that part's own target, the division is refused with the check and missing path named; rewrite the check for the part and ask again. codeaf does not guess or expand filename stems such as `issue_.go`. - **And you are only told once.** If the same task asks again with the same shared check still in every part — which is what a worker does when it cannot rewrite three done-conditions — the division is **taken** rather than refused a second time, with that check **removed from every diff --git a/internal/session/task_divide_scope.go b/internal/session/task_divide_scope.go index 0aeeca896a..28fa6bda8d 100644 --- a/internal/session/task_divide_scope.go +++ b/internal/session/task_divide_scope.go @@ -32,9 +32,6 @@ package session // sentence that was always about the finished tree: the DONE-CONDITION. import ( - "fmt" - "os" - "path/filepath" "sort" "strings" @@ -122,11 +119,6 @@ func scopeCollisions(parts []dividePart, tree string) []string { // WHAT DIFFERS BETWEEN THE TWO IS THE ENDING AND NOTHING ELSE, because what // differs is what has already been spent by the time the refusal is written. func (a *Agent) scopeRefusal(parts []dividePart, ending string) string { - // A CHECK THAT NAMES A PATH NOBODY HAS PROVES NO SLICE AT ALL, so it is - // refused on the same two roads as an overlapping scope (#1573). - if said := partCheckRefusal(parts, a.config.Workspace, ending); said != "" { - return said - } shared := scopeCollisions(parts, a.config.Workspace) if len(shared) == 0 { return "" @@ -134,63 +126,6 @@ func (a *Agent) scopeRefusal(parts []dividePart, ending string) string { return divisionScopesOverlap(shared, ending) } -// missingPartCheck is the admission seam for a declared check that names a -// path. The planner writes checks verbatim, so a path that is neither present -// in the workspace nor one of the part's own done-condition targets is a -// planner error, not a filename template the harness may invent. -func missingPartCheck(parts []dividePart, workspace string) string { - for index, part := range parts { - targets := make(map[string]bool) - for _, token := range pathTokens(part.Acceptance) { - targets[partCheckPathKey(workspace, token)] = true - } - for _, check := range part.Checks { - for _, token := range pathTokens(check) { - if strings.Contains(token, "...") { - continue - } - if targets[partCheckPathKey(workspace, token)] || partCheckExists(workspace, token) { - continue - } - return fmtPartCheckRefusal(index+1, check, token) - } - } - } - return "" -} - -func partCheckRefusal(parts []dividePart, workspace, ending string) string { - if missing := missingPartCheck(parts, workspace); missing != "" { - return "not split: " + missing + "; " + ending - } - return "" -} - -func partCheckPathKey(workspace, token string) string { - if strings.TrimSpace(workspace) == "" && !filepath.IsAbs(token) { - return filepath.ToSlash(filepath.Clean(token)) - } - return canonicalPath(resolvePath(workspace, token)) -} - -func partCheckExists(workspace, token string) bool { - workspace = canonicalPath(strings.TrimSpace(workspace)) - if workspace == "" { - return false - } - path := resolvePath(workspace, token) - relative, ok := insideWorkspace(workspace, path) - if !ok { - return false - } - _, err := os.Stat(filepath.Join(workspace, filepath.FromSlash(relative))) - return err == nil -} - -func fmtPartCheckRefusal(part int, check, path string) string { - return fmt.Sprintf("part %d check %q names %q, which does not exist in the workspace and is not that part's target; rewrite the check with a real path", part, check, path) -} - // The two endings a scope refusal can have, and they are two because THE // REFUSAL MUST NOT CLAIM A COST THAT WAS ALREADY PAID. A division refused // before the reviewer is read cost nothing, and saying so is the whole of what diff --git a/internal/session/task_divide_test.go b/internal/session/task_divide_test.go index 1648a94a15..7d560a8e32 100644 --- a/internal/session/task_divide_test.go +++ b/internal/session/task_divide_test.go @@ -2008,21 +2008,6 @@ func divideArgsFor(evidence string, parts ...dividePart) json.RawMessage { return raw } -func TestDeclaredPartChecksRefuseMissingUnclaimedPaths(t *testing.T) { - nest := newDivideNest(t, wideBrief, 0) - answer := nest.divide(t, divideArgsFor(wideEvidence, - dividePart{Title: "issue_01.go", Summary: "s", Brief: "fix issue_01.go", Acceptance: "issue_01.go is fixed", Checks: []string{"gofmt -l issue_.go"}}, - dividePart{Title: "issue_02.go", Summary: "s", Brief: "fix issue_02.go", Acceptance: "issue_02.go is fixed", Checks: []string{"gofmt -l issue_.go"}})) - if !strings.HasPrefix(answer, "not split:") || !strings.Contains(answer, "gofmt -l issue_.go") || - !strings.Contains(answer, "does not exist in the workspace") || - !strings.Contains(answer, "not that part's target") { - t.Fatalf("division answer = %q, want a refusal naming the invalid check and path", answer) - } - if kids := nest.graph.children(nest.parent.id); len(kids) != 0 { - t.Fatalf("admitted parts = %d, want none after check refusal", len(kids)) - } -} - // THE MEASURED DATA-LOSS CASE. The ledger is the contract of what ships and it // is staged once, so two parts writing one file is one version silently over the // other — with no conflict for anybody to notice. It is refused where it is still From 4643131c5bb4cb5aa48f4a35d956fd4211e63432 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 02:31:00 -0400 Subject: [PATCH 41/76] changes: drop the part-check line from the batch entry Co-Authored-By: Claude Opus 5.5 --- docs/changes/unreleased/1604-docs-audit-batch.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/changes/unreleased/1604-docs-audit-batch.md b/docs/changes/unreleased/1604-docs-audit-batch.md index c0ebbe9d39..3192036966 100644 --- a/docs/changes/unreleased/1604-docs-audit-batch.md +++ b/docs/changes/unreleased/1604-docs-audit-batch.md @@ -17,7 +17,6 @@ invalidates: - "A sub-team started by `team_start` did not inherit the starting manager's approval posture, and the start card showed the rule word `default`. The posture is carried to the child, and a fallback rule shows the cost clause." - "`team_start`'s description said the person is always asked first, so a manager under an allowing posture reported an approval nobody gave. It now says approval follows the posture." - "Every check in a fan-out run drew from one checker-seat tally, so later checks stopped at a ceiling other checks had spent, and the run still said done. Each check has its own ceiling, and a run with unfinished checks does not end done." - - "A per-part check naming a path that does not exist and that the part does not produce (`issue_.go`) was stored and run. Division refuses it with the check and path named." - "A run branch kept on a protected or moved checkout was invisible to `/land`, and `.orig` backups could reach a task branch. `/land` lists and lands kept run branches, and `.orig` files are droppings." - "A Home conversation could be opened on another project's live engine. A conversation whose engine answers for a different workspace is refused visibly before anything is sent." - "The wall showed other windows' conversations as open here, and a tile opened from Teams landed back on Teams. It shows only this window's conversations, and a tile lands on its conversation." From 53945ab1a74ffb219c23c59d70451d953962423b Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 09:56:31 -0400 Subject: [PATCH 42/76] standing: a firing that only reported a sentence comes to said, not landed A task firing that changed no file but reported a sentence was logged as landed. The rule that such a run is not "nothing" stands (its words reached the person); it now comes to said, which the outcome set already has, and only a saved change is landed. Fixes #1582 Co-Authored-By: Claude Opus 5.5 --- internal/session/standing_nothing_test.go | 8 ++++---- internal/session/standing_run.go | 7 +++++-- internal/standing/tick_test.go | 1 + 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/internal/session/standing_nothing_test.go b/internal/session/standing_nothing_test.go index ef816ce63d..5605c74c10 100644 --- a/internal/session/standing_nothing_test.go +++ b/internal/session/standing_nothing_test.go @@ -62,7 +62,7 @@ func nightly(workspace string) standing.Item { } // A CHILD THAT SAVED NOTHING AND SAID NOTHING CAME TO NOTHING, and the same -// child with one sentence to its name landed. The run folder's own marker is +// child with one sentence to its name said. The run folder's own marker is // written by the pass from this word ([standing.CameTo]), so it is the outcome // and not the folder that has to be right here. func TestAFiringThatLeftNothingBehindComesToNothing(t *testing.T) { @@ -97,8 +97,8 @@ func TestAFiringThatLeftNothingBehindComesToNothing(t *testing.T) { if err != nil { t.Fatalf("Run: %v", err) } - if outcome.Kind != "landed" { - t.Fatalf("a run with a report to give came to %q, wanted landed", outcome.Kind) + if outcome.Kind != "said" { + t.Fatalf("a run with a report to give came to %q, wanted said", outcome.Kind) } if !strings.Contains(outcome.Text, "flaky tests passed") { t.Fatalf("the run lost its own report: %q", outcome.Text) @@ -179,7 +179,7 @@ func TestWhatCountsAsARunThatCameToNothing(t *testing.T) { {false, "", "", standing.OutcomeNothing}, {false, " \n ", "", standing.OutcomeNothing}, {true, "", "", "landed"}, - {false, "the suite is green", "", "landed"}, + {false, "the suite is green", "", "said"}, {true, "the suite is green", "", "landed"}, // A run stopped on something only a person can allow is neither: it is // work waiting for them, and it is waiting whatever else it did. diff --git a/internal/session/standing_run.go b/internal/session/standing_run.go index 4ec302a5c9..a3aa29edfa 100644 --- a/internal/session/standing_run.go +++ b/internal/session/standing_run.go @@ -765,13 +765,16 @@ func (r *standingRunner) Run(ctx context.Context, item standing.Item, runDir, ev // AND A SENTENCE COUNTS AS SOMETHING. A nightly job that changed no file and // reported "the three flaky tests passed this time" delivered that report to // the person ([standingRunner.deliver]), and a run whose words somebody read is -// not a run that came to nothing however little it touched. +// not a run that came to nothing however little it touched. It is said, while a +// run that saved a file is landed. func standingCameTo(saved bool, report, needs string) string { switch { case needs != "": return "needs-you" - case saved || strings.TrimSpace(report) != "": + case saved: return "landed" + case strings.TrimSpace(report) != "": + return "said" } return standing.OutcomeNothing } diff --git a/internal/standing/tick_test.go b/internal/standing/tick_test.go index ad4694f978..d564528826 100644 --- a/internal/standing/tick_test.go +++ b/internal/standing/tick_test.go @@ -863,6 +863,7 @@ func TestTickWritesWhatEachRunCameTo(t *testing.T) { kind string reaped bool }{ + {"said", false}, {"landed", false}, {"needs-you", false}, {OutcomeNothing, true}, From fe8fc0e189d0c7655b1ee7e77a56223fe1434d6d Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 09:57:48 -0400 Subject: [PATCH 43/76] senior-dev: an unsubmitted run with passing checks says so and gives the command The contract stays: checks passing is not a submission and the run exits 2. But the ending said only that the run ended without submitting. It now says "the checks passed, but nothing was submitted", names the kept tree, and gives the one command that hands it in. Fixes #1584 Co-Authored-By: Claude Opus 5.5 --- internal/manual/chat/senior-dev.md | 6 ++ internal/seniordev/app/pipeline.go | 3 + internal/seniordev/app/pipeline_smoke_test.go | 55 +++++++++++++++++++ internal/seniordev/app/solo_ship.go | 37 ++++++++++--- 4 files changed, 93 insertions(+), 8 deletions(-) diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 3e21092840..5312b2c67b 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -218,6 +218,12 @@ broke — and acts on it: - **broke**: the chat hands it back once if the cause looks passing (a network or model service failure), and otherwise tells you what broke. +If the project's checks passed but the model never called `submit`, the ending says plainly +`the checks passed, but nothing was submitted`. There is no resume or submit command for an +ended run. The ending names the kept tree and gives the one command to start senior-dev in +that tree and hand it in: `codeaf senior-dev --dir -- "submit the existing work"`. +The run still ends incomplete, with exit 2, because passing checks are not a submission. + **codeaf sends senior-dev back at most twice on its own** for one piece of work. A third hand-off it tries, or one after a limit, is refused (`senior-dev has been sent back to this work 2 times already, the most codeaf does on its diff --git a/internal/seniordev/app/pipeline.go b/internal/seniordev/app/pipeline.go index a8d9fe4dcf..675841ca61 100644 --- a/internal/seniordev/app/pipeline.go +++ b/internal/seniordev/app/pipeline.go @@ -229,6 +229,9 @@ func soloResultStatus(outcome soloOutcome) (string, string) { case "pass-unverified": return "pass", "submitted; verification did not complete" case "unsubmitted": + if soloVerificationPassed(outcome.Verification) && outcome.TerminalReason != "" { + return "fail", outcome.TerminalReason + } return "fail", "the run ended without submitting" default: if reason == "" { diff --git a/internal/seniordev/app/pipeline_smoke_test.go b/internal/seniordev/app/pipeline_smoke_test.go index fdd51e4513..66d1910529 100644 --- a/internal/seniordev/app/pipeline_smoke_test.go +++ b/internal/seniordev/app/pipeline_smoke_test.go @@ -206,6 +206,61 @@ func TestSoloRunNudgesThenGivesUpHonestly(t *testing.T) { } } +func TestUnsubmittedPassingVerificationNamesHowToSubmitTheKeptTree(t *testing.T) { + workspace, base := guardWorkspace(t) + backend := &soloScriptedBackend{ + onTurn: func(_ int, request turn) (turnResult, bool, error) { + if err := writeFile(filepath.Join(request.Workspace, "feature.txt"), "implemented\n"); err != nil { + return turnResult{}, true, err + } + return turnResult{Text: "the work is ready"}, true, nil + }, + } + runner := newPipeline(cliArgs{}, workspace, pipelineDeps{ + Backend: backend, Events: newEventWriter(io.Discard), Notes: io.Discard, + }) + defer runner.runtime.Close() + runner.verifyForTest = func(context.Context) projectVerificationResult { + return soloTestVerification(0, false) + } + + outcome, err := runner.runSolo(context.Background(), "Add the feature.", base) + if err != nil { + t.Fatal(err) + } + status, reason := soloResultStatus(outcome) + if status != "fail" { + t.Fatalf("status = %q, want fail so exit code remains 2", status) + } + ending := endingOf(pipelineResult{Status: status, Reason: reason, Terminal: outcome.TerminalData}) + command := fmt.Sprintf("codeaf senior-dev --dir %q -- \"submit the existing work\"", workspace) + for name, message := range map[string]string{ + "human ending": ending.Message, + "terminal reason": outcome.TerminalReason, + } { + if !strings.Contains(message, "the checks passed, but nothing was submitted") { + t.Errorf("%s = %q, want the plain unsubmitted passing-check sentence", name, message) + } + if !strings.Contains(message, command) { + t.Errorf("%s = %q, want the command for submitting the kept tree", name, message) + } + } + + var encoded bytes.Buffer + if err := delegate.NewEmitter(&encoded).Terminal(ending); err != nil { + t.Fatal(err) + } + var record map[string]any + if err := json.Unmarshal(bytes.TrimSpace(encoded.Bytes()), &record); err != nil { + t.Fatal(err) + } + message, _ := record["message"].(string) + if !strings.Contains(message, "the checks passed, but nothing was submitted") || + !strings.Contains(message, command) { + t.Fatalf("JSON terminal message = %q, want the same sentence and command", message) + } +} + func TestSoloRunCorrectsPlainTextDSMLWithoutSpendingANudge(t *testing.T) { workspace := gitWorkspace(t, map[string]string{ "README.md": "base\n", diff --git a/internal/seniordev/app/solo_ship.go b/internal/seniordev/app/solo_ship.go index b2404b4e83..6311e76dc5 100644 --- a/internal/seniordev/app/solo_ship.go +++ b/internal/seniordev/app/solo_ship.go @@ -24,14 +24,7 @@ func (runner *pipeline) soloShip( // earlier green or coherent checkpoint available. outcome.Status = "unsubmitted" runner.soloFinalizeUnsubmitted(ctx, state, outcome) - reason := "no submission: the run stopped without calling submit" - if converseErr != nil { - reason += " (" + converseErr.Error() + ")" - } - if outcome.RestoreSource != "" { - reason += "; the live tree's suite could not start and it was restored from " + outcome.RestoreSource - } - runner.soloTerminal(outcome, reason) + runner.soloTerminal(outcome, runner.soloUnsubmittedEnding(outcome, converseErr)) return } outcome.Frozen = candidate @@ -115,6 +108,34 @@ func (runner *pipeline) soloShip( runner.soloTerminal(outcome, endingReason) } +// soloUnsubmittedEnding is the one sentence source for an unsubmitted run's +// final account. A passing check is useful only when the person is also told +// that the model never handed it in and where to continue the kept tree. +func (runner *pipeline) soloUnsubmittedEnding(outcome *soloOutcome, converseErr error) string { + if soloVerificationPassed(outcome.Verification) { + return fmt.Sprintf( + "the checks passed, but nothing was submitted. The kept tree is at %q; "+ + "to submit it, run: `codeaf senior-dev --dir %q -- \"submit the existing work\"`", + runner.workspace, runner.workspace, + ) + } + reason := "no submission: the run stopped without calling submit" + if converseErr != nil { + reason += " (" + converseErr.Error() + ")" + } + if outcome.RestoreSource != "" { + reason += "; the live tree's suite could not start and it was restored from " + outcome.RestoreSource + } + return reason +} + +// soloVerificationPassed recognizes a useful pass for an unsubmitted tree. +// A command that found no tests is not evidence that the requested work passed. +func soloVerificationPassed(verification *projectVerificationResult) bool { + return verification != nil && !verification.TimedOut && verification.Failed == nil && + len(verification.Commands) > 0 && !(verification.NoTests && verification.NewFailures == 1) +} + // verificationUnaffordable reports whether post-submit verification can still // be run at all, and why not. Both conditions are ordinary endings rather than // faults: a run is expected to use its whole budget, and the context is From 5b32e396b4b1e1ac93f6930cef64ca4d9e18b820 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 09:58:15 -0400 Subject: [PATCH 44/76] teams: the default daily cap is each team's own, and All teams has none unless set The profile's default per-team cap resolved to one pool owned by the top of the chain, so every team under All teams shared one default pool and All teams inherited the cap itself. Three callers (the cap gate, the header, move accounting) each re-derived that owner. The pool owner is now resolved once, in teams.File.Effective: a default cap is the team's own pool, the root has no default cap, and an explicit ancestor cap still owns its subtree. Fixes #1575 Co-Authored-By: Claude Opus 5.5 --- docs/design/conversations-and-teams/DESIGN.md | 31 +++++++------ internal/manual/chat/team-manager.md | 3 +- .../manual/chat/team-questions-and-caps.md | 9 ++-- internal/manual/chat/teams-page.md | 8 ++-- internal/session/team_cap.go | 19 +++----- internal/session/team_delegation_test.go | 43 +++++++++++++++++++ internal/teams/delegation_test.go | 35 ++++++++++++--- internal/teams/move.go | 18 ++------ internal/teams/root.go | 14 +++--- internal/teams/teams.go | 3 +- internal/teams/teamsettings.go | 25 +++++++---- internal/tui3/teamspage.go | 20 +++------ internal/tui3/teamspage_test.go | 29 +++++++++++++ 13 files changed, 173 insertions(+), 84 deletions(-) diff --git a/docs/design/conversations-and-teams/DESIGN.md b/docs/design/conversations-and-teams/DESIGN.md index 5947b470ed..b8c3836971 100644 --- a/docs/design/conversations-and-teams/DESIGN.md +++ b/docs/design/conversations-and-teams/DESIGN.md @@ -664,7 +664,7 @@ Everything below is `internal/teams` unless named otherwise. | teams.json field | Go | Meaning | Band | |---|---|---|---| | `questions_up` | `*bool` | members' questions go to the home manager first | | -| `cap_usd_day` | `*float64` | dollars per local day for the team and everything under it; `0` is an explicit no cap | `>= 0` | +| `cap_usd_day` | `*float64` | an explicit dollars-per-day cap for the team and everything under it; `0` is an explicit no cap | `>= 0` | | `depth_limit` | `*int` | levels of teams, the top counting as one | 1 to 10 | | `sub_share` | `*float64` | fraction of this team's cap a new sub-team is made with | (0, 1] | | `wake` | `*bool` | team traffic wakes idle conversations (section 5); the old `"wake": false` reads as off | | @@ -684,13 +684,16 @@ Everything below is `internal/teams` unless named otherwise. - `(*File).Depth(id) int` (the root is 0, top level 1), `(*File).CanNest(parent, Defaults) bool` (parent open and one more level inside its effective limit), `(*File).SubTeamCap(parent, Defaults) float64` (parent's effective cap times its share, to the cent; 0 when the parent - has none). The session writes that figure on the new sub-team with `SetSettings`, so a later + has none). With no derived cap, the new sub-team follows the ordinary effective-cap walk. + The session writes a positive figure on the new sub-team with `SetSettings`, so a later change of the share moves no team that exists. -**A cap is a pool.** A team's spend counts every team under it, so an inherited cap is the -ancestor's one pool, shared, never a second allowance of the same size. `Effective.CapFrom.Team` -names the pool's owner; when the cap comes from Settings, the owner is the top of the chain -(the root when there is one). The spend drawn beside a cap is always the owner's. +**A cap is a pool.** An explicit cap on a team counts that team and every team under it, so an +inherited cap is the ancestor's one pool, shared, never a second allowance of the same size. +The profile's default cap is different: it gives each ordinary team its own pool, while the +`All teams` root has no cap unless it is explicitly set. `Effective.CapFrom.Team` names the +pool's owner; when the cap comes from Settings, the owner is the team itself for an ordinary +team. The spend drawn beside a cap is always the owner's. **Config keys** (`internal/config/teamdefaults.go`, flat dotted keys, category `teams`, one settings tab `Teams`, each a row with a named reader `TeamDefaultsAt` and a ledger line): @@ -867,8 +870,8 @@ forbids every new store door in a frame (`framedisk_law_test.go`). - **One level.** `team_send`, `team_stop` and `team_start` reach only the manager's own team's members (a sub-team's manager is one). Links may send fyi notes (`KindNote`) and nothing else. - **Caps.** Before each model request of a member, the session compares - `TeamSpend(owner, Today())` with the pool owner's effective cap (`Effective.CapFrom.Team`, or - the top of the chain). Reached: it raises one `cap` packet to `Person` for that team and day + `TeamSpend(owner, Today())` with the pool owner's effective cap (`Effective.CapFrom.Team`). + Reached: it raises one `cap` packet to `Person` for that team and day (not one per request; look for an open one first) with options `raise` (`Raise to $10`, twice the cap) and `stop` (`Stop for today`) and a recommendation, and holds new member turns in that pool until it is decided. Managers never raise a cap: money is the person's. @@ -1018,13 +1021,15 @@ its tab stays open. Other shared conversations keep the prior rule.) - **The settings group is its own tab, `Teams`.** The settings tabs are one-to-one with their categories for the four newer tabs; a group inside Tasks would be a row filed under one category and drawn under another. -- **A cap is a pool, and the header says whose.** `$1.20 of $5 today · from harbor` beside a +- **A cap is a pool, and the header says whose.** `$1.20 of $5 today · harbor's cap` beside a sub-team would read as a second $5; the header shows the pool owner's spend and says `harbor's cap`. The settings card still says `from harbor`, which is true of the value. - **Cap packets always go to the person.** A manager that could raise its own cap would make the cap advice. Managers may stop their own team early; they may not spend more. -- **The global manager is a real root team**, not a special case beside the tree, so every - rule above holds for it unchanged; the root is not a level and cannot close. +- **The global manager is a real root team**, not a special case beside the tree, so its + membership, authority and spend walk are ordinary; the root is not a level and cannot close. + Its profile-default cap is the deliberate exception: `All teams` has no cap until the person + explicitly sets one on it. - **Team defaults are rails**, refused to model self-service like the spending and consent rows. A team's own overrides are written only by the interface for the person; the team tools must not write them (d1). @@ -1095,8 +1100,8 @@ without waking. `Decide` on a question now logs `answered @web: