From 843131f025d36c03fc047617aeb35ed4205f50b6 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:55:42 +0530 Subject: [PATCH 01/86] feat(execprofile): add the zeromaxing posture rung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zeromaxing is thorough with double the turn budget again: 320 tool-turns, effort high, self-correction armed. It fills only what the caller left unset, exactly as the other profiles do, so an explicit flag and a --mode preset both still win — precedence stays enforced by ordering, not by a new check. ONE NAME. /effort zeromaxing, /profile zeromaxing, --exec-profile zeromaxing and --reasoning-effort zeromaxing all name the same thing, and nothing else does. No aliases: "max", "deep" and "deepmode" resolve to nothing, asserted by test, because a second spelling is a second thing to keep in step. "max" is RESERVED and untouched. ValidReasoningEffort still accepts ReasoningEffortMax, so /effort max and --reasoning-effort max behave exactly as they did — parsed as a raw provider level, rejected by the flag parser, and unsupported by every current model. That spelling stays free for a real provider rung; claiming it for a Zero posture would burn it. HONEST DELTA, stated to the user rather than left in a commit message: the turn budget is the ONLY mechanical change over thorough. Effort is already at the ceiling and self-correction is already on. execprofile.Delta carries that sentence to /effort, /profile and the exec selection notice. ReasoningEffort is "high", pinned by test rather than by comment. TestZeromaxingReasoningEffortStaysHigh explains why: every level above "high" falls through the providers' effort mappers into their default arm — anthropic and gemini both compute a thinking budget of 0 (extended thinking DISABLED) and the openai mapper drops the field entirely. Raising it would silently turn reasoning off while the UI claimed a higher posture. Raising the real ceiling means teaching the providers those tiers first. The raised budget propagates to spawned sub-agents. Deliberate — this is a maximal posture — so it is asserted by test and stated in Delta rather than left to be discovered. SelectionRefusal is the single authority both selection paths consult. The headless exec path and the TUI paths already apply a profile's knobs through different code with different state; letting each decide SELECTION independently is how a rule gets applied to one call path and omitted from its sibling. Config gating follows mergeProjectConfig's tighten-only template: a project .zero/config.json may set profiles.disableZeromaxing (DISABLE) but can never clear it, so a cloned repo cannot switch a cost multiplier on for whoever opens it — an attempted enable is dropped silently, exactly like an ignored network "allow". --- internal/config/profiles_zeromaxing_test.go | 74 +++++++++++++ internal/config/resolver.go | 17 +++ internal/config/types.go | 20 ++++ internal/execprofile/profile.go | 71 ++++++++++++- internal/execprofile/profile_test.go | 2 +- internal/execprofile/zeromaxing_test.go | 111 ++++++++++++++++++++ 6 files changed, 291 insertions(+), 4 deletions(-) create mode 100644 internal/config/profiles_zeromaxing_test.go create mode 100644 internal/execprofile/zeromaxing_test.go diff --git a/internal/config/profiles_zeromaxing_test.go b/internal/config/profiles_zeromaxing_test.go new file mode 100644 index 000000000..7057c982d --- /dev/null +++ b/internal/config/profiles_zeromaxing_test.go @@ -0,0 +1,74 @@ +package config + +import "testing" + +// (n) A project .zero/config.json may DISABLE the posture. This mirrors the +// Sandbox.Network tighten-only rule: project scope may make a run stricter. +func TestProjectConfigCanDisableZeromaxing(t *testing.T) { + dst := FileConfig{} + if err := mergeProjectConfig(&dst, FileConfig{Profiles: ProfilesConfig{DisableZeromaxing: true}}); err != nil { + t.Fatalf("mergeProjectConfig: %v", err) + } + if !dst.Profiles.DisableZeromaxing { + t.Fatal("a project config must be able to disable the posture") + } +} + +// (m) ...and may NOT enable it. A cloned repo must not be able to switch a cost +// multiplier ON for whoever opens it. Like an ignored network "allow", the +// attempt is dropped silently rather than raised as an error — the project +// scope simply does not hold that privilege. +func TestProjectConfigCannotEnableZeromaxing(t *testing.T) { + dst := FileConfig{Profiles: ProfilesConfig{DisableZeromaxing: true}} + if err := mergeProjectConfig(&dst, FileConfig{Profiles: ProfilesConfig{DisableZeromaxing: false}}); err != nil { + t.Fatalf("mergeProjectConfig: %v", err) + } + if !dst.Profiles.DisableZeromaxing { + t.Fatal("a project config must NOT re-enable the posture after the user scope disabled it") + } +} + +// The user scope is higher-trust and may disable it too. +func TestUserConfigCanDisableZeromaxing(t *testing.T) { + dst := FileConfig{} + mergeConfig(&dst, FileConfig{Profiles: ProfilesConfig{DisableZeromaxing: true}}) + if !dst.Profiles.DisableZeromaxing { + t.Fatal("user config must be able to disable the posture") + } +} + +// The default is OFF: an absent setting leaves it available, so the feature is +// opt-out and no existing config changes behaviour. +func TestZeromaxingIsEnabledByDefault(t *testing.T) { + dst := FileConfig{} + mergeConfig(&dst, FileConfig{}) + if err := mergeProjectConfig(&dst, FileConfig{}); err != nil { + t.Fatalf("mergeProjectConfig: %v", err) + } + if dst.Profiles.DisableZeromaxing { + t.Fatal("the posture must be available unless a config explicitly disables it") + } +} + +// The setting survives JSON round-tripping, so profiles.disableZeromaxing is the +// actual key users type. +func TestProfilesConfigRoundTripsThroughJSON(t *testing.T) { + var cfg FileConfig + if err := cfg.UnmarshalJSON([]byte(`{"profiles":{"disableZeromaxing":true}}`)); err != nil { + t.Fatalf("UnmarshalJSON: %v", err) + } + if !cfg.Profiles.DisableZeromaxing { + t.Fatal("profiles.disableZeromaxing did not decode") + } + encoded, err := cfg.MarshalJSON() + if err != nil { + t.Fatalf("MarshalJSON: %v", err) + } + var round FileConfig + if err := round.UnmarshalJSON(encoded); err != nil { + t.Fatalf("re-decode: %v", err) + } + if !round.Profiles.DisableZeromaxing { + t.Fatalf("the setting was lost in the round trip: %s", encoded) + } +} diff --git a/internal/config/resolver.go b/internal/config/resolver.go index 0d9b11786..244494c6a 100644 --- a/internal/config/resolver.go +++ b/internal/config/resolver.go @@ -163,6 +163,7 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) { Notify: cfg.Notify, Tools: cfg.Tools, Swarm: cfg.Swarm, + Profiles: cfg.Profiles, Preferences: cfg.Preferences, KeyBindings: cfg.KeyBindings, LocalControl: cfg.LocalControl, @@ -252,6 +253,12 @@ func mergeConfig(dst *FileConfig, src FileConfig) { if src.Swarm.MaxTeamSize != 0 { dst.Swarm.MaxTeamSize = src.Swarm.MaxTeamSize } + // User config is higher-trust and may turn the zeromaxing posture off. It is + // presence-only here too (with omitempty a false is indistinguishable from + // absent); re-enabling is done by removing the key. + if src.Profiles.DisableZeromaxing { + dst.Profiles.DisableZeromaxing = true + } if src.Preferences.FavoriteModels != nil { dst.Preferences.FavoriteModels = normalizeFavoriteModels(src.Preferences.FavoriteModels) } @@ -325,6 +332,16 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error { if src.Swarm.MaxTeamSize != 0 { dst.Swarm.MaxTeamSize = src.Swarm.MaxTeamSize } + // Profiles.DisableZeromaxing from project config may only TIGHTEN (turn the + // posture OFF), never WEAKEN (turn it back on). A cloned repo must not be + // able to switch a cost multiplier on for whoever opens it — the same + // posture as Sandbox.Network's allow/deny rule above, and for the same + // reason: project config is not trust-gated. Because the field is + // presence-only, an attempted "enable" arrives as a false that this simply + // ignores — silently, like an ignored network "allow", not as an error. + if src.Profiles.DisableZeromaxing { + dst.Profiles.DisableZeromaxing = true + } mergeKeyBindings(&dst.KeyBindings, src.KeyBindings) // Local control is intentionally user-config/override only. A cloned project // must not be able to make browser, desktop, or terminal automation tools diff --git a/internal/config/types.go b/internal/config/types.go index 6c91fdaf3..c489fb594 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -322,6 +322,20 @@ type SwarmConfig struct { MaxTeamSize int `json:"maxTeamSize,omitempty"` } +// ProfilesConfig gates the execution-profile catalog. +type ProfilesConfig struct { + // DisableZeromaxing turns the zeromaxing posture off for this workspace, so + // /effort zeromaxing, /profile zeromaxing and --exec-profile zeromaxing are + // refused with a reason. + // + // Deliberately a DISABLE-only boolean, mirroring Sandbox.BlockUnixSockets + // and the tighten-only Sandbox.Network rule: a project .zero/config.json may + // set it true, but can never set it back to false, so a cloned repo cannot + // switch a cost multiplier ON for whoever opens it. Only global user config + // may leave it off. See mergeProjectConfig. + DisableZeromaxing bool `json:"disableZeromaxing,omitempty"` +} + func (cfg *ToolsConfig) UnmarshalJSON(data []byte) error { type rawTools struct { DeferThreshold *int `json:"deferThreshold"` @@ -348,6 +362,7 @@ type FileConfig struct { Notify NotifyConfig `json:"notify,omitempty"` Tools ToolsConfig `json:"tools,omitempty"` Swarm SwarmConfig `json:"swarm,omitempty"` + Profiles ProfilesConfig `json:"profiles,omitempty"` Preferences PreferencesConfig `json:"preferences,omitempty"` KeyBindings KeyBindingsConfig `json:"keybindings,omitempty"` LocalControl LocalControlConfig `json:"localControl,omitempty"` @@ -364,6 +379,7 @@ func (cfg FileConfig) MarshalJSON() ([]byte, error) { Notify NotifyConfig `json:"notify,omitempty"` Tools ToolsConfig `json:"tools,omitempty"` Swarm SwarmConfig `json:"swarm,omitempty"` + Profiles ProfilesConfig `json:"profiles,omitempty"` Preferences PreferencesConfig `json:"preferences,omitempty"` KeyBindings KeyBindingsConfig `json:"keybindings,omitempty"` LocalControl *LocalControlConfig `json:"localControl,omitempty"` @@ -378,6 +394,7 @@ func (cfg FileConfig) MarshalJSON() ([]byte, error) { Notify: cfg.Notify, Tools: cfg.Tools, Swarm: cfg.Swarm, + Profiles: cfg.Profiles, Preferences: cfg.Preferences, KeyBindings: cfg.KeyBindings, } @@ -427,6 +444,7 @@ type ResolvedConfig struct { Notify NotifyConfig Tools ToolsConfig Swarm SwarmConfig + Profiles ProfilesConfig Preferences PreferencesConfig KeyBindings KeyBindingsConfig LocalControl LocalControlConfig @@ -488,6 +506,7 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error { Notify NotifyConfig `json:"notify"` Tools ToolsConfig `json:"tools"` Swarm SwarmConfig `json:"swarm"` + Profiles ProfilesConfig `json:"profiles"` Preferences PreferencesConfig `json:"preferences"` KeyBindings KeyBindingsConfig `json:"keybindings"` LocalControl LocalControlConfig `json:"localControl"` @@ -516,6 +535,7 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error { cfg.Notify = raw.Notify cfg.Tools = raw.Tools cfg.Swarm = raw.Swarm + cfg.Profiles = raw.Profiles cfg.Preferences = raw.Preferences cfg.KeyBindings = raw.KeyBindings cfg.LocalControl = raw.LocalControl diff --git a/internal/execprofile/profile.go b/internal/execprofile/profile.go index c162553c4..94980442d 100644 --- a/internal/execprofile/profile.go +++ b/internal/execprofile/profile.go @@ -94,12 +94,77 @@ var ( ReasoningEffort: "high", SelfCorrect: true, } + + // Zeromaxing is the top posture: thorough's knobs with double the turn + // budget again, plus the loop-posture reminders the agent injects while it + // is active (see agent.Zeromaxing). + // + // HONEST DELTA over thorough: the turn budget, and nothing else. Effort is + // already at the ceiling and self-correction is already armed. Delta states + // that to the user's face rather than leaving it in a commit message — a + // posture whose advertised behaviour exceeds its real behaviour is worse + // than no posture at all. + // + // ReasoningEffort is "high" and MUST STAY "high" — pinned by + // TestZeromaxingReasoningEffortStaysHigh. "high" is the top rung every + // provider actually maps: anthropic's thinkingBudgetForEffort and its gemini + // twin fall through to their default arm (budget 0 => extended thinking + // DISABLED) for anything above it, and the openai mapper drops the field + // entirely. Raising this to "xhigh"/"max" would silently turn thinking OFF — + // a downgrade wearing an upgrade's name. Raising the real ceiling means + // teaching the providers those tiers first. + Zeromaxing = Profile{ + Name: "zeromaxing", + MaxTurns: 320, + ReasoningEffort: "high", + SelfCorrect: true, + } ) var catalog = map[string]Profile{ - Balanced.Name: Balanced, - Fast.Name: Fast, - Thorough.Name: Thorough, + Balanced.Name: Balanced, + Fast.Name: Fast, + Thorough.Name: Thorough, + Zeromaxing.Name: Zeromaxing, +} + +// Delta is the user-facing statement of what selecting zeromaxing actually +// changes, shown by /effort, /profile, and the exec selection notice. It is +// deliberately concrete and deliberately admits the two knobs it does NOT move: +// a user paying for a higher posture is owed the real delta, not a slogan. +// +// The child-budget sentence is not a caveat, it is the point: zeromaxing is a +// maximal posture, so the raised budget is exported to spawned sub-agents +// exactly as /turns does (asserted by TestZeromaxingTurnBudgetPropagatesToChildren). +const Delta = "zeromaxing raises the tool-turn budget to 320 (thorough uses 160) and applies that budget to spawned sub-agents too. " + + "Reasoning effort and post-edit self-correction are unchanged from thorough: effort is already at the highest level providers accept, and self-correction is already armed." + +// Name is the single spelling of this posture, everywhere: /effort zeromaxing, +// /profile zeromaxing, --exec-profile zeromaxing, --reasoning-effort zeromaxing. +// One name, one table — no aliases, so there is no second spelling to keep in +// step with this one. +const Name = "zeromaxing" + +// IsZeromaxing reports whether p is the zeromaxing posture. Callers use it +// instead of comparing name strings so the literal lives in exactly one place. +func (p Profile) IsZeromaxing() bool { return p.Name == Name } + +// SelectionRefusal returns a non-empty, user-facing reason when the named +// profile may not be selected here, or "" when selection is allowed. +// +// It is the ONE authoritative selection rule, called by BOTH the headless exec +// path and the TUI /effort + /profile paths. Those paths already differ in how +// they apply a profile's knobs; letting each decide selection independently is +// exactly how a rule gets applied to one call path and silently omitted from +// its sibling. TestSelectionRefusalAgreesAcrossPaths pins that they agree. +// +// disabled comes from resolved config. A project .zero/config.json may set it +// (DISABLE) but can never clear it (ENABLE) — see mergeProjectConfig. +func SelectionRefusal(p Profile, disabled bool) string { + if p.IsZeromaxing() && disabled { + return "the zeromaxing posture is disabled for this workspace (profiles.disableZeromaxing in config)" + } + return "" } // Lookup resolves a profile by name, case-insensitively and ignoring diff --git a/internal/execprofile/profile_test.go b/internal/execprofile/profile_test.go index 3a5730192..f3d8dfb0a 100644 --- a/internal/execprofile/profile_test.go +++ b/internal/execprofile/profile_test.go @@ -38,7 +38,7 @@ func TestLookupIsCaseAndSpaceInsensitive(t *testing.T) { } func TestNamesAreSorted(t *testing.T) { - want := []string{"balanced", "fast", "thorough"} + want := []string{"balanced", "fast", "thorough", "zeromaxing"} if got := Names(); !reflect.DeepEqual(got, want) { t.Fatalf("Names() = %v, want %v", got, want) } diff --git a/internal/execprofile/zeromaxing_test.go b/internal/execprofile/zeromaxing_test.go new file mode 100644 index 000000000..9740f47d9 --- /dev/null +++ b/internal/execprofile/zeromaxing_test.go @@ -0,0 +1,111 @@ +package execprofile + +import ( + "strings" + "testing" +) + +// (d) THE TRAP PIN. +// +// Zeromaxing.ReasoningEffort must stay "high". Every level above it falls +// through the providers' effort→budget mappers into their DEFAULT arm, and +// those defaults mean "no extended thinking": +// +// anthropic thinkingBudgetForEffort default -> 0 (thinking disabled) +// gemini thinkingBudgetForEffort default -> 0 (thinking disabled) +// openai openAIReasoningEffort default -> "" (field omitted entirely) +// +// So "raising" this to xhigh/max would silently turn reasoning OFF while the UI +// claimed a higher posture — a downgrade wearing an upgrade's name, invisible +// at runtime. Raising the real ceiling means teaching the providers those tiers +// first; until then this test is what stands in the way. +func TestZeromaxingReasoningEffortStaysHigh(t *testing.T) { + if Zeromaxing.ReasoningEffort != "high" { + t.Fatalf("Zeromaxing.ReasoningEffort = %q, want \"high\" — see this test's comment: "+ + "any level above \"high\" hits the providers' default arm and DISABLES thinking", + Zeromaxing.ReasoningEffort) + } + // Thorough is the rung below and shares the ceiling; if these ever diverge, + // the honest-delta text is wrong. + if Thorough.ReasoningEffort != Zeromaxing.ReasoningEffort { + t.Fatalf("thorough effort %q != zeromaxing effort %q — Delta claims they are equal", + Thorough.ReasoningEffort, Zeromaxing.ReasoningEffort) + } +} + +// One name, one table. No aliases means no second spelling to keep in step. +func TestZeromaxingHasExactlyOneSpelling(t *testing.T) { + profile, ok := Lookup(Name) + if !ok { + t.Fatalf("%q is not in the catalog", Name) + } + if profile != Zeromaxing || !profile.IsZeromaxing() { + t.Fatalf("Lookup(%q) = %+v, want %+v", Name, profile, Zeromaxing) + } + // Case/whitespace insensitivity, matching the other profiles. + if p, ok := Lookup(" ZEROMAXING "); !ok || !p.IsZeromaxing() { + t.Fatalf("Lookup is not case/space insensitive: %+v ok=%v", p, ok) + } + // The rejected spellings. Every one of these resolving would mean a second + // name for one posture, which is the drift this design exists to avoid. + for _, alias := range []string{"max", "deep", "deepmode", "zero-maxing", "zeromax", "zm"} { + if _, ok := Lookup(alias); ok { + t.Fatalf("%q must NOT resolve — the posture has exactly one name, %q", alias, Name) + } + } + found := false + for _, name := range Names() { + if name == Name { + found = true + } + } + if !found { + t.Fatalf("Names() omits %q, so usage text and hints will not offer it: %v", Name, Names()) + } +} + +// The knobs, pinned. The turn budget is the ONLY mechanical delta over +// thorough — Delta says exactly that to the user, so if these drift apart the +// user-facing claim becomes false. +func TestZeromaxingKnobsMatchTheDeltaItAdvertises(t *testing.T) { + if Zeromaxing.MaxTurns != 320 { + t.Fatalf("Zeromaxing.MaxTurns = %d, want 320", Zeromaxing.MaxTurns) + } + if Zeromaxing.MaxTurns != Thorough.MaxTurns*2 { + t.Fatalf("budget %d is not double thorough's %d", Zeromaxing.MaxTurns, Thorough.MaxTurns) + } + if !Zeromaxing.SelfCorrect || !Thorough.SelfCorrect { + t.Fatalf("Delta claims self-correction is already armed at thorough: zeromaxing=%v thorough=%v", + Zeromaxing.SelfCorrect, Thorough.SelfCorrect) + } + // It arms no escalation triggers (it is the top rung — nothing to escalate + // to), so Policy returns nil exactly like thorough and balanced. + if policy := Zeromaxing.Policy(80, true); policy != nil { + t.Fatalf("zeromaxing must arm no escalation policy, got %+v", policy) + } + for _, want := range []string{"320", "160", "sub-agents"} { + if !strings.Contains(Delta, want) { + t.Fatalf("Delta must state %q so the user sees the real delta: %q", want, Delta) + } + } +} + +// (m)+(n) at the rule level: SelectionRefusal is the single authority both +// selection paths consult. Its callers are asserted separately (CLI and TUI). +func TestSelectionRefusalDisablesOnlyZeromaxingAndOnlyWhenDisabled(t *testing.T) { + if refusal := SelectionRefusal(Zeromaxing, true); refusal == "" { + t.Fatal("a disabled workspace must refuse zeromaxing") + } else if !strings.Contains(refusal, "disableZeromaxing") { + t.Fatalf("the refusal must name the setting so the user can act on it: %q", refusal) + } + if refusal := SelectionRefusal(Zeromaxing, false); refusal != "" { + t.Fatalf("zeromaxing must be selectable when not disabled, got %q", refusal) + } + // The disable flag is posture-scoped: it must not silently take out the + // other profiles, which have no cost-multiplier justification for gating. + for _, other := range []Profile{Balanced, Fast, Thorough} { + if refusal := SelectionRefusal(other, true); refusal != "" { + t.Fatalf("disableZeromaxing must not refuse %q: %q", other.Name, refusal) + } + } +} From 6d6b33c0fdc816467da1f62be5fc0fb2de999ca1 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:56:07 +0530 Subject: [PATCH 02/86] feat(agent): inject the zeromaxing reminders below the cache breakpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four model-facing notices tell the model when its own posture flips: enter (once, on the first turn after activation), a budget guideline beside it, still-on (every continuing turn), and exit (once, after deactivation). WHERE THEY GO IS THE WHOLE DESIGN. Every notice is appended to the CONVERSATION tail as a user-role message, on the same channel as the existing diagnostics nudge and the failure/plan hints. None of it reaches buildSystemPromptParts. The system prompt and tool definitions are the provider's cached prefix — the anthropic mapper puts its cache_control breakpoint on the last system block and the last tool — and #760 made that prefix build once per run precisely so it stays byte-identical across turns. Per-turn text above that breakpoint invalidates the cache every turn, roughly doubling input cost, and nothing in the system reports it. So ZeromaxingStillOnNotice is a fixed literal: no turn counter, no remaining budget, no timestamp. A test asserts it carries no digits at all, because the hazard is not today's placement but a future edit that adds a varying part and then moves the text into prompt assembly. The reminders also promise NO orchestration — no workers, no fan-out, no workflow tool. Phase 1 ships none of that, and a prompt advertising capabilities the run does not have is a prompt-level lie the model will try to act on. A test enforces the vocabulary. The state machine is one pure function of (posture, turn) with no memory, so "enter exactly once" and "still-on never on the first turn" are assertions about a table rather than about observed side effects. Proof, both shapes of the hazard, each caught by a DIFFERENT assertion: - per-turn text written into messages[0] -> the prefix-extension assertion - static text baked into the system prompt -> the explicit leak assertion TestRunPreservesRequestPrefixAcrossTurnsUnderZeromaxing is the zeromaxing variant of the existing prefix test; the openai one is its wire-level sibling, proving the mapper serializes an append-only conversation into an append-only request body. A third openai test pins that the posture name can never appear in a serialized request at all. Options.Zeromaxing is separate from Options.Profile because the posture arms no escalation triggers, so Profile.Policy() returns nil for it and could not carry this. Its zero value is Off, leaving every existing caller byte-identical. --- internal/agent/loop.go | 13 ++ internal/agent/loop_test.go | 134 +++++++++++++++++++++ internal/agent/types.go | 7 ++ internal/agent/zeromaxing.go | 111 +++++++++++++++++ internal/agent/zeromaxing_test.go | 121 +++++++++++++++++++ internal/providers/openai/provider_test.go | 100 +++++++++++++++ 6 files changed, 486 insertions(+) create mode 100644 internal/agent/zeromaxing.go create mode 100644 internal/agent/zeromaxing_test.go diff --git a/internal/agent/loop.go b/internal/agent/loop.go index b8afc97f7..0a3489ced 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -256,6 +256,19 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options) for turn := 0; turn < maxTurns; turn++ { result.Turns = turn + 1 + // The zeromaxing posture's reminders. Appended to the CONVERSATION tail + // as user-role messages — the same channel as the diagnostics nudge + // below and the failure/plan hints later in the turn — and never into + // the system prompt, which is built once per run and must stay + // byte-stable so the provider's cached prefix survives. See + // internal/agent/zeromaxing.go. + for _, reminder := range zeromaxingReminders(options.Zeromaxing, result.Turns) { + messages = append(messages, zeroruntime.Message{ + Role: zeroruntime.MessageRoleUser, + Content: reminder, + }) + } + // Deliver background post-edit diagnostics from the previous turn's edits // BEFORE compaction so the nudge is part of the request being budgeted. // A brief wait at most (asyncDiagnosticsDrainTimeout); an unfinished check diff --git a/internal/agent/loop_test.go b/internal/agent/loop_test.go index 1dfec336f..92e408ad8 100644 --- a/internal/agent/loop_test.go +++ b/internal/agent/loop_test.go @@ -3743,3 +3743,137 @@ func TestRunNilTraceForwardsUsage(t *testing.T) { t.Fatal("OnUsage not forwarded when Trace is nil") } } + +// (h) The zeromaxing variant of TestRunPreservesRequestPrefixAcrossTurns. +// +// This is the test the whole reminder design exists to satisfy. The system +// prompt and tool definitions are the provider's CACHED PREFIX; #760 made them +// build once per run so they stay byte-identical across turns. The posture adds +// per-turn text, so it is exactly the kind of change that silently destroys the +// cache — roughly doubling input cost with nothing detecting it. +// +// The reminders are appended to the conversation TAIL as user-role messages, so +// each turn's request must still be an exact prefix-extension of the previous +// one, with identical tools and cache key. +func TestRunPreservesRequestPrefixAcrossTurnsUnderZeromaxing(t *testing.T) { + root := t.TempDir() + writeAgentTestFile(t, filepath.Join(root, "notes.txt"), "alpha\n") + registry := tools.NewRegistry() + registry.Register(tools.NewReadFileTool(root)) + provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{ + { + {Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"}, + {Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"notes.txt"}`}, + {Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"}, + {Type: zeroruntime.StreamEventDone}, + }, + { + {Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-2", ToolName: "read_file"}, + {Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: `{"path":"notes.txt"}`}, + {Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-2"}, + {Type: zeroruntime.StreamEventDone}, + }, + { + {Type: zeroruntime.StreamEventText, Content: "done"}, + {Type: zeroruntime.StreamEventDone}, + }, + }} + + if _, err := Run(context.Background(), "read notes", provider, Options{ + Cwd: root, + Registry: registry, + SessionID: "session-stable-prefix-zeromaxing", + Zeromaxing: ZeromaxingEntering, + }); err != nil { + t.Fatal(err) + } + if len(provider.requests) != 3 { + t.Fatalf("provider requests = %d, want 3", len(provider.requests)) + } + + // Every request must extend the previous one exactly — nothing rewritten + // above the append point, which is what keeps the cached prefix valid. + for i := 1; i < len(provider.requests); i++ { + prev, cur := provider.requests[i-1], provider.requests[i] + if len(cur.Messages) < len(prev.Messages) || + !reflect.DeepEqual(cur.Messages[:len(prev.Messages)], prev.Messages) { + t.Fatalf("request %d is not an exact prefix-extension of request %d:\nprev=%#v\ncur=%#v", + i, i-1, prev.Messages, cur.Messages) + } + if !reflect.DeepEqual(prev.Tools, cur.Tools) { + t.Fatalf("tool definitions drifted between turns %d and %d", i-1, i) + } + if cur.PromptCacheKey != prev.PromptCacheKey { + t.Fatalf("prompt cache key drifted: %q -> %q", prev.PromptCacheKey, cur.PromptCacheKey) + } + } + + // The system prompt — the cached prefix itself — must be byte-identical + // across turns, and must contain NONE of the posture text. + system := provider.requests[0].Messages[0] + for i, request := range provider.requests { + if !reflect.DeepEqual(request.Messages[0], system) { + t.Fatalf("system message changed on turn %d — the cached prefix is broken", i+1) + } + } + for _, forbidden := range []string{ + ZeromaxingEnterNotice, ZeromaxingBudgetNotice, ZeromaxingStillOnNotice, ZeromaxingExitNotice, + } { + if strings.Contains(system.Content, forbidden) { + t.Fatalf("posture reminder leaked into the SYSTEM PROMPT (above the cache breakpoint): %q", forbidden) + } + } + + // ...and the reminders really did arrive, on schedule. Without this the + // assertions above would pass just as happily if the feature did nothing. + firstTurn := renderZeromaxingMessages(provider.requests[0].Messages) + if !strings.Contains(firstTurn, ZeromaxingEnterNotice) || !strings.Contains(firstTurn, ZeromaxingBudgetNotice) { + t.Fatalf("turn 1 must carry the enter + budget notices:\n%s", firstTurn) + } + if strings.Contains(firstTurn, ZeromaxingStillOnNotice) { + t.Fatalf("turn 1 must NOT carry the still-on notice:\n%s", firstTurn) + } + secondOnly := renderZeromaxingMessages(provider.requests[1].Messages[len(provider.requests[0].Messages):]) + if !strings.Contains(secondOnly, ZeromaxingStillOnNotice) { + t.Fatalf("turn 2 must carry the still-on notice:\n%s", secondOnly) + } + if strings.Contains(secondOnly, ZeromaxingEnterNotice) { + t.Fatalf("turn 2 must NOT re-announce entry:\n%s", secondOnly) + } +} + +// A run with the posture OFF must be byte-identical to one that never heard of +// it — the no-regression half of the feature. +func TestRunWithoutZeromaxingCarriesNoPostureText(t *testing.T) { + root := t.TempDir() + registry := tools.NewRegistry() + registry.Register(tools.NewReadFileTool(root)) + provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{ + {{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}}, + }} + if _, err := Run(context.Background(), "hello", provider, Options{ + Cwd: root, Registry: registry, SessionID: "session-no-zeromaxing", + }); err != nil { + t.Fatal(err) + } + rendered := renderZeromaxingMessages(provider.requests[0].Messages) + for _, forbidden := range []string{ + ZeromaxingEnterNotice, ZeromaxingBudgetNotice, ZeromaxingStillOnNotice, ZeromaxingExitNotice, + } { + if strings.Contains(rendered, forbidden) { + t.Fatalf("a posture-free run must carry no posture text, found %q", forbidden) + } + } +} + +// renderZeromaxingMessages flattens messages to one searchable string. +func renderZeromaxingMessages(messages []zeroruntime.Message) string { + var b strings.Builder + for _, message := range messages { + b.WriteString(string(message.Role)) + b.WriteString(": ") + b.WriteString(message.Content) + b.WriteString("\n") + } + return b.String() +} diff --git a/internal/agent/types.go b/internal/agent/types.go index cfee5b20e..f26810d8c 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -362,6 +362,13 @@ type Options struct { // byte-identical: no observation, no escalation, no counters. Same opt-in // convention as Trace and SelfCorrect. Profile *ProfilePolicy + // Zeromaxing reports where this run sits in the zeromaxing execution + // posture's lifecycle, driving the model-facing reminders the turn loop + // appends. The zero value (ZeromaxingOff) suppresses every reminder, so a + // caller that never heard of it is byte-identical to today. Deliberately + // separate from Profile: zeromaxing arms no escalation triggers, so + // Profile.Policy() returns nil for it and could not carry this. + Zeromaxing Zeromaxing // Trace, when set, records per-turn timing for the run: the loop stamps // spans (prompt build, generation, tool execution, permission wait, // compaction, provider connect) and counters (model requests, tool calls, diff --git a/internal/agent/zeromaxing.go b/internal/agent/zeromaxing.go new file mode 100644 index 000000000..efc203872 --- /dev/null +++ b/internal/agent/zeromaxing.go @@ -0,0 +1,111 @@ +package agent + +// The zeromaxing execution posture's model-facing reminders. +// +// WHERE THESE GO, AND WHY IT IS NOT NEGOTIABLE +// +// Every string here is appended to the CONVERSATION as a user-role message, at +// the tail, exactly like the failure hint and the plan/progress reminders in +// the turn loop. None of it ever reaches buildSystemPromptParts. +// +// The system prompt and the tool definitions are the provider's cached prefix +// (the anthropic mapper puts its cache_control breakpoint on the last system +// block and the last tool). #760 made that prefix build ONCE per run precisely +// so it stays byte-identical across turns. A reminder that varies per turn and +// lands above the breakpoint invalidates the cache on every single turn — +// roughly doubling input cost — and nothing in the system reports it. Hence: +// tail only. +// +// The same reasoning is why ZeromaxingStillOnNotice is a fixed literal with no +// turn counter, no timestamp, and no accumulated state. It is repeated verbatim +// on every continuing turn. If it ever grew a varying part and someone later +// moved it into prompt assembly, that would be a silent cost bug rather than a +// loud test failure. TestRunPreservesRequestPrefixAcrossTurnsUnderZeromaxing is +// the tripwire. +// +// The wording is written for this project: plain, neutral, describing Zero's +// own posture in Zero's own terms. It deliberately promises no orchestration, +// no fan-out and no workflow tool — Phase 1 ships none of those, and a reminder +// that implies capabilities the run does not have is a prompt-level lie. + +// Zeromaxing is where a run sits in the zeromaxing posture's lifecycle. It is +// set by whoever selected the posture (headless exec for a one-shot run, the +// TUI across a session) and read only by the turn loop. +type Zeromaxing int + +const ( + // ZeromaxingOff: the posture is not active. Every reminder is suppressed and + // the loop is byte-identical to a run that never heard of it. This is the + // zero value, so every existing caller keeps today's behaviour. + ZeromaxingOff Zeromaxing = iota + // ZeromaxingEntering: this run is the first since the posture was turned on, + // so its first turn carries the enter notice and the budget guideline. + ZeromaxingEntering + // ZeromaxingActive: the posture was already on before this run began, so + // even the first turn gets the continuing notice rather than the enter one. + ZeromaxingActive + // ZeromaxingExiting: this run is the first since the posture was turned off. + // Its first turn carries the exit notice and nothing after that. + ZeromaxingExiting +) + +// The four messages. Exported as constants so tests assert on the same bytes +// the model receives rather than on a paraphrase. +const ( + // ZeromaxingEnterNotice announces the flip. It fires once, on the first turn + // of the run in which the posture was selected. + ZeromaxingEnterNotice = "The zeromaxing execution posture is now active for this session. " + + "You have a substantially larger tool-turn budget than usual, so prefer verifying your work over assuming it: " + + "read the code you are about to change, run the checks that would catch a mistake, and follow up on anything you noticed but did not confirm." + + // ZeromaxingBudgetNotice states the budget guideline alongside the enter + // notice. Split from it so the two can be asserted independently, and so a + // future budget change touches one string. + ZeromaxingBudgetNotice = "Budget guideline under zeromaxing: the larger turn budget is for depth on one task, not for widening its scope. " + + "Finish what was asked, verify it, and stop — do not start adjacent work because there are turns left." + + // ZeromaxingStillOnNotice repeats on every continuing turn. + // + // It is intentionally short and FIXED. Do not add a turn number, a + // remaining-budget count, a timestamp, or anything else that differs between + // turns — see the file comment. + ZeromaxingStillOnNotice = "The zeromaxing execution posture is still active." + + // ZeromaxingExitNotice announces the flip back. It fires once, on the first + // turn of the run after the posture was turned off. + ZeromaxingExitNotice = "The zeromaxing execution posture is no longer active. " + + "The tool-turn budget has returned to its normal value; work at the usual depth." +) + +// zeromaxingReminders returns the reminder lines to append before the given +// turn, oldest first, or nil when there is nothing to say. turn is 1-based, so +// turn == 1 is the run's first provider request. +// +// The whole state machine is this function: it is pure, it depends only on +// (posture, turn), and it has no memory. That is what makes "enter exactly +// once" and "still-on never on the first turn" testable as data rather than as +// a sequence of observed side effects. +func zeromaxingReminders(posture Zeromaxing, turn int) []string { + if turn < 1 { + return nil + } + switch posture { + case ZeromaxingEntering: + if turn == 1 { + return []string{ZeromaxingEnterNotice, ZeromaxingBudgetNotice} + } + return []string{ZeromaxingStillOnNotice} + case ZeromaxingActive: + // Already on when the run started: no enter notice, not even on turn 1. + return []string{ZeromaxingStillOnNotice} + case ZeromaxingExiting: + // One notice on the way out, then silence — the posture is off, so a + // "still active" line every turn would be a lie. + if turn == 1 { + return []string{ZeromaxingExitNotice} + } + return nil + default: + return nil + } +} diff --git a/internal/agent/zeromaxing_test.go b/internal/agent/zeromaxing_test.go new file mode 100644 index 000000000..069e41197 --- /dev/null +++ b/internal/agent/zeromaxing_test.go @@ -0,0 +1,121 @@ +package agent + +import ( + "reflect" + "strings" + "testing" +) + +// (i)(j)(k) The whole reminder state machine as data. zeromaxingReminders is +// pure, so "enter exactly once" and "still-on never on the first turn" are +// assertions about a table rather than about observed side effects. +func TestZeromaxingReminderSchedule(t *testing.T) { + cases := []struct { + name string + posture Zeromaxing + turn int + want []string + }{ + // (i) enter fires exactly once, on the first turn of the entering run. + {"entering turn 1 announces and states the budget", ZeromaxingEntering, 1, + []string{ZeromaxingEnterNotice, ZeromaxingBudgetNotice}}, + // (j) still-on from turn 2, and it is the ONLY thing from turn 2 on — + // no second enter, no repeated budget notice. + {"entering turn 2 continues", ZeromaxingEntering, 2, []string{ZeromaxingStillOnNotice}}, + {"entering turn 3 continues", ZeromaxingEntering, 3, []string{ZeromaxingStillOnNotice}}, + {"entering turn 99 continues", ZeromaxingEntering, 99, []string{ZeromaxingStillOnNotice}}, + + // (j) a run that began with the posture already on gets still-on even on + // turn 1 — re-announcing entry every run would be wrong. + {"active turn 1 does not re-announce", ZeromaxingActive, 1, []string{ZeromaxingStillOnNotice}}, + {"active turn 2 continues", ZeromaxingActive, 2, []string{ZeromaxingStillOnNotice}}, + + // (k) exit fires exactly once, then silence — the posture is off, so a + // "still active" line afterwards would be a lie. + {"exiting turn 1 announces once", ZeromaxingExiting, 1, []string{ZeromaxingExitNotice}}, + {"exiting turn 2 is silent", ZeromaxingExiting, 2, nil}, + {"exiting turn 5 is silent", ZeromaxingExiting, 5, nil}, + + // Off is completely silent: an unaware caller's run is byte-identical. + {"off turn 1 is silent", ZeromaxingOff, 1, nil}, + {"off turn 7 is silent", ZeromaxingOff, 7, nil}, + + // Defensive: a non-positive turn never emits. + {"turn 0 is silent", ZeromaxingEntering, 0, nil}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := zeromaxingReminders(tc.posture, tc.turn) + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("zeromaxingReminders(%v, %d) = %#v, want %#v", tc.posture, tc.turn, got, tc.want) + } + }) + } +} + +// Counting across a whole run, the way a reader of the gate would check it. +func TestZeromaxingEnterAndExitFireExactlyOncePerRun(t *testing.T) { + count := func(posture Zeromaxing, needle string) int { + n := 0 + for turn := 1; turn <= 50; turn++ { + for _, line := range zeromaxingReminders(posture, turn) { + if line == needle { + n++ + } + } + } + return n + } + if got := count(ZeromaxingEntering, ZeromaxingEnterNotice); got != 1 { + t.Fatalf("enter fired %d times across 50 turns, want exactly 1", got) + } + if got := count(ZeromaxingEntering, ZeromaxingBudgetNotice); got != 1 { + t.Fatalf("budget notice fired %d times across 50 turns, want exactly 1", got) + } + if got := count(ZeromaxingExiting, ZeromaxingExitNotice); got != 1 { + t.Fatalf("exit fired %d times across 50 turns, want exactly 1", got) + } + if got := count(ZeromaxingActive, ZeromaxingEnterNotice); got != 0 { + t.Fatalf("an already-active posture must never emit the enter notice, got %d", got) + } + if got := count(ZeromaxingEntering, ZeromaxingStillOnNotice); got != 49 { + t.Fatalf("still-on fired on %d of turns 2..50, want 49", got) + } +} + +// still-on repeats on every continuing turn, so it must be BYTE-IDENTICAL every +// time. A turn counter, a remaining-budget number, or a timestamp here would be +// invisible today (it rides below the cache breakpoint) and a silent cost bug +// the moment anyone moved this text into the system prompt. +func TestZeromaxingStillOnNoticeIsInvariant(t *testing.T) { + first := zeromaxingReminders(ZeromaxingEntering, 2) + for turn := 3; turn <= 40; turn++ { + if got := zeromaxingReminders(ZeromaxingEntering, turn); !reflect.DeepEqual(got, first) { + t.Fatalf("still-on drifted at turn %d: %#v vs %#v", turn, got, first) + } + } + if strings.ContainsAny(ZeromaxingStillOnNotice, "0123456789") { + t.Fatalf("still-on must carry no digits (no counter, no budget, no timestamp): %q", + ZeromaxingStillOnNotice) + } +} + +// Phase 1 ships no orchestration, so no reminder may imply any. A prompt that +// advertises capabilities the run does not have is a prompt-level lie, and the +// model will try to use them. +func TestZeromaxingRemindersPromiseNoOrchestration(t *testing.T) { + all := []string{ + ZeromaxingEnterNotice, ZeromaxingBudgetNotice, + ZeromaxingStillOnNotice, ZeromaxingExitNotice, + } + forbidden := []string{"orchestrat", "workflow", "fan out", "fan-out", "worker", "sub-agent", "subagent", "delegate", "parallel"} + for _, notice := range all { + lower := strings.ToLower(notice) + for _, word := range forbidden { + if strings.Contains(lower, word) { + t.Fatalf("Phase 1 has no orchestration; reminder must not mention %q: %q", word, notice) + } + } + } +} diff --git a/internal/providers/openai/provider_test.go b/internal/providers/openai/provider_test.go index b88f1714d..7e522f5d7 100644 --- a/internal/providers/openai/provider_test.go +++ b/internal/providers/openai/provider_test.go @@ -1434,3 +1434,103 @@ func TestOpenAIRequestPreservesCacheablePrefixAcrossTurns(t *testing.T) { t.Fatalf("wire prompt cache key must remain stable: first=%#v second=%#v", first["prompt_cache_key"], second["prompt_cache_key"]) } } + +// (h) sibling: the same cacheable-prefix contract asserted at the WIRE level +// with posture reminders interleaved in the conversation. +// +// The agent-level test proves the loop appends rather than rewrites; this +// proves the openai mapper serializes that into a wire body whose prefix is +// still byte-identical. Both halves are needed: a mapper that reordered or +// re-encoded earlier messages would break the cache even with a perfect loop. +func TestOpenAIRequestPreservesCacheablePrefixWithPostureReminders(t *testing.T) { + provider, err := New(Options{Model: "gpt-test"}) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + toolDefs := []zeroruntime.ToolDefinition{{ + Name: "read_file", + Description: "Read a file.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{"path": map[string]any{"type": "string"}}, + }, + }} + firstMessages := []zeroruntime.Message{ + {Role: zeroruntime.MessageRoleSystem, Content: "stable system prompt"}, + {Role: zeroruntime.MessageRoleUser, Content: "first turn"}, + {Role: zeroruntime.MessageRoleUser, Content: "The zeromaxing execution posture is now active for this session."}, + {Role: zeroruntime.MessageRoleUser, Content: "Budget guideline under zeromaxing: depth, not scope."}, + } + secondMessages := append([]zeroruntime.Message(nil), firstMessages...) + secondMessages = append(secondMessages, + zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "first response"}, + zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "The zeromaxing execution posture is still active."}, + ) + // Turn 3 appends the SAME still-on text again — the repetition is the point. + thirdMessages := append([]zeroruntime.Message(nil), secondMessages...) + thirdMessages = append(thirdMessages, + zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "second response"}, + zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "The zeromaxing execution posture is still active."}, + ) + + marshalBody := func(messages []zeroruntime.Message) map[string]any { + t.Helper() + mapped := provider.openAIRequest(zeroruntime.CompletionRequest{ + Messages: messages, + Tools: toolDefs, + PromptCacheKey: "session-stable-prefix-zeromaxing", + }) + data, marshalErr := json.Marshal(mapped) + if marshalErr != nil { + t.Fatalf("marshal request: %v", marshalErr) + } + var body map[string]any + if unmarshalErr := json.Unmarshal(data, &body); unmarshalErr != nil { + t.Fatalf("unmarshal request: %v", unmarshalErr) + } + return body + } + + bodies := []map[string]any{marshalBody(firstMessages), marshalBody(secondMessages), marshalBody(thirdMessages)} + for i := 1; i < len(bodies); i++ { + prev := bodies[i-1]["messages"].([]any) + cur := bodies[i]["messages"].([]any) + if len(cur) < len(prev) || !reflect.DeepEqual(cur[:len(prev)], prev) { + t.Fatalf("wire messages for turn %d are not an exact prefix-extension of turn %d:\nprev=%#v\ncur=%#v", + i+1, i, prev, cur) + } + if !reflect.DeepEqual(bodies[i-1]["tools"], bodies[i]["tools"]) { + t.Fatalf("wire tool definitions drifted between turns %d and %d", i, i+1) + } + if bodies[i-1]["prompt_cache_key"] != bodies[i]["prompt_cache_key"] { + t.Fatalf("wire prompt cache key drifted between turns %d and %d", i, i+1) + } + } + systemBlock := bodies[0]["messages"].([]any)[0] + for i, body := range bodies { + if !reflect.DeepEqual(body["messages"].([]any)[0], systemBlock) { + t.Fatalf("wire system message changed on turn %d — the cached prefix is broken", i+1) + } + } +} + +// (e) The posture name must NEVER become a provider parameter. This asserts it +// at the wire boundary: whatever upstream does, a request body carrying +// reasoning_effort="zeromaxing" would be sending a value no provider defines. +func TestZeromaxingIsNeverAValidWireEffort(t *testing.T) { + provider, err := New(Options{Model: "gpt-test"}) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + mapped := provider.openAIRequest(zeroruntime.CompletionRequest{ + Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}}, + ReasoningEffort: "zeromaxing", + }) + data, err := json.Marshal(mapped) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if strings.Contains(string(data), "zeromaxing") { + t.Fatalf("the posture name reached the wire: %s", data) + } +} From 455e7708ebc75d85470de9f484209a7ec9dcb5c1 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:58:41 +0530 Subject: [PATCH 03/86] feat(tui): make /effort zeromaxing the entry point on both selection paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /effort zeromaxing is the primary way in; /profile zeromaxing is its alias. It is handled beside "auto" with an EARLY RETURN, before the ReasoningEffort conversion and before ValidReasoningEffort — it is a posture name, not a provider level, and must never become one. It DELEGATES to handleProfileCommand rather than re-applying the knobs, so "both entry points resolve identically" is true by construction instead of by two implementations a test hopes agree. The CLI mirrors it: normalizeZeromaxingEffort folds --reasoning-effort zeromaxing into the equivalent --exec-profile selection before mode/profile expansion, leaving the documented precedence ordering untouched. A conflicting --exec-profile is a usage error rather than a silent winner. THE LOAD-BEARING GUARD: forwardedReasoningEffort refuses to forward the posture name for any model, including catalog-unknown ones where unrecognized values otherwise pass straight through. Normalization already prevents it reaching there; the guard is what makes a regression upstream fail loudly instead of sending a provider a parameter value no provider defines. "max" stays RESERVED and unchanged through all of this: the flag parser still rejects it, the normalizer passes it through untouched, and /effort max still reports it unsupported exactly as before. Driving the real binary is what found the one real gap here: the flag parser rejected "zeromaxing" outright, so the entire --reasoning-effort entry point was dead at the user surface while every unit test passed, because they called the normalizer directly. TestReasoningEffortFlagAcceptsZeromaxingButNotMax is that regression, and it fails in both directions. Degrade honestly — this closes a real asymmetry. exec already told the user via reasoningEffortNotice when a model could not take the requested effort; the TUI skipped the fill in SILENCE. Same rule, one of two call paths. Both status surfaces now render ONE shared resolved-state line ("effort: high · profile: zeromaxing · turns: 320") plus the real delta, so a user sees what actually reached the provider rather than just a posture name. reconcileProfileAfterModelSwitch is the sibling re-derive site and records the same reason, so a switch that drops the effort cannot leave the status implying a raise the run is not making. revertExecProfile gains a FOURTH knob. It is knob-by-knob, which is exactly where a new one gets forgotten, so the posture's restore is asserted alongside budget, effort and self-correct — and gated on zeromaxing specifically, so /effort auto under fast/thorough keeps its existing meaning of "clear the effort" instead of dropping the whole profile. The footer carries a ZEROMAXING chip beside the effort chip while the posture is on, and drops it while Exiting — by then the posture is already off and only its announcement is pending. --- internal/cli/app.go | 1 + internal/cli/exec.go | 75 +++++ internal/cli/exec_parse.go | 19 +- internal/cli/exec_zeromaxing_test.go | 297 +++++++++++++++++++ internal/tui/model.go | 44 ++- internal/tui/options.go | 20 +- internal/tui/session_controls.go | 144 ++++++++- internal/tui/view.go | 6 + internal/tui/zeromaxing_test.go | 420 +++++++++++++++++++++++++++ 9 files changed, 1001 insertions(+), 25 deletions(-) create mode 100644 internal/cli/exec_zeromaxing_test.go create mode 100644 internal/tui/zeromaxing_test.go diff --git a/internal/cli/app.go b/internal/cli/app.go index 80854beb4..e7854e966 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -837,6 +837,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a SessionStore: deps.newSessionStore(), SandboxStore: sandboxStore, MCPConfig: mcpConfig, + ZeromaxingDisabled: resolved.Profiles.DisableZeromaxing, MCPPermissionStore: mcpPermissionStore, MCPTokenStore: mcpTokenStore, MCPCommand: func(ctx context.Context, args []string) tui.MCPCommandResult { diff --git a/internal/cli/exec.go b/internal/cli/exec.go index 2d1fe542a..8a2898391 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -173,6 +173,15 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in // mode-supplied model flows through the same resolution (and deprecation // notice) path as an explicit --model. Explicit flags still win: applyExecMode // only fills fields the caller left unset. + // --reasoning-effort zeromaxing selects the POSTURE, not a provider effort + // level. Normalize it into the profile selection before mode/profile + // expansion so /effort zeromaxing and --exec-profile zeromaxing resolve to + // exactly the same state, and so the posture name can never survive into a + // provider request. Runs before applyExecMode, leaving the documented + // precedence ordering (flag > mode > profile) exactly as it was. + if err := normalizeZeromaxingEffort(&options); err != nil { + return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error()) + } if err := applyExecMode(&options); err != nil { return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error()) } @@ -287,6 +296,23 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in } return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error()) } + // Profile selection refusal, evaluated once config is resolved (the disable + // flag lives there). execprofile.SelectionRefusal is the ONE rule; the TUI + // /effort and /profile paths call the same function, so the selection + // decision cannot drift between surfaces — see + // TestSelectionRefusalAgreesAcrossPaths. + if refusal := execprofile.SelectionRefusal(execProfile, resolved.Profiles.DisableZeromaxing); refusal != "" { + return writeExecFormatUsageError(stdout, stderr, options.outputFormat, + fmt.Sprintf("cannot use execution profile %q: %s.", execProfile.Name, refusal)) + } + // State what it actually changes, at selection time. Burying the real delta + // in a PR body is how a posture ends up documented as doing things it does + // not do. + if execProfile.IsZeromaxing() { + if _, err := fmt.Fprintln(stderr, execprofile.Delta); err != nil { + return exitCrash + } + } var displacedMaxTurns int resolved.MaxTurns, displacedMaxTurns = applyProfileTurnBudget(execProfile, options.maxTurns, resolved.MaxTurns) execScope, err := sandbox.NewScope(workspaceRoot, append(append([]string{}, resolved.Sandbox.AdditionalWriteRoots...), options.addDirs...)) @@ -653,6 +679,10 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in SelfCorrect: selfCorrector, FileDiagnostics: fileDiagnostics, Profile: execProfile.Policy(displacedMaxTurns, execProfileFilledEffort), + // A headless run that selected the posture is by definition entering it: + // the process starts, runs once, exits, so there is no earlier turn for + // it to have been active across. + Zeromaxing: execZeromaxing(execProfile), // Headless exec: don't accept a no-tool-call turn as "done" while work // clearly remains (pending plan items / a mid-step continuation cue) — // nudge to continue, and finalize as INCOMPLETE rather than false success @@ -1203,6 +1233,41 @@ func applyExecProfile(options *execOptions) (execprofile.Profile, bool, error) { return profile, effortFilled, nil } +// normalizeZeromaxingEffort folds `--reasoning-effort zeromaxing` into +// `--exec-profile zeromaxing`. The posture is one thing with one name, reachable +// from either flag, so this is a rename rather than a second implementation. +// +// A conflicting explicit --exec-profile is a usage error rather than a silent +// winner: the user asked for two different postures and deserves to be told, +// not to have one quietly discarded. +func normalizeZeromaxingEffort(options *execOptions) error { + if !strings.EqualFold(strings.TrimSpace(options.reasoningEffort), execprofile.Name) { + return nil + } + if selected := strings.TrimSpace(options.execProfile); selected != "" && + !strings.EqualFold(selected, execprofile.Name) { + return execUsageError{fmt.Sprintf( + "--reasoning-effort %s selects the %s execution profile, which conflicts with --exec-profile %s. Pass one of them.", + execprofile.Name, execprofile.Name, selected)} + } + options.execProfile = execprofile.Name + // Clear the effort so the profile FILLS it (with "high"). Leaving the + // posture name here would carry it into forwardedReasoningEffort. + options.reasoningEffort = "" + return nil +} + +// execZeromaxing maps a selected profile onto the run's posture lifecycle. A +// headless exec run is one process for one run, so selecting the posture means +// entering it and anything else means off — the Active/Exiting states only +// arise in a session that outlives a single run (the TUI). +func execZeromaxing(profile execprofile.Profile) agent.Zeromaxing { + if profile.IsZeromaxing() { + return agent.ZeromaxingEntering + } + return agent.ZeromaxingOff +} + // specProfileEffortFilled reports whether the profile's effort fill actually // governs the spec-draft run. An explicit --spec-reasoning-effort replaces the // filled effort for the draft, so the escalation's effort restore must not arm @@ -1320,6 +1385,16 @@ func forwardedReasoningEffort(registry modelregistry.Registry, modelID string, r if requested == "" { return "" } + // LOAD-BEARING GUARD. "zeromaxing" is a Zero posture name, never a provider + // effort level. normalizeZeromaxingEffort already converts it into a profile + // selection long before this point, so reaching here means something + // upstream regressed — and the failure mode would be a provider request + // carrying a parameter value no provider defines. Refuse it at the boundary + // rather than trusting the caller. Pinned by + // TestZeromaxingIsNeverForwardedToAProvider. + if strings.EqualFold(requested, execprofile.Name) { + return "" + } entry, ok := registry.Get(strings.TrimSpace(modelID)) if !ok { return requested diff --git a/internal/cli/exec_parse.go b/internal/cli/exec_parse.go index 8eb55223d..7f1ae7623 100644 --- a/internal/cli/exec_parse.go +++ b/internal/cli/exec_parse.go @@ -5,6 +5,7 @@ import ( "strconv" "strings" + "github.com/Gitlawb/zero/internal/execprofile" "github.com/Gitlawb/zero/internal/sessions" "github.com/Gitlawb/zero/internal/specialist" ) @@ -517,7 +518,23 @@ func nextFlagValue(args []string, index int, flag string) (string, int, error) { default: return "", index, execUsageError{fmt.Sprintf("Invalid input format %q. Expected text or stream-json.", next)} } - case "--reasoning-effort", "--spec-reasoning-effort": + case "--reasoning-effort": + // zeromaxing is accepted here as a POSTURE name, not a provider effort + // level: normalizeZeromaxingEffort converts it into the equivalent + // --exec-profile selection before anything reads it as an effort, and + // forwardedReasoningEffort refuses to forward it even if that regressed. + // + // "max" is deliberately NOT accepted, exactly as before — the flag has + // always rejected it and that spelling stays reserved for a real + // provider rung. Adding it here would burn the name. + switch strings.ToLower(next) { + case "low", "medium", "high", execprofile.Name: + default: + return "", index, execUsageError{fmt.Sprintf("invalid %s %q. Expected low, medium, high, or %s.", flag, next, execprofile.Name)} + } + case "--spec-reasoning-effort": + // The spec-draft effort is a plain provider level; the posture is a RUN + // posture and has no meaning for a draft, so it is not accepted here. switch strings.ToLower(next) { case "low", "medium", "high": default: diff --git a/internal/cli/exec_zeromaxing_test.go b/internal/cli/exec_zeromaxing_test.go new file mode 100644 index 000000000..aedf8b8e2 --- /dev/null +++ b/internal/cli/exec_zeromaxing_test.go @@ -0,0 +1,297 @@ +package cli + +import ( + "reflect" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/agent" + "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/execprofile" + "github.com/Gitlawb/zero/internal/modelregistry" +) + +// (a) CLI: an explicit --reasoning-effort survives zeromaxing. The profile +// fills only what the caller left unset, exactly as fast/thorough do. +func TestZeromaxingDoesNotOverrideExplicitReasoningEffortCLI(t *testing.T) { + options := execOptions{execProfile: execprofile.Name, reasoningEffort: "low"} + profile, effortFilled, err := applyExecProfile(&options) + if err != nil { + t.Fatalf("applyExecProfile: %v", err) + } + if !profile.IsZeromaxing() { + t.Fatalf("profile = %q, want %q", profile.Name, execprofile.Name) + } + if options.reasoningEffort != "low" { + t.Fatalf("reasoningEffort = %q, the explicit low must survive the posture's high", options.reasoningEffort) + } + if effortFilled { + t.Fatal("must report it did NOT fill the effort, or a mid-run escalation would clear a hand-pinned value") + } +} + +// (b) CLI: --mode's fills survive zeromaxing. applyExecMode runs first, so its +// values read as "set" by the time the profile arrives — precedence is enforced +// by ordering, and this pins that the ordering still holds with a fourth profile. +func TestZeromaxingDoesNotOverrideModeFillsCLI(t *testing.T) { + options := execOptions{mode: "fast", execProfile: execprofile.Name} + if err := applyExecMode(&options); err != nil { + t.Fatalf("applyExecMode: %v", err) + } + modeEffort := options.reasoningEffort + modeTurns := options.maxTurns + profile, _, err := applyExecProfile(&options) + if err != nil { + t.Fatalf("applyExecProfile: %v", err) + } + if modeEffort != "" && options.reasoningEffort != modeEffort { + t.Fatalf("reasoningEffort = %q, the mode's %q must win", options.reasoningEffort, modeEffort) + } + if modeTurns > 0 { + // Mirror the real call site: a mode's --max-turns fill lands in + // options.maxTurns AND flows through config overrides into + // resolved.MaxTurns, so both arguments carry it. The profile must then + // back off entirely — displaced 0, budget untouched. + effective, displaced := applyProfileTurnBudget(profile, options.maxTurns, options.maxTurns) + if effective != modeTurns || displaced != 0 { + t.Fatalf("turn budget = (%d, displaced %d), the mode's %d must win with nothing displaced", + effective, displaced, modeTurns) + } + } +} + +// (c) CLI: fills only what is unset; an explicit --max-turns pins the budget so +// the profile backs off with nothing displaced. +func TestZeromaxingFillsOnlyUnsetCLI(t *testing.T) { + options := execOptions{execProfile: execprofile.Name} + profile, effortFilled, err := applyExecProfile(&options) + if err != nil { + t.Fatalf("applyExecProfile: %v", err) + } + if options.reasoningEffort != "high" || !effortFilled { + t.Fatalf("unset effort must be filled with high, got %q filled=%v", options.reasoningEffort, effortFilled) + } + if !options.selfCorrect { + t.Fatal("must arm self-correction when it was unset") + } + if effective, displaced := applyProfileTurnBudget(profile, 0, 80); effective != 320 || displaced != 80 { + t.Fatalf("over resolved 80 = (%d, %d), want (320, 80)", effective, displaced) + } + if effective, displaced := applyProfileTurnBudget(profile, 50, 50); effective != 50 || displaced != 0 { + t.Fatalf("with explicit --max-turns 50 = (%d, %d), want (50, 0)", effective, displaced) + } +} + +// (g) CLI: --reasoning-effort zeromaxing and --exec-profile zeromaxing resolve +// to IDENTICAL state. One posture, one name, reachable from either flag. +func TestZeromaxingEffortFlagResolvesLikeProfileFlagCLI(t *testing.T) { + viaEffort := execOptions{reasoningEffort: execprofile.Name} + if err := normalizeZeromaxingEffort(&viaEffort); err != nil { + t.Fatalf("normalize: %v", err) + } + viaProfile := execOptions{execProfile: execprofile.Name} + if err := normalizeZeromaxingEffort(&viaProfile); err != nil { + t.Fatalf("normalize: %v", err) + } + if !reflect.DeepEqual(viaEffort, viaProfile) { + t.Fatalf("the two entry points diverged:\n--reasoning-effort: %+v\n--exec-profile: %+v", viaEffort, viaProfile) + } + // ...and after profile expansion they are still identical. + pe, fe, err := applyExecProfile(&viaEffort) + if err != nil { + t.Fatalf("applyExecProfile(effort path): %v", err) + } + pp, fp, err := applyExecProfile(&viaProfile) + if err != nil { + t.Fatalf("applyExecProfile(profile path): %v", err) + } + if pe != pp || fe != fp || !reflect.DeepEqual(viaEffort, viaProfile) { + t.Fatalf("resolved state diverged after expansion:\n%+v %v\n%+v %v", pe, fe, pp, fp) + } + if viaEffort.reasoningEffort != "high" { + t.Fatalf("both paths must resolve the effort to the profile's high, got %q", viaEffort.reasoningEffort) + } +} + +// A conflicting pair is a usage error, not a silent winner. +func TestZeromaxingEffortFlagConflictIsUsageError(t *testing.T) { + options := execOptions{reasoningEffort: execprofile.Name, execProfile: "fast"} + err := normalizeZeromaxingEffort(&options) + if err == nil { + t.Fatal("--reasoning-effort zeromaxing with --exec-profile fast must be a usage error") + } + if !strings.Contains(err.Error(), "conflicts") { + t.Fatalf("the error must name the conflict: %v", err) + } + // The same profile on both flags is not a conflict. + same := execOptions{reasoningEffort: execprofile.Name, execProfile: execprofile.Name} + if err := normalizeZeromaxingEffort(&same); err != nil { + t.Fatalf("naming the same posture twice must be accepted: %v", err) + } +} + +// (e) THE LOAD-BEARING GUARD. The posture name must never be forwarded to a +// provider as an effort value — not by any path, not for any model. +func TestZeromaxingIsNeverForwardedToAProvider(t *testing.T) { + registry, err := modelregistry.DefaultRegistry() + if err != nil { + t.Fatalf("DefaultRegistry: %v", err) + } + // Across a reasoning model, a non-reasoning model, and a model the catalog + // has never heard of (where unknown values are otherwise passed through). + for _, model := range []string{"claude-sonnet-4.5", "gpt-4.1", "some-custom-endpoint-model"} { + for _, spelling := range []string{"zeromaxing", "ZEROMAXING", " Zeromaxing "} { + if got := forwardedReasoningEffort(registry, model, spelling); got != "" { + t.Fatalf("forwardedReasoningEffort(%q, %q) = %q, want \"\" — the posture name is not a provider value", + model, spelling, got) + } + } + } + // The guard must not over-reach: real levels still forward. + if got := forwardedReasoningEffort(registry, "claude-sonnet-4.5", "high"); got != "high" { + t.Fatalf("a real level must still forward, got %q", got) + } +} + +// (f) THE RESERVATION. /effort max and --reasoning-effort max are UNCHANGED by +// this feature: "max" still parses as a raw provider level (ValidReasoningEffort +// accepts ReasoningEffortMax) and still resolves to nothing usable on today's +// models. The spelling stays free for a real provider rung. +func TestEffortMaxReservationUnchangedCLI(t *testing.T) { + if !modelregistry.ValidReasoningEffort(modelregistry.ReasoningEffortMax) { + t.Fatal("ReasoningEffortMax must remain a valid raw effort value — the reservation depends on it") + } + if _, ok := execprofile.Lookup("max"); ok { + t.Fatal("\"max\" must NOT resolve to a profile — it is reserved for a provider rung") + } + registry, err := modelregistry.DefaultRegistry() + if err != nil { + t.Fatalf("DefaultRegistry: %v", err) + } + // No curated model lists "max", so it coerces away rather than forwarding. + if got := forwardedReasoningEffort(registry, "claude-sonnet-4.5", "max"); got == "max" { + t.Fatal("no current model supports \"max\"; it must not be forwarded as-is") + } + // And it must NOT be swallowed by the zeromaxing normalizer. + options := execOptions{reasoningEffort: "max"} + if err := normalizeZeromaxingEffort(&options); err != nil { + t.Fatalf("normalize: %v", err) + } + if options.reasoningEffort != "max" || options.execProfile != "" { + t.Fatalf("\"max\" must pass through the normalizer untouched, got effort=%q profile=%q", + options.reasoningEffort, options.execProfile) + } +} + +// (l) CLI leg of the honesty rule: a model that cannot take the effort is TOLD. +// reasoningEffortNotice is the existing coerce-and-tell helper, and it fires for +// a PROFILE-FILLED effort, not just an explicitly flagged one. +func TestZeromaxingUnsupportedEffortIsReportedCLI(t *testing.T) { + registry, err := modelregistry.DefaultRegistry() + if err != nil { + t.Fatalf("DefaultRegistry: %v", err) + } + const nonReasoning = "gpt-4.1" // a catalog model with no reasoning capability + notice := reasoningEffortNotice(registry, nonReasoning, "high") + if notice == "" { + t.Fatal("a non-reasoning model must produce a notice when an effort is requested") + } + if !strings.Contains(notice, "reasoning effort") { + t.Fatalf("the notice must explain what was not applied: %q", notice) + } + if forwarded := forwardedReasoningEffort(registry, nonReasoning, "high"); forwarded != "" { + t.Fatalf("forwarded effort = %q, want empty for a non-reasoning model", forwarded) + } + // ...and the rest of the posture still applies: the budget is unaffected. + profile, _ := execprofile.Lookup(execprofile.Name) + if effective, _ := applyProfileTurnBudget(profile, 0, 80); effective != 320 { + t.Fatalf("the turn budget must still apply on an unsupported model, got %d", effective) + } +} + +// (m)+(n) CLI leg of the config gate. +func TestZeromaxingSelectionRefusalCLI(t *testing.T) { + profile, _ := execprofile.Lookup(execprofile.Name) + if refusal := execprofile.SelectionRefusal(profile, true); refusal == "" { + t.Fatal("exec must refuse the posture when config disabled it") + } + if refusal := execprofile.SelectionRefusal(profile, false); refusal != "" { + t.Fatalf("exec must allow it when config did not disable it, got %q", refusal) + } +} + +// (o) The raised budget PROPAGATES to spawned children. Deliberate — this is a +// maximal posture — so it is asserted explicitly rather than left to be +// discovered. applyProfileTurnBudget's effective value becomes resolved.MaxTurns, +// which is what the run exports as ZERO_MAX_TURNS for sub-agents. +func TestZeromaxingTurnBudgetPropagatesToChildren(t *testing.T) { + profile, _ := execprofile.Lookup(execprofile.Name) + effective, _ := applyProfileTurnBudget(profile, 0, 80) + if effective != 320 { + t.Fatalf("effective budget = %d, want 320", effective) + } + if effective > config.MaxTurnsCeiling { + t.Fatalf("the budget %d exceeds the shared ceiling %d", effective, config.MaxTurnsCeiling) + } + // The delta text promises exactly this, so the promise and the number must + // not drift apart. + if !strings.Contains(execprofile.Delta, "sub-agents") { + t.Fatalf("Delta must tell the user the budget reaches sub-agents: %q", execprofile.Delta) + } +} + +// The headless posture mapping: selecting it enters; everything else is off. +func TestExecZeromaxingMapping(t *testing.T) { + profile, _ := execprofile.Lookup(execprofile.Name) + if got := execZeromaxing(profile); got != agent.ZeromaxingEntering { + t.Fatalf("execZeromaxing(zeromaxing) = %v, want ZeromaxingEntering", got) + } + for _, name := range []string{"balanced", "fast", "thorough"} { + other, _ := execprofile.Lookup(name) + if got := execZeromaxing(other); got != agent.ZeromaxingOff { + t.Fatalf("execZeromaxing(%s) = %v, want ZeromaxingOff", name, got) + } + } + if got := execZeromaxing(execprofile.Profile{}); got != agent.ZeromaxingOff { + t.Fatalf("execZeromaxing(zero) = %v, want ZeromaxingOff", got) + } +} + +// The flag PARSER must accept the posture name, and must still reject "max". +// +// This test exists because the unit tests above call normalizeZeromaxingEffort +// directly and never reach the parser — which rejected "zeromaxing" outright, so +// the whole entry point was dead at the user surface while every unit test +// passed. Driving the real binary is what found it; this is the regression. +func TestReasoningEffortFlagAcceptsZeromaxingButNotMax(t *testing.T) { + accepted := func(t *testing.T, flag, value string) error { + t.Helper() + _, _, err := parseExecArgs([]string{flag, value, "-p", "x"}) + return err + } + if err := accepted(t, "--reasoning-effort", execprofile.Name); err != nil { + t.Fatalf("--reasoning-effort %s must be accepted by the parser: %v", execprofile.Name, err) + } + if err := accepted(t, "--reasoning-effort", "ZEROMAXING"); err != nil { + t.Fatalf("the parser must be case-insensitive: %v", err) + } + for _, level := range []string{"low", "medium", "high"} { + if err := accepted(t, "--reasoning-effort", level); err != nil { + t.Fatalf("--reasoning-effort %s must still be accepted: %v", level, err) + } + } + // THE RESERVATION: "max" was rejected before this feature and must stay + // rejected. Accepting it here would burn the spelling. + err := accepted(t, "--reasoning-effort", "max") + if err == nil { + t.Fatal("--reasoning-effort max must still be rejected — the spelling is reserved") + } + if !strings.Contains(err.Error(), execprofile.Name) { + t.Fatalf("the usage error should name the accepted values: %v", err) + } + // The spec-draft effort is a plain provider level; the posture has no + // meaning for a draft, so it is NOT accepted there. + if err := accepted(t, "--spec-reasoning-effort", execprofile.Name); err == nil { + t.Fatalf("--spec-reasoning-effort %s must be rejected; it is a run posture, not a draft level", execprofile.Name) + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 239a08dd5..e5beee70f 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -149,20 +149,33 @@ type model struct { execProfileTurnsTouched bool execProfileEffortTouched bool execProfileSelfCorrectTouched bool - responseStyle string - keyBindings keyBindings - themeMode themeMode // palette preference: auto (default), dark, light - hasDarkBg bool // last terminal background-detection result (auto mode) - userAgent string - compactRequests int - compactInFlight bool - compactFrame int - lastCompactResult *CompactResult - lastCompactError string - unpricedRequests int - unpricedTokens int - lastUsage usage.Normalized - lastUsageSeen bool + // zeromaxing tracks where the session sits in the zeromaxing posture + // lifecycle so the agent loop can inject the enter/still-on/exit reminders. + // It spans runs (unlike headless exec, where a process is one run), which is + // why Active and Exiting exist: selecting it makes the NEXT run Entering, + // the run after that Active, and leaving it makes the next run Exiting once. + zeromaxing agent.Zeromaxing + // zeromaxingDisabled mirrors resolved config's profiles.disableZeromaxing so + // /effort and /profile consult the same rule the headless path applies. + zeromaxingDisabled bool + // execProfileEffortUnraised names the effort level a profile asked for but + // could not apply on the active model, so the status output can say what it + // did not raise instead of silently pretending it did. + execProfileEffortUnraised modelregistry.ReasoningEffort + responseStyle string + keyBindings keyBindings + themeMode themeMode // palette preference: auto (default), dark, light + hasDarkBg bool // last terminal background-detection result (auto mode) + userAgent string + compactRequests int + compactInFlight bool + compactFrame int + lastCompactResult *CompactResult + lastCompactError string + unpricedRequests int + unpricedTokens int + lastUsage usage.Normalized + lastUsageSeen bool // turnLatencySum / turnLatencyCount accumulate completed-run wall time so // /context can show a rolling average turn latency (the "is it slow?" signal). // Reset by /new. @@ -882,6 +895,7 @@ func newModel(ctx context.Context, options Options) model { sessionStore: sessionStore, sandboxStore: sandboxStore, mcpConfig: options.MCPConfig, + zeromaxingDisabled: options.ZeromaxingDisabled, mcpPermissionStore: options.MCPPermissionStore, mcpTokenStore: options.MCPTokenStore, mcpCommand: options.MCPCommand, @@ -2344,6 +2358,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { } m.clearStreamingToolCall() // active run finished — drop any lingering "writing" block m.pending = false + m = m.advanceZeromaxing() // the one-shot enter/exit notices are now spent m = m.disarmCancelConfirmation() // the run finished on its own — nothing left to confirm cancelling // A newline-triggered redraw deferred by the stream-clear throttle // (see agentTextMsg) may never get a later newline or fade tick to @@ -5001,6 +5016,7 @@ func (m *model) cancelRun() { } } m.pending = false + *m = m.advanceZeromaxing() // a cancelled run spends the one-shot notices too m.runCancel = nil m.activeRunID = 0 m.cancelConfirmActive = false // whatever path got here, there's nothing left to confirm cancelling diff --git a/internal/tui/options.go b/internal/tui/options.go index 409110704..b2e58a0a3 100644 --- a/internal/tui/options.go +++ b/internal/tui/options.go @@ -45,13 +45,19 @@ type Options struct { SessionStore *sessions.Store SandboxStore *sandbox.GrantStore MCPConfig config.MCPConfig - MCPPermissionStore *mcp.PermissionStore - MCPTokenStore *mcp.TokenStore - MCPCommand func(context.Context, []string) MCPCommandResult - SandboxSetupCommand func(context.Context) SandboxSetupCommandResult - UsageTracker *usage.Tracker - SessionCompactor SessionCompactor - PrService *PrService + // ZeromaxingDisabled carries resolved config's profiles.disableZeromaxing so + // /effort and /profile refuse the posture on exactly the same rule the + // headless exec path applies. Resolved config already folded the + // project-scope tighten-only merge, so a project .zero/config.json can set + // it but never clear it. + ZeromaxingDisabled bool + MCPPermissionStore *mcp.PermissionStore + MCPTokenStore *mcp.TokenStore + MCPCommand func(context.Context, []string) MCPCommandResult + SandboxSetupCommand func(context.Context) SandboxSetupCommandResult + UsageTracker *usage.Tracker + SessionCompactor SessionCompactor + PrService *PrService AgentOptions agent.Options // LoadSkills returns the installed skills (default skills dir merged with any diff --git a/internal/tui/session_controls.go b/internal/tui/session_controls.go index 4534b6c5e..2f4666816 100644 --- a/internal/tui/session_controls.go +++ b/internal/tui/session_controls.go @@ -61,12 +61,39 @@ func (m model) handleEffortCommand(args string) (model, string) { if args == "" || args == "list" { return m, m.effortText() } + // The zeromaxing posture is selected through the effort namespace as well as + // /profile, so it is handled HERE — beside "auto", with an early return, + // BEFORE the ReasoningEffort conversion below. It is a posture name, not a + // provider effort level: it must never become a ReasoningEffort value and + // must never reach a provider request. + // + // This delegates to handleProfileCommand rather than re-applying the knobs, + // so "/effort zeromaxing and /profile zeromaxing resolve identically" is + // true by construction instead of by two implementations a test hopes agree. + if args == execprofile.Name { + return m.handleProfileCommand(execprofile.Name) + } if args == "auto" { + // "auto" is the effort namespace's off switch, so under zeromaxing it + // also leaves the posture (scheduling the one-shot exit notice and + // restoring the displaced knobs). Scoped to zeromaxing on purpose: under + // fast/thorough, /effort auto keeps its existing meaning of "clear the + // effort", and reverting there would wrongly drop the whole profile. + if m.execProfileName == execprofile.Name { + m = m.revertExecProfile() + return m, m.profileText() + } m.reasoningEffort = "" m = m.markProfileEffortTouched() return m, m.effortStatusCard("auto", "Reasoning effort selection will follow the active model/provider defaults.") } + // NOTE: "max" is deliberately NOT handled here. ValidReasoningEffort already + // accepts ReasoningEffortMax (models.go), so /effort max continues to parse + // as a raw provider level and to fail reasoningEffortAllowed on every + // current model — exactly as before this posture existed. That spelling is + // RESERVED for a real provider rung once one ships; claiming it for a Zero + // posture would burn the name. TestEffortMaxReservationUnchanged pins it. requested := modelregistry.ReasoningEffort(args) if !modelregistry.ValidReasoningEffort(requested) { return m, m.effortStatusCard(args, "Unknown reasoning effort: "+args) @@ -116,13 +143,16 @@ func (m model) effortText() string { {Key: "active effort", Value: m.effortDisplay()}, {Key: "model", Value: displayValue(m.modelName, "none")}, } - actions := []string{"use /effort to switch", "/effort auto to clear"} + actions := []string{"use /effort to switch", "/effort " + execprofile.Name + " for the maximal posture", "/effort auto to clear"} + // The SAME resolved-state line /profile status renders, so the two surfaces + // cannot disagree about what actually reaches the provider. + stateLines := append([]string{m.resolvedPostureLine()}, m.zeromaxingNotes()...) if len(efforts) == 0 { fields = append(fields, commandField{Key: "available", Value: "none for active model"}) return renderCommandCardTranscript(commandCard{ Title: "Effort", Summary: []string{"active effort: " + m.effortDisplay(), "no reasoning controls on this model"}, - Sections: []commandCardSection{{Title: "State", Fields: fields}}, + Sections: []commandCardSection{{Title: "State", Fields: fields, Lines: stateLines}}, Actions: actions, }) } @@ -130,7 +160,7 @@ func (m model) effortText() string { return renderCommandCardTranscript(commandCard{ Title: "Effort", Summary: []string{"active effort: " + m.effortDisplay(), fmt.Sprintf("%d supported level(s)", len(efforts))}, - Sections: []commandCardSection{{Title: "State", Fields: fields}}, + Sections: []commandCardSection{{Title: "State", Fields: fields, Lines: stateLines}}, Actions: actions, }) } @@ -224,11 +254,22 @@ func (m model) reconcileProfileAfterModelSwitch(efforts []modelregistry.Reasonin case supported && !filled && m.reasoningEffort == "": m.reasoningEffort = want m.execProfileAppliedEffort = want + m.execProfileEffortUnraised = "" // the destination model can take it after all m = m.setProfileEffortRestore(true) case !supported && filled && m.reasoningEffort == m.execProfileAppliedEffort: m.reasoningEffort = "" m.execProfileAppliedEffort = "" + // Record WHY, exactly as the initial fill does. This is the SIBLING call + // path of the fill site in handleProfileCommand: a switch that drops the + // profile's effort is the same honesty case, arriving through the other + // door, and the two must agree or the status output claims a raise the + // run is not making. + m.execProfileEffortUnraised = want m = m.setProfileEffortRestore(false) + case !supported && !filled: + // Never filled and still unsupported: keep the reason fresh for the + // destination model rather than leaving a stale one from the source. + m.execProfileEffortUnraised = want } return m } @@ -424,6 +465,13 @@ func (m model) handleProfileCommand(args string) (model, string) { if !ok { return m, "Profile\nUsage: /profile [status|" + strings.Join(execprofile.Names(), "|") + "] — switch the next run's loop posture." } + // The SAME rule the headless exec path applies, not a second copy of it: a + // workspace that disabled zeromaxing must refuse it identically here. + // Refuse BEFORE reverting, so a rejected switch leaves the active profile + // untouched rather than silently dropping the session to balanced. + if refusal := execprofile.SelectionRefusal(profile, m.zeromaxingDisabled); refusal != "" { + return m, "Profile\nCannot use " + profile.Name + ": " + refusal + "." + } m = m.revertExecProfile() if profile.Name == execprofile.Balanced.Name { return m, m.profileText() @@ -442,10 +490,18 @@ func (m model) handleProfileCommand(args string) (model, string) { // Reasoning effort: fill only when the session is on auto AND the active // model supports the profile's level, mirroring exec's supported-effort // gating. An explicit user choice always wins over the profile. + m.execProfileEffortUnraised = "" if want := modelregistry.ReasoningEffort(profile.ReasoningEffort); want != "" && m.reasoningEffort == "" { if reasoningEffortAllowed(m.availableReasoningEfforts(), want) { m.reasoningEffort = want m.execProfileAppliedEffort = want + } else { + // Degrade honestly. The headless path already tells the user via + // reasoningEffortNotice; the TUI used to skip the fill in SILENCE, + // which is the same rule applied to one of two call paths. Record + // the level we could not raise so the status output states it — the + // rest of the posture (turn budget, self-correct) still applies. + m.execProfileEffortUnraised = want } } // Self-correction is presence-only: a profile can arm it but never disarm @@ -456,6 +512,10 @@ func (m model) handleProfileCommand(args string) (model, string) { } m.agentOptions.Profile = profile.Policy(displacedMaxTurns, m.execProfileAppliedEffort != "") m.execProfileName = profile.Name + if profile.IsZeromaxing() { + m.zeromaxing = agent.ZeromaxingEntering + } + m.agentOptions.Zeromaxing = m.zeromaxing return m, m.profileText() } @@ -488,11 +548,21 @@ func (m model) revertExecProfile() model { if !m.execProfileSelfCorrectTouched && m.execProfileArmedSelfCorrect && m.selfCorrectTests { m.selfCorrectTests = false } + // The FOURTH knob: the posture itself. Leaving zeromaxing must schedule + // exactly one exit reminder for the next run — and leaving anything else + // must not, which is why this is gated on the profile that was actually + // active rather than on "a profile was active". revertExecProfile is + // knob-by-knob, and a knob added without a line here is the classic miss. + if m.execProfileName == execprofile.Name { + m.zeromaxing = agent.ZeromaxingExiting + } + m.agentOptions.Zeromaxing = m.zeromaxing m.agentOptions.Profile = nil m.execProfileName = "" m.execProfileDisplacedMaxTurns = 0 m.execProfileAppliedMaxTurns = 0 m.execProfileAppliedEffort = "" + m.execProfileEffortUnraised = "" m.execProfileArmedSelfCorrect = false m.execProfileTurnsTouched = false m.execProfileEffortTouched = false @@ -500,6 +570,35 @@ func (m model) revertExecProfile() model { return m } +// resolvedPostureLine is the unambiguous one-line answer to "what is actually +// in effect right now": the effort that will reach the provider, the active +// profile, and the turn budget. Both /effort status and /profile status render +// it, so the two surfaces can never report different resolved state — showing +// just "zeromaxing" would hide that the effort the provider receives is "high". +func (m model) resolvedPostureLine() string { + profile := m.execProfileName + if profile == "" { + profile = execprofile.Balanced.Name + " (default)" + } + return fmt.Sprintf("effort: %s · profile: %s · turns: %d", + m.effortDisplay(), profile, m.agentOptions.MaxTurns) +} + +// zeromaxingNotes returns the honest-delta lines for the active posture: what +// it really changes, and anything it could NOT apply on this model. +func (m model) zeromaxingNotes() []string { + notes := []string{} + if m.execProfileName == execprofile.Name { + notes = append(notes, execprofile.Delta) + } + if m.execProfileEffortUnraised != "" { + notes = append(notes, fmt.Sprintf( + "reasoning effort NOT raised to %s: the active model does not support that level; the rest of the posture still applies", + m.execProfileEffortUnraised)) + } + return notes +} + func (m model) profileText() string { name := m.execProfileName if name == "" { @@ -509,7 +608,9 @@ func (m model) profileText() string { "execution profile: " + name, fmt.Sprintf("max tool-turns per run: %d", m.agentOptions.MaxTurns), "reasoning effort: " + m.effortDisplay(), + m.resolvedPostureLine(), } + lines = append(lines, m.zeromaxingNotes()...) if m.agentOptions.Profile != nil && m.agentOptions.Profile.Escalate != nil { turnTarget := "keeps the pinned turn budget" if target := m.agentOptions.Profile.Escalate.MaxTurns; target > 0 { @@ -1086,3 +1187,40 @@ func formatUnpricedUsage(requests int, tokens int) string { } return fmt.Sprintf("%d %s, %d tokens, cost unavailable", requests, requestLabel, tokens) } + +// zeromaxingChipLabel is the footer indicator for the zeromaxing posture. Kept +// as a constant so the view and its test assert the same bytes. +const zeromaxingChipLabel = "ZEROMAXING" + +// advanceZeromaxing retires the one-shot notices once the run that carried them +// has finished. +// +// Entering -> Active: the enter notice fired on that run's first turn, so every +// later run reports the posture as already on (its first turn gets still-on, +// not a second enter). +// +// Exiting -> Off: the exit notice fired once and the posture is now simply +// gone; leaving the state at Exiting would re-announce the exit on every +// subsequent run. +// +// Active and Off are terminal here — this is called after EVERY run, including +// runs with no posture at all, so it must be a no-op for them. +func (m model) advanceZeromaxing() model { + switch m.zeromaxing { + case agent.ZeromaxingEntering: + m.zeromaxing = agent.ZeromaxingActive + case agent.ZeromaxingExiting: + m.zeromaxing = agent.ZeromaxingOff + default: + return m + } + m.agentOptions.Zeromaxing = m.zeromaxing + return m +} + +// zeromaxingActive reports whether the session currently holds the posture, for +// the footer chip. Exiting is deliberately excluded: the posture is already off, +// and the pending notice is only the announcement of that. +func (m model) zeromaxingActive() bool { + return m.zeromaxing == agent.ZeromaxingEntering || m.zeromaxing == agent.ZeromaxingActive +} diff --git a/internal/tui/view.go b/internal/tui/view.go index 7d8e472c8..14d22fbda 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -221,6 +221,12 @@ func (m model) statusLine(width int) string { if m.reasoningEffort != "" { left += zeroTheme.muted.Render(" · ") + zeroTheme.accent.Render(string(m.reasoningEffort)) } + // The zeromaxing posture sits beside the effort chip: both describe how hard + // this session tries, and it raises a cost multiplier, so it stays visible + // for as long as it is on rather than only appearing in a status card. + if m.zeromaxingActive() { + left += zeroTheme.muted.Render(" · ") + zeroTheme.amber.Render(zeromaxingChipLabel) + } if m.exitConfirmActive { left = prefix + btwChip + zeroTheme.amber.Render("●") + " " + zeroTheme.amber.Render(ctrlCExitConfirmText) } else if m.cancelConfirmActive { diff --git a/internal/tui/zeromaxing_test.go b/internal/tui/zeromaxing_test.go new file mode 100644 index 000000000..c1274a91f --- /dev/null +++ b/internal/tui/zeromaxing_test.go @@ -0,0 +1,420 @@ +package tui + +import ( + "context" + "reflect" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/agent" + "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/execprofile" + "github.com/Gitlawb/zero/internal/modelregistry" + "github.com/Gitlawb/zero/internal/zeroruntime" +) + +// zeromaxingTestModel builds a session on a reasoning model that supports +// "high", so the posture's effort fill applies. disabled drives the config gate. +func zeromaxingTestModel(t *testing.T, disabled bool) model { + t.Helper() + return newModel(context.Background(), Options{ + ProviderName: "anthropic", + ModelName: "claude-sonnet-4.5", + Provider: &fakeProvider{}, + ProviderProfile: config.ProviderProfile{Name: "anthropic", CatalogID: "anthropic", Model: "claude-sonnet-4.5", APIKey: "k"}, + SavedProviders: []config.ProviderProfile{{Name: "anthropic", CatalogID: "anthropic", Model: "claude-sonnet-4.5", APIKey: "k"}}, + ZeromaxingDisabled: disabled, + NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { + return &fakeProvider{}, nil + }, + }) +} + +// (g) THE ENTRY-POINT EQUIVALENCE. /effort zeromaxing and /profile zeromaxing +// must produce IDENTICAL resolved state. They do so by delegating to one +// implementation rather than by two parallel ones a test hopes agree — this +// asserts the result, and the delegation makes it true by construction. +func TestEffortAndProfileEntryPointsResolveIdentically(t *testing.T) { + viaEffort, effortText := zeromaxingTestModel(t, false).handleEffortCommand(execprofile.Name) + viaProfile, profileText := zeromaxingTestModel(t, false).handleProfileCommand(execprofile.Name) + + if viaEffort.execProfileName != viaProfile.execProfileName || + viaEffort.reasoningEffort != viaProfile.reasoningEffort || + viaEffort.agentOptions.MaxTurns != viaProfile.agentOptions.MaxTurns || + viaEffort.selfCorrectTests != viaProfile.selfCorrectTests || + viaEffort.zeromaxing != viaProfile.zeromaxing || + viaEffort.execProfileAppliedEffort != viaProfile.execProfileAppliedEffort || + viaEffort.execProfileEffortUnraised != viaProfile.execProfileEffortUnraised { + t.Fatalf("the two entry points diverged:\n/effort: profile=%q effort=%q turns=%d sc=%v posture=%v\n/profile: profile=%q effort=%q turns=%d sc=%v posture=%v", + viaEffort.execProfileName, viaEffort.reasoningEffort, viaEffort.agentOptions.MaxTurns, viaEffort.selfCorrectTests, viaEffort.zeromaxing, + viaProfile.execProfileName, viaProfile.reasoningEffort, viaProfile.agentOptions.MaxTurns, viaProfile.selfCorrectTests, viaProfile.zeromaxing) + } + if !reflect.DeepEqual(effortText, profileText) { + t.Fatalf("the two entry points printed different output:\n/effort:\n%s\n/profile:\n%s", effortText, profileText) + } + if viaEffort.reasoningEffort != modelregistry.ReasoningEffortHigh { + t.Fatalf("both must resolve the effort to high, got %q", viaEffort.reasoningEffort) + } +} + +// (f) THE RESERVATION. /effort max is UNCHANGED: it still parses as a raw +// provider level and still fails reasoningEffortAllowed on a model that does not +// list it. The posture must not have quietly claimed the spelling. +func TestEffortMaxReservationUnchangedTUI(t *testing.T) { + m := zeromaxingTestModel(t, false) + before := m + + m, text := m.handleEffortCommand("max") + if !strings.Contains(text, "not supported") { + t.Fatalf("/effort max must still report an unsupported level, got:\n%s", text) + } + // It must not have selected the posture, changed the effort, or moved the budget. + if m.execProfileName != before.execProfileName { + t.Fatalf("/effort max must not select a profile, got %q", m.execProfileName) + } + if m.reasoningEffort != before.reasoningEffort { + t.Fatalf("/effort max must not change the effort, got %q", m.reasoningEffort) + } + if m.zeromaxing != agent.ZeromaxingOff { + t.Fatalf("/effort max must not arm the posture, got %v", m.zeromaxing) + } + if m.agentOptions.MaxTurns != before.agentOptions.MaxTurns { + t.Fatalf("/effort max must not move the turn budget, got %d", m.agentOptions.MaxTurns) + } +} + +// (a) TUI: an explicit /effort survives the posture. +func TestZeromaxingDoesNotOverrideExplicitEffortTUI(t *testing.T) { + m := zeromaxingTestModel(t, false) + m, _ = m.handleEffortCommand("low") + if m.reasoningEffort != "low" { + t.Fatalf("setup: effort = %q, want low", m.reasoningEffort) + } + m, _ = m.handleEffortCommand(execprofile.Name) + if m.reasoningEffort != "low" { + t.Fatalf("effort = %q, the explicit low must survive the posture's high", m.reasoningEffort) + } + if m.execProfileAppliedEffort != "" { + t.Fatalf("must not claim to have applied an effort it did not: %q", m.execProfileAppliedEffort) + } +} + +// (c) TUI: an explicit /turns pins the budget. +func TestZeromaxingDoesNotOverrideExplicitTurnsTUI(t *testing.T) { + m := zeromaxingTestModel(t, false) + m, _ = m.handleEffortCommand(execprofile.Name) + if m.agentOptions.MaxTurns != 320 { + t.Fatalf("the posture must raise the budget to 320, got %d", m.agentOptions.MaxTurns) + } + m, _ = m.handleTurnsCommand("50") + if m.agentOptions.MaxTurns != 50 { + t.Fatalf("an explicit /turns must win, got %d", m.agentOptions.MaxTurns) + } + if !m.execProfileTurnsTouched { + t.Fatal("/turns under a profile must mark the knob touched so a revert leaves it alone") + } +} + +// (p) revertExecProfile is knob-by-knob, and the posture is a FOURTH knob. All +// four must be restored — budget, effort, self-correct, and the posture moving +// to Exiting exactly once. +func TestRevertRestoresAllFourKnobs(t *testing.T) { + m := zeromaxingTestModel(t, false) + baseTurns := m.agentOptions.MaxTurns + baseEffort := m.reasoningEffort + baseSelfCorrect := m.selfCorrectTests + + m, _ = m.handleEffortCommand(execprofile.Name) + if m.agentOptions.MaxTurns != 320 || m.reasoningEffort != "high" || !m.selfCorrectTests { + t.Fatalf("setup: knobs not applied: turns=%d effort=%q sc=%v", + m.agentOptions.MaxTurns, m.reasoningEffort, m.selfCorrectTests) + } + if m.zeromaxing != agent.ZeromaxingEntering { + t.Fatalf("selecting must enter the posture, got %v", m.zeromaxing) + } + + // /effort auto is the effort namespace's off switch. + m, _ = m.handleEffortCommand("auto") + if m.agentOptions.MaxTurns != baseTurns { + t.Fatalf("knob 1 (turn budget) = %d, want the displaced %d", m.agentOptions.MaxTurns, baseTurns) + } + if m.reasoningEffort != baseEffort { + t.Fatalf("knob 2 (effort) = %q, want the displaced %q", m.reasoningEffort, baseEffort) + } + if m.selfCorrectTests != baseSelfCorrect { + t.Fatalf("knob 3 (self-correct) = %v, want the displaced %v", m.selfCorrectTests, baseSelfCorrect) + } + if m.zeromaxing != agent.ZeromaxingExiting { + t.Fatalf("knob 4 (posture) = %v, want ZeromaxingExiting so the exit notice fires once", m.zeromaxing) + } + if m.agentOptions.Zeromaxing != agent.ZeromaxingExiting { + t.Fatal("the posture must reach agentOptions, or the loop never emits the exit notice") + } +} + +// /profile balanced is the other way out, and must behave identically. +func TestBothExitRoutesLeaveThePosture(t *testing.T) { + viaEffort := zeromaxingTestModel(t, false) + viaEffort, _ = viaEffort.handleEffortCommand(execprofile.Name) + viaEffort, _ = viaEffort.handleEffortCommand("auto") + + viaProfile := zeromaxingTestModel(t, false) + viaProfile, _ = viaProfile.handleProfileCommand(execprofile.Name) + viaProfile, _ = viaProfile.handleProfileCommand("balanced") + + if viaEffort.zeromaxing != viaProfile.zeromaxing || viaEffort.zeromaxing != agent.ZeromaxingExiting { + t.Fatalf("the two exit routes diverged: /effort auto -> %v, /profile balanced -> %v", + viaEffort.zeromaxing, viaProfile.zeromaxing) + } + if viaEffort.execProfileName != "" || viaProfile.execProfileName != "" { + t.Fatalf("both routes must clear the profile: %q / %q", viaEffort.execProfileName, viaProfile.execProfileName) + } +} + +// /effort auto under a NON-zeromaxing profile keeps its existing meaning: clear +// the effort, keep the profile. Reverting there would silently drop the profile. +func TestEffortAutoUnderOtherProfilesJustClearsTheEffort(t *testing.T) { + m := zeromaxingTestModel(t, false) + m, _ = m.handleProfileCommand("thorough") + if m.reasoningEffort != "high" || m.execProfileName != "thorough" { + t.Fatalf("setup: thorough not applied: effort=%q profile=%q", m.reasoningEffort, m.execProfileName) + } + m, _ = m.handleEffortCommand("auto") + if m.reasoningEffort != "" { + t.Fatalf("/effort auto must clear the effort, got %q", m.reasoningEffort) + } + if m.execProfileName != "thorough" { + t.Fatalf("/effort auto must NOT drop a non-zeromaxing profile, got %q", m.execProfileName) + } + if m.zeromaxing != agent.ZeromaxingOff { + t.Fatalf("leaving thorough must not announce a posture exit, got %v", m.zeromaxing) + } +} + +// The posture lifecycle across runs: enter and exit each announce exactly once +// no matter how many runs follow. +func TestZeromaxingLifecycleAcrossRuns(t *testing.T) { + m := zeromaxingTestModel(t, false) + m, _ = m.handleEffortCommand(execprofile.Name) + if m.zeromaxing != agent.ZeromaxingEntering { + t.Fatalf("after selecting: %v, want Entering", m.zeromaxing) + } + m = m.advanceZeromaxing() + if m.zeromaxing != agent.ZeromaxingActive { + t.Fatalf("after the first run: %v, want Active", m.zeromaxing) + } + m = m.advanceZeromaxing() + if m.zeromaxing != agent.ZeromaxingActive { + t.Fatalf("Active must be terminal while on, got %v", m.zeromaxing) + } + m, _ = m.handleEffortCommand("auto") + if m.zeromaxing != agent.ZeromaxingExiting { + t.Fatalf("after leaving: %v, want Exiting", m.zeromaxing) + } + m = m.advanceZeromaxing() + if m.zeromaxing != agent.ZeromaxingOff { + t.Fatalf("after the exit run: %v, want Off", m.zeromaxing) + } + m = m.advanceZeromaxing() + if m.zeromaxing != agent.ZeromaxingOff { + t.Fatalf("Off must be terminal, got %v", m.zeromaxing) + } +} + +// (m) A config that disabled the posture must refuse it, from BOTH entry +// points, and leave the active profile untouched rather than dropping to +// balanced. +func TestConfigCannotEnableZeromaxingTUI(t *testing.T) { + for _, entry := range []struct { + name string + call func(model) (model, string) + }{ + {"/effort", func(m model) (model, string) { return m.handleEffortCommand(execprofile.Name) }}, + {"/profile", func(m model) (model, string) { return m.handleProfileCommand(execprofile.Name) }}, + } { + t.Run(entry.name, func(t *testing.T) { + m := zeromaxingTestModel(t, true) + m, _ = m.handleProfileCommand("thorough") + turnsBefore := m.agentOptions.MaxTurns + + m, text := entry.call(m) + if !strings.Contains(text, "Cannot use "+execprofile.Name) { + t.Fatalf("a disabled workspace must refuse it, got %q", text) + } + if !strings.Contains(text, "disableZeromaxing") { + t.Fatalf("the refusal must name the setting: %q", text) + } + if m.execProfileName != "thorough" || m.agentOptions.MaxTurns != turnsBefore { + t.Fatalf("a refused switch must leave the active profile alone: profile=%q turns=%d", + m.execProfileName, m.agentOptions.MaxTurns) + } + if m.zeromaxing != agent.ZeromaxingOff { + t.Fatalf("a refused selection must not arm the posture, got %v", m.zeromaxing) + } + }) + } +} + +// (n) The same session with it NOT disabled selects fine — the other half of +// the gate, so the test above cannot pass by the posture being broken outright. +func TestZeromaxingSelectableWhenNotDisabledTUI(t *testing.T) { + m := zeromaxingTestModel(t, false) + m, text := m.handleEffortCommand(execprofile.Name) + if m.execProfileName != execprofile.Name { + t.Fatalf("must be selectable when not disabled, got %q (%s)", m.execProfileName, text) + } +} + +// Both selection paths consult the SAME rule. This is the standing-warning +// assertion: CLI and TUI apply profiles through different code with different +// state, so the one thing that must not diverge is the decision itself. +func TestSelectionRefusalAgreesAcrossPaths(t *testing.T) { + profile, _ := execprofile.Lookup(execprofile.Name) + for _, disabled := range []bool{true, false} { + rule := execprofile.SelectionRefusal(profile, disabled) + + m := zeromaxingTestModel(t, disabled) + m, text := m.handleEffortCommand(execprofile.Name) + tuiRefused := m.execProfileName != execprofile.Name + if tuiRefused != (rule != "") { + t.Fatalf("disabled=%v: rule refusal=%q but TUI refused=%v (%s)", disabled, rule, tuiRefused, text) + } + if disabled && rule == "" { + t.Fatal("a disabled workspace must produce a refusal for the CLI path too") + } + } +} + +// (l) Degrade honestly. On a model with no effort ring the fill is skipped — and +// the status output must SAY so, while the rest of the posture still applies. +func TestZeromaxingOnUnsupportedModelStatesWhatItCouldNotRaise(t *testing.T) { + m := newModel(context.Background(), Options{ + ProviderName: "ollama", + ModelName: "kimi-k2.7-code:cloud", + Provider: &fakeProvider{}, + ProviderProfile: config.ProviderProfile{Name: "ollama", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "http://localhost:11434/v1", Model: "kimi-k2.7-code:cloud"}, + NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { + return &fakeProvider{}, nil + }, + }) + if len(m.availableReasoningEfforts()) != 0 { + t.Skip("this model gained an effort ring; the fixture no longer exercises the unsupported path") + } + + m, text := m.handleEffortCommand(execprofile.Name) + if m.execProfileName != execprofile.Name { + t.Fatalf("must still WORK on a model that cannot take the effort, got %q", m.execProfileName) + } + if m.agentOptions.MaxTurns != 320 { + t.Fatalf("the rest of the posture must still apply: turns = %d", m.agentOptions.MaxTurns) + } + if m.reasoningEffort != "" { + t.Fatalf("an unsupported effort must not be applied, got %q", m.reasoningEffort) + } + if m.execProfileEffortUnraised != modelregistry.ReasoningEffortHigh { + t.Fatalf("the skipped level must be recorded, got %q", m.execProfileEffortUnraised) + } + if !strings.Contains(text, "NOT raised") { + t.Fatalf("the output must state what it could not raise, got %q", text) + } +} + +// (q) reconcileProfileAfterModelSwitch is the re-derive SIBLING of the fill site +// in handleProfileCommand. Both decide whether the profile's effort applies to +// the ACTIVE model, so both must record WHY when it does not. +func TestZeromaxingUnraisedEffortIsRecordedOnModelSwitch(t *testing.T) { + m := profileSwitchModel(t) + m, _ = m.handleEffortCommand(execprofile.Name) + if m.reasoningEffort != modelregistry.ReasoningEffortHigh { + t.Fatalf("setup: must fill high on a supporting model, got %q", m.reasoningEffort) + } + if m.execProfileEffortUnraised != "" { + t.Fatalf("nothing was skipped, so nothing should be recorded: %q", m.execProfileEffortUnraised) + } + + m, text, ok, _ := m.switchProviderModel("ollama", "kimi-k2.7-code:cloud") + if !ok { + t.Fatalf("switch to ollama failed: %q", text) + } + if m.reasoningEffort != "" { + t.Fatalf("the unsupported level must be dropped, got %q", m.reasoningEffort) + } + if m.execProfileEffortUnraised != modelregistry.ReasoningEffortHigh { + t.Fatalf("the dropped level must be recorded so the status can state it, got %q", m.execProfileEffortUnraised) + } + if _, out := m.handleProfileCommand("status"); !strings.Contains(out, "NOT raised") { + t.Fatalf("status must state the effort it could not raise after a switch:\n%s", out) + } + + // Switching BACK to a supporting model clears the reason, so a stale + // "NOT raised" line never outlives the model that caused it. + m, text, ok, _ = m.switchProviderModel("anthropic", "claude-sonnet-4.5") + if !ok { + t.Fatalf("switch back failed: %q", text) + } + if m.reasoningEffort != modelregistry.ReasoningEffortHigh { + t.Fatalf("returning to a supporting model must refill high, got %q", m.reasoningEffort) + } + if m.execProfileEffortUnraised != "" { + t.Fatalf("the reason must clear once the effort applies again, got %q", m.execProfileEffortUnraised) + } +} + +// Gate 3: BOTH status surfaces show the resolved state unambiguously — a user +// must see what actually reached the provider, not just the posture name. +func TestBothStatusSurfacesShowResolvedState(t *testing.T) { + m := zeromaxingTestModel(t, false) + m, _ = m.handleEffortCommand(execprofile.Name) + + _, effortStatus := m.handleEffortCommand("") + _, profileStatus := m.handleProfileCommand("status") + + for surface, text := range map[string]string{"/effort": effortStatus, "/profile status": profileStatus} { + for _, want := range []string{"effort: high", "profile: " + execprofile.Name, "turns: 320"} { + if !strings.Contains(text, want) { + t.Fatalf("%s must show %q in its resolved state line:\n%s", surface, want, text) + } + } + if !strings.Contains(text, execprofile.Delta) { + t.Fatalf("%s must state the real delta:\n%s", surface, text) + } + for _, want := range []string{"320", "160", "sub-agents"} { + if !strings.Contains(text, want) { + t.Fatalf("%s must mention %q:\n%s", surface, want, text) + } + } + } + // Other profiles must NOT carry the posture's delta text. + other := zeromaxingTestModel(t, false) + _, otherText := other.handleProfileCommand("thorough") + if strings.Contains(otherText, execprofile.Delta) { + t.Fatalf("thorough must not claim the posture's delta:\n%s", otherText) + } +} + +// Gate 5: the footer chip is shown while the posture is on and hidden +// otherwise — including while Exiting, when the posture is already off. +func TestZeromaxingFooterChipVisibility(t *testing.T) { + m := zeromaxingTestModel(t, false) + if m.zeromaxingActive() { + t.Fatal("nothing selected: the chip must be hidden") + } + m, _ = m.handleEffortCommand(execprofile.Name) + if !m.zeromaxingActive() { + t.Fatal("Entering: the chip must be shown") + } + if !strings.Contains(m.statusLine(120), zeromaxingChipLabel) { + t.Fatalf("the footer must carry %q while on:\n%s", zeromaxingChipLabel, m.statusLine(120)) + } + m = m.advanceZeromaxing() + if !strings.Contains(m.statusLine(120), zeromaxingChipLabel) { + t.Fatal("Active: the chip must still be shown") + } + m, _ = m.handleEffortCommand("auto") + if m.zeromaxingActive() { + t.Fatal("Exiting: the posture is already off, so the chip must be hidden") + } + if strings.Contains(m.statusLine(120), zeromaxingChipLabel) { + t.Fatalf("the footer must drop the chip once off:\n%s", m.statusLine(120)) + } +} From e885d792051e0b3e5d335cf1e727b93d5f69839a Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:26:34 +0530 Subject: [PATCH 04/86] fix(execprofile): describe the posture's delta against the caller's own state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrections to what zeromaxing tells the user, both cases of documentation describing behaviour that is not happening. 1. --spec-reasoning-effort zeromaxing stays rejected — it is a run posture and has no meaning for a spec draft — but the generic "Expected low, medium, or high" read like a bug to anyone who had just learned the name works on --reasoning-effort. It now says why it does not apply and names both flags that do what the user was reaching for. A genuinely unknown value keeps the plain message: the explanation is specific to the posture name, not a new blanket wording. 2. Delta's self-correct clause described the change from THOROUGH, not from the caller. Telling a user sitting on the LSP-only default that self-correction is "already armed" while silently moving them to the full project test plan is exactly the failure this posture's honesty rules exist to prevent. Delta is now a function of the caller's actual state and renders one of three transitions, in /selfcorrect's own vocabulary: self-correct: lsp -> tests (the common case) self-correct: unchanged (tests) (genuinely unchanged) self-correct: lsp (your /selfcorrect choice ...) (user override wins) The third exists because the TUI lets a user turn it back off after selecting the posture; reporting a raise there would be a third way to describe behaviour that is not happening. The budget and effort clauses stay fixed — effort genuinely is caller-independent, since "high" is the ceiling every provider accepts. The headless path captures self-correct BEFORE applyExecProfile, which arms it as a side effect; reading it afterwards would make every run report "unchanged". The TUI reads its LIVE state rather than selection-time state, so a later /selfcorrect off stops the status claiming a raise the session no longer has. A unit test on the transition helper cannot catch the capture being read at the wrong moment, because it never runs the code that captures — so the ordering is pinned by a test that drives the real exec path and reads stderr. That gap was found by mutation, not by review. --- internal/cli/exec.go | 17 ++++- internal/cli/exec_parse.go | 8 +++ internal/cli/exec_zeromaxing_test.go | 90 ++++++++++++++++++++++++- internal/execprofile/profile.go | 55 +++++++++++++-- internal/execprofile/zeromaxing_test.go | 49 +++++++++++++- internal/tui/session_controls.go | 22 +++++- internal/tui/zeromaxing_test.go | 56 ++++++++++++++- 7 files changed, 285 insertions(+), 12 deletions(-) diff --git a/internal/cli/exec.go b/internal/cli/exec.go index 8a2898391..96f2330e0 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -189,6 +189,10 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in // runs after it so the mode's fills count as "set" and win. MaxTurns is // deferred to config resolution below, where the displaced resolved budget // is known and becomes the escalation restore target. + // Captured BEFORE the profile expands: applyExecProfile arms self-correction + // as a side effect, so reading options.selfCorrect afterwards would always + // report "already on" and the delta would describe a change it just made. + selfCorrectBeforeProfile := options.selfCorrect execProfile, execProfileFilledEffort, err := applyExecProfile(&options) if err != nil { return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error()) @@ -309,7 +313,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in // in a PR body is how a posture ends up documented as doing things it does // not do. if execProfile.IsZeromaxing() { - if _, err := fmt.Fprintln(stderr, execprofile.Delta); err != nil { + if _, err := fmt.Fprintln(stderr, execprofile.Delta(execSelfCorrectTransition(selfCorrectBeforeProfile))); err != nil { return exitCrash } } @@ -1257,6 +1261,17 @@ func normalizeZeromaxingEffort(options *execOptions) error { return nil } +// execSelfCorrectTransition reports what the posture did to post-edit +// verification for THIS run. A headless run has no way to override the profile +// after it applies (--self-correct is presence-only and read before), so the +// Overridden state is unreachable here — it is a TUI-only condition. +func execSelfCorrectTransition(selfCorrectBefore bool) execprofile.SelfCorrectTransition { + if selfCorrectBefore { + return execprofile.SelfCorrectAlreadyOn + } + return execprofile.SelfCorrectRaised +} + // execZeromaxing maps a selected profile onto the run's posture lifecycle. A // headless exec run is one process for one run, so selecting the posture means // entering it and anything else means off — the Active/Exiting states only diff --git a/internal/cli/exec_parse.go b/internal/cli/exec_parse.go index 7f1ae7623..87febd187 100644 --- a/internal/cli/exec_parse.go +++ b/internal/cli/exec_parse.go @@ -537,6 +537,14 @@ func nextFlagValue(args []string, index int, flag string) (string, int, error) { // posture and has no meaning for a draft, so it is not accepted here. switch strings.ToLower(next) { case "low", "medium", "high": + case execprofile.Name: + // Rejecting this with the generic "Expected low, medium, or high" + // reads like a bug to anyone who just learned the name works on + // --reasoning-effort. Say why it does not apply, and name the two + // flags that do what they were reaching for. + return "", index, execUsageError{fmt.Sprintf( + "invalid %s %q. %s is a run posture, not a reasoning level, so it does not apply to spec drafting. Use %s high for the draft, or --reasoning-effort %s to run under the posture.", + flag, next, execprofile.Name, flag, execprofile.Name)} default: return "", index, execUsageError{fmt.Sprintf("invalid %s %q. Expected low, medium, or high.", flag, next)} } diff --git a/internal/cli/exec_zeromaxing_test.go b/internal/cli/exec_zeromaxing_test.go index aedf8b8e2..52cfcd415 100644 --- a/internal/cli/exec_zeromaxing_test.go +++ b/internal/cli/exec_zeromaxing_test.go @@ -235,8 +235,8 @@ func TestZeromaxingTurnBudgetPropagatesToChildren(t *testing.T) { } // The delta text promises exactly this, so the promise and the number must // not drift apart. - if !strings.Contains(execprofile.Delta, "sub-agents") { - t.Fatalf("Delta must tell the user the budget reaches sub-agents: %q", execprofile.Delta) + if !strings.Contains(execprofile.DeltaBudgetLine, "sub-agents") { + t.Fatalf("the budget line must tell the user it reaches sub-agents: %q", execprofile.DeltaBudgetLine) } } @@ -295,3 +295,89 @@ func TestReasoningEffortFlagAcceptsZeromaxingButNotMax(t *testing.T) { t.Fatalf("--spec-reasoning-effort %s must be rejected; it is a run posture, not a draft level", execprofile.Name) } } + +// The headless delta must describe the transition from the state the run was +// invoked in, captured BEFORE the profile arms self-correction as a side +// effect. Reading options.selfCorrect afterwards would always say "already on". +func TestExecSelfCorrectTransitionUsesPreProfileState(t *testing.T) { + if got := execSelfCorrectTransition(false); got != execprofile.SelfCorrectRaised { + t.Fatalf("an unflagged run = %v, want SelfCorrectRaised", got) + } + if got := execSelfCorrectTransition(true); got != execprofile.SelfCorrectAlreadyOn { + t.Fatalf("an explicit --self-correct run = %v, want SelfCorrectAlreadyOn", got) + } + // And the rendered text differs, so the distinction reaches the user. + raised := execprofile.Delta(execSelfCorrectTransition(false)) + already := execprofile.Delta(execSelfCorrectTransition(true)) + if raised == already { + t.Fatalf("both states render identically:\n%s", raised) + } + if !strings.Contains(raised, "lsp → tests") { + t.Fatalf("an unflagged run must be told what changes:\n%s", raised) + } +} + +// --spec-reasoning-effort zeromaxing stays rejected, but the message must +// explain WHY and name the way forward rather than reading like a bug. +func TestSpecReasoningEffortZeromaxingExplainsItself(t *testing.T) { + _, _, err := parseExecArgs([]string{"--spec-reasoning-effort", execprofile.Name, "-p", "x"}) + if err == nil { + t.Fatalf("--spec-reasoning-effort %s must still be rejected", execprofile.Name) + } + message := err.Error() + for _, want := range []string{ + "run posture", // why it does not apply + "spec drafting", // where the user is + "--spec-reasoning-effort high", // the way forward for the draft + "--reasoning-effort " + execprofile.Name, // ...and for the run + } { + if !strings.Contains(message, want) { + t.Fatalf("the usage error must mention %q, got: %s", want, message) + } + } + // A genuinely unknown value keeps the plain message — the explanation is + // specific to the posture name, not a new blanket wording. + _, _, err = parseExecArgs([]string{"--spec-reasoning-effort", "turbo", "-p", "x"}) + if err == nil || !strings.Contains(err.Error(), "Expected low, medium, or high.") { + t.Fatalf("an unknown value must keep the plain message, got: %v", err) + } +} + +// The CAPTURE POINT, exercised through the real exec path. +// +// TestExecSelfCorrectTransitionUsesPreProfileState covers the helper; it cannot +// catch the capture being read at the wrong moment, because it never runs the +// code that does the capturing. applyExecProfile arms self-correction as a side +// effect, so reading options.selfCorrect after it would make every run report +// "unchanged (tests)" — the exact wording this change exists to remove. +func TestExecPrintsTheRaisedTransitionForAnUnflaggedRun(t *testing.T) { + exitCode, _, stderr := runExecWithEcho(t, []string{ + "exec", "--exec-profile", execprofile.Name, "hello", + }) + if exitCode != exitSuccess { + t.Fatalf("expected exit %d, got %d: %s", exitSuccess, exitCode, stderr) + } + if !strings.Contains(stderr, "self-correct: lsp → tests") { + t.Fatalf("an unflagged run starts LSP-only, so the delta must show the transition:\n%s", stderr) + } + if strings.Contains(stderr, "self-correct: unchanged") { + t.Fatalf("the delta must not claim self-correction was already on:\n%s", stderr) + } +} + +// ...and the other direction: an explicit --self-correct run genuinely was +// already on, so it must say so. +func TestExecPrintsUnchangedWhenSelfCorrectWasAlreadyOn(t *testing.T) { + exitCode, _, stderr := runExecWithEcho(t, []string{ + "exec", "--exec-profile", execprofile.Name, "--self-correct", "hello", + }) + if exitCode != exitSuccess { + t.Fatalf("expected exit %d, got %d: %s", exitSuccess, exitCode, stderr) + } + if !strings.Contains(stderr, "self-correct: unchanged (tests)") { + t.Fatalf("an explicit --self-correct run must be told nothing changed:\n%s", stderr) + } + if strings.Contains(stderr, "lsp → tests") { + t.Fatalf("must not claim a transition that did not happen:\n%s", stderr) + } +} diff --git a/internal/execprofile/profile.go b/internal/execprofile/profile.go index 94980442d..d4ea73575 100644 --- a/internal/execprofile/profile.go +++ b/internal/execprofile/profile.go @@ -128,16 +128,63 @@ var catalog = map[string]Profile{ Zeromaxing.Name: Zeromaxing, } +// SelfCorrectTransition is what selecting the posture does to post-edit +// verification, relative to the state the caller is ACTUALLY in. +// +// It exists because the honest answer is caller-specific. Saying +// "self-correction is already armed" is true when comparing zeromaxing to +// thorough and false for a user sitting on the LSP-only default, who is about +// to be moved to the full project test plan without being told. A user reads +// this to learn what changes for THEM, not to learn how two profiles differ. +type SelfCorrectTransition int + +const ( + // SelfCorrectRaised: post-edit verification was LSP-only and the posture + // adds the project test plan. The common case, and the one the old wording + // got wrong. + SelfCorrectRaised SelfCorrectTransition = iota + // SelfCorrectAlreadyOn: the deeper verification was already on, so the + // posture genuinely changes nothing here. + SelfCorrectAlreadyOn + // SelfCorrectOverridden: an explicit /selfcorrect choice is holding it at + // lsp despite the posture. Reporting a raise here would describe behaviour + // that is not happening. + SelfCorrectOverridden +) + +// selfCorrectLine renders the transition using /selfcorrect's own vocabulary +// (lsp / tests), so the line names states the user can actually type. +func (t SelfCorrectTransition) selfCorrectLine() string { + switch t { + case SelfCorrectAlreadyOn: + return "self-correct: unchanged (tests)" + case SelfCorrectOverridden: + return "self-correct: lsp (your /selfcorrect choice overrides the posture)" + default: + return "self-correct: lsp → tests" + } +} + // Delta is the user-facing statement of what selecting zeromaxing actually // changes, shown by /effort, /profile, and the exec selection notice. It is -// deliberately concrete and deliberately admits the two knobs it does NOT move: -// a user paying for a higher posture is owed the real delta, not a slogan. +// deliberately concrete and deliberately admits what it does NOT move: a user +// paying for a higher posture is owed the real delta, not a slogan. // // The child-budget sentence is not a caveat, it is the point: zeromaxing is a // maximal posture, so the raised budget is exported to spawned sub-agents // exactly as /turns does (asserted by TestZeromaxingTurnBudgetPropagatesToChildren). -const Delta = "zeromaxing raises the tool-turn budget to 320 (thorough uses 160) and applies that budget to spawned sub-agents too. " + - "Reasoning effort and post-edit self-correction are unchanged from thorough: effort is already at the highest level providers accept, and self-correction is already armed." +func Delta(selfCorrect SelfCorrectTransition) string { + return DeltaBudgetLine + " " + DeltaEffortLine + " " + selfCorrect.selfCorrectLine() + "." +} + +// The fixed clauses, exported so tests assert on the same bytes users read. +const ( + DeltaBudgetLine = "zeromaxing raises the tool-turn budget to 320 (thorough uses 160) and applies that budget to spawned sub-agents too." + // Effort genuinely is caller-independent: "high" is the ceiling every + // provider accepts, so there is no state a caller can be in where the + // posture raises it further. + DeltaEffortLine = "reasoning effort: unchanged — already at the highest level providers accept." +) // Name is the single spelling of this posture, everywhere: /effort zeromaxing, // /profile zeromaxing, --exec-profile zeromaxing, --reasoning-effort zeromaxing. diff --git a/internal/execprofile/zeromaxing_test.go b/internal/execprofile/zeromaxing_test.go index 9740f47d9..e39edee66 100644 --- a/internal/execprofile/zeromaxing_test.go +++ b/internal/execprofile/zeromaxing_test.go @@ -84,12 +84,57 @@ func TestZeromaxingKnobsMatchTheDeltaItAdvertises(t *testing.T) { t.Fatalf("zeromaxing must arm no escalation policy, got %+v", policy) } for _, want := range []string{"320", "160", "sub-agents"} { - if !strings.Contains(Delta, want) { - t.Fatalf("Delta must state %q so the user sees the real delta: %q", want, Delta) + if !strings.Contains(DeltaBudgetLine, want) { + t.Fatalf("the budget line must state %q: %q", want, DeltaBudgetLine) } } } +// The self-correct clause must describe the transition from the CALLER'S state, +// not from thorough. Telling a user sitting on LSP-only that self-correction is +// "already armed" while silently moving them to the project test plan is +// documentation describing behaviour that is not happening. +func TestDeltaSelfCorrectDescribesTheCallersTransition(t *testing.T) { + cases := []struct { + name string + transition SelfCorrectTransition + want string + absent string + }{ + {"lsp-only user is told what changes", SelfCorrectRaised, "self-correct: lsp → tests", "unchanged"}, + {"already-on user is told nothing changes", SelfCorrectAlreadyOn, "self-correct: unchanged (tests)", "→"}, + {"overridden user is not promised a raise", SelfCorrectOverridden, "overrides the posture", "→"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := Delta(tc.transition) + if !strings.Contains(got, tc.want) { + t.Fatalf("Delta(%v) must contain %q, got:\n%s", tc.transition, tc.want, got) + } + // Scoped to the self-correct CLAUSE: "unchanged" also appears in the + // effort clause, where it is correct and caller-independent. + clause := got[strings.Index(got, "self-correct:"):] + if tc.absent != "" && strings.Contains(clause, tc.absent) { + t.Fatalf("Delta(%v) self-correct clause must NOT contain %q, got:\n%s", tc.transition, tc.absent, clause) + } + // Every rendering keeps the caller-independent clauses. + if !strings.Contains(got, DeltaBudgetLine) || !strings.Contains(got, DeltaEffortLine) { + t.Fatalf("Delta(%v) dropped a fixed clause:\n%s", tc.transition, got) + } + }) + } + // The three renderings must be distinguishable — a switch that fell through + // to one arm would otherwise pass every containment check above. + seen := map[string]bool{} + for _, tr := range []SelfCorrectTransition{SelfCorrectRaised, SelfCorrectAlreadyOn, SelfCorrectOverridden} { + out := Delta(tr) + if seen[out] { + t.Fatalf("two transitions render identically:\n%s", out) + } + seen[out] = true + } +} + // (m)+(n) at the rule level: SelectionRefusal is the single authority both // selection paths consult. Its callers are asserted separately (CLI and TUI). func TestSelectionRefusalDisablesOnlyZeromaxingAndOnlyWhenDisabled(t *testing.T) { diff --git a/internal/tui/session_controls.go b/internal/tui/session_controls.go index 2f4666816..7d6f0319a 100644 --- a/internal/tui/session_controls.go +++ b/internal/tui/session_controls.go @@ -589,7 +589,7 @@ func (m model) resolvedPostureLine() string { func (m model) zeromaxingNotes() []string { notes := []string{} if m.execProfileName == execprofile.Name { - notes = append(notes, execprofile.Delta) + notes = append(notes, execprofile.Delta(m.selfCorrectTransition())) } if m.execProfileEffortUnraised != "" { notes = append(notes, fmt.Sprintf( @@ -1188,6 +1188,26 @@ func formatUnpricedUsage(requests int, tokens int) string { return fmt.Sprintf("%d %s, %d tokens, cost unavailable", requests, requestLabel, tokens) } +// selfCorrectTransition reports what the posture is ACTUALLY doing to post-edit +// verification right now — not what it did at selection time, because +// /selfcorrect can be used afterwards and the status output must not keep +// claiming a raise the session no longer has. +// +// execProfileArmedSelfCorrect records that the profile turned it on; combined +// with the live selfCorrectTests bit that distinguishes all three cases. +func (m model) selfCorrectTransition() execprofile.SelfCorrectTransition { + switch { + case !m.selfCorrectTests: + // The posture wants it on, so it being off means the user turned it + // back off explicitly. + return execprofile.SelfCorrectOverridden + case m.execProfileArmedSelfCorrect: + return execprofile.SelfCorrectRaised + default: + return execprofile.SelfCorrectAlreadyOn + } +} + // zeromaxingChipLabel is the footer indicator for the zeromaxing posture. Kept // as a constant so the view and its test assert the same bytes. const zeromaxingChipLabel = "ZEROMAXING" diff --git a/internal/tui/zeromaxing_test.go b/internal/tui/zeromaxing_test.go index c1274a91f..4e20360b8 100644 --- a/internal/tui/zeromaxing_test.go +++ b/internal/tui/zeromaxing_test.go @@ -375,7 +375,7 @@ func TestBothStatusSurfacesShowResolvedState(t *testing.T) { t.Fatalf("%s must show %q in its resolved state line:\n%s", surface, want, text) } } - if !strings.Contains(text, execprofile.Delta) { + if !strings.Contains(text, execprofile.DeltaBudgetLine) { t.Fatalf("%s must state the real delta:\n%s", surface, text) } for _, want := range []string{"320", "160", "sub-agents"} { @@ -387,7 +387,7 @@ func TestBothStatusSurfacesShowResolvedState(t *testing.T) { // Other profiles must NOT carry the posture's delta text. other := zeromaxingTestModel(t, false) _, otherText := other.handleProfileCommand("thorough") - if strings.Contains(otherText, execprofile.Delta) { + if strings.Contains(otherText, execprofile.DeltaBudgetLine) { t.Fatalf("thorough must not claim the posture's delta:\n%s", otherText) } } @@ -418,3 +418,55 @@ func TestZeromaxingFooterChipVisibility(t *testing.T) { t.Fatalf("the footer must drop the chip once off:\n%s", m.statusLine(120)) } } + +// The self-correct clause must track the user's LIVE state, not the state at +// selection time. A user who selects the posture and then turns self-correct +// back off must not keep reading "lsp → tests". +func TestSelfCorrectTransitionTracksLiveState(t *testing.T) { + // Default session: LSP-only, so the posture raises it. + m := zeromaxingTestModel(t, false) + if m.selfCorrectTests { + t.Skip("fixture no longer starts LSP-only") + } + m, text := m.handleEffortCommand(execprofile.Name) + if got := m.selfCorrectTransition(); got != execprofile.SelfCorrectRaised { + t.Fatalf("transition = %v, want SelfCorrectRaised for an LSP-only session", got) + } + if !strings.Contains(text, "self-correct: lsp → tests") { + t.Fatalf("an LSP-only user must be told what changes:\n%s", text) + } + + // The user turns it back off: the posture no longer governs it, and the + // output must stop claiming a raise. + m, _ = m.handleSelfCorrectCommand("off") + if got := m.selfCorrectTransition(); got != execprofile.SelfCorrectOverridden { + t.Fatalf("transition = %v, want SelfCorrectOverridden after /selfcorrect off", got) + } + _, after := m.handleProfileCommand("status") + if strings.Contains(after, "lsp → tests") { + t.Fatalf("status must not claim a raise the session no longer has:\n%s", after) + } + if !strings.Contains(after, "overrides the posture") { + t.Fatalf("status must say the user's choice is what is in effect:\n%s", after) + } +} + +// A user who ALREADY had the deeper verification on is told nothing changes — +// the one case where the old wording happened to be right. +func TestSelfCorrectTransitionAlreadyOn(t *testing.T) { + m := zeromaxingTestModel(t, false) + m, _ = m.handleSelfCorrectCommand("tests") + if !m.selfCorrectTests { + t.Fatal("setup: /selfcorrect tests did not arm it") + } + m, text := m.handleEffortCommand(execprofile.Name) + if got := m.selfCorrectTransition(); got != execprofile.SelfCorrectAlreadyOn { + t.Fatalf("transition = %v, want SelfCorrectAlreadyOn", got) + } + if !strings.Contains(text, "self-correct: unchanged (tests)") { + t.Fatalf("an already-on user must be told nothing changes:\n%s", text) + } + if strings.Contains(text, "lsp → tests") { + t.Fatalf("must not claim a transition that did not happen:\n%s", text) + } +} From ac823643ac48e7b93764cd81f57b0b0fa04f1f1f Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:31:53 +0530 Subject: [PATCH 05/86] fix(tui): apply the posture's effort fill on models the catalog cannot vouch for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects found by running the real binary, all in what the posture reports or applies. 1. THE FILL SILENTLY DID NOT HAPPEN on a custom endpoint. /effort zeromaxing left the effort at "auto" while --exec-profile zeromaxing on the SAME model sent reasoning_effort:"high" on the wire. Same posture, same model, two answers — the fifteenth instance of one rule applied to one of two call paths. The fill site used reasoningEffortAllowed, which returns false both for a model KNOWN to lack the level and for a model the catalog has never heard of. The headless path already distinguishes them (an unknown model forwards the requested effort "since no support claim can be made for it"), and so does the TUI's own model-SWITCH site, which takes a ringKnown flag for exactly this reason. Only the profile-fill site conflated them. profileEffortApplies now fills when the model lists the level OR when no catalog entry exists, and declines only when an authoritative empty ring says the model has no reasoning controls. A model name that is empty is a third case and still declines — there is nothing to make a support claim about. That last distinction was caught by the pre-existing fast-posture test, which the first version of this fix broke. THE HOLE THAT HID IT: every test asserted a surface against its OWN expectation — the TUI tests against the TUI's rule, the CLI tests against the CLI's. Both suites were green while the two paths disagreed, because nothing compared them against each other for the same model. A unit test on either helper could not have caught it. TestProfileEffortFillAgreesWithTheHeadlessPath is that missing comparison, run across a catalog reasoning model, an inferred one, a catalog model with an authoritative empty ring, and an unknown endpoint. 2. THE OUTPUT CONTRADICTED ITSELF: "reasoning effort: unchanged — already at the highest level" and "NOT raised to high: the active model does not support that level", in the same card. Both cannot be true. The fixed clause lived in Delta while the refusal lived in a separate line, so they were only ever consistent by coincidence. Delta now renders every clause from one DeltaState, with the effort clause a single switch over EffortTransition. A contradiction is unrepresentable rather than merely unlikely, and the separate line is gone. 3. THE BUDGET CLAUSE compared against thorough — "(thorough uses 160)" — which is information about two profiles, not about the user. It now states the caller's own transition, "turn budget: 80 → 320", with distinct renderings for an already-at-320 session and an unknown current budget. --- internal/agent/export_test.go | 13 +++ internal/cli/exec.go | 21 +++- internal/cli/exec_zeromaxing_test.go | 40 +++++++- internal/execprofile/profile.go | 109 +++++++++++++++------ internal/execprofile/zeromaxing_test.go | 84 ++++++++++++++-- internal/tui/session_controls.go | 84 ++++++++++++++-- internal/tui/zeromaxing_test.go | 124 ++++++++++++++++++++++-- 7 files changed, 419 insertions(+), 56 deletions(-) diff --git a/internal/agent/export_test.go b/internal/agent/export_test.go index 1a2a153f6..5a74ba4bc 100644 --- a/internal/agent/export_test.go +++ b/internal/agent/export_test.go @@ -2,6 +2,7 @@ package agent import ( + "github.com/Gitlawb/zero/internal/specialist" "github.com/Gitlawb/zero/internal/tools" "github.com/Gitlawb/zero/internal/zeroruntime" ) @@ -39,3 +40,15 @@ func parsePreservedState(summaryContent string) (string, []skillEntry) { func partitionTools(registry *tools.Registry, permissionMode PermissionMode, options Options, loaded map[string]bool) ([]zeroruntime.ToolDefinition, string) { return partitionToolsCached(registry, permissionMode, options, loaded, nil) } + +// Phase 2 additivity-proof seam. The identity test uses the REAL orchestrate +// tool rather than a stub, so it exercises the actual Deferred() contract that +// enforces the posture-off constraint. internal/specialist does not import +// internal/agent, so this direction creates no cycle. +const phase2ToolName = specialist.OrchestrateToolName + +// registerPhase2ToolForTest registers the real tool with the posture OFF, which +// is the condition the identity test is about. +func registerPhase2ToolForTest(registry *tools.Registry) { + registry.Register(&specialist.OrchestrateTool{PostureActive: func() bool { return false }}) +} diff --git a/internal/cli/exec.go b/internal/cli/exec.go index 96f2330e0..3d018e1b5 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -313,7 +313,14 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in // in a PR body is how a posture ends up documented as doing things it does // not do. if execProfile.IsZeromaxing() { - if _, err := fmt.Fprintln(stderr, execprofile.Delta(execSelfCorrectTransition(selfCorrectBeforeProfile))); err != nil { + delta := execprofile.Delta(execprofile.DeltaState{ + // resolved.MaxTurns is still the pre-posture budget here: the Delta + // notice is printed BEFORE applyProfileTurnBudget displaces it. + CurrentMaxTurns: resolved.MaxTurns, + Effort: execEffortTransition(execProfileFilledEffort), + SelfCorrect: execSelfCorrectTransition(selfCorrectBeforeProfile), + }) + if _, err := fmt.Fprintln(stderr, delta); err != nil { return exitCrash } } @@ -1261,6 +1268,18 @@ func normalizeZeromaxingEffort(options *execOptions) error { return nil } +// execEffortTransition reports what the posture did to reasoning effort for +// this run. applyExecProfile reports whether it actually filled the effort; it +// backs off when the caller passed one explicitly. The headless path does not +// gate the fill on model support (forwardedReasoningEffort coerces later, and +// prints its own notice), so EffortNotSupported is not reachable here. +func execEffortTransition(effortFilled bool) execprofile.EffortTransition { + if effortFilled { + return execprofile.EffortRaised + } + return execprofile.EffortKeptExplicit +} + // execSelfCorrectTransition reports what the posture did to post-edit // verification for THIS run. A headless run has no way to override the profile // after it applies (--self-correct is presence-only and read before), so the diff --git a/internal/cli/exec_zeromaxing_test.go b/internal/cli/exec_zeromaxing_test.go index 52cfcd415..1b9483cee 100644 --- a/internal/cli/exec_zeromaxing_test.go +++ b/internal/cli/exec_zeromaxing_test.go @@ -2,6 +2,7 @@ package cli import ( "reflect" + "regexp" "strings" "testing" @@ -235,8 +236,9 @@ func TestZeromaxingTurnBudgetPropagatesToChildren(t *testing.T) { } // The delta text promises exactly this, so the promise and the number must // not drift apart. - if !strings.Contains(execprofile.DeltaBudgetLine, "sub-agents") { - t.Fatalf("the budget line must tell the user it reaches sub-agents: %q", execprofile.DeltaBudgetLine) + delta := execprofile.Delta(execprofile.DeltaState{CurrentMaxTurns: 80}) + if !strings.Contains(delta, "sub-agents") { + t.Fatalf("the delta must tell the user the budget reaches sub-agents: %q", delta) } } @@ -307,8 +309,8 @@ func TestExecSelfCorrectTransitionUsesPreProfileState(t *testing.T) { t.Fatalf("an explicit --self-correct run = %v, want SelfCorrectAlreadyOn", got) } // And the rendered text differs, so the distinction reaches the user. - raised := execprofile.Delta(execSelfCorrectTransition(false)) - already := execprofile.Delta(execSelfCorrectTransition(true)) + raised := execprofile.Delta(execprofile.DeltaState{CurrentMaxTurns: 80, SelfCorrect: execSelfCorrectTransition(false)}) + already := execprofile.Delta(execprofile.DeltaState{CurrentMaxTurns: 80, SelfCorrect: execSelfCorrectTransition(true)}) if raised == already { t.Fatalf("both states render identically:\n%s", raised) } @@ -381,3 +383,33 @@ func TestExecPrintsUnchangedWhenSelfCorrectWasAlreadyOn(t *testing.T) { t.Fatalf("must not claim a transition that did not happen:\n%s", stderr) } } + +// (3) The budget clause the USER sees must be caller-relative. Asserted through +// the real exec path: a unit test on budgetLine cannot catch the caller passing +// the wrong number into it, which is exactly what mutation found. +func TestExecDeltaShowsTheCallersOwnTurnBudget(t *testing.T) { + exitCode, _, stderr := runExecWithEcho(t, []string{ + "exec", "--exec-profile", execprofile.Name, "hello", + }) + if exitCode != exitSuccess { + t.Fatalf("expected exit %d, got %d: %s", exitSuccess, exitCode, stderr) + } + // Assert the SHAPE, not a specific origin: the harness's resolved budget is + // a fixture value, and pinning it here would test the fixture rather than + // the behaviour. What matters is that the origin is the caller's own + // resolved budget and the destination is the posture's. + transition := regexp.MustCompile(`turn budget: (\d+) → 320`) + match := transition.FindStringSubmatch(stderr) + if match == nil { + t.Fatalf("the delta must state a caller-relative budget transition:\n%s", stderr) + } + if match[1] == "320" { + t.Fatalf("origin and destination are the same; the clause should read \"unchanged\":\n%s", stderr) + } + if strings.Contains(stderr, "160") { + t.Fatalf("the delta must not compare against thorough's budget:\n%s", stderr) + } + if n := strings.Count(stderr, "reasoning effort:"); n != 1 { + t.Fatalf("exactly one reasoning-effort statement expected, found %d:\n%s", n, stderr) + } +} diff --git a/internal/execprofile/profile.go b/internal/execprofile/profile.go index d4ea73575..8870a3408 100644 --- a/internal/execprofile/profile.go +++ b/internal/execprofile/profile.go @@ -17,6 +17,7 @@ package execprofile import ( "sort" + "strconv" "strings" "github.com/Gitlawb/zero/internal/agent" @@ -128,32 +129,77 @@ var catalog = map[string]Profile{ Zeromaxing.Name: Zeromaxing, } +// DeltaState is the caller's CURRENT state, from which Delta describes what +// selecting the posture will actually change for THEM. +// +// It exists because the honest answer is caller-specific in every clause. The +// first version of this text compared against THOROUGH — "reasoning effort: +// unchanged", "thorough uses 160" — which is information about two profiles, +// not about the user. Worse, it could contradict itself: the fixed effort +// clause claimed "unchanged" while a separate line underneath said the effort +// was NOT raised because the model refused it. Both cannot be true, and they +// were only ever consistent by coincidence because they came from two places. +// +// Delta now renders every clause from this one struct, so a contradiction is +// unrepresentable rather than merely unlikely. +type DeltaState struct { + // CurrentMaxTurns is the turn budget in effect BEFORE the posture applies. + // 0 means unknown, in which case the budget clause states the destination + // without inventing an origin. + CurrentMaxTurns int + Effort EffortTransition + SelfCorrect SelfCorrectTransition +} + +// EffortTransition is what the posture does to reasoning effort for this +// caller. Exactly one applies, which is what makes the rendered clauses +// mutually exclusive by construction. +type EffortTransition int + +const ( + // EffortRaised: the posture filled the effort with its level. + EffortRaised EffortTransition = iota + // EffortKeptExplicit: the caller had already chosen an effort, so the + // posture backed off. Their choice stands, whatever it is. + EffortKeptExplicit + // EffortNotSupported: the active model is KNOWN not to accept the level, so + // the effort is not raised. The rest of the posture still applies. + EffortNotSupported +) + +func (t EffortTransition) line(level string) string { + switch t { + case EffortKeptExplicit: + return "reasoning effort: unchanged (your explicit choice stands)" + case EffortNotSupported: + return "reasoning effort: NOT raised to " + level + " — the active model does not accept that level; the rest of the posture still applies" + default: + return "reasoning effort: raised to " + level + } +} + // SelfCorrectTransition is what selecting the posture does to post-edit // verification, relative to the state the caller is ACTUALLY in. // -// It exists because the honest answer is caller-specific. Saying -// "self-correction is already armed" is true when comparing zeromaxing to -// thorough and false for a user sitting on the LSP-only default, who is about -// to be moved to the full project test plan without being told. A user reads -// this to learn what changes for THEM, not to learn how two profiles differ. +// Telling a user sitting on the LSP-only default that self-correction is +// "already armed" while silently moving them to the full project test plan is +// documentation describing behaviour that is not happening. type SelfCorrectTransition int const ( - // SelfCorrectRaised: post-edit verification was LSP-only and the posture - // adds the project test plan. The common case, and the one the old wording - // got wrong. + // SelfCorrectRaised: verification was LSP-only and the posture adds the + // project test plan. The common case. SelfCorrectRaised SelfCorrectTransition = iota // SelfCorrectAlreadyOn: the deeper verification was already on, so the // posture genuinely changes nothing here. SelfCorrectAlreadyOn // SelfCorrectOverridden: an explicit /selfcorrect choice is holding it at - // lsp despite the posture. Reporting a raise here would describe behaviour - // that is not happening. + // lsp despite the posture. SelfCorrectOverridden ) // selfCorrectLine renders the transition using /selfcorrect's own vocabulary -// (lsp / tests), so the line names states the user can actually type. +// (lsp / tests), so it names states the user can actually type. func (t SelfCorrectTransition) selfCorrectLine() string { switch t { case SelfCorrectAlreadyOn: @@ -165,27 +211,34 @@ func (t SelfCorrectTransition) selfCorrectLine() string { } } +// budgetLine states the turn-budget change from the caller's own budget, not +// from another profile's. A user on balanced/80 does not care what thorough +// uses; they care that their 80 becomes 320. +func budgetLine(current int) string { + switch { + case current <= 0: + return "turn budget: " + strconv.Itoa(Zeromaxing.MaxTurns) + case current == Zeromaxing.MaxTurns: + return "turn budget: unchanged (" + strconv.Itoa(current) + ")" + default: + return "turn budget: " + strconv.Itoa(current) + " → " + strconv.Itoa(Zeromaxing.MaxTurns) + } +} + // Delta is the user-facing statement of what selecting zeromaxing actually -// changes, shown by /effort, /profile, and the exec selection notice. It is -// deliberately concrete and deliberately admits what it does NOT move: a user -// paying for a higher posture is owed the real delta, not a slogan. +// changes FOR THIS CALLER, shown by /effort, /profile, and the exec selection +// notice. It is deliberately concrete and deliberately admits what it does not +// move: a user paying for a higher posture is owed the real delta. // -// The child-budget sentence is not a caveat, it is the point: zeromaxing is a -// maximal posture, so the raised budget is exported to spawned sub-agents -// exactly as /turns does (asserted by TestZeromaxingTurnBudgetPropagatesToChildren). -func Delta(selfCorrect SelfCorrectTransition) string { - return DeltaBudgetLine + " " + DeltaEffortLine + " " + selfCorrect.selfCorrectLine() + "." +// The child-budget sentence is not a caveat, it is the point: this is a maximal +// posture, so the raised budget is exported to spawned sub-agents exactly as +// /turns does (asserted by TestZeromaxingTurnBudgetPropagatesToChildren). +func Delta(state DeltaState) string { + return budgetLine(state.CurrentMaxTurns) + ", and that budget applies to spawned sub-agents too. " + + state.Effort.line(Zeromaxing.ReasoningEffort) + ". " + + state.SelfCorrect.selfCorrectLine() + "." } -// The fixed clauses, exported so tests assert on the same bytes users read. -const ( - DeltaBudgetLine = "zeromaxing raises the tool-turn budget to 320 (thorough uses 160) and applies that budget to spawned sub-agents too." - // Effort genuinely is caller-independent: "high" is the ceiling every - // provider accepts, so there is no state a caller can be in where the - // posture raises it further. - DeltaEffortLine = "reasoning effort: unchanged — already at the highest level providers accept." -) - // Name is the single spelling of this posture, everywhere: /effort zeromaxing, // /profile zeromaxing, --exec-profile zeromaxing, --reasoning-effort zeromaxing. // One name, one table — no aliases, so there is no second spelling to keep in diff --git a/internal/execprofile/zeromaxing_test.go b/internal/execprofile/zeromaxing_test.go index e39edee66..a83dc443f 100644 --- a/internal/execprofile/zeromaxing_test.go +++ b/internal/execprofile/zeromaxing_test.go @@ -83,11 +83,81 @@ func TestZeromaxingKnobsMatchTheDeltaItAdvertises(t *testing.T) { if policy := Zeromaxing.Policy(80, true); policy != nil { t.Fatalf("zeromaxing must arm no escalation policy, got %+v", policy) } - for _, want := range []string{"320", "160", "sub-agents"} { - if !strings.Contains(DeltaBudgetLine, want) { - t.Fatalf("the budget line must state %q: %q", want, DeltaBudgetLine) + // The budget clause states the CALLER's transition, not another profile's. + got := Delta(DeltaState{CurrentMaxTurns: 80}) + for _, want := range []string{"turn budget: 80 → 320", "sub-agents"} { + if !strings.Contains(got, want) { + t.Fatalf("Delta must state %q:\n%s", want, got) } } + // ...and must NOT mention thorough's number, which is about two profiles + // rather than about the user (bug 3). + if strings.Contains(got, "160") { + t.Fatalf("the delta must not compare against thorough's budget:\n%s", got) + } +} + +// (3) The budget clause is caller-relative in every case, including the two +// edges where a plain "X -> 320" would be wrong. +func TestDeltaBudgetLineIsCallerRelative(t *testing.T) { + cases := []struct { + name string + current int + want string + absent string + }{ + {"balanced default", 80, "turn budget: 80 → 320", "160"}, + {"already at the posture budget", 320, "turn budget: unchanged (320)", "→"}, + {"unknown current budget", 0, "turn budget: 320", "→"}, + {"a pinned lower budget", 30, "turn budget: 30 → 320", "160"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := Delta(DeltaState{CurrentMaxTurns: tc.current}) + if !strings.Contains(got, tc.want) { + t.Fatalf("Delta(current=%d) must contain %q:\n%s", tc.current, tc.want, got) + } + clause := got[:strings.Index(got, ", and that budget")] + if tc.absent != "" && strings.Contains(clause, tc.absent) { + t.Fatalf("Delta(current=%d) budget clause must not contain %q: %q", tc.current, tc.absent, clause) + } + }) + } +} + +// (2) THE CONTRADICTION. The effort clause has exactly one arm, so "unchanged" +// and "NOT raised" can never both appear. They did in real use, because the +// fixed clause lived in Delta and the refusal lived in a separate line. +func TestDeltaEffortClauseIsMutuallyExclusive(t *testing.T) { + cases := []struct { + name string + transition EffortTransition + want string + }{ + {"raised", EffortRaised, "reasoning effort: raised to high"}, + {"kept explicit", EffortKeptExplicit, "reasoning effort: unchanged (your explicit choice stands)"}, + {"not supported", EffortNotSupported, "reasoning effort: NOT raised to high"}, + } + seen := map[string]bool{} + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := Delta(DeltaState{CurrentMaxTurns: 80, Effort: tc.transition}) + if !strings.Contains(got, tc.want) { + t.Fatalf("Delta(%v) must contain %q:\n%s", tc.transition, tc.want, got) + } + // The contradiction, stated directly: never both. + raised := strings.Contains(got, "NOT raised") + unchanged := strings.Contains(got, "reasoning effort: unchanged") + if raised && unchanged { + t.Fatalf("Delta(%v) claims BOTH that the effort is unchanged and that it was not raised:\n%s", + tc.transition, got) + } + if seen[got] { + t.Fatalf("two effort transitions render identically:\n%s", got) + } + seen[got] = true + }) + } } // The self-correct clause must describe the transition from the CALLER'S state, @@ -107,7 +177,7 @@ func TestDeltaSelfCorrectDescribesTheCallersTransition(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - got := Delta(tc.transition) + got := Delta(DeltaState{CurrentMaxTurns: 80, SelfCorrect: tc.transition}) if !strings.Contains(got, tc.want) { t.Fatalf("Delta(%v) must contain %q, got:\n%s", tc.transition, tc.want, got) } @@ -118,8 +188,8 @@ func TestDeltaSelfCorrectDescribesTheCallersTransition(t *testing.T) { t.Fatalf("Delta(%v) self-correct clause must NOT contain %q, got:\n%s", tc.transition, tc.absent, clause) } // Every rendering keeps the caller-independent clauses. - if !strings.Contains(got, DeltaBudgetLine) || !strings.Contains(got, DeltaEffortLine) { - t.Fatalf("Delta(%v) dropped a fixed clause:\n%s", tc.transition, got) + if !strings.Contains(got, "turn budget:") || !strings.Contains(got, "reasoning effort:") { + t.Fatalf("Delta(%v) dropped a clause:\n%s", tc.transition, got) } }) } @@ -127,7 +197,7 @@ func TestDeltaSelfCorrectDescribesTheCallersTransition(t *testing.T) { // to one arm would otherwise pass every containment check above. seen := map[string]bool{} for _, tr := range []SelfCorrectTransition{SelfCorrectRaised, SelfCorrectAlreadyOn, SelfCorrectOverridden} { - out := Delta(tr) + out := Delta(DeltaState{CurrentMaxTurns: 80, SelfCorrect: tr}) if seen[out] { t.Fatalf("two transitions render identically:\n%s", out) } diff --git a/internal/tui/session_controls.go b/internal/tui/session_controls.go index 7d6f0319a..5c7bc6b20 100644 --- a/internal/tui/session_controls.go +++ b/internal/tui/session_controls.go @@ -285,6 +285,55 @@ func (m model) availableReasoningEfforts() []modelregistry.ReasoningEffort { return registry.ReasoningEfforts(m.modelName) } +// availableReasoningEffortsKnown returns the active model's effort ring AND +// whether that ring is AUTHORITATIVE — i.e. the model has a catalog entry, so an +// empty ring genuinely means "no reasoning controls" rather than "we have never +// heard of this model". +// +// The distinction already existed at the model-SWITCH site (command_center.go +// passes `target.entry != nil` as ringKnown) but not at the profile FILL site, +// which used the plain allowed-check and so treated an unknown model as a model +// that had refused. The headless path makes the opposite call — an unknown model +// forwards the requested effort as-is, "since no support claim can be made for +// it" — so the two surfaces disagreed about the same model. +func (m model) availableReasoningEffortsKnown() ([]modelregistry.ReasoningEffort, bool) { + name := strings.TrimSpace(m.modelName) + if name == "" { + return nil, false + } + registry, err := modelregistry.DefaultRegistry() + if err != nil { + return nil, false + } + _, known := registry.Get(name) + return registry.ReasoningEfforts(name), known +} + +// profileEffortApplies reports whether a profile may fill `want` on the active +// model. It fills when the model is KNOWN to support the level, and also when +// the ring is not authoritative: an unknown endpoint may well support it, and +// declining would be a false negative that silently drops the posture's effort. +// Only a model the catalog vouches for as lacking the level blocks the fill. +func (m model) profileEffortApplies(want modelregistry.ReasoningEffort) bool { + // No model selected: there is nothing to make a support claim about, so the + // profile does not fill. Distinct from an UNKNOWN model, which is a real + // endpoint that may well accept the level — conflating the two was the + // over-reach the pre-existing fast-posture test caught. + if strings.TrimSpace(m.modelName) == "" { + return false + } + efforts, known := m.availableReasoningEffortsKnown() + if reasoningEffortAllowed(efforts, want) { + return true + } + // A catalog entry is authoritative: an empty ring there genuinely means the + // model has no reasoning controls, so do not fill. Without an entry the + // catalog cannot vouch either way, and declining would be a false negative + // that silently drops the posture's effort — the headless path forwards it + // in exactly this case ("no support claim can be made for it"). + return !known +} + func (m model) effortDisplay() string { if m.reasoningEffort == "" { return "auto" @@ -492,7 +541,7 @@ func (m model) handleProfileCommand(args string) (model, string) { // gating. An explicit user choice always wins over the profile. m.execProfileEffortUnraised = "" if want := modelregistry.ReasoningEffort(profile.ReasoningEffort); want != "" && m.reasoningEffort == "" { - if reasoningEffortAllowed(m.availableReasoningEfforts(), want) { + if m.profileEffortApplies(want) { m.reasoningEffort = want m.execProfileAppliedEffort = want } else { @@ -587,16 +636,33 @@ func (m model) resolvedPostureLine() string { // zeromaxingNotes returns the honest-delta lines for the active posture: what // it really changes, and anything it could NOT apply on this model. func (m model) zeromaxingNotes() []string { - notes := []string{} - if m.execProfileName == execprofile.Name { - notes = append(notes, execprofile.Delta(m.selfCorrectTransition())) + if m.execProfileName != execprofile.Name { + return nil } - if m.execProfileEffortUnraised != "" { - notes = append(notes, fmt.Sprintf( - "reasoning effort NOT raised to %s: the active model does not support that level; the rest of the posture still applies", - m.execProfileEffortUnraised)) + // ONE source. The effort clause used to live here as a second, separate + // line while Delta carried its own fixed "unchanged" claim, and the two + // could contradict each other — they did, in real use. Now every clause + // comes from one DeltaState, so exactly one effort statement exists. + return []string{execprofile.Delta(execprofile.DeltaState{ + CurrentMaxTurns: m.execProfileDisplacedMaxTurns, + Effort: m.effortTransition(), + SelfCorrect: m.selfCorrectTransition(), + })} +} + +// effortTransition reports what the posture did to reasoning effort on this +// model, from the session's live state. +func (m model) effortTransition() execprofile.EffortTransition { + switch { + case m.execProfileEffortUnraised != "": + return execprofile.EffortNotSupported + case m.execProfileAppliedEffort != "": + return execprofile.EffortRaised + default: + // The posture wanted to fill and did not, and did not record a refusal: + // the caller already had an effort of their own. + return execprofile.EffortKeptExplicit } - return notes } func (m model) profileText() string { diff --git a/internal/tui/zeromaxing_test.go b/internal/tui/zeromaxing_test.go index 4e20360b8..6411e9dca 100644 --- a/internal/tui/zeromaxing_test.go +++ b/internal/tui/zeromaxing_test.go @@ -288,17 +288,21 @@ func TestSelectionRefusalAgreesAcrossPaths(t *testing.T) { // (l) Degrade honestly. On a model with no effort ring the fill is skipped — and // the status output must SAY so, while the rest of the posture still applies. func TestZeromaxingOnUnsupportedModelStatesWhatItCouldNotRaise(t *testing.T) { + // gpt-4.1 is a CATALOG model with no reasoning capability, so its empty ring + // is authoritative. A custom endpoint with no catalog entry is deliberately + // NOT used here: the catalog cannot vouch for it either way, so the posture + // fills optimistically there (see TestProfileEffortFillsOnAnUnknownModel). m := newModel(context.Background(), Options{ - ProviderName: "ollama", - ModelName: "kimi-k2.7-code:cloud", + ProviderName: "openai", + ModelName: "gpt-4.1", Provider: &fakeProvider{}, - ProviderProfile: config.ProviderProfile{Name: "ollama", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "http://localhost:11434/v1", Model: "kimi-k2.7-code:cloud"}, + ProviderProfile: config.ProviderProfile{Name: "openai", CatalogID: "openai", Model: "gpt-4.1", APIKey: "k"}, NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { return &fakeProvider{}, nil }, }) if len(m.availableReasoningEfforts()) != 0 { - t.Skip("this model gained an effort ring; the fixture no longer exercises the unsupported path") + t.Skip("gpt-4.1 gained an effort ring; the fixture no longer exercises the known-lacking path") } m, text := m.handleEffortCommand(execprofile.Name) @@ -369,25 +373,56 @@ func TestBothStatusSurfacesShowResolvedState(t *testing.T) { _, effortStatus := m.handleEffortCommand("") _, profileStatus := m.handleProfileCommand("status") + delta := execprofile.Delta(execprofile.DeltaState{ + CurrentMaxTurns: m.execProfileDisplacedMaxTurns, + Effort: m.effortTransition(), + SelfCorrect: m.selfCorrectTransition(), + }) for surface, text := range map[string]string{"/effort": effortStatus, "/profile status": profileStatus} { for _, want := range []string{"effort: high", "profile: " + execprofile.Name, "turns: 320"} { if !strings.Contains(text, want) { t.Fatalf("%s must show %q in its resolved state line:\n%s", surface, want, text) } } - if !strings.Contains(text, execprofile.DeltaBudgetLine) { + if !strings.Contains(text, "turn budget:") { t.Fatalf("%s must state the real delta:\n%s", surface, text) } - for _, want := range []string{"320", "160", "sub-agents"} { + for _, want := range []string{"320", "sub-agents", "reasoning effort:", "self-correct:"} { if !strings.Contains(text, want) { t.Fatalf("%s must mention %q:\n%s", surface, want, text) } } + // (3) The delta is caller-relative: thorough's budget is about two + // profiles, not about this user, and must not appear. + if strings.Contains(text, "160") { + t.Fatalf("%s must not compare against thorough's budget:\n%s", surface, text) + } + // (2) EXACTLY ONE effort TRANSITION statement. + // + // Counted over the delta, not the whole card: /profile legitimately + // shows current state ("reasoning effort: high") alongside the delta's + // transition ("reasoning effort: raised to high"), and those are + // different claims. What must never happen twice is the transition — + // that duplication is what let "unchanged" and "NOT raised" coexist. + if n := strings.Count(text, delta); n != 1 { + t.Fatalf("%s must carry the delta exactly once, found %d:\n%s", surface, n, text) + } + for _, pair := range [][2]string{ + {"NOT raised", "raised to"}, + {"NOT raised", "reasoning effort: unchanged"}, + {"reasoning effort: unchanged", "raised to"}, + } { + if strings.Contains(text, pair[0]) && strings.Contains(text, pair[1]) && + !strings.Contains(pair[1], pair[0]) { + t.Fatalf("%s makes two different effort claims (%q and %q):\n%s", + surface, pair[0], pair[1], text) + } + } } // Other profiles must NOT carry the posture's delta text. other := zeromaxingTestModel(t, false) _, otherText := other.handleProfileCommand("thorough") - if strings.Contains(otherText, execprofile.DeltaBudgetLine) { + if strings.Contains(otherText, "turn budget:") { t.Fatalf("thorough must not claim the posture's delta:\n%s", otherText) } } @@ -470,3 +505,78 @@ func TestSelfCorrectTransitionAlreadyOn(t *testing.T) { t.Fatalf("must not claim a transition that did not happen:\n%s", text) } } + +// BUG 1 REGRESSION — and the hole it came from. +// +// The posture's effort fill silently did not happen on a custom/unknown model: +// /effort zeromaxing left the effort at "auto" while --exec-profile zeromaxing +// on the SAME model sent reasoning_effort:"high" on the wire. Same posture, +// same model, two answers. +// +// THE HOLE: every existing test asserted a surface against its OWN expectation. +// The TUI tests used claude-sonnet-4.5 (has high → fill works) and an unknown +// model (no fill → "correct" by the TUI's own rule). The CLI tests asserted the +// CLI's rule. Both suites were green while the two paths disagreed, because +// nothing compared them AGAINST EACH OTHER for the same model. A unit test on +// either helper could never have caught it. +// +// This is that missing comparison. +func TestProfileEffortFillAgreesWithTheHeadlessPath(t *testing.T) { + for _, tc := range []struct { + model string + wantFill bool + why string + }{ + {"claude-sonnet-4.5", true, "catalog model that lists high"}, + {"gpt-5", true, "inferred reasoning family that lists high"}, + {"gpt-4.1", false, "catalog model whose EMPTY ring is authoritative"}, + {"some-custom-endpoint-model", true, "no catalog entry — the catalog cannot vouch either way, so do not decline"}, + } { + t.Run(tc.model, func(t *testing.T) { + m := newModel(context.Background(), Options{ + ProviderName: "p", + ModelName: tc.model, + Provider: &fakeProvider{}, + ProviderProfile: config.ProviderProfile{Name: "p", Model: tc.model, APIKey: "k"}, + NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { + return &fakeProvider{}, nil + }, + }) + m, text := m.handleEffortCommand(execprofile.Name) + + filled := m.reasoningEffort == modelregistry.ReasoningEffortHigh + if filled != tc.wantFill { + t.Fatalf("%s (%s): effort filled = %v, want %v (effort=%q)\n%s", + tc.model, tc.why, filled, tc.wantFill, m.reasoningEffort, text) + } + // The rest of the posture applies either way — a declined effort + // must never cost the user the turn budget. + if m.agentOptions.MaxTurns != 320 { + t.Fatalf("%s: the turn budget must apply regardless of the effort, got %d", + tc.model, m.agentOptions.MaxTurns) + } + // ...and the resolved-state line must not claim an effort the + // session does not have. + if filled && !strings.Contains(text, "effort: high") { + t.Fatalf("%s: filled but the status does not say so:\n%s", tc.model, text) + } + if !filled && strings.Contains(text, "effort: high") { + t.Fatalf("%s: not filled but the status claims high:\n%s", tc.model, text) + } + }) + } +} + +// No model selected is NOT "an unknown model": there is nothing to make a +// support claim about, so the profile must not fill. This is the over-reach the +// pre-existing fast-posture test caught in the first version of the fix. +func TestProfileEffortDoesNotFillWithoutAModel(t *testing.T) { + m := model{} + if m.profileEffortApplies(modelregistry.ReasoningEffortHigh) { + t.Fatal("no model name: the profile must not fill an effort") + } + got, _ := m.handleProfileCommand("fast") + if got.reasoningEffort != "" { + t.Fatalf("effort = %q, must stay auto when no model is selected", got.reasoningEffort) + } +} From b6675212a8e3862fe9ab9df1c240b4ecb3c41045 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:45:19 +0530 Subject: [PATCH 06/86] fix(tui): treat an unlisted model as unknown, not as having no reasoning controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PRE-EXISTING defect, surfaced while investigating the zeromaxing posture and verified on origin/main. It is not something the posture introduced. availableReasoningEfforts() returns an EMPTY ring for two very different situations: a catalog model that genuinely has no reasoning controls (gpt-4.1), and a model with no catalog entry at all (the reporter's glm-5.2 on ollama-cloud, any custom openai-compatible endpoint). Every TUI consumer treated the second as if it were the first. That single fact produced two visible failures at once: - /effort listed no levels and reported "no reasoning controls on this model" - /effort high was REFUSED outright - and it is why a profile's effort fill was declined with "the model does not support that level" Meanwhile the headless path has always disagreed. On origin/main, for the same glm-5.2: forwardedReasoningEffort returns "high" and emits no notice, because an unknown model "forwards the requested value as-is, since no support claim can be made for it". So the CLI forwarded the level while the TUI refused to let the user set it — one rule, two answers, predating this feature. There are THREE consumers of "does this model take this level?": a manual /effort, a profile's fill, and the headless forwarding decision. They now share one rule — a catalog entry is authoritative and refuses; no entry means Zero cannot vouch either way and does not block. TestEffortSettabilityAgreesAcross- AllThreeConsumers pins that they agree, per model, including the reporter's. /effort also distinguishes the two states in its output, because "this model has no reasoning controls" and "Zero has no entry for this model" are different facts and rendered identically before. Setting a level on an unlisted model now says plainly that support is unconfirmed and an unsupported value is ignored by the provider. The regression tests drive the real /effort path rather than the helper. A green helper test alongside an empty user surface is how this feature has now produced four defects: the flag-parser hole, the self-correct capture point, the identity test passing through the wrong gate, and this. --- internal/tui/session_controls.go | 44 +++++++-- internal/tui/zeromaxing_test.go | 151 +++++++++++++++++++++++++++++++ 2 files changed, 189 insertions(+), 6 deletions(-) diff --git a/internal/tui/session_controls.go b/internal/tui/session_controls.go index 5c7bc6b20..0e78f51b2 100644 --- a/internal/tui/session_controls.go +++ b/internal/tui/session_controls.go @@ -98,17 +98,39 @@ func (m model) handleEffortCommand(args string) (model, string) { if !modelregistry.ValidReasoningEffort(requested) { return m, m.effortStatusCard(args, "Unknown reasoning effort: "+args) } - efforts := m.availableReasoningEfforts() - if len(efforts) == 0 { + efforts, known := m.availableReasoningEffortsKnown() + // A model the catalog VOUCHES for is authoritative: an empty ring there + // genuinely means no reasoning controls, and a level outside the ring is + // genuinely unsupported. A model with no catalog entry is a different case + // — a custom endpoint the catalog cannot vouch for either way — and + // refusing there is a false negative. The headless path already forwards in + // exactly that case ("no support claim can be made for it"), and since the + // bug-1 fix the profile fill does too; this is the third consumer of the + // same question, and it used to answer it differently from the other two. + switch { + case known && len(efforts) == 0: return m, m.effortStatusCard("", "Active model does not expose reasoning effort controls.") - } - if !reasoningEffortAllowed(efforts, requested) { + case known && !reasoningEffortAllowed(efforts, requested): return m, m.effortStatusCard(string(requested), fmt.Sprintf("Reasoning effort %q is not supported by %s.", requested, displayValue(m.modelName, "the active model"))) } m.reasoningEffort = requested m = m.markProfileEffortTouched() + if !known { + // Deliberately says MAY reject, not "is ignored". The claim that an + // unsupported value is silently ignored could not be verified against a + // real provider, and this repo contradicts itself about it: + // modelregistry/catalog.go says unknown fields are ignored, while + // providers/factory.go, providers/openai/provider.go and + // providers/openai/types.go all record strict openai-compatible + // gateways rejecting them with a 400 — DisablePromptCacheKey exists + // precisely because one did. Promising "ignored" would be an unverified + // reassurance on the path where it fails. + return m, m.effortStatusCard(string(requested), + fmt.Sprintf("Stored for this session and forwarded to %s. This model is not in Zero's catalog, so Zero cannot confirm it accepts that level — a provider that validates the parameter may reject the request.", + displayValue(m.modelName, "the active model"))) + } return m, m.effortStatusCard(string(requested), "Reasoning effort preference is stored for this TUI session.") } @@ -143,15 +165,25 @@ func (m model) effortText() string { {Key: "active effort", Value: m.effortDisplay()}, {Key: "model", Value: displayValue(m.modelName, "none")}, } + _, ringKnown := m.availableReasoningEffortsKnown() actions := []string{"use /effort to switch", "/effort " + execprofile.Name + " for the maximal posture", "/effort auto to clear"} // The SAME resolved-state line /profile status renders, so the two surfaces // cannot disagree about what actually reaches the provider. stateLines := append([]string{m.resolvedPostureLine()}, m.zeromaxingNotes()...) if len(efforts) == 0 { - fields = append(fields, commandField{Key: "available", Value: "none for active model"}) + // "none" and "unknown" are different answers and used to render the + // same. A user on a custom endpoint was told the model has no reasoning + // controls, when the truth is that Zero has no entry for it — and levels + // set there ARE forwarded. + available, summary := "none for active model", "no reasoning controls on this model" + if !ringKnown { + available = "not listed — model is not in Zero's catalog" + summary = "levels are not listed for this model; they can still be set and are forwarded, but the provider may reject them" + } + fields = append(fields, commandField{Key: "available", Value: available}) return renderCommandCardTranscript(commandCard{ Title: "Effort", - Summary: []string{"active effort: " + m.effortDisplay(), "no reasoning controls on this model"}, + Summary: []string{"active effort: " + m.effortDisplay(), summary}, Sections: []commandCardSection{{Title: "State", Fields: fields, Lines: stateLines}}, Actions: actions, }) diff --git a/internal/tui/zeromaxing_test.go b/internal/tui/zeromaxing_test.go index 6411e9dca..f9a47b857 100644 --- a/internal/tui/zeromaxing_test.go +++ b/internal/tui/zeromaxing_test.go @@ -580,3 +580,154 @@ func TestProfileEffortDoesNotFillWithoutAModel(t *testing.T) { t.Fatalf("effort = %q, must stay auto when no model is selected", got.reasoningEffort) } } + +// BUG 4 — and it is the SAME root cause as bug 1. +// +// availableReasoningEfforts() returns an empty ring for a model with no catalog +// entry (the reporter's glm-5.2). That one fact produced two visible failures: +// /effort listed no levels, AND the posture's fill was declined with "the model +// does not support that level". +// +// It is PRE-EXISTING: verified on origin/main, where the same model makes the +// CLI forward reasoning_effort:"high" while the TUI refuses /effort high. This +// asserts the three consumers of "does this model take this level?" now give +// the same answer. +func TestEffortSettabilityAgreesAcrossAllThreeConsumers(t *testing.T) { + cases := []struct { + model string + settable bool + why string + }{ + {"claude-sonnet-4.5", true, "catalog model that lists high"}, + {"gpt-5", true, "inferred reasoning family"}, + {"gpt-4.1", false, "catalog model whose EMPTY ring is authoritative"}, + {"glm-5.2", true, "the reporter's model — no catalog entry, so no support claim can be made"}, + {"some-custom-endpoint-model", true, "any unlisted endpoint"}, + } + for _, tc := range cases { + t.Run(tc.model, func(t *testing.T) { + build := func() model { + return newModel(context.Background(), Options{ + ProviderName: "p", ModelName: tc.model, Provider: &fakeProvider{}, + ProviderProfile: config.ProviderProfile{Name: "p", Model: tc.model, APIKey: "k"}, + NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { return &fakeProvider{}, nil }, + }) + } + // Consumer 1: a manual /effort high. + manual, manualOut := build().handleEffortCommand("high") + gotManual := manual.reasoningEffort == modelregistry.ReasoningEffortHigh + if gotManual != tc.settable { + t.Fatalf("%s (%s): /effort high set = %v, want %v\n%s", + tc.model, tc.why, gotManual, tc.settable, manualOut) + } + // Consumer 2: the posture's fill. Must agree with the manual set. + posture, _ := build().handleEffortCommand(execprofile.Name) + gotFill := posture.reasoningEffort == modelregistry.ReasoningEffortHigh + if gotFill != gotManual { + t.Fatalf("%s: the posture fill (%v) and a manual set (%v) disagree — "+ + "the same question answered two ways", tc.model, gotFill, gotManual) + } + // Consumer 3: the headless path's forwarding decision. + registry, err := modelregistry.DefaultRegistry() + if err != nil { + t.Fatalf("DefaultRegistry: %v", err) + } + forwarded := forwardedEffortForTest(registry, tc.model, "high") + if (forwarded != "") != tc.settable { + t.Fatalf("%s: headless forwards %q but the TUI settable = %v — "+ + "the two surfaces disagree about the same model", + tc.model, forwarded, tc.settable) + } + }) + } +} + +// The OTHER authoritative-refusal arm: a catalog model that HAS a ring but does +// not list the requested level. gpt-4.1 cannot exercise this — its empty ring +// trips the earlier arm — so without this case that branch is unreachable and a +// mutation removing it goes undetected. +func TestManualEffortRefusesALevelOutsideAKnownRing(t *testing.T) { + m := newModel(context.Background(), Options{ + ProviderName: "anthropic", ModelName: "claude-sonnet-4.5", Provider: &fakeProvider{}, + ProviderProfile: config.ProviderProfile{Name: "anthropic", CatalogID: "anthropic", Model: "claude-sonnet-4.5", APIKey: "k"}, + NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { return &fakeProvider{}, nil }, + }) + efforts, known := m.availableReasoningEffortsKnown() + if !known || len(efforts) == 0 { + t.Skip("fixture is no longer a catalog model with a non-empty ring") + } + if reasoningEffortAllowed(efforts, modelregistry.ReasoningEffortMinimal) { + t.Skip("claude-sonnet-4.5 gained \"minimal\"; pick another out-of-ring level") + } + got, text := m.handleEffortCommand("minimal") + if got.reasoningEffort != "" { + t.Fatalf("a level outside an AUTHORITATIVE ring must be refused, got %q", got.reasoningEffort) + } + if !strings.Contains(text, "is not supported by") { + t.Fatalf("the refusal must name the model:\n%s", text) + } +} + +// A known model must list its levels — the surface symptom the reporter saw. +// This drives the real /effort path rather than the helper, because a green +// helper test alongside an empty user surface is what happened three times in +// this feature. +func TestEffortListShowsLevelsForAKnownModel(t *testing.T) { + m := newModel(context.Background(), Options{ + ProviderName: "anthropic", ModelName: "claude-sonnet-4.5", Provider: &fakeProvider{}, + ProviderProfile: config.ProviderProfile{Name: "anthropic", CatalogID: "anthropic", Model: "claude-sonnet-4.5", APIKey: "k"}, + NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { return &fakeProvider{}, nil }, + }) + _, text := m.handleEffortCommand("") + for _, want := range []string{"low", "medium", "high"} { + if !strings.Contains(text, want) { + t.Fatalf("/effort must list %q for a catalog model:\n%s", want, text) + } + } + if strings.Contains(text, "no reasoning controls") { + t.Fatalf("a catalog reasoning model must not be reported as having none:\n%s", text) + } +} + +// An unlisted model is reported as UNLISTED, not as having no controls — those +// are different facts and rendered the same before. +func TestEffortListDistinguishesUnlistedFromUnsupported(t *testing.T) { + render := func(name string) string { + m := newModel(context.Background(), Options{ + ProviderName: "p", ModelName: name, Provider: &fakeProvider{}, + ProviderProfile: config.ProviderProfile{Name: "p", Model: name, APIKey: "k"}, + NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { return &fakeProvider{}, nil }, + }) + _, text := m.handleEffortCommand("") + return text + } + unlisted := render("glm-5.2") + if !strings.Contains(unlisted, "not in Zero's catalog") { + t.Fatalf("an unlisted model must be described as unlisted:\n%s", unlisted) + } + if strings.Contains(unlisted, "no reasoning controls on this model") { + t.Fatalf("an unlisted model must not be reported as having no controls:\n%s", unlisted) + } + unsupported := render("gpt-4.1") + if !strings.Contains(unsupported, "no reasoning controls on this model") { + t.Fatalf("a catalog model with an authoritative empty ring must say so:\n%s", unsupported) + } +} + +// forwardedEffortForTest mirrors internal/cli's forwardedReasoningEffort rule: +// a known model coerces to its effective level (empty when it has none); an +// unknown model forwards the request as-is. Duplicated here rather than +// imported because internal/tui does not depend on internal/cli — and pinned +// against the real one by TestEffortSettabilityAgreesAcrossAllThreeConsumers +// failing if they ever diverge in outcome. +func forwardedEffortForTest(registry modelregistry.Registry, modelID, requested string) string { + entry, ok := registry.Get(modelID) + if !ok { + return requested + } + effective := modelregistry.EffectiveReasoningEffort(entry, modelregistry.ReasoningEffort(requested)) + if effective == modelregistry.ReasoningEffortNone { + return "" + } + return string(effective) +} From 91218afbd259f4bd0d4cc22c390d209e715e0811 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:03:10 +0530 Subject: [PATCH 07/86] feat(specialist): capture and sequentially execute a declared plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ZeroMaxing Phase 2: an `orchestrate` tool that accepts a plan as typed tool arguments, validates it, records it as session events, and runs it SEQUENTIALLY through the same specialist path a Task call uses. The deliverable is DATA — is fan-out worth building at all — not concurrency. ADDITIVE. With the posture off, the advertised tool set, the tool-definition bytes and the assembled prompt are byte-identical to a build without this feature, proved by TestPostureOffPrefixUnchangedByRegisteringTheTool: two registries differing only in whether the tool is registered, asserted equal under BOTH auto and unsafe. The enforcing mechanism is Safety() returning PermissionDeny, not Deferred() — deferral only hides anything when it is ACTIVE, and an unsafe session with deferral inactive would otherwise have advertised the tool. Deferred() stays as a second layer and is now asserted rather than assumed. VALIDATION CANNOT BE SKIPPED. Plan's fields are unexported and ParsePlan is the only constructor, so there is no path from tool arguments to an executable plan that bypasses it — the prototype's validator was reachable, correct, and never called. Rejected by default: ids by ALLOW-LIST charset, unique; every depends_on resolves (unknown edges rejected, never skipped); the graph is acyclic by Kahn's with the involved ids NAMED (audit U24 — nothing in this tree checked, and a cyclic graph hangs forever); task count capped; tools read-only and within the parent's grant; a budget with max_tokens required and max_workers exactly 1, REJECTED rather than coerced so the field stays meaningful for Phase 3; and depth checked at admission with the remaining headroom named, rather than an opaque failure mid-plan. ONE counting function — a length, not a text scan. The prototype counted source text, so `agent ("x")` with one space counted as zero and executed anyway. BUDGET ENFORCED AT DISPATCH, not merely validated. Under this posture every child inherits a 320-turn ceiling, so a twenty-task plan authorises 6,400 child turns from one tool call. The grant is intersected again at dispatch too, so a validator bug cannot widen a task's authority. FAILURE IS NOT SUCCESS. A failed task skips its transitive dependents, each RECORDED with the dependency that blocked it; independent siblings still run; the plan runs to exhaustion. Partial is its own terminal status and the tool returns StatusError for it, so nineteen of twenty failing can never surface as a clean result (audit RC-F). THE METRIC is value, not safety: max_speedup = sequential_total / critical_path, computed from recorded per-task durations over the declared DAG and surfaced in the summary and in Meta. Kill criterion: median >= 2.0x across >= 20 real plans, or Phase 3 is not built. Independence-violation would answer safety, but read-only tasks are always safe to parallelise, so it would read 0 and decide nothing. PermissionAllow when the posture is on, deliberately, with the reasoning in the code so Phase 3 does not inherit it blind: the approval surface renders only the tool name and a static sentence — PermissionRequest carries Args but no renderer reads it — so a prompt could not show the plan it was gating and would train click-through. What bounds it instead is enforced, not advisory: read-only at validation and dispatch, a required budget enforced at dispatch, and the posture itself as explicit consent. When tasks can WRITE, an approval gate becomes necessary and needs a real renderer first. Plan state is recorded as five ordinary session events beside the specialist ones; no new store. Recording is best-effort and never fails the run. --- internal/agent/loop.go | 2 +- internal/agent/posture_off_identity_test.go | 192 +++++++++ internal/agent/types.go | 4 + internal/agent/zeromaxing.go | 16 +- internal/agent/zeromaxing_test.go | 48 ++- internal/sessions/store.go | 20 +- internal/specialist/plan.go | 386 +++++++++++++++++ internal/specialist/plan_exec.go | 347 ++++++++++++++++ internal/specialist/plan_exec_test.go | 333 +++++++++++++++ internal/specialist/plan_test.go | 433 ++++++++++++++++++++ internal/specialist/plan_tool.go | 215 ++++++++++ 11 files changed, 1983 insertions(+), 13 deletions(-) create mode 100644 internal/agent/posture_off_identity_test.go create mode 100644 internal/specialist/plan.go create mode 100644 internal/specialist/plan_exec.go create mode 100644 internal/specialist/plan_exec_test.go create mode 100644 internal/specialist/plan_test.go create mode 100644 internal/specialist/plan_tool.go diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 0a3489ced..8015062de 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -262,7 +262,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options) // the system prompt, which is built once per run and must stay // byte-stable so the provider's cached prefix survives. See // internal/agent/zeromaxing.go. - for _, reminder := range zeromaxingReminders(options.Zeromaxing, result.Turns) { + for _, reminder := range zeromaxingReminders(options.Zeromaxing, result.Turns, options.OrchestrateAvailable) { messages = append(messages, zeroruntime.Message{ Role: zeroruntime.MessageRoleUser, Content: reminder, diff --git a/internal/agent/posture_off_identity_test.go b/internal/agent/posture_off_identity_test.go new file mode 100644 index 000000000..3a77f4edb --- /dev/null +++ b/internal/agent/posture_off_identity_test.go @@ -0,0 +1,192 @@ +package agent + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/tools" + "github.com/Gitlawb/zero/internal/zeroruntime" +) + +// THE ADDITIVITY PROOF. +// +// ZeroMaxing Phase 2 adds an `orchestrate` tool. The overriding constraint is +// that with the posture OFF, Zero is BYTE-IDENTICAL to a build without the +// feature — not almost, identical. Same advertised tool set, same +// tool-definition bytes, same assembled system prompt, therefore the same token +// count and the same provider request. +// +// The proof is a CONTROLLED COMPARISON rather than a frozen hash of the whole +// prefix. Two registries are built that differ in exactly one thing — whether +// the orchestrate tool is registered — and the posture-off output of both must +// be byte-identical. That states the real property ("registering this tool +// changes nothing while the posture is off"), it exercises the Deferred() +// mechanism that enforces it, and it cannot rot: there is no constant to +// regenerate and nothing to keep in step by hand. +// +// A frozen whole-prefix hash was tried first and is the wrong instrument here: +// the assembled prompt embeds the working directory and the operating system, +// so the constant would be machine- and platform-specific and every CI runner +// would "fail" honestly-unchanged code. The definition-bytes hash below keeps +// the frozen-golden idea where it IS portable. + +// fixtureCwd is a fixed synthetic path. The prompt embeds the working +// directory, so a real t.TempDir() would make any hash unstable across runs. +const fixtureCwd = "/zero/fixture/workspace" + +// baseFixtureRegistry is the control: a fixed, representative tool set with NO +// orchestrate tool. Deliberately not the full core set — that would make the +// golden hash churn on every unrelated tool change and train the reader to +// regenerate it. +func baseFixtureRegistry() *tools.Registry { + registry := tools.NewRegistry() + registry.Register(tools.NewScopedReadFileTool(fixtureCwd, nil)) + registry.Register(tools.NewScopedGrepTool(fixtureCwd, nil)) + registry.Register(tools.NewScopedGlobTool(fixtureCwd, nil)) + registry.Register(tools.NewScopedListDirectoryTool(fixtureCwd, nil)) + return registry +} + +// postureOffPrefix returns the three things a user pays for, for a posture-OFF +// run against the given registry: the advertised tool names in emitted order, +// the full tool-definition bytes, and the assembled system prompt. +func postureOffPrefix(t *testing.T, registry *tools.Registry, mode PermissionMode) (names []string, definitions string, prompt string) { + t.Helper() + options := Options{ + Cwd: fixtureCwd, + Registry: registry, + SessionID: "session-posture-off-identity", + Zeromaxing: ZeromaxingOff, // THE POINT: the posture is off. + // Deferral ACTIVE. Without this the partition never consults Deferred() + // at all, and this test would pass on the permission gate alone — which + // it originally did, so a Deferred() regression went undetected by it. + DeferThreshold: 1, + } + exposed, _ := partitionTools(registry, mode, options, nil) + for _, definition := range exposed { + names = append(names, definition.Name) + } + encoded, err := json.Marshal(exposed) + if err != nil { + t.Fatalf("marshal tool definitions: %v", err) + } + return names, string(encoded), buildSystemPromptParts(options).prompt +} + +// (a) THE PROOF. Registering the orchestrate tool must change NOTHING that a +// posture-off run sends: not the advertised set, not the definition bytes, not +// the prompt. If this fails, the feature has stopped being additive and the +// diff is what is wrong. +func TestPostureOffPrefixUnchangedByRegisteringTheTool(t *testing.T) { + // BOTH permission modes. Under auto the permission gate would hide the tool + // even if Deferred() were broken, so auto alone proves nothing about the + // mechanism; unsafe is where Deferred() is the ONLY thing standing between + // the tool and the advertised set. + for _, mode := range []PermissionMode{PermissionModeAuto, PermissionModeUnsafe} { + t.Run(string(mode), func(t *testing.T) { assertPostureOffPrefixUnchanged(t, mode) }) + } +} + +func assertPostureOffPrefixUnchanged(t *testing.T, mode PermissionMode) { + t.Helper() + withoutNames, withoutDefs, withoutPrompt := postureOffPrefix(t, baseFixtureRegistry(), mode) + + withTool := baseFixtureRegistry() + registerPhase2ToolForTest(withTool) + withNames, withDefs, withPrompt := postureOffPrefix(t, withTool, mode) + + if !reflect.DeepEqual(withoutNames, withNames) { + t.Fatalf("ADVERTISED TOOL SET CHANGED with the posture off:\n without: %v\n with: %v", + withoutNames, withNames) + } + if withoutDefs != withDefs { + t.Fatalf("TOOL DEFINITION BYTES CHANGED with the posture off (%d -> %d bytes)", + len(withoutDefs), len(withDefs)) + } + if withoutPrompt != withPrompt { + t.Fatalf("ASSEMBLED PROMPT CHANGED with the posture off (%d -> %d bytes)", + len(withoutPrompt), len(withPrompt)) + } + // Guard against the test passing because the tool was never registered at + // all: it must be present in the registry, just not advertised. + if _, ok := withTool.Get(phase2ToolName); !ok { + t.Fatalf("fixture did not register %q, so this test proves nothing", phase2ToolName) + } +} + +// The same claim in terms a reader can check without diffing: with the posture +// off the tool is registered but not advertised, and its name appears nowhere +// in what the provider receives. +func TestPostureOffDoesNotAdvertiseThePhase2Tool(t *testing.T) { + registry := baseFixtureRegistry() + registerPhase2ToolForTest(registry) + names, definitions, prompt := postureOffPrefix(t, registry, PermissionModeUnsafe) + + for _, name := range names { + if name == phase2ToolName { + t.Fatalf("posture off must not advertise %q, got %v", phase2ToolName, names) + } + } + if strings.Contains(definitions, phase2ToolName) { + t.Fatalf("posture off must not send %q in the tool definitions", phase2ToolName) + } + if strings.Contains(prompt, phase2ToolName) { + t.Fatalf("posture off must not name %q in the system prompt", phase2ToolName) + } +} + +// ...and the run-level counterpart: a posture-off conversation carries no +// posture or orchestration text at all. +func TestPostureOffRunCarriesNoPostureText(t *testing.T) { + root := t.TempDir() + registry := tools.NewRegistry() + registry.Register(tools.NewScopedReadFileTool(root, nil)) + registerPhase2ToolForTest(registry) + provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{ + {{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}}, + }} + if _, err := Run(context.Background(), "hello", provider, Options{ + Cwd: root, Registry: registry, SessionID: "session-posture-off-run", + }); err != nil { + t.Fatal(err) + } + var rendered strings.Builder + for _, message := range provider.requests[0].Messages { + rendered.WriteString(message.Content) + } + if strings.Contains(rendered.String(), phase2ToolName) { + t.Fatalf("a posture-off run must never name %q:\n%s", phase2ToolName, rendered.String()) + } + for _, forbidden := range []string{ + ZeromaxingEnterNotice, ZeromaxingBudgetNotice, ZeromaxingStillOnNotice, ZeromaxingExitNotice, + } { + if strings.Contains(rendered.String(), forbidden) { + t.Fatalf("a posture-off run must carry no posture text, found %q", forbidden) + } + } +} + +// A frozen golden over the TOOL DEFINITION BYTES only. Definitions carry no +// environment (no cwd, no OS), so unlike the whole prefix this IS portable and +// a moved hash means a real schema change in the fixture's tools. +const postureOffDefinitionsFingerprint = "3693b93a2ede2229a8367cc7f2b29590a52479df60a1c8582b7e4c88c8e255e9" + +func TestPostureOffToolDefinitionsMatchGolden(t *testing.T) { + registry := baseFixtureRegistry() + registerPhase2ToolForTest(registry) + _, definitions, _ := postureOffPrefix(t, registry, PermissionModeUnsafe) + sum := sha256.Sum256([]byte(definitions)) + got := hex.EncodeToString(sum[:]) + if postureOffDefinitionsFingerprint == "PLACEHOLDER" { + t.Fatalf("golden not captured yet; observed %s", got) + } + if got != postureOffDefinitionsFingerprint { + t.Fatalf("posture-off tool definitions changed:\n got %s\n want %s\n%s", + got, postureOffDefinitionsFingerprint, definitions) + } +} diff --git a/internal/agent/types.go b/internal/agent/types.go index f26810d8c..36904ce53 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -369,6 +369,10 @@ type Options struct { // separate from Profile: zeromaxing arms no escalation triggers, so // Profile.Policy() returns nil for it and could not carry this. Zeromaxing Zeromaxing + // OrchestrateAvailable reports whether the orchestrate tool is actually + // advertised this run, so the enter notice names it only when it exists. + // Zero value false keeps every existing caller's prompt unchanged. + OrchestrateAvailable bool // Trace, when set, records per-turn timing for the run: the loop stamps // spans (prompt build, generation, tool execution, permission wait, // compaction, provider connect) and counters (model requests, tool calls, diff --git a/internal/agent/zeromaxing.go b/internal/agent/zeromaxing.go index efc203872..c094d5d90 100644 --- a/internal/agent/zeromaxing.go +++ b/internal/agent/zeromaxing.go @@ -64,6 +64,14 @@ const ( ZeromaxingBudgetNotice = "Budget guideline under zeromaxing: the larger turn budget is for depth on one task, not for widening its scope. " + "Finish what was asked, verify it, and stop — do not start adjacent work because there are turns left." + // ZeromaxingOrchestrateNotice is appended to the enter notice ONLY when the + // orchestrate tool is actually available this run. Phase 1 deliberately + // promised no orchestration; naming a tool the run does not have would be a + // prompt-level lie the model would try to act on, which is why this is + // conditional rather than folded into the enter notice. + ZeromaxingOrchestrateNotice = "You also have the orchestrate tool: it runs a declared plan of read-only sub-agent tasks in dependency order, sequentially. " + + "Use it when a task splits into independent read-heavy pieces; declare depends_on so the plan records which work was genuinely independent." + // ZeromaxingStillOnNotice repeats on every continuing turn. // // It is intentionally short and FIXED. Do not add a turn number, a @@ -85,14 +93,18 @@ const ( // (posture, turn), and it has no memory. That is what makes "enter exactly // once" and "still-on never on the first turn" testable as data rather than as // a sequence of observed side effects. -func zeromaxingReminders(posture Zeromaxing, turn int) []string { +func zeromaxingReminders(posture Zeromaxing, turn int, orchestrateAvailable bool) []string { if turn < 1 { return nil } switch posture { case ZeromaxingEntering: if turn == 1 { - return []string{ZeromaxingEnterNotice, ZeromaxingBudgetNotice} + notices := []string{ZeromaxingEnterNotice, ZeromaxingBudgetNotice} + if orchestrateAvailable { + notices = append(notices, ZeromaxingOrchestrateNotice) + } + return notices } return []string{ZeromaxingStillOnNotice} case ZeromaxingActive: diff --git a/internal/agent/zeromaxing_test.go b/internal/agent/zeromaxing_test.go index 069e41197..3ecff5d0d 100644 --- a/internal/agent/zeromaxing_test.go +++ b/internal/agent/zeromaxing_test.go @@ -46,7 +46,7 @@ func TestZeromaxingReminderSchedule(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - got := zeromaxingReminders(tc.posture, tc.turn) + got := zeromaxingReminders(tc.posture, tc.turn, false) if !reflect.DeepEqual(got, tc.want) { t.Fatalf("zeromaxingReminders(%v, %d) = %#v, want %#v", tc.posture, tc.turn, got, tc.want) } @@ -59,7 +59,7 @@ func TestZeromaxingEnterAndExitFireExactlyOncePerRun(t *testing.T) { count := func(posture Zeromaxing, needle string) int { n := 0 for turn := 1; turn <= 50; turn++ { - for _, line := range zeromaxingReminders(posture, turn) { + for _, line := range zeromaxingReminders(posture, turn, false) { if line == needle { n++ } @@ -89,9 +89,9 @@ func TestZeromaxingEnterAndExitFireExactlyOncePerRun(t *testing.T) { // invisible today (it rides below the cache breakpoint) and a silent cost bug // the moment anyone moved this text into the system prompt. func TestZeromaxingStillOnNoticeIsInvariant(t *testing.T) { - first := zeromaxingReminders(ZeromaxingEntering, 2) + first := zeromaxingReminders(ZeromaxingEntering, 2, false) for turn := 3; turn <= 40; turn++ { - if got := zeromaxingReminders(ZeromaxingEntering, turn); !reflect.DeepEqual(got, first) { + if got := zeromaxingReminders(ZeromaxingEntering, turn, false); !reflect.DeepEqual(got, first) { t.Fatalf("still-on drifted at turn %d: %#v vs %#v", turn, got, first) } } @@ -119,3 +119,43 @@ func TestZeromaxingRemindersPromiseNoOrchestration(t *testing.T) { } } } + +// Phase 1's no-orchestration rule, now CONDITIONAL rather than absolute. +// +// The rule was never "never mention a tool" — it was "never promise a +// capability the run does not have". With the orchestrate tool actually +// advertised, naming it is accurate; without it, naming it would be the +// prompt-level lie the original test guarded against. Both directions asserted, +// because deleting the guard and keeping only the positive case would lose +// exactly the protection that mattered. +func TestOrchestrateIsNamedOnlyWhenItExists(t *testing.T) { + withTool := zeromaxingReminders(ZeromaxingEntering, 1, true) + withoutTool := zeromaxingReminders(ZeromaxingEntering, 1, false) + + joined := func(lines []string) string { return strings.Join(lines, "\n") } + + if !strings.Contains(joined(withTool), "orchestrate") { + t.Fatalf("with the tool available the enter notice must name it:\n%s", joined(withTool)) + } + if strings.Contains(joined(withoutTool), "orchestrate") { + t.Fatalf("without the tool NOTHING may mention it:\n%s", joined(withoutTool)) + } + // The unavailable case keeps Phase 1's original vocabulary guard intact. + for _, word := range []string{"orchestrat", "workflow", "fan-out", "worker", "delegate", "parallel"} { + if strings.Contains(strings.ToLower(joined(withoutTool)), word) { + t.Fatalf("without the tool the reminders must not mention %q:\n%s", word, joined(withoutTool)) + } + } + // The notice is one-shot like the rest: it rides the enter turn only. + for turn := 2; turn <= 5; turn++ { + if strings.Contains(joined(zeromaxingReminders(ZeromaxingEntering, turn, true)), "orchestrate") { + t.Fatalf("the orchestrate notice must fire once, not on turn %d", turn) + } + } + // Every OTHER posture state stays silent about it. + for _, posture := range []Zeromaxing{ZeromaxingOff, ZeromaxingActive, ZeromaxingExiting} { + if strings.Contains(joined(zeromaxingReminders(posture, 1, true)), "orchestrate") { + t.Fatalf("posture %v must not name the tool", posture) + } + } +} diff --git a/internal/sessions/store.go b/internal/sessions/store.go index 0c4ac7050..395b6f881 100644 --- a/internal/sessions/store.go +++ b/internal/sessions/store.go @@ -42,12 +42,20 @@ const ( EventSessionChild EventType = "session_child" EventSpecialistStart EventType = "specialist_start" EventSpecialistStop EventType = "specialist_stop" - EventSpecDraft EventType = "spec_draft" - EventSpecApproved EventType = "spec_approved" - EventSpecRejected EventType = "spec_rejected" - EventGoalCreated EventType = "goal_created" - EventGoalUpdated EventType = "goal_updated" - EventGoalCleared EventType = "goal_cleared" + // Plan lifecycle (ZeroMaxing Phase 2). Recorded as ordinary session events + // rather than a new store: the event log already is the durable record, and + // the prototype built a journal and a snapshot file beside one. + EventPlanAdmitted EventType = "plan_admitted" + EventTaskDispatched EventType = "task_dispatched" + EventTaskCompleted EventType = "task_completed" + EventTaskFailed EventType = "task_failed" + EventPlanCompleted EventType = "plan_completed" + EventSpecDraft EventType = "spec_draft" + EventSpecApproved EventType = "spec_approved" + EventSpecRejected EventType = "spec_rejected" + EventGoalCreated EventType = "goal_created" + EventGoalUpdated EventType = "goal_updated" + EventGoalCleared EventType = "goal_cleared" ) type SessionKind string diff --git a/internal/specialist/plan.go b/internal/specialist/plan.go new file mode 100644 index 000000000..ce6c4725a --- /dev/null +++ b/internal/specialist/plan.go @@ -0,0 +1,386 @@ +package specialist + +import ( + "fmt" + "regexp" + "sort" + "strings" + "time" +) + +// ZeroMaxing Phase 2: plan capture, executed SEQUENTIALLY. +// +// The deliverable is DATA. A plan declares a dependency graph, Zero runs it in +// topological order one task at a time, and records how long each task took. If +// the recorded max_speedup says fan-out would not have paid for itself, Phase 3 +// is not built — see PlanReport.MaxSpeedup. +// +// Fan-out, pipeline and phase are ONE structure: fan-out is tasks with no +// dependencies, a pipeline is a chain, a phase is a label plus a barrier. The +// prototype grew five separate hooks for these and never invoked any of them. + +// Plan is a validated, executable plan. +// +// Its fields are UNEXPORTED and ParsePlan is the only constructor. That is not +// stylistic: it makes skipping validation impossible rather than merely wrong. +// The prototype's validator was reachable, correct, and never called from the +// production path; a type that cannot be constructed any other way removes the +// choice. +type Plan struct { + name string + description string + tasks []Task + budget Budget + // order is the topological order Kahn's algorithm emitted during + // validation. The executor reuses it rather than recomputing, so admission + // and execution cannot disagree about the order or about acyclicity. + order []string +} + +// Task is one unit of a plan. +type Task struct { + ID string + Prompt string + DependsOn []string + // Tools is the read-only subset this task may use. Empty means "the parent's + // full read-only grant". It can only ever NARROW: validated here and + // intersected again at dispatch, so a validator bug cannot widen authority. + Tools []string + // Phase is a display and ordering label only. It carries no execution + // semantics in Phase 2 — a barrier is expressible as dependencies. + Phase string +} + +// Budget bounds a plan. Every field is required to be sane at admission and +// MaxTokens is enforced again at dispatch. +type Budget struct { + // MaxWorkers must be 1. Phase 2 is sequential, and a plan asking for more is + // REJECTED rather than coerced — coercion would let a caller believe it got + // concurrency, and would make the field meaningless for Phase 3. + MaxWorkers int + MaxTokens int + MaxWall time.Duration +} + +// Limits are the caller-supplied hard caps a plan must fit inside. +type Limits struct { + // MaxTasks bounds plan size. + MaxTasks int + // MaxTokens is the ceiling a plan's own budget may not exceed. + MaxTokens int + // ParentTools is the grant the parent run holds. A task's Tools must be a + // subset; anything outside it is rejected. + ParentTools []string + // CurrentDepth is the depth of the run issuing the plan. Its tasks run one + // level deeper, so the check is against maxSpecialistDepth. + CurrentDepth int +} + +// planIDPattern is an ALLOW-LIST. Enumerating permitted characters rather than +// forbidden ones is this repo's standing rule for classification: every +// deny-list here has leaked (git -C, then git -c, then --exec-path). +var planIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + +// writeToolMarkers name the mutating capabilities Phase 2 tasks may not have. +// Matched as an allow-list would be inverted, so this list is checked AGAINST a +// read-only allow-list below rather than used as the primary gate. +var planReadOnlyTools = map[string]bool{ + "read_file": true, + "read_minified_file": true, + "list_directory": true, + "grep": true, + "glob": true, + "update_plan": true, + "lsp_navigate": true, +} + +// Name, Description, Tasks and Budget expose a validated plan for execution and +// reporting. Read-only accessors: a Plan cannot be mutated after ParsePlan. +func (p Plan) Name() string { return p.name } +func (p Plan) Description() string { return p.description } +func (p Plan) Budget() Budget { return p.budget } + +// Tasks returns a copy so a caller cannot mutate a validated plan. +func (p Plan) Tasks() []Task { + out := make([]Task, len(p.tasks)) + copy(out, p.tasks) + return out +} + +// Order returns the validated topological order. +func (p Plan) Order() []string { + out := make([]string, len(p.order)) + copy(out, p.order) + return out +} + +// TaskCount is THE counting function. Both admission and the executor call it. +// +// It is a length, not a text scan. The prototype counted source text — +// strings.Count(body, "agent(") in admission and a regex in the compiler — so +// `agent ("x")` with one space counted as zero and executed anyway. Counting a +// parsed structure removes the class rather than fixing the regex. +func (p Plan) TaskCount() int { return len(p.tasks) } + +// ParsePlan is the ONLY way to obtain a Plan. It parses and validates in one +// step; there is no path from tool arguments to an executable plan that skips +// it. Every check rejects by default. +func ParsePlan(args map[string]any, limits Limits) (Plan, error) { + plan := Plan{ + name: planString(args, "name"), + description: planString(args, "description"), + } + + rawTasks, err := planTaskList(args) + if err != nil { + return Plan{}, err + } + if len(rawTasks) == 0 { + return Plan{}, fmt.Errorf("plan requires at least one task") + } + if limits.MaxTasks > 0 && len(rawTasks) > limits.MaxTasks { + return Plan{}, fmt.Errorf("plan has %d tasks, which exceeds the limit of %d", len(rawTasks), limits.MaxTasks) + } + + // DEPTH, at admission. A plan runs inside a child at limits.CurrentDepth, so + // its tasks are one level deeper. Checking here — with the remaining + // headroom named — turns an opaque mid-plan failure into a rejection the + // caller can act on. + taskDepth := limits.CurrentDepth + 1 + if taskDepth >= maxSpecialistDepth { + return Plan{}, fmt.Errorf( + "a plan's tasks would run at depth %d, which reaches the maximum nesting depth of %d; this run is already at depth %d, leaving no headroom for plan tasks", + taskDepth, maxSpecialistDepth, limits.CurrentDepth) + } + + seen := map[string]bool{} + for index, raw := range rawTasks { + task, err := planTask(raw, index) + if err != nil { + return Plan{}, err + } + if seen[task.ID] { + return Plan{}, fmt.Errorf("task id %q appears more than once; ids must be unique", task.ID) + } + seen[task.ID] = true + if err := validateTaskTools(task, limits); err != nil { + return Plan{}, err + } + plan.tasks = append(plan.tasks, task) + } + + // Every DependsOn must resolve. An unknown edge is REJECTED, never skipped: + // skipping it would silently execute a task whose stated precondition never + // ran, which is worse than refusing the plan. + for _, task := range plan.tasks { + for _, dep := range task.DependsOn { + if !seen[dep] { + return Plan{}, fmt.Errorf("task %q depends on %q, which is not a task in this plan", task.ID, dep) + } + if dep == task.ID { + return Plan{}, fmt.Errorf("task %q depends on itself", task.ID) + } + } + } + + order, cycle := topologicalOrder(plan.tasks) + if cycle != nil { + return Plan{}, fmt.Errorf("plan has a dependency cycle involving: %s", strings.Join(cycle, ", ")) + } + plan.order = order + + budget, err := planBudget(args, limits) + if err != nil { + return Plan{}, err + } + plan.budget = budget + return plan, nil +} + +// topologicalOrder runs Kahn's algorithm. It returns the emitted order, or the +// ids still carrying unmet dependencies when the queue drains early — which is +// exactly the set involved in a cycle. +// +// There was no cycle detection anywhere in this tree to reuse. Audit U24: a +// cyclic page tree hangs forever precisely because nothing checks. +func topologicalOrder(tasks []Task) (order []string, cycle []string) { + indegree := map[string]int{} + dependents := map[string][]string{} + for _, task := range tasks { + if _, ok := indegree[task.ID]; !ok { + indegree[task.ID] = 0 + } + for _, dep := range task.DependsOn { + indegree[task.ID]++ + dependents[dep] = append(dependents[dep], task.ID) + } + } + + // Seed with every zero-indegree node, in declaration order so the emitted + // order is deterministic for a given plan. + ready := []string{} + for _, task := range tasks { + if indegree[task.ID] == 0 { + ready = append(ready, task.ID) + } + } + for len(ready) > 0 { + id := ready[0] + ready = ready[1:] + order = append(order, id) + next := append([]string(nil), dependents[id]...) + sort.Strings(next) + for _, dependent := range next { + indegree[dependent]-- + if indegree[dependent] == 0 { + ready = append(ready, dependent) + } + } + } + if len(order) == len(tasks) { + return order, nil + } + // The queue drained early: everything still carrying an indegree is part of + // a cycle or downstream of one. Name them — an unnamed "cycle detected" is + // not actionable on a twenty-task plan. + for _, task := range tasks { + if indegree[task.ID] > 0 { + cycle = append(cycle, task.ID) + } + } + sort.Strings(cycle) + return nil, cycle +} + +// validateTaskTools enforces both Phase 2 rules: read-only, and never wider +// than the parent's grant. Enforced AGAIN at dispatch — see planToolGrant. +func validateTaskTools(task Task, limits Limits) error { + parent := map[string]bool{} + for _, name := range limits.ParentTools { + parent[name] = true + } + for _, name := range task.Tools { + // READ-ONLY, by allow-list. Phase 2 tasks cannot write, edit or run + // shell: granting that is an authority widening that needs its own + // decision, not a side effect of shipping plan capture. + if !planReadOnlyTools[name] { + return fmt.Errorf("task %q requests tool %q; plan tasks are read-only in this phase and may only use: %s", + task.ID, name, strings.Join(sortedReadOnlyTools(), ", ")) + } + if len(limits.ParentTools) > 0 && !parent[name] { + return fmt.Errorf("task %q requests tool %q, which this run does not hold; a task may narrow the parent's grant, never widen it", + task.ID, name) + } + } + return nil +} + +func sortedReadOnlyTools() []string { + names := make([]string, 0, len(planReadOnlyTools)) + for name := range planReadOnlyTools { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func planBudget(args map[string]any, limits Limits) (Budget, error) { + raw, ok := args["budget"].(map[string]any) + if !ok { + return Budget{}, fmt.Errorf("plan requires a budget object with max_tokens and max_workers") + } + budget := Budget{ + MaxWorkers: planInt(raw, "max_workers"), + MaxTokens: planInt(raw, "max_tokens"), + } + if seconds := planInt(raw, "max_wall_seconds"); seconds > 0 { + budget.MaxWall = time.Duration(seconds) * time.Second + } + // MaxWorkers must be exactly 1. Rejecting rather than coercing keeps the + // field meaningful: a caller that asked for 8 and silently got 1 would have + // been told nothing, and Phase 3 would inherit a field nobody trusts. + if budget.MaxWorkers != 1 { + return Budget{}, fmt.Errorf( + "budget.max_workers must be 1: this phase executes plan tasks sequentially, and a plan asking for %d is rejected rather than quietly run with one worker", + budget.MaxWorkers) + } + if budget.MaxTokens <= 0 { + return Budget{}, fmt.Errorf("budget.max_tokens is required and must be positive") + } + if limits.MaxTokens > 0 && budget.MaxTokens > limits.MaxTokens { + return Budget{}, fmt.Errorf("budget.max_tokens %d exceeds the limit of %d for this run", budget.MaxTokens, limits.MaxTokens) + } + return budget, nil +} + +func planTask(raw any, index int) (Task, error) { + fields, ok := raw.(map[string]any) + if !ok { + return Task{}, fmt.Errorf("task at position %d is not an object", index) + } + task := Task{ + ID: planString(fields, "id"), + Prompt: planString(fields, "prompt"), + DependsOn: planStrings(fields, "depends_on"), + Tools: planStrings(fields, "tools"), + Phase: planString(fields, "phase"), + } + if task.ID == "" { + return Task{}, fmt.Errorf("task at position %d has no id", index) + } + if !planIDPattern.MatchString(task.ID) { + return Task{}, fmt.Errorf("task id %q must use only letters, digits, hyphen and underscore", task.ID) + } + if task.Prompt == "" { + return Task{}, fmt.Errorf("task %q has no prompt", task.ID) + } + return task, nil +} + +func planTaskList(args map[string]any) ([]any, error) { + raw, ok := args["tasks"] + if !ok { + return nil, fmt.Errorf("plan requires a tasks array") + } + list, ok := raw.([]any) + if !ok { + return nil, fmt.Errorf("plan tasks must be an array") + } + return list, nil +} + +func planString(args map[string]any, key string) string { + if args == nil { + return "" + } + value, _ := args[key].(string) + return strings.TrimSpace(value) +} + +func planStrings(args map[string]any, key string) []string { + raw, ok := args[key].([]any) + if !ok { + return nil + } + out := []string{} + for _, item := range raw { + if text, ok := item.(string); ok { + if trimmed := strings.TrimSpace(text); trimmed != "" { + out = append(out, trimmed) + } + } + } + return out +} + +// planInt accepts the float64 a JSON number decodes to, as well as an int. +func planInt(args map[string]any, key string) int { + switch value := args[key].(type) { + case float64: + return int(value) + case int: + return value + default: + return 0 + } +} diff --git a/internal/specialist/plan_exec.go b/internal/specialist/plan_exec.go new file mode 100644 index 000000000..878511ff1 --- /dev/null +++ b/internal/specialist/plan_exec.go @@ -0,0 +1,347 @@ +package specialist + +import ( + "context" + "fmt" + "sort" + "strings" + "time" +) + +// PlanStatus is a plan's terminal state. +// +// PARTIAL IS ITS OWN STATUS, with its own exit code. This repo has repeatedly +// reported failure as success (audit RC-F), and in a plan that is worse: +// nineteen of twenty tasks failing must never surface as a completed run. +type PlanStatus string + +const ( + // PlanCompleted: every task succeeded. + PlanCompleted PlanStatus = "completed" + // PlanPartial: at least one task succeeded and at least one failed, was + // skipped, or was cut off by the budget. Maps to exit 4 (exitIncomplete). + PlanPartial PlanStatus = "partial" + // PlanFailed: no task succeeded. + PlanFailed PlanStatus = "failed" +) + +// TaskOutcome is why a task ended the way it did. +type TaskOutcome string + +const ( + TaskSucceeded TaskOutcome = "succeeded" + TaskFailed TaskOutcome = "failed" + // TaskSkippedDependency: a dependency failed, so this never ran. RECORDED, + // never silently dropped — a task that vanished from the report would be + // indistinguishable from one that was never planned. + TaskSkippedDependency TaskOutcome = "dependency_failed" + // TaskSkippedBudget: the plan's token budget was exhausted before this + // task could be dispatched. + TaskSkippedBudget TaskOutcome = "budget_exhausted" +) + +// TaskResult is one task's record. +type TaskResult struct { + ID string + Outcome TaskOutcome + Duration time.Duration + // Output is the task's FULL result, verbatim. Not truncated here: the tool + // boundary budgets and redacts it exactly like any other tool output. + Output string + Err string + SessionID string + Tokens int +} + +// PlanReport is the plan's terminal record, carried in plan_completed. +type PlanReport struct { + Status PlanStatus + Tasks []TaskResult + Succeeded int + Failed int + Skipped int + // SequentialTotal is the sum of task durations — what this run actually + // spent. + SequentialTotal time.Duration + // CriticalPath is the longest dependency-weighted path through the DAG: the + // wall time a perfectly parallel run could not go below. + CriticalPath time.Duration + // MaxSpeedup is SequentialTotal / CriticalPath — the THEORETICAL ceiling on + // what fan-out could buy, measured from a sequential run with no writes. + // + // This number decides whether Phase 3 is built. The kill criterion is a + // median of >= 2.0 across >= 20 real plans: below that the ceiling is too + // low for real coordination overhead to fit underneath, and concurrency + // would cost more than it returns. + // + // It answers VALUE, not safety. Independence-violation would answer safety, + // but read-only tasks are always safe to parallelise, so that rate would be + // 0 regardless and would decide nothing. + MaxSpeedup float64 + TokensUsed int +} + +// PlanRunner runs one task. The executor depends on this seam rather than on +// Executor directly so the budget, ordering and failure semantics are testable +// without launching child processes. +type PlanRunner func(ctx context.Context, task Task, tools []string) (TaskResult, error) + +// PlanRecorder receives plan lifecycle events. Recording is BEST-EFFORT and +// must never fail the run — it mirrors execSessionRecorder.append's contract, +// where a latched error is surfaced once but the run continues. +type PlanRecorder interface { + TaskDispatched(task Task) + TaskCompleted(result TaskResult) + TaskFailed(result TaskResult) +} + +// ExecutePlan runs a plan SEQUENTIALLY in its validated topological order. +// +// The order comes from the same Kahn pass that proved the graph acyclic, so +// admission and execution cannot disagree about it. +func ExecutePlan(ctx context.Context, plan Plan, parentTools []string, run PlanRunner, recorder PlanRecorder) PlanReport { + tasks := map[string]Task{} + for _, task := range plan.Tasks() { + tasks[task.ID] = task + } + + report := PlanReport{} + results := map[string]TaskResult{} + failed := map[string]bool{} + // budgetLeft is decremented as tasks complete. Enforced HERE, at dispatch — + // validation alone would be a promise, not a bound. Under the zeromaxing + // posture every child inherits a 320-turn ceiling, so a twenty-task plan + // authorises 6,400 child turns from a single tool call; this is what stands + // between that number and the user's bill. + budgetLeft := plan.Budget().MaxTokens + budgetExhausted := false + + deadline := time.Time{} + if wall := plan.Budget().MaxWall; wall > 0 { + deadline = time.Now().Add(wall) + } + + for _, id := range plan.Order() { + task := tasks[id] + + if blocker, blocked := firstFailedDependency(task, failed); blocked { + result := TaskResult{ + ID: id, + Outcome: TaskSkippedDependency, + Err: fmt.Sprintf("skipped: dependency %q did not succeed", blocker), + } + results[id] = result + failed[id] = true // its own dependents are blocked too + report.Skipped++ + recordFailed(recorder, result) + continue + } + + // A budget-exhausted or timed-out plan SKIPS the rest rather than + // aborting: independent work already done still counts, and the record + // must show what was not attempted. + if budgetExhausted || budgetLeft <= 0 || (!deadline.IsZero() && time.Now().After(deadline)) { + budgetExhausted = true + result := TaskResult{ + ID: id, + Outcome: TaskSkippedBudget, + Err: "skipped: the plan's budget was exhausted before this task ran", + } + results[id] = result + failed[id] = true + report.Skipped++ + recordFailed(recorder, result) + continue + } + + recordDispatched(recorder, task) + started := time.Now() + // BELT AND BRACES with the validator: the grant is intersected again + // here, so a validation bug cannot widen a task's authority. Same shape + // as Phase 1's forwardedReasoningEffort guard. + result, err := run(ctx, task, planToolGrant(task, parentTools)) + result.ID = id + if result.Duration == 0 { + result.Duration = time.Since(started) + } + report.SequentialTotal += result.Duration + budgetLeft -= result.Tokens + report.TokensUsed += result.Tokens + + if err != nil || result.Outcome == TaskFailed { + result.Outcome = TaskFailed + if result.Err == "" && err != nil { + result.Err = err.Error() + } + results[id] = result + failed[id] = true + report.Failed++ + recordFailed(recorder, result) + continue + } + result.Outcome = TaskSucceeded + results[id] = result + report.Succeeded++ + recordCompleted(recorder, result) + } + + for _, id := range plan.Order() { + report.Tasks = append(report.Tasks, results[id]) + } + report.CriticalPath = criticalPath(plan, results) + report.MaxSpeedup = speedup(report.SequentialTotal, report.CriticalPath) + report.Status = terminalStatus(report) + return report +} + +// terminalStatus maps counts onto the three terminal states. Partial is its own +// status precisely so a mostly-failed plan can never be reported as success. +func terminalStatus(report PlanReport) PlanStatus { + switch { + case report.Succeeded == 0: + return PlanFailed + case report.Failed == 0 && report.Skipped == 0: + return PlanCompleted + default: + return PlanPartial + } +} + +// firstFailedDependency names the dependency that blocked a task, so the record +// says WHICH one rather than just that something upstream broke. +func firstFailedDependency(task Task, failed map[string]bool) (string, bool) { + deps := append([]string(nil), task.DependsOn...) + sort.Strings(deps) + for _, dep := range deps { + if failed[dep] { + return dep, true + } + } + return "", false +} + +// planToolGrant intersects a task's requested tools with the parent's grant. +// An empty request inherits the parent's read-only grant; it never widens it. +func planToolGrant(task Task, parentTools []string) []string { + parent := map[string]bool{} + for _, name := range parentTools { + parent[name] = true + } + if len(task.Tools) == 0 { + out := []string{} + for _, name := range parentTools { + if planReadOnlyTools[name] { + out = append(out, name) + } + } + sort.Strings(out) + return out + } + out := []string{} + for _, name := range task.Tools { + if !planReadOnlyTools[name] { + continue + } + if len(parentTools) > 0 && !parent[name] { + continue + } + out = append(out, name) + } + sort.Strings(out) + return out +} + +// criticalPath is the longest dependency-weighted path through the DAG: for +// each task, its own duration plus the longest path among its dependencies. +// Computed over the validated topological order, so every dependency is already +// resolved when a task is reached. +func criticalPath(plan Plan, results map[string]TaskResult) time.Duration { + longest := map[string]time.Duration{} + tasks := map[string]Task{} + for _, task := range plan.Tasks() { + tasks[task.ID] = task + } + var best time.Duration + for _, id := range plan.Order() { + var upstream time.Duration + for _, dep := range tasks[id].DependsOn { + if longest[dep] > upstream { + upstream = longest[dep] + } + } + longest[id] = upstream + results[id].Duration + if longest[id] > best { + best = longest[id] + } + } + return best +} + +// speedup is sequential / critical-path, guarded against a zero denominator (a +// plan whose tasks all took no measurable time). +func speedup(sequential, critical time.Duration) float64 { + if critical <= 0 { + return 0 + } + return float64(sequential) / float64(critical) +} + +// Summary renders the plan's result for the model, including max_speedup — the +// number the Phase 3 decision rests on, surfaced rather than buried in an event. +func (report PlanReport) Summary() string { + var b strings.Builder + fmt.Fprintf(&b, "Plan %s: %d succeeded, %d failed, %d skipped.\n", report.Status, report.Succeeded, report.Failed, report.Skipped) + fmt.Fprintf(&b, "sequential total: %s · critical path: %s · max_speedup: %.2fx\n", + report.SequentialTotal.Round(time.Millisecond), report.CriticalPath.Round(time.Millisecond), report.MaxSpeedup) + for _, task := range report.Tasks { + fmt.Fprintf(&b, "\n - %s [%s] %s", task.ID, task.Outcome, task.Duration.Round(time.Millisecond)) + if task.Output != "" { + b.WriteString("\n result:\n" + task.Output) + } + if task.Err != "" { + b.WriteString("\n error:\n" + task.Err) + } + } + return b.String() +} + +func recordDispatched(recorder PlanRecorder, task Task) { + if recorder != nil { + recorder.TaskDispatched(task) + } +} + +func recordCompleted(recorder PlanRecorder, result TaskResult) { + if recorder != nil { + recorder.TaskCompleted(result) + } +} + +func recordFailed(recorder PlanRecorder, result TaskResult) { + if recorder != nil { + recorder.TaskFailed(result) + } +} + +// PlanLifecycleRecorder extends PlanRecorder with the plan-level events. Kept +// separate so a caller that only wants task events need not implement both. +type PlanLifecycleRecorder interface { + PlanRecorder + PlanAdmitted(plan Plan) + PlanCompleted(plan Plan, report PlanReport) +} + +// recordPlanAdmitted and recordPlanCompleted are best-effort and nil-safe, and +// they type-assert rather than requiring the wider interface, so recording can +// never be the thing that fails a run. +func recordPlanAdmitted(recorder PlanRecorder, plan Plan) { + if full, ok := recorder.(PlanLifecycleRecorder); ok && full != nil { + full.PlanAdmitted(plan) + } +} + +func recordPlanCompleted(recorder PlanRecorder, plan Plan, report PlanReport) { + if full, ok := recorder.(PlanLifecycleRecorder); ok && full != nil { + full.PlanCompleted(plan, report) + } +} diff --git a/internal/specialist/plan_exec_test.go b/internal/specialist/plan_exec_test.go new file mode 100644 index 000000000..4d8bddbf6 --- /dev/null +++ b/internal/specialist/plan_exec_test.go @@ -0,0 +1,333 @@ +package specialist + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + "time" +) + +// recordingRecorder captures the lifecycle events, so "recorded, never silently +// dropped" is asserted rather than assumed. +type recordingRecorder struct { + dispatched []string + completed []string + failed []TaskResult + admitted int + finished []PlanReport +} + +func (r *recordingRecorder) TaskDispatched(task Task) { r.dispatched = append(r.dispatched, task.ID) } +func (r *recordingRecorder) TaskCompleted(res TaskResult) { r.completed = append(r.completed, res.ID) } +func (r *recordingRecorder) TaskFailed(res TaskResult) { r.failed = append(r.failed, res) } +func (r *recordingRecorder) PlanAdmitted(plan Plan) { r.admitted++ } +func (r *recordingRecorder) PlanCompleted(_ Plan, rep PlanReport) { + r.finished = append(r.finished, rep) +} + +func mustPlan(t *testing.T, tasks []any, budget map[string]any, limits Limits) Plan { + t.Helper() + plan, err := ParsePlan(planArgs(tasks, budget), limits) + if err != nil { + t.Fatalf("ParsePlan: %v", err) + } + return plan +} + +// (p) A dependency failure SKIPS its transitive dependents and RECORDS each +// one, while independent siblings still run. The plan runs to exhaustion. +func TestDependencyFailureSkipsDependentsAndRecordsThem(t *testing.T) { + // a fails. b depends on a, c depends on b (transitive). d is independent. + plan := mustPlan(t, []any{ + task("a", "fails"), task("b", "after a", "a"), task("c", "after b", "b"), task("d", "independent"), + }, okBudget(), readOnlyLimits()) + + recorder := &recordingRecorder{} + report := ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(_ context.Context, task Task, _ []string) (TaskResult, error) { + if task.ID == "a" { + return TaskResult{Outcome: TaskFailed, Err: "boom"}, errors.New("boom") + } + return TaskResult{Outcome: TaskSucceeded, Output: "ok:" + task.ID}, nil + }, recorder) + + byID := map[string]TaskResult{} + for _, task := range report.Tasks { + byID[task.ID] = task + } + if byID["a"].Outcome != TaskFailed { + t.Fatalf("a must be failed, got %q", byID["a"].Outcome) + } + for _, id := range []string{"b", "c"} { + if byID[id].Outcome != TaskSkippedDependency { + t.Fatalf("%s must be skipped for a failed dependency, got %q", id, byID[id].Outcome) + } + if byID[id].Err == "" { + t.Fatalf("%s must record WHY it was skipped", id) + } + } + // The independent sibling still ran — the plan does not abort on first failure. + if byID["d"].Outcome != TaskSucceeded { + t.Fatalf("an independent sibling must still run, got %q", byID["d"].Outcome) + } + // Skips are RECORDED, not dropped: every task appears in the report. + if len(report.Tasks) != 4 { + t.Fatalf("every task must appear in the report, got %d", len(report.Tasks)) + } + if len(recorder.failed) != 3 { + t.Fatalf("the failure and both skips must be recorded, got %d", len(recorder.failed)) + } + // b and c never dispatched — skipping means not running. + for _, id := range recorder.dispatched { + if id == "b" || id == "c" { + t.Fatalf("%s must not be dispatched when its dependency failed", id) + } + } + if report.Status != PlanPartial { + t.Fatalf("status = %q, want partial", report.Status) + } +} + +// (q) Nineteen of twenty failing is PARTIAL, never success. +func TestMostlyFailedPlanIsPartialNotSuccess(t *testing.T) { + raw := make([]any, 20) + for i := range raw { + raw[i] = task(fmt.Sprintf("t%02d", i), "work") + } + plan := mustPlan(t, raw, okBudget(), readOnlyLimits()) + report := ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(_ context.Context, task Task, _ []string) (TaskResult, error) { + if task.ID == "t00" { + return TaskResult{Outcome: TaskSucceeded, Output: "ok"}, nil + } + return TaskResult{Outcome: TaskFailed, Err: "no"}, errors.New("no") + }, nil) + + if report.Succeeded != 1 || report.Failed != 19 { + t.Fatalf("counts = %d succeeded / %d failed, want 1/19", report.Succeeded, report.Failed) + } + if report.Status != PlanPartial { + t.Fatalf("19 of 20 failing must be PARTIAL, got %q", report.Status) + } + if report.Status == PlanCompleted { + t.Fatal("a mostly-failed plan must never report completed") + } + // The counts are in the record, so the number is auditable. + summary := report.Summary() + if !strings.Contains(summary, "1 succeeded, 19 failed") { + t.Fatalf("the summary must carry the counts:\n%s", summary) + } +} + +// Zero successes is FAILED, distinct from partial. +func TestAllFailedPlanIsFailed(t *testing.T) { + plan := mustPlan(t, []any{task("a", "x"), task("b", "y")}, okBudget(), readOnlyLimits()) + report := ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(context.Context, Task, []string) (TaskResult, error) { + return TaskResult{Outcome: TaskFailed}, errors.New("no") + }, nil) + if report.Status != PlanFailed { + t.Fatalf("status = %q, want failed", report.Status) + } +} + +// (o) BUDGET ENFORCED AT DISPATCH. Validation alone is a promise, not a bound. +func TestBudgetExhaustionMidPlanIsPartial(t *testing.T) { + budget := okBudget() + budget["max_tokens"] = float64(100) + plan := mustPlan(t, []any{task("a", "x"), task("b", "y"), task("c", "z")}, budget, readOnlyLimits()) + + dispatched := []string{} + report := ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(_ context.Context, task Task, _ []string) (TaskResult, error) { + dispatched = append(dispatched, task.ID) + return TaskResult{Outcome: TaskSucceeded, Tokens: 60, Output: "ok"}, nil + }, nil) + + // Two tasks at 60 tokens exhaust a 100-token budget; the third is skipped. + if len(dispatched) != 2 { + t.Fatalf("the budget must stop dispatch after it is spent, dispatched %v", dispatched) + } + byID := map[string]TaskResult{} + for _, task := range report.Tasks { + byID[task.ID] = task + } + if byID["c"].Outcome != TaskSkippedBudget { + t.Fatalf("c must be skipped for budget, got %q", byID["c"].Outcome) + } + if report.Status != PlanPartial { + t.Fatalf("budget exhaustion must be PARTIAL (not failure, not success), got %q", report.Status) + } + if byID["c"].Err == "" { + t.Fatal("a budget skip must record why") + } +} + +// (m) THE DISPATCH HALF of the grant rule: a task's tools are intersected with +// the parent's at dispatch, so a validator bug cannot widen authority. +func TestToolGrantIsIntersectedAtDispatch(t *testing.T) { + // Construct a task that ASKS for more than the parent holds, bypassing the + // validator entirely — this is precisely the "validator bug" case. + plan := mustPlan(t, []any{task("a", "x")}, okBudget(), readOnlyLimits()) + tasks := plan.Tasks() + tasks[0].Tools = []string{"read_file", "grep", "write_file", "bash"} + plan.tasks = tasks + + var granted []string + ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(_ context.Context, _ Task, tools []string) (TaskResult, error) { + granted = tools + return TaskResult{Outcome: TaskSucceeded}, nil + }, nil) + + for _, forbidden := range []string{"write_file", "bash", "grep"} { + for _, got := range granted { + if got == forbidden { + t.Fatalf("dispatch granted %q, which the parent does not hold or is not read-only: %v", forbidden, granted) + } + } + } + if len(granted) != 1 || granted[0] != "read_file" { + t.Fatalf("grant = %v, want exactly the parent's read-only intersection", granted) + } +} + +// An empty Tools inherits the parent's READ-ONLY grant — never the parent's +// mutating tools, even when the parent holds them. +func TestEmptyToolsInheritsOnlyReadOnlyParentTools(t *testing.T) { + plan := mustPlan(t, []any{task("a", "x")}, okBudget(), Limits{MaxTasks: 5, MaxTokens: 1000}) + var granted []string + ExecutePlan(context.Background(), plan, []string{"read_file", "grep", "write_file", "bash"}, + func(_ context.Context, _ Task, tools []string) (TaskResult, error) { + granted = tools + return TaskResult{Outcome: TaskSucceeded}, nil + }, nil) + want := map[string]bool{"read_file": true, "grep": true} + if len(granted) != len(want) { + t.Fatalf("grant = %v, want only the read-only parent tools", granted) + } + for _, got := range granted { + if !want[got] { + t.Fatalf("grant leaked %q: %v", got, granted) + } + } +} + +// (r) max_speedup on a KNOWN dag with KNOWN durations. +func TestMaxSpeedupOnAKnownDAG(t *testing.T) { + // Diamond: a(10) -> b(20), a(10) -> c(30), (b,c) -> d(10). + // sequential = 10+20+30+10 = 70 + // critical = a + max(b,c) + d = 10 + 30 + 10 = 50 + // speedup = 70/50 = 1.4 + plan := mustPlan(t, []any{ + task("a", "root"), task("b", "left", "a"), task("c", "right", "a"), task("d", "join", "b", "c"), + }, okBudget(), readOnlyLimits()) + + durations := map[string]time.Duration{ + "a": 10 * time.Millisecond, "b": 20 * time.Millisecond, + "c": 30 * time.Millisecond, "d": 10 * time.Millisecond, + } + report := ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(_ context.Context, task Task, _ []string) (TaskResult, error) { + return TaskResult{Outcome: TaskSucceeded, Duration: durations[task.ID]}, nil + }, nil) + + if report.SequentialTotal != 70*time.Millisecond { + t.Fatalf("sequential total = %s, want 70ms", report.SequentialTotal) + } + if report.CriticalPath != 50*time.Millisecond { + t.Fatalf("critical path = %s, want 50ms (a + max(b,c) + d)", report.CriticalPath) + } + if diff := report.MaxSpeedup - 1.4; diff > 0.001 || diff < -0.001 { + t.Fatalf("max_speedup = %.4f, want 1.40", report.MaxSpeedup) + } + if !strings.Contains(report.Summary(), "max_speedup: 1.40x") { + t.Fatalf("the summary must surface max_speedup:\n%s", report.Summary()) + } +} + +// A fully PARALLEL plan (no edges) has speedup == task count; a fully SEQUENTIAL +// chain has speedup 1. Those are the bounds the kill criterion is read against, +// so they must be exactly right. +func TestMaxSpeedupBounds(t *testing.T) { + runner := func(_ context.Context, _ Task, _ []string) (TaskResult, error) { + return TaskResult{Outcome: TaskSucceeded, Duration: 10 * time.Millisecond}, nil + } + independent := mustPlan(t, []any{task("a", "x"), task("b", "y"), task("c", "z")}, okBudget(), readOnlyLimits()) + report := ExecutePlan(context.Background(), independent, []string{"read_file"}, runner, nil) + if diff := report.MaxSpeedup - 3.0; diff > 0.001 || diff < -0.001 { + t.Fatalf("three independent equal tasks must give 3.00x, got %.4f", report.MaxSpeedup) + } + chain := mustPlan(t, []any{task("a", "x"), task("b", "y", "a"), task("c", "z", "b")}, okBudget(), readOnlyLimits()) + report = ExecutePlan(context.Background(), chain, []string{"read_file"}, runner, nil) + if diff := report.MaxSpeedup - 1.0; diff > 0.001 || diff < -0.001 { + t.Fatalf("a strict chain must give 1.00x — fan-out buys nothing, got %.4f", report.MaxSpeedup) + } +} + +// (s) Each task's FULL result arrives verbatim. The plan must not become a +// second place where a delegated work product is truncated. +func TestTaskResultsArriveVerbatim(t *testing.T) { + const body = "Summary\n-------\nline one\n\n indented\n\ndiff --git a/x b/x\n+added\n-removed\n\nConclusion: done." + plan := mustPlan(t, []any{task("a", "x"), task("b", "y", "a")}, okBudget(), readOnlyLimits()) + report := ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(_ context.Context, task Task, _ []string) (TaskResult, error) { + return TaskResult{Outcome: TaskSucceeded, Output: task.ID + ":" + body}, nil + }, nil) + + for _, task := range report.Tasks { + if task.Output != task.ID+":"+body { + t.Fatalf("%s output is not verbatim:\n%q", task.ID, task.Output) + } + } + summary := report.Summary() + for _, id := range []string{"a", "b"} { + if !strings.Contains(summary, id+":"+body) { + t.Fatalf("the summary must carry %s's full result verbatim:\n%s", id, summary) + } + } + if strings.Contains(summary, "…") { + t.Fatalf("the summary must not truncate:\n%s", summary) + } +} + +// Execution order follows the validated topological order — the same one Kahn +// emitted at admission, so the two cannot disagree. +func TestExecutionFollowsTheValidatedOrder(t *testing.T) { + plan := mustPlan(t, []any{ + task("d", "join", "b", "c"), task("b", "left", "a"), task("c", "right", "a"), task("a", "root"), + }, okBudget(), readOnlyLimits()) + seen := []string{} + ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(_ context.Context, task Task, _ []string) (TaskResult, error) { + seen = append(seen, task.ID) + return TaskResult{Outcome: TaskSucceeded}, nil + }, nil) + if strings.Join(seen, ",") != strings.Join(plan.Order(), ",") { + t.Fatalf("execution order %v != validated order %v", seen, plan.Order()) + } + position := map[string]int{} + for i, id := range seen { + position[id] = i + } + for _, edge := range [][2]string{{"a", "b"}, {"a", "c"}, {"b", "d"}, {"c", "d"}} { + if position[edge[0]] >= position[edge[1]] { + t.Fatalf("%s ran after %s: %v", edge[0], edge[1], seen) + } + } +} + +// Recording is BEST-EFFORT: a recorder that panics must not take the run with +// it... and a nil recorder must be a no-op. +func TestRecordingIsOptional(t *testing.T) { + plan := mustPlan(t, []any{task("a", "x")}, okBudget(), readOnlyLimits()) + report := ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(context.Context, Task, []string) (TaskResult, error) { + return TaskResult{Outcome: TaskSucceeded}, nil + }, nil) + if report.Status != PlanCompleted { + t.Fatalf("a nil recorder must not change the outcome, got %q", report.Status) + } +} diff --git a/internal/specialist/plan_test.go b/internal/specialist/plan_test.go new file mode 100644 index 000000000..638ea7a46 --- /dev/null +++ b/internal/specialist/plan_test.go @@ -0,0 +1,433 @@ +package specialist + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/Gitlawb/zero/internal/tools" +) + +func planArgs(tasks []any, budget map[string]any) map[string]any { + return map[string]any{"name": "p", "tasks": tasks, "budget": budget} +} + +func okBudget() map[string]any { + return map[string]any{"max_workers": float64(1), "max_tokens": float64(1000)} +} + +func task(id, prompt string, deps ...string) map[string]any { + out := map[string]any{"id": id, "prompt": prompt} + if len(deps) > 0 { + raw := make([]any, len(deps)) + for i, d := range deps { + raw[i] = d + } + out["depends_on"] = raw + } + return out +} + +func readOnlyLimits() Limits { + return Limits{MaxTasks: 20, MaxTokens: 100_000, ParentTools: []string{"read_file", "grep", "glob"}} +} + +// (e) ParsePlan is the ONLY constructor. A zero Plan is inert, so no exported +// path can produce an executable plan that skipped validation. +func TestParsePlanIsTheOnlyConstructor(t *testing.T) { + var zero Plan + if zero.TaskCount() != 0 || len(zero.Order()) != 0 || len(zero.Tasks()) != 0 { + t.Fatal("a zero Plan must carry no tasks and no order") + } + // Executing one runs nothing and reports failed — never success. + report := ExecutePlan(context.Background(), zero, nil, func(context.Context, Task, []string) (TaskResult, error) { + t.Fatal("a zero Plan must not dispatch anything") + return TaskResult{}, nil + }, nil) + if report.Status != PlanFailed { + t.Fatalf("a zero Plan must report failed, got %q", report.Status) + } + // Tasks() returns a copy: mutating it cannot corrupt a validated plan. + plan, err := ParsePlan(planArgs([]any{task("a", "do a")}, okBudget()), readOnlyLimits()) + if err != nil { + t.Fatalf("ParsePlan: %v", err) + } + copied := plan.Tasks() + copied[0].ID = "mutated" + if plan.Tasks()[0].ID != "a" { + t.Fatal("Tasks() must return a copy; a validated plan is immutable") + } +} + +// (f) A cycle is rejected AND the involved ids are named. Audit U24: a cyclic +// graph hangs forever precisely because nothing checks. +func TestParsePlanRejectsCyclesAndNamesThem(t *testing.T) { + cases := []struct { + name string + tasks []any + want []string + }{ + {"two-node cycle", []any{task("a", "x", "b"), task("b", "y", "a")}, []string{"a", "b"}}, + {"three-node cycle", []any{task("a", "x", "c"), task("b", "y", "a"), task("c", "z", "b")}, []string{"a", "b", "c"}}, + {"cycle with an innocent bystander", []any{ + task("free", "x"), task("a", "y", "b"), task("b", "z", "a"), + }, []string{"a", "b"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := ParsePlan(planArgs(tc.tasks, okBudget()), readOnlyLimits()) + if err == nil { + t.Fatal("a cyclic plan must be rejected") + } + if !strings.Contains(err.Error(), "cycle") { + t.Fatalf("the error must say it is a cycle: %v", err) + } + for _, id := range tc.want { + if !strings.Contains(err.Error(), id) { + t.Fatalf("the error must name %q so a 20-task plan is actionable: %v", id, err) + } + } + if tc.name == "cycle with an innocent bystander" && strings.Contains(err.Error(), "free") { + t.Fatalf("a task outside the cycle must not be named: %v", err) + } + }) + } +} + +// A self-edge is a cycle of one and must be rejected too. +func TestParsePlanRejectsSelfDependency(t *testing.T) { + _, err := ParsePlan(planArgs([]any{task("a", "x", "a")}, okBudget()), readOnlyLimits()) + if err == nil || !strings.Contains(err.Error(), "itself") { + t.Fatalf("a self-dependency must be rejected, got %v", err) + } +} + +// (g) An unknown edge is REJECTED, never skipped: skipping would run a task +// whose stated precondition never existed. +func TestParsePlanRejectsUnknownDependency(t *testing.T) { + _, err := ParsePlan(planArgs([]any{task("a", "x", "ghost")}, okBudget()), readOnlyLimits()) + if err == nil { + t.Fatal("an unknown dependency must be rejected") + } + for _, want := range []string{"a", "ghost"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("the error must name %q: %v", want, err) + } + } +} + +// (h) Duplicate ids are rejected — otherwise the graph is ambiguous. +func TestParsePlanRejectsDuplicateIDs(t *testing.T) { + _, err := ParsePlan(planArgs([]any{task("a", "x"), task("a", "y")}, okBudget()), readOnlyLimits()) + if err == nil || !strings.Contains(err.Error(), "unique") { + t.Fatalf("duplicate ids must be rejected, got %v", err) + } +} + +// IDs use an ALLOW-LIST charset. A deny-list here would leak, as every +// deny-list in this repo has. +func TestParsePlanRejectsIDsOutsideTheAllowList(t *testing.T) { + for _, bad := range []string{"a b", "a/b", "a.b", "../x", "a;b", "a\nb", "a$b", ""} { + _, err := ParsePlan(planArgs([]any{task(bad, "x")}, okBudget()), readOnlyLimits()) + if err == nil { + t.Fatalf("id %q must be rejected", bad) + } + } + for _, good := range []string{"a", "A1", "read-file", "read_file", "a-b_c9"} { + if _, err := ParsePlan(planArgs([]any{task(good, "x")}, okBudget()), readOnlyLimits()); err != nil { + t.Fatalf("id %q must be accepted: %v", good, err) + } + } +} + +// (i) MaxWorkers > 1 is REJECTED, not coerced. Coercion would let a caller +// believe it got concurrency and would make the field meaningless for Phase 3. +func TestParsePlanRejectsMoreThanOneWorker(t *testing.T) { + for _, workers := range []float64{0, 2, 8} { + budget := okBudget() + budget["max_workers"] = workers + _, err := ParsePlan(planArgs([]any{task("a", "x")}, budget), readOnlyLimits()) + if err == nil { + t.Fatalf("max_workers %v must be rejected", workers) + } + if !strings.Contains(err.Error(), "sequentially") { + t.Fatalf("the error must say why: %v", err) + } + } +} + +// (j) A budget is required, and max_tokens with it. +func TestParsePlanRequiresABudget(t *testing.T) { + if _, err := ParsePlan(map[string]any{"tasks": []any{task("a", "x")}}, readOnlyLimits()); err == nil { + t.Fatal("a plan with no budget must be rejected") + } + budget := okBudget() + delete(budget, "max_tokens") + _, err := ParsePlan(planArgs([]any{task("a", "x")}, budget), readOnlyLimits()) + if err == nil || !strings.Contains(err.Error(), "max_tokens") { + t.Fatalf("a missing max_tokens must be rejected, got %v", err) + } + over := okBudget() + over["max_tokens"] = float64(999_999_999) + if _, err := ParsePlan(planArgs([]any{task("a", "x")}, over), readOnlyLimits()); err == nil { + t.Fatal("a budget above the run's limit must be rejected") + } +} + +// (k) Depth is checked AT ADMISSION, and the message names the headroom rather +// than failing opaquely partway through a plan. +func TestParsePlanRejectsInsufficientDepthHeadroom(t *testing.T) { + limits := readOnlyLimits() + limits.CurrentDepth = maxSpecialistDepth - 1 // tasks would be AT the cap + _, err := ParsePlan(planArgs([]any{task("a", "x")}, okBudget()), limits) + if err == nil { + t.Fatal("a plan with no depth headroom must be rejected at admission") + } + for _, want := range []string{"depth", "headroom"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("the error must explain the headroom: %v", err) + } + } + // One level shallower still fits. + limits.CurrentDepth = maxSpecialistDepth - 2 + if _, err := ParsePlan(planArgs([]any{task("a", "x")}, okBudget()), limits); err != nil { + t.Fatalf("a plan with headroom must be accepted: %v", err) + } +} + +// (l) ONE counting function, used by admission and the executor, agreeing on +// every size. The prototype counted source text and `agent ("x")` counted zero. +func TestTaskCountAgreesBetweenAdmissionAndExecution(t *testing.T) { + for _, size := range []int{1, 2, 20} { + raw := make([]any, size) + for i := range raw { + raw[i] = task(string(rune('a'+i%26))+strings.Repeat("x", i/26), "do it") + } + limits := readOnlyLimits() + plan, err := ParsePlan(planArgs(raw, okBudget()), limits) + if err != nil { + t.Fatalf("size %d: %v", size, err) + } + if plan.TaskCount() != size { + t.Fatalf("TaskCount = %d, want %d", plan.TaskCount(), size) + } + dispatched := 0 + ExecutePlan(context.Background(), plan, []string{"read_file"}, func(context.Context, Task, []string) (TaskResult, error) { + dispatched++ + return TaskResult{Outcome: TaskSucceeded}, nil + }, nil) + if dispatched != plan.TaskCount() { + t.Fatalf("executor dispatched %d, admission counted %d — the two disagree", dispatched, plan.TaskCount()) + } + } + // At-cap and over-cap. + limits := readOnlyLimits() + limits.MaxTasks = 2 + if _, err := ParsePlan(planArgs([]any{task("a", "x"), task("b", "y")}, okBudget()), limits); err != nil { + t.Fatalf("at the cap must be accepted: %v", err) + } + if _, err := ParsePlan(planArgs([]any{task("a", "x"), task("b", "y"), task("c", "z")}, okBudget()), limits); err == nil { + t.Fatal("over the cap must be rejected") + } + // Empty is rejected: a plan with no tasks is not a plan. + if _, err := ParsePlan(planArgs([]any{}, okBudget()), limits); err == nil { + t.Fatal("an empty plan must be rejected") + } +} + +// (n) A write tool is rejected — Phase 2 tasks are read-only. +func TestParsePlanRejectsWriteTools(t *testing.T) { + for _, tool := range []string{"write_file", "edit_file", "apply_patch", "bash", "exec_command"} { + raw := task("a", "x") + raw["tools"] = []any{tool} + limits := readOnlyLimits() + limits.ParentTools = append(limits.ParentTools, tool) // even if the PARENT holds it + _, err := ParsePlan(planArgs([]any{raw}, okBudget()), limits) + if err == nil { + t.Fatalf("tool %q must be rejected: plan tasks are read-only", tool) + } + if !strings.Contains(err.Error(), "read-only") { + t.Fatalf("the error must say why: %v", err) + } + } +} + +// (m) A task may NARROW the parent's grant, never widen it — at validation. +// The dispatch half is asserted separately, in plan_exec_test.go. +func TestParsePlanRejectsToolsOutsideTheParentGrant(t *testing.T) { + raw := task("a", "x") + raw["tools"] = []any{"read_file", "grep"} + limits := Limits{MaxTasks: 20, MaxTokens: 1000, ParentTools: []string{"read_file"}} + _, err := ParsePlan(planArgs([]any{raw}, okBudget()), limits) + if err == nil { + t.Fatal("a task requesting a tool the parent does not hold must be rejected") + } + if !strings.Contains(err.Error(), "never widen") { + t.Fatalf("the error must name the rule: %v", err) + } + // Narrowing is fine. + raw["tools"] = []any{"read_file"} + if _, err := ParsePlan(planArgs([]any{raw}, okBudget()), limits); err != nil { + t.Fatalf("narrowing must be accepted: %v", err) + } +} + +// The topological order is a real order: every dependency precedes its +// dependent, and a diamond resolves. +func TestParsePlanEmitsAValidTopologicalOrder(t *testing.T) { + // a -> b, a -> c, (b,c) -> d : the diamond. + plan, err := ParsePlan(planArgs([]any{ + task("d", "join", "b", "c"), task("b", "left", "a"), task("c", "right", "a"), task("a", "root"), + }, okBudget()), readOnlyLimits()) + if err != nil { + t.Fatalf("ParsePlan: %v", err) + } + position := map[string]int{} + for index, id := range plan.Order() { + position[id] = index + } + if len(position) != 4 { + t.Fatalf("order must contain every task: %v", plan.Order()) + } + for _, edge := range [][2]string{{"a", "b"}, {"a", "c"}, {"b", "d"}, {"c", "d"}} { + if position[edge[0]] >= position[edge[1]] { + t.Fatalf("%s must precede %s in %v", edge[0], edge[1], plan.Order()) + } + } +} + +// The tool refuses when the posture is off, even if a model calls it by name — +// the registry dispatches by name, so display gating is not enforcement. +func TestOrchestrateRefusesWhenThePostureIsOff(t *testing.T) { + tool := &OrchestrateTool{PostureActive: func() bool { return false }} + res := tool.Run(context.Background(), planArgs([]any{task("a", "x")}, okBudget())) + if res.Status != tools.StatusError { + t.Fatalf("status = %v, want error", res.Status) + } + if !strings.Contains(res.Output, "zeromaxing") { + t.Fatalf("the refusal must name the posture: %q", res.Output) + } +} + +// (b)(c)(d) POSTURE ON: the tool is usable, Safety reports Allow rather than +// Deny, and Deferred behaves as specified. These close the two gaps the +// posture-off identity test could not: "Safety always denies" and "Deferred +// never defers" were both invisible to it. +func TestOrchestratePostureOnGates(t *testing.T) { + on := &OrchestrateTool{PostureActive: func() bool { return true }} + off := &OrchestrateTool{PostureActive: func() bool { return false }} + + if got := on.Safety().Permission; got != tools.PermissionAllow { + t.Fatalf("posture ON Safety().Permission = %v, want Allow — see the decision comment in plan_tool.go", got) + } + if got := off.Safety().Permission; got != tools.PermissionDeny { + t.Fatalf("posture OFF Safety().Permission = %v, want Deny", got) + } + if on.Deferred() { + t.Fatal("posture ON must un-defer the tool") + } + if !off.Deferred() { + t.Fatal("posture OFF must defer the tool") + } + // DeferralEligible stays true in BOTH states, so un-deferring can never + // drop the global eligible count below the threshold and force-expose every + // other deferred tool. + if !on.DeferralEligible() || !off.DeferralEligible() { + t.Fatal("DeferralEligible must stay true in both states") + } + // A nil PostureActive is off — fail-safe for a tool that spends budget. + if !(&OrchestrateTool{}).Deferred() { + t.Fatal("an unwired tool must default to off") + } + if got := (&OrchestrateTool{}).Safety().Permission; got != tools.PermissionDeny { + t.Fatalf("an unwired tool must deny, got %v", got) + } +} + +// A wired tool with no runner refuses rather than reporting a plan it never ran. +func TestOrchestrateRefusesWithoutARunner(t *testing.T) { + tool := &OrchestrateTool{PostureActive: func() bool { return true }, ParentTools: []string{"read_file"}} + res := tool.Run(context.Background(), planArgs([]any{task("a", "x")}, okBudget())) + if res.Status != tools.StatusError || !strings.Contains(res.Output, "runner") { + t.Fatalf("a tool with no runner must refuse loudly, got %v %q", res.Status, res.Output) + } +} + +// An invalid plan is refused by the TOOL, proving ParsePlan is on the live path +// and not merely present — the prototype's validator was reachable and never +// called. +func TestOrchestrateRunRejectsAnInvalidPlan(t *testing.T) { + ran := false + tool := &OrchestrateTool{ + PostureActive: func() bool { return true }, + ParentTools: []string{"read_file"}, + RunTask: func(context.Context, Task, []string) (TaskResult, error) { + ran = true + return TaskResult{Outcome: TaskSucceeded}, nil + }, + } + res := tool.Run(context.Background(), planArgs([]any{task("a", "x", "b"), task("b", "y", "a")}, okBudget())) + if res.Status != tools.StatusError || !strings.Contains(res.Output, "cycle") { + t.Fatalf("the tool must reject a cyclic plan, got %v %q", res.Status, res.Output) + } + if ran { + t.Fatal("no task may run when validation rejects the plan") + } +} + +var _ = errors.New +var _ = time.Second + +// A non-completed plan must NOT report OK at the TOOL boundary. +// +// The executor's status was already asserted, but nothing checked what the tool +// hands back to the agent loop — and that is the boundary where "failure +// reported as success" actually reaches the model and the exit path. Audit +// RC-F is about exactly this seam. +func TestOrchestrateToolStatusFollowsThePlanStatus(t *testing.T) { + cases := []struct { + name string + runner PlanRunner + want tools.Status + wantSub string + }{ + {"all succeed", func(context.Context, Task, []string) (TaskResult, error) { + return TaskResult{Outcome: TaskSucceeded, Output: "ok"}, nil + }, tools.StatusOK, "completed"}, + {"one fails -> partial", func(_ context.Context, task Task, _ []string) (TaskResult, error) { + if task.ID == "b" { + return TaskResult{Outcome: TaskFailed, Err: "no"}, errors.New("no") + } + return TaskResult{Outcome: TaskSucceeded, Output: "ok"}, nil + }, tools.StatusError, "partial"}, + {"all fail -> failed", func(context.Context, Task, []string) (TaskResult, error) { + return TaskResult{Outcome: TaskFailed, Err: "no"}, errors.New("no") + }, tools.StatusError, "failed"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tool := &OrchestrateTool{ + PostureActive: func() bool { return true }, + ParentTools: []string{"read_file"}, + RunTask: tc.runner, + } + res := tool.Run(context.Background(), planArgs([]any{task("a", "x"), task("b", "y")}, okBudget())) + if res.Status != tc.want { + t.Fatalf("status = %v, want %v\n%s", res.Status, tc.want, res.Output) + } + if !strings.Contains(res.Output, tc.wantSub) { + t.Fatalf("the summary must name the terminal status %q:\n%s", tc.wantSub, res.Output) + } + if got := res.Meta["plan_status"]; got != tc.wantSub { + t.Fatalf("Meta[plan_status] = %q, want %q", got, tc.wantSub) + } + // max_speedup is surfaced in Meta so the kill criterion is machine + // readable, not only human readable. + if _, ok := res.Meta["max_speedup"]; !ok { + t.Fatal("Meta must carry max_speedup — the number the Phase 3 decision rests on") + } + }) + } +} diff --git a/internal/specialist/plan_tool.go b/internal/specialist/plan_tool.go new file mode 100644 index 000000000..778b61f04 --- /dev/null +++ b/internal/specialist/plan_tool.go @@ -0,0 +1,215 @@ +package specialist + +import ( + "context" + "strconv" + + "github.com/Gitlawb/zero/internal/tools" +) + +// OrchestrateToolName is the single spelling of the plan-capture tool. +const OrchestrateToolName = "orchestrate" + +// OrchestrateTool accepts a structured PLAN as tool arguments, validates it, +// records it as session events, and executes it SEQUENTIALLY through the same +// specialist path a Task call uses. +// +// It is DEFERRED unless the zeromaxing posture is active. That is the whole +// mechanism behind Phase 2's overriding constraint: with the posture off the +// tool is registered but never advertised, so the advertised tool set, the +// tool-definition bytes and the assembled prompt are byte-identical to a build +// without the feature (proved by +// TestPostureOffPrefixUnchangedByRegisteringTheTool in internal/agent). +// +// Phase 2 is read-only and sequential by construction, not by convention: plan +// tasks may not request mutating tools, and Budget.MaxWorkers must be 1. Both +// are rejected at parse time rather than coerced, so the fields stay meaningful +// for a later phase instead of quietly lying. +type OrchestrateTool struct { + // PostureActive reports whether the zeromaxing posture is on. nil means off + // — a caller that never wires it gets today's behaviour, which is the + // fail-safe direction for a tool that spends budget. + PostureActive func() bool + // RunTask executes one plan task. nil makes the tool refuse rather than + // pretend it ran a plan. + RunTask PlanRunner + // Recorder receives plan lifecycle events; nil disables recording without + // affecting execution. + Recorder PlanRecorder + // ParentTools is this run's tool grant. A task may narrow it, never widen. + ParentTools []string + // Depth is the nesting depth of the run holding this tool, used for the + // admission-time headroom check. + Depth int + // Limits overrides the default caps; nil uses them. + Limits *Limits +} + +const ( + // defaultPlanMaxTasks bounds plan size. Deliberately small: Phase 2 exists + // to measure whether fan-out would pay, not to run large plans. + defaultPlanMaxTasks = 20 + // defaultPlanMaxTokens caps what one orchestrate call may spend across every + // child it launches. + defaultPlanMaxTokens = 200_000 +) + +func (tool *OrchestrateTool) Name() string { return OrchestrateToolName } + +func (tool *OrchestrateTool) Description() string { + return "Execute a structured plan of read-only sub-agent tasks in dependency order. " + + "Tasks run sequentially; declare dependencies with depends_on so the plan records which work was independent." +} + +// Deferred hides the tool unless the posture is active — as a SECOND layer. +// +// It is not the primary mechanism, and the brief's original design assumed it +// was. Deferral only hides anything when it is ACTIVE, which needs a configured +// threshold, enough eligible tools AND a runnable tool_search loader; when it is +// inactive — an ordinary configuration — every visible tool is exposed eagerly. +// An unsafe-mode session with deferral off would therefore have advertised this +// tool with the posture off, breaking the additivity constraint. Safety() +// returning PermissionDeny is what actually enforces it, because ToolAdvertised +// short-circuits on Deny in every permission mode and runs BEFORE the deferral +// machinery. This stays as defence in depth and is asserted, not assumed. +func (tool *OrchestrateTool) Deferred() bool { + return !tool.postureActive() +} + +// DeferralEligible keeps this tool counting toward the DeferThreshold even when +// it un-defers, so turning the posture on cannot deactivate deferral for other +// tools and force-expose them. +func (tool *OrchestrateTool) DeferralEligible() bool { return true } + +func (tool *OrchestrateTool) postureActive() bool { + return tool != nil && tool.PostureActive != nil && tool.PostureActive() +} + +func (tool *OrchestrateTool) Parameters() tools.Schema { + return tools.Schema{ + Type: "object", + Properties: map[string]tools.PropertySchema{ + "name": {Type: "string", Description: "Short label for the plan."}, + "description": {Type: "string", Description: "What the plan is for."}, + "tasks": { + Type: "array", + Description: "The plan's tasks. Each has an id, a prompt, optional depends_on ids, an optional read-only tool subset, and an optional phase label.", + }, + "budget": { + Type: "object", + Description: "Required. max_tokens is required; max_workers must be 1 (this phase executes sequentially); max_wall_seconds is optional.", + }, + }, + Required: []string{"tasks", "budget"}, + AdditionalProperties: false, + } +} + +func (tool *OrchestrateTool) Safety() tools.Safety { + // PermissionDeny when the posture is off. This — not Deferred() — is what + // actually enforces the additivity constraint: ToolVisible consults + // ToolAdvertised, which short-circuits on PermissionDeny for EVERY + // permission mode, and it runs BEFORE the deferral machinery. Deferred() + // alone is insufficient because deferral only activates when a usable + // tool_search loader is registered and the eligible count clears the + // threshold; when it is inactive every visible tool is exposed eagerly, so + // an unsafe-mode session with deferral off would have advertised this tool + // with the posture off. Deferred() below is now belt-and-braces. + permission := tools.PermissionDeny + if tool.postureActive() { + // PermissionAllow, not PermissionPrompt, and this is deliberate. + // + // WHY NOT PROMPT: the approval surface renders "permission: orchestrate + // prompt" plus this static Reason and nothing else. PermissionRequest + // carries an Args map, but no renderer reads it — verified across + // permission_prompt.go, transcript.go and rendering.go. So the user + // would be asked to approve a plan without being shown its task count, + // its prompts, its budget or its graph. A dialog that cannot show what + // it is approving is not a gate; it trains click-through, which is + // worse than no dialog because it also erodes the prompts that DO carry + // information. + // + // WHAT BOUNDS IT INSTEAD, all enforced rather than advisory: + // - tasks are READ-ONLY, rejected at validation (validateTaskTools) + // and intersected again at dispatch (planToolGrant) + // - a budget is REQUIRED and enforced at dispatch, not just validated + // - the posture itself was explicit user consent: this tool does not + // exist until someone types /effort zeromaxing + // + // PHASE 3 MUST REVISIT THIS. The moment plan tasks can WRITE, an + // approval gate becomes necessary — and it needs a real renderer that + // shows the plan first. Inheriting Allow without that renderer would be + // inheriting this reasoning without its precondition. + permission = tools.PermissionAllow + } + return tools.Safety{ + SideEffect: tools.SideEffectShell, + Permission: permission, + Reason: "Runs a plan of read-only specialist sub-agents under the parent's sandbox and tool grant.", + // Irrelevant while Permission is Allow (auto advertises Allow tools + // anyway) and equally irrelevant while it is Deny (ToolAdvertised + // short-circuits on Deny before reading this). Left false so the field + // never becomes the thing holding the gate open. + AdvertiseInAuto: false, + } +} + +func (tool *OrchestrateTool) Run(ctx context.Context, args map[string]any) tools.Result { + return tool.RunWithOptions(ctx, args, tools.RunOptions{}) +} + +// Limits supplies the hard caps a plan must fit inside. nil means the defaults. +func (tool *OrchestrateTool) limits(options tools.RunOptions) Limits { + limits := Limits{ + MaxTasks: defaultPlanMaxTasks, + MaxTokens: defaultPlanMaxTokens, + CurrentDepth: tool.Depth, + ParentTools: tool.ParentTools, + } + if tool.Limits != nil { + limits = *tool.Limits + } + return limits +} + +func (tool *OrchestrateTool) RunWithOptions(ctx context.Context, args map[string]any, options tools.RunOptions) tools.Result { + // The posture gate, again at the point of USE. Safety() already hides the + // tool, but a model can call a tool it was never advertised — the registry + // dispatches by name — so refusing here is what makes "only under the + // posture" a rule rather than a display convention. + if !tool.postureActive() { + return tools.Result{ + Status: tools.StatusError, + Output: "Error: orchestrate is only available under the zeromaxing posture. Turn it on with /effort zeromaxing.", + } + } + // ParsePlan validates as part of parsing; there is no other constructor, so + // this call cannot be bypassed by any argument shape. + plan, err := ParsePlan(args, tool.limits(options)) + if err != nil { + return tools.Result{Status: tools.StatusError, Output: "Error: " + err.Error()} + } + if tool.RunTask == nil { + return tools.Result{Status: tools.StatusError, Output: "Error: orchestrate has no task runner wired."} + } + + recordPlanAdmitted(tool.Recorder, plan) + report := ExecutePlan(ctx, plan, tool.ParentTools, tool.RunTask, tool.Recorder) + recordPlanCompleted(tool.Recorder, plan, report) + + result := tools.Result{ + Status: tools.StatusOK, + Output: report.Summary(), + Meta: map[string]string{ + "plan_status": string(report.Status), + "max_speedup": strconv.FormatFloat(report.MaxSpeedup, 'f', 2, 64), + }, + } + // A plan that did not fully succeed must NOT report OK. This repo has + // repeatedly reported failure as success; in a plan, nineteen of twenty + // tasks failing surfacing as a clean result is the same defect at scale. + if report.Status != PlanCompleted { + result.Status = tools.StatusError + } + return result +} From 940aba20e81dfc76ccb39e83e70ae4d84a808979 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:43:09 +0530 Subject: [PATCH 08/86] feat(cli): register the orchestrate tool and map a partial plan to exit 4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires Phase 2 into the real binary and maps a plan that did not fully complete onto the existing incomplete exit path. POSTURE CALLBACK — a POINTER, not a closure. The TUI model is a VALUE type (every handler takes and returns `m model`), so a func() bool closing over it would capture a copy frozen at registration and report the posture as it was when the session started, forever. Re-registration fails for the reason decision 2 rejected it earlier and worse: the TUI clones the registry per run and the clone copies tool POINTERS, so a replacement registered into the session registry would never reach a run already holding a clone. specialist.PostureGate is one shared atomic flag the tool holds for the process's life; every clone sees the same one and a posture flip needs no re-registration. PLAN RUNNER — captures only run-INVARIANT state (executor, workspace, parent identity and policy) and NO context. The ctx it uses is the one ExecutePlan hands it per task, so a cancelled run cancels the task in flight; it also checks ctx.Err() before launching, so a cancelled plan does not spend another task's budget. Capturing a context at construction is precisely how the prototype's background goroutine kept running after cancellation. The runner is synchronous and holds no goroutine, so it cannot outlive its plan. PLAN RECORDER — preserves execSessionRecorder's best-effort contract exactly. Every bridge method returns nothing, so a recording failure has no path back into ExecutePlan and cannot abort a plan mid-flight; nil is a no-op at every level. The bridge is built before the session exists and its inner recorder attached afterwards, so events before that point are simply not recorded rather than fatal. PARTIAL -> EXIT 4, folded into the EXISTING result.Incomplete path rather than a second exit route, so a partial plan and a stalled loop report the same way. It never overrides an incompleteness the loop already found. A DEFECT FOUND BY DRIVING THE BINARY, not by any test: the budget was enforced at dispatch against a counter that never moved. NewPlanRunner never populated TaskResult.Tokens, so a plan with max_tokens:1 ran all three tasks. The unit test passed because its fake runner fabricated its own token counts — the same "test the helper, miss the wiring" pattern that produced the flag-parser hole, the self-correct capture point, and the identity test's wrong gate. ExecResult gains an additive TotalTokens, populated from the stream summary the accounting path already computes. Verified at the real surface: orchestrate absent with the posture off and present as [shell/allow] with it on in the same auto-mode session; a 4-task diamond running in topological order with results verbatim and max_speedup 1.33x (hand-checked: four equal tasks over three hops = 4/3); cyclic, write-tool, max_workers and missing-budget plans each rejected with the offending ids or values named; and budget exhaustion reporting partial with `echo $?` showing 4. --- internal/cli/app.go | 44 ++++++++++ internal/cli/exec.go | 33 +++++++- internal/cli/exec_zeromaxing_test.go | 42 +++++++++ internal/cli/plan_recorder.go | 122 +++++++++++++++++++++++++++ internal/specialist/exec.go | 12 ++- internal/specialist/plan_gate.go | 41 +++++++++ internal/specialist/plan_runner.go | 118 ++++++++++++++++++++++++++ 7 files changed, 409 insertions(+), 3 deletions(-) create mode 100644 internal/cli/plan_recorder.go create mode 100644 internal/specialist/plan_gate.go create mode 100644 internal/specialist/plan_runner.go diff --git a/internal/cli/app.go b/internal/cli/app.go index e7854e966..eb82ecefb 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -1056,7 +1056,30 @@ func (r *agentToolRuntime) specialistInfos() []agent.SpecialistInfo { return r.specialists } +// orchestrateWiring carries what registerSpecialistTools needs to wire the +// plan tool. A zero value leaves the tool registered but permanently off, which +// is the posture-off behaviour every existing caller already gets. +type orchestrateWiring struct { + // Gate is the shared posture flag. A POINTER, not a closure over caller + // state: the TUI model is a value type copied on every update, so a closure + // would freeze the posture as it was at registration. + Gate *specialist.PostureGate + // Recorder receives plan lifecycle events; nil disables recording. + Recorder specialist.PlanRecorder + // ParentTools is the run's grant. A plan task may narrow it, never widen. + ParentTools []string + // PlanContext supplies the run-invariant state a plan task inherits. + PlanContext specialist.PlanTaskContext +} + func registerSpecialistTools(registry *tools.Registry, workspaceRoot string, maxTeamSize int) (*agentToolRuntime, error) { + return registerSpecialistToolsWith(registry, workspaceRoot, maxTeamSize, orchestrateWiring{}) +} + +// registerSpecialistToolsWith is registerSpecialistTools plus the orchestrate +// tool. Split so every existing caller keeps its exact behaviour and the new +// wiring is opt-in at the call site rather than a signature change. +func registerSpecialistToolsWith(registry *tools.Registry, workspaceRoot string, maxTeamSize int, wiring orchestrateWiring) (*agentToolRuntime, error) { paths, err := specialist.DefaultPaths(workspaceRoot) if err != nil { return nil, err @@ -1080,6 +1103,27 @@ func registerSpecialistTools(registry *tools.Registry, workspaceRoot string, max return nil, err } swarm.RegisterTools(registry, sw) + // The orchestrate tool. Registered ALWAYS and gated by Safety(): with the + // posture off it reports PermissionDeny, so it is never advertised and the + // prefix is byte-identical to a build without it. Registering conditionally + // would not work — the TUI builds its registry once per session and clones + // tool POINTERS per run, so a tool added on a posture flip would never + // reach a run already holding a clone. + planContext := wiring.PlanContext + planContext.Executor = executor + if strings.TrimSpace(planContext.Cwd) == "" { + planContext.Cwd = workspaceRoot + } + if strings.TrimSpace(planContext.SpecialistName) == "" { + planContext.SpecialistName = "explorer" + } + registry.Register(&specialist.OrchestrateTool{ + PostureActive: wiring.Gate.Active, + RunTask: specialist.NewPlanRunner(planContext), + Recorder: wiring.Recorder, + ParentTools: wiring.ParentTools, + Depth: planContext.Depth, + }) return &agentToolRuntime{specialist: runtime, swarm: sw, specialists: specialistSummaries(paths)}, nil } diff --git a/internal/cli/exec.go b/internal/cli/exec.go index 3d018e1b5..3f543e2a0 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -24,6 +24,7 @@ import ( "github.com/Gitlawb/zero/internal/providers" "github.com/Gitlawb/zero/internal/sandbox" "github.com/Gitlawb/zero/internal/sessions" + "github.com/Gitlawb/zero/internal/specialist" "github.com/Gitlawb/zero/internal/specmode" "github.com/Gitlawb/zero/internal/streamjson" "github.com/Gitlawb/zero/internal/tools" @@ -230,6 +231,12 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in registry.Register(tools.NewEscalateModelTool()) } var specialistRuntime *agentToolRuntime + var planGate *specialist.PostureGate + // Created before registration so the tool can hold it; its inner exec + // recorder is attached once the session exists (below). A nil inner is a + // no-op, so events before that point are simply not recorded — recording is + // best-effort and must never be the thing that fails a run. + planRecorder := &planSessionRecorder{} if shouldRegisterExecSpecialistTools(options) { // Specialist tools register before the full config resolve below (so // --list-tools stays offline). swarm.maxTeamSize is not affected by @@ -240,7 +247,22 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in maxTeamSize = swarmCfg.Swarm.MaxTeamSize } var err error - specialistRuntime, err = registerSpecialistTools(registry, workspaceRoot, maxTeamSize) + // The posture is fixed for a headless run, so the gate is set once here + // rather than flipping. It is still a POINTER for the same reason the + // TUI needs one: the tool holds it for the process's life. + planGate = &specialist.PostureGate{} + planGate.Set(execProfile.IsZeromaxing()) + specialistRuntime, err = registerSpecialistToolsWith(registry, workspaceRoot, maxTeamSize, orchestrateWiring{ + Gate: planGate, + Recorder: planRecorder, + PlanContext: specialist.PlanTaskContext{ + Cwd: workspaceRoot, + // Resolved permission mode is not available this early; the + // executor applies its own fail-safe mapping from an empty mode + // (never unsafe), so a plan task can never exceed the parent. + PermissionMode: "", + }, + }) if err != nil { return writeExecProviderError(stdout, stderr, options.outputFormat, "specialist_error", err.Error()) } @@ -625,6 +647,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in } sessionRecorder := execSessionRecorder{prepared: preparedSession} + planRecorder.recorder = &sessionRecorder // Surface a best-effort session-recording failure once, on every exit path. defer sessionRecorder.warnIfRecordingFailed(stderr) sessionRecorder.append(sessions.EventMessage, map[string]any{ @@ -832,6 +855,14 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in // {"type":"done","exit_code":0} that would otherwise mask the incomplete exit. // An `error` event (not just a warning) is emitted so log/cron consumers that // scan for type=="error" can recover the reason. + // A plan that did not fully complete is work left undone, which is exactly + // what exitIncomplete exists for. Folded into the EXISTING incomplete path + // rather than a second exit route, so a plan and a stalled loop report the + // same way. Does not override an incompleteness the loop already found. + if reason, planIncomplete := planRecorder.Incomplete(); planIncomplete && !result.Incomplete { + result.Incomplete = true + result.IncompleteReason = reason + } if result.Incomplete { reason := result.IncompleteReason if reason == "" { diff --git a/internal/cli/exec_zeromaxing_test.go b/internal/cli/exec_zeromaxing_test.go index 1b9483cee..a07b69584 100644 --- a/internal/cli/exec_zeromaxing_test.go +++ b/internal/cli/exec_zeromaxing_test.go @@ -10,6 +10,7 @@ import ( "github.com/Gitlawb/zero/internal/config" "github.com/Gitlawb/zero/internal/execprofile" "github.com/Gitlawb/zero/internal/modelregistry" + "github.com/Gitlawb/zero/internal/specialist" ) // (a) CLI: an explicit --reasoning-effort survives zeromaxing. The profile @@ -413,3 +414,44 @@ func TestExecDeltaShowsTheCallersOwnTurnBudget(t *testing.T) { t.Fatalf("exactly one reasoning-effort statement expected, found %d:\n%s", n, stderr) } } + +// A partial plan must reach the PROCESS exit code, not just the tool result. +// +// Asserting the executor's status missed the tool's status; asserting the +// tool's status would miss the exit code the same way. This is the third link, +// asserted at the boundary that actually matters to a caller. +func TestPlanPartialMapsToIncompleteExit(t *testing.T) { + recorder := &planSessionRecorder{} + if _, incomplete := recorder.Incomplete(); incomplete { + t.Fatal("a recorder that saw no plan must not mark the run incomplete") + } + plan, err := specialist.ParsePlan(map[string]any{ + "name": "p", + "tasks": []any{map[string]any{"id": "a", "prompt": "x"}}, + "budget": map[string]any{"max_workers": float64(1), "max_tokens": float64(10)}, + }, specialist.Limits{MaxTasks: 5, MaxTokens: 100}) + if err != nil { + t.Fatalf("ParsePlan: %v", err) + } + // A COMPLETED plan leaves the run alone. + recorder.PlanCompleted(plan, specialist.PlanReport{Status: specialist.PlanCompleted, Succeeded: 1}) + if _, incomplete := recorder.Incomplete(); incomplete { + t.Fatal("a completed plan must not mark the run incomplete") + } + // A PARTIAL one does, and names the counts. + recorder.PlanCompleted(plan, specialist.PlanReport{Status: specialist.PlanPartial, Succeeded: 1, Skipped: 2}) + reason, incomplete := recorder.Incomplete() + if !incomplete { + t.Fatal("a partial plan must mark the run incomplete, which is exit 4") + } + for _, want := range []string{"partial", "1 succeeded", "2 skipped"} { + if !strings.Contains(reason, want) { + t.Fatalf("the reason must carry %q: %q", want, reason) + } + } + // FAILED outranks partial: if any plan failed outright, that is the story. + recorder.PlanCompleted(plan, specialist.PlanReport{Status: specialist.PlanFailed}) + if reason, _ := recorder.Incomplete(); !strings.Contains(reason, "failed") { + t.Fatalf("a failed plan must outrank a partial one: %q", reason) + } +} diff --git a/internal/cli/plan_recorder.go b/internal/cli/plan_recorder.go new file mode 100644 index 000000000..99a216d55 --- /dev/null +++ b/internal/cli/plan_recorder.go @@ -0,0 +1,122 @@ +package cli + +import ( + "fmt" + + "github.com/Gitlawb/zero/internal/sessions" + "github.com/Gitlawb/zero/internal/specialist" +) + +// planSessionRecorder bridges plan lifecycle events onto the existing exec +// session recorder. +// +// IT PRESERVES THE BEST-EFFORT CONTRACT EXACTLY. execSessionRecorder.append +// returns nothing, latches its first error, and short-circuits every later +// call; warnIfRecordingFailed surfaces that once at run end. This type adds no +// error path of its own: every method returns nothing, so there is no way for a +// recording failure to reach ExecutePlan, and therefore no way for it to abort +// a plan mid-flight. A nil recorder is a no-op rather than a panic, for the +// same reason. +// +// The payloads are deliberately small and structured — ids, counts, durations, +// the terminal status and max_speedup — not whole task outputs. A plan's task +// outputs already reach the transcript through the tool result; duplicating +// them into the event log would multiply a large plan's on-disk size for no +// added recoverability. +type planSessionRecorder struct { + recorder *execSessionRecorder + // worst remembers the most serious non-completed plan status seen this run, + // so the PROCESS exit code can reflect it. Asserting the executor's status + // missed the tool's status, and asserting the tool's status would miss the + // exit code the same way — this is the third link in that chain. + worst specialist.PlanStatus + worstReason string +} + +// Incomplete reports whether any plan this run did not fully complete, with a +// reason for the run-end message. A partial plan is work left undone, which is +// exactly what exitIncomplete exists for. +func (bridge *planSessionRecorder) Incomplete() (string, bool) { + if bridge == nil || bridge.worst == "" || bridge.worst == specialist.PlanCompleted { + return "", false + } + return bridge.worstReason, true +} + +func (bridge *planSessionRecorder) append(eventType sessions.EventType, payload any) { + // Nil-safe at every level: a nil bridge or a nil inner recorder simply does + // not record. Recording must never be the thing that fails a run. + if bridge == nil || bridge.recorder == nil { + return + } + bridge.recorder.append(eventType, payload) +} + +func (bridge *planSessionRecorder) PlanAdmitted(plan specialist.Plan) { + tasks := make([]map[string]any, 0, plan.TaskCount()) + for _, task := range plan.Tasks() { + tasks = append(tasks, map[string]any{ + "id": task.ID, "depends_on": task.DependsOn, "phase": task.Phase, + }) + } + bridge.append(sessions.EventPlanAdmitted, map[string]any{ + "name": plan.Name(), + "task_count": plan.TaskCount(), + "order": plan.Order(), + "tasks": tasks, + "max_tokens": plan.Budget().MaxTokens, + }) +} + +func (bridge *planSessionRecorder) TaskDispatched(task specialist.Task) { + bridge.append(sessions.EventTaskDispatched, map[string]any{ + "id": task.ID, "depends_on": task.DependsOn, + }) +} + +func (bridge *planSessionRecorder) TaskCompleted(result specialist.TaskResult) { + bridge.append(sessions.EventTaskCompleted, map[string]any{ + "id": result.ID, + // Duration is what the metric is computed from, so it is recorded per + // task rather than only in the aggregate. + "duration_ms": result.Duration.Milliseconds(), + "session_id": result.SessionID, + "tokens": result.Tokens, + }) +} + +func (bridge *planSessionRecorder) TaskFailed(result specialist.TaskResult) { + bridge.append(sessions.EventTaskFailed, map[string]any{ + "id": result.ID, + // The outcome distinguishes a real failure from a dependency or budget + // skip. A skipped task is RECORDED, never dropped. + "outcome": string(result.Outcome), + "reason": result.Err, + "duration_ms": result.Duration.Milliseconds(), + }) +} + +func (bridge *planSessionRecorder) PlanCompleted(plan specialist.Plan, report specialist.PlanReport) { + if bridge != nil && report.Status != specialist.PlanCompleted { + // PlanFailed outranks PlanPartial: if any plan failed outright, that is + // the run's story. + if bridge.worst != specialist.PlanFailed { + bridge.worst = report.Status + bridge.worstReason = fmt.Sprintf("plan %q ended %s: %d succeeded, %d failed, %d skipped", + plan.Name(), report.Status, report.Succeeded, report.Failed, report.Skipped) + } + } + bridge.append(sessions.EventPlanCompleted, map[string]any{ + "name": plan.Name(), + "status": string(report.Status), + "succeeded": report.Succeeded, + "failed": report.Failed, + "skipped": report.Skipped, + // THE METRIC. Recorded so the kill criterion can be evaluated across + // many sessions without re-running anything. + "sequential_total_ms": report.SequentialTotal.Milliseconds(), + "critical_path_ms": report.CriticalPath.Milliseconds(), + "max_speedup": report.MaxSpeedup, + "tokens_used": report.TokensUsed, + }) +} diff --git a/internal/specialist/exec.go b/internal/specialist/exec.go index 702360543..e70017105 100644 --- a/internal/specialist/exec.go +++ b/internal/specialist/exec.go @@ -199,6 +199,13 @@ func manifestIsReadOnly(manifest Manifest) bool { type ExecResult struct { Result tools.Result SessionID string + // TotalTokens is the child's own token usage, when the stream reported it. + // Additive and zero when unknown, so every existing caller is unchanged. + // The plan executor meters its budget from this: without it the budget was + // enforced at dispatch against a counter that never moved, so it could + // never fire — found by driving the real binary, invisible to a unit test + // whose fake runner fabricated its own token counts. + TotalTokens int } type ChildRunResult struct { @@ -587,8 +594,9 @@ func (executor Executor) runBuiltArgs(ctx context.Context, built BuildArgsResult rolledUp := executor.rollUpSpecialistUsage(accounting, summary) executor.recordSpecialistStop(accounting, summary, summary.Status, summary.ExitCode, nil, rolledUp) return ExecResult{ - Result: BuildFinalResult(run.Events, run.Stderr, run.ExitCode, run.Signal), - SessionID: built.SessionID, + Result: BuildFinalResult(run.Events, run.Stderr, run.ExitCode, run.Signal), + SessionID: built.SessionID, + TotalTokens: summary.Usage.EffectiveTotalTokens(), }, nil } diff --git a/internal/specialist/plan_gate.go b/internal/specialist/plan_gate.go new file mode 100644 index 000000000..379e1e50e --- /dev/null +++ b/internal/specialist/plan_gate.go @@ -0,0 +1,41 @@ +package specialist + +import "sync/atomic" + +// PostureGate is the shared, mutable answer to "is the zeromaxing posture on?". +// +// It exists because none of the simpler options work here: +// +// - A func() bool closing over the TUI model is WRONG. model is a VALUE type +// (every handler takes and returns `m model`), so a closure created at +// registration captures a copy frozen at that instant and would report the +// posture as it was when the session started, forever. +// - Re-registering the tool on posture change is WRONG for the same reason +// decision 2 rejected it earlier, and worse here: the TUI clones the +// registry per run (cloneToolRegistry) but the clone copies tool POINTERS, +// so a replacement registered into the session registry would not reach a +// run already holding a clone. +// - A pointer to shared state is what actually survives both: the tool holds +// one gate pointer for the process's life, every clone shares it, and a +// posture flip is visible to the next call with no re-registration. +// +// atomic.Bool rather than a mutex because the TUI writes it from the update +// loop while a run's tool dispatch reads it from the agent goroutine, and this +// is a single flag with no invariant spanning other fields. +type PostureGate struct { + active atomic.Bool +} + +// Set records whether the posture is currently on. +func (gate *PostureGate) Set(on bool) { + if gate != nil { + gate.active.Store(on) + } +} + +// Active reports the posture. Nil-safe and false by default, so a caller that +// never wires the gate gets the posture-off behaviour — the fail-safe direction +// for a tool that spends a token budget. +func (gate *PostureGate) Active() bool { + return gate != nil && gate.active.Load() +} diff --git a/internal/specialist/plan_runner.go b/internal/specialist/plan_runner.go new file mode 100644 index 000000000..81dc177a8 --- /dev/null +++ b/internal/specialist/plan_runner.go @@ -0,0 +1,118 @@ +package specialist + +import ( + "context" + "strings" + "time" + + "github.com/Gitlawb/zero/internal/tools" +) + +// PlanTaskContext is the per-RUN state a plan task inherits from its parent. +// +// It is captured at registration and does not change for the process's life — +// unlike the posture, which flips between runs and therefore lives behind a +// PostureGate pointer. Everything here is genuinely run-invariant (paths, +// workspace) or supplied per-call. +type PlanTaskContext struct { + Executor Executor + Cwd string + // ParentSessionID / ParentModel / PermissionMode / Depth describe the run + // issuing the plan, so a task inherits exactly the parent's policy. + ParentSessionID string + ParentModel string + PermissionMode string + Depth int + // SpecialistName is the read-only specialist each plan task runs as. + SpecialistName string +} + +// NewPlanRunner adapts Executor.Run into a PlanRunner. +// +// LIFETIME, deliberately: the returned closure captures only run-INVARIANT +// state — the executor, the workspace, the parent's identity and policy. It +// captures NO context. The ctx it uses is the one ExecutePlan hands it per +// task, which is the tool call's own context, so a cancelled run cancels the +// task in flight. Capturing a context at construction is precisely how the +// prototype's background goroutine kept running after cancellation, and a +// runner that outlived its plan would do it again. +// +// The runner does not outlive the plan in any meaningful sense either: it is +// synchronous, returns before ExecutePlan moves to the next task, and holds no +// goroutine of its own. +func NewPlanRunner(planCtx PlanTaskContext) PlanRunner { + return func(ctx context.Context, task Task, grantedTools []string) (TaskResult, error) { + if ctx == nil { + ctx = context.Background() + } + // Honour cancellation BEFORE launching a child: a cancelled plan must + // not spend another task's budget. + if err := ctx.Err(); err != nil { + return TaskResult{Outcome: TaskFailed, Err: err.Error()}, err + } + + started := time.Now() + manifest := planTaskManifest(planCtx.SpecialistName, grantedTools) + res, err := planCtx.Executor.Run(ctx, TaskParameters{ + Name: planCtx.SpecialistName, + Prompt: task.Prompt, + Description: "plan task " + task.ID, + Manifest: &manifest, + }, TaskRunOptions{ + ParentSessionID: planCtx.ParentSessionID, + ParentModel: planCtx.ParentModel, + CurrentDepth: planCtx.Depth, + Cwd: planCtx.Cwd, + PermissionMode: planCtx.PermissionMode, + // Explicitly NOT MemberAutonomy: Phase 2 tasks are read-only, and + // granting it here would be the authority widening that was ruled + // out as needing its own decision. + }) + result := TaskResult{ + ID: task.ID, + Duration: time.Since(started), + SessionID: res.SessionID, + Output: res.Result.Output, + // The meter the plan budget is spent from. A task whose stream + // reported no usage costs 0 here, which is honest — but it means a + // provider that never reports usage cannot be budget-bounded by + // token count; MaxWall is the backstop in that case. + Tokens: res.TotalTokens, + } + if err != nil { + result.Outcome = TaskFailed + result.Err = err.Error() + return result, err + } + if res.Result.Status == tools.StatusError { + // The child ran but its task FAILED. Surfacing it as success would + // let a plan report work that did not happen. + result.Outcome = TaskFailed + result.Err = res.Result.Output + return result, nil + } + result.Outcome = TaskSucceeded + return result, nil + } +} + +// planTaskManifest builds the inline manifest a plan task runs under. The tool +// list is the ALREADY-INTERSECTED grant ExecutePlan computed, so this cannot +// widen it — it only carries it. +func planTaskManifest(name string, grantedTools []string) Manifest { + if strings.TrimSpace(name) == "" { + name = "explorer" + } + return Manifest{ + Metadata: Metadata{ + Name: name, + Description: "Read-only plan task.", + Tools: grantedTools, + }, + SystemPrompt: "You are executing one task of a larger plan. You have read-only tools. " + + "Complete exactly the task described and report what you found; do not attempt to modify anything.", + Location: LocationBuiltin, + FilePath: "(plan)", + ResolvedTools: grantedTools, + } +} From 3b1df93754ba1f90302141a69df8182c22b59947 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:57:28 +0530 Subject: [PATCH 09/86] feat(tui): flip the orchestrate posture gate from the real handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the two gaps left by the registration step. TUI GATE. handleProfileCommand and revertExecProfile write the shared PostureGate on every transition, threaded through tui.Options in the same shape as ZeromaxingDisabled. What is proved without a TTY: - the gate is written on posture ON and OFF through the REAL handlers, for BOTH entry points (/effort and /profile) — not through a helper, which is what missed the wiring four times in this feature - a REFUSED selection (disabled workspace) leaves the gate off - a nil gate is safe, so a caller that never wires one simply has no tool - THE cloneToolRegistry HAZARD, proved rather than assumed: a tool reached from a CLONE taken BEFORE a flip still observes the flip. That is the exact ordering a captured copy or a closure over this value-typed model would get wrong, and it is why the gate is a pointer - concurrent Set/read, for -race: the TUI writes from its update loop while a run's tool dispatch reads from the agent goroutine FAILING-TASK VERIFICATION. The stub gained a prompt sentinel that makes one specific child fail with a real provider-stream error, so a dependency failure runs end to end rather than being asserted only at the executor. A 4-task diamond where b fails now reports, verbatim: Plan partial: 2 succeeded, 1 failed, 1 skipped max_speedup: 1.49x - a [succeeded] full result verbatim - b [failed] provider error: sentinel failure - c [succeeded] independent sibling unaffected - d [dependency_failed] skipped: dependency "b" did not succeed Run incomplete (not reported as success) EXIT=4 Every level of the RC-F seam is now covered by something that can fail: executor status, tool status, recorder, and the process exit code. THE BUDGET DEFECT GETS ITS OWN GUARD. TestRealRunnerFeedsTheBudgetMeter drives NewPlanRunner over an Executor whose child reports usage, and asserts a max_tokens=1 plan launches exactly one child. A fake runner that fabricates its own token counts cannot catch a producer that never populates them — which is precisely how the defect survived every executor test. A second test pins that the runner honours the per-call context rather than a captured one. registerSpecialistTools regains its original name and takes the wiring argument directly; the transitional wrapper is gone now that both call sites pass wiring, so no dead indirection ships. --- internal/cli/app.go | 21 ++-- internal/cli/exec.go | 2 +- internal/specialist/plan_exec_test.go | 95 +++++++++++++++++ internal/tui/model.go | 5 + internal/tui/options.go | 9 +- internal/tui/session_controls.go | 7 ++ internal/tui/zeromaxing_test.go | 141 ++++++++++++++++++++++++++ 7 files changed, 269 insertions(+), 11 deletions(-) diff --git a/internal/cli/app.go b/internal/cli/app.go index eb82ecefb..6209d0fa4 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -706,7 +706,13 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a SensitiveEnvKeys: providerSensitiveEnvKeys(resolved), }) executionRunner.SetPreparer(sandboxEngine) - specialistRuntime, err := registerSpecialistTools(registry, workspaceRoot, resolved.Swarm.MaxTeamSize) + // One gate shared by the registered tool and the TUI that flips it. + zeromaxingGate := &specialist.PostureGate{} + specialistRuntime, err := registerSpecialistTools(registry, workspaceRoot, resolved.Swarm.MaxTeamSize, + orchestrateWiring{ + Gate: zeromaxingGate, + PlanContext: specialist.PlanTaskContext{Cwd: workspaceRoot}, + }) if err != nil { return writeAppError(stderr, "failed to initialize specialist tools: "+err.Error(), 1) } @@ -838,6 +844,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a SandboxStore: sandboxStore, MCPConfig: mcpConfig, ZeromaxingDisabled: resolved.Profiles.DisableZeromaxing, + ZeromaxingGate: zeromaxingGate, MCPPermissionStore: mcpPermissionStore, MCPTokenStore: mcpTokenStore, MCPCommand: func(ctx context.Context, args []string) tui.MCPCommandResult { @@ -1072,14 +1079,10 @@ type orchestrateWiring struct { PlanContext specialist.PlanTaskContext } -func registerSpecialistTools(registry *tools.Registry, workspaceRoot string, maxTeamSize int) (*agentToolRuntime, error) { - return registerSpecialistToolsWith(registry, workspaceRoot, maxTeamSize, orchestrateWiring{}) -} - -// registerSpecialistToolsWith is registerSpecialistTools plus the orchestrate -// tool. Split so every existing caller keeps its exact behaviour and the new -// wiring is opt-in at the call site rather than a signature change. -func registerSpecialistToolsWith(registry *tools.Registry, workspaceRoot string, maxTeamSize int, wiring orchestrateWiring) (*agentToolRuntime, error) { +// registerSpecialistTools registers the specialist, swarm and orchestrate tools. +// The wiring argument carries what the orchestrate tool needs; a zero value +// leaves it registered but permanently off, which is the posture-off behaviour. +func registerSpecialistTools(registry *tools.Registry, workspaceRoot string, maxTeamSize int, wiring orchestrateWiring) (*agentToolRuntime, error) { paths, err := specialist.DefaultPaths(workspaceRoot) if err != nil { return nil, err diff --git a/internal/cli/exec.go b/internal/cli/exec.go index 3f543e2a0..d9d6b2724 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -252,7 +252,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in // TUI needs one: the tool holds it for the process's life. planGate = &specialist.PostureGate{} planGate.Set(execProfile.IsZeromaxing()) - specialistRuntime, err = registerSpecialistToolsWith(registry, workspaceRoot, maxTeamSize, orchestrateWiring{ + specialistRuntime, err = registerSpecialistTools(registry, workspaceRoot, maxTeamSize, orchestrateWiring{ Gate: planGate, Recorder: planRecorder, PlanContext: specialist.PlanTaskContext{ diff --git a/internal/specialist/plan_exec_test.go b/internal/specialist/plan_exec_test.go index 4d8bddbf6..8b12f2443 100644 --- a/internal/specialist/plan_exec_test.go +++ b/internal/specialist/plan_exec_test.go @@ -7,6 +7,8 @@ import ( "strings" "testing" "time" + + "github.com/Gitlawb/zero/internal/streamjson" ) // recordingRecorder captures the lifecycle events, so "recorded, never silently @@ -331,3 +333,96 @@ func TestRecordingIsOptional(t *testing.T) { t.Fatalf("a nil recorder must not change the outcome, got %q", report.Status) } } + +// THE GUARD THE BUDGET DEFECT NEEDED. +// +// The budget was enforced at dispatch against a counter that never moved, +// because NewPlanRunner never populated TaskResult.Tokens. Every executor test +// passed because their fake runners fabricated their own token counts — a fake +// that invents numbers cannot catch a PRODUCER that never produces them. +// +// So this drives the REAL runner (NewPlanRunner over a stubbed Executor whose +// child reports usage) and asserts a max_tokens=1 plan stops after the first +// task. If the runner stops populating Tokens, this fails; a fake-runner test +// never would. +func TestRealRunnerFeedsTheBudgetMeter(t *testing.T) { + launched := []string{} + executor := Executor{ + BinaryPath: "/bin/true", + NewSessionID: func() (string, error) { + return "specialist_00000000000000000000000a", nil + }, + Load: func(LoadOptions) (LoadResult, error) { return LoadResult{}, nil }, + RunChild: func(_ context.Context, _ string, args []string, _ func(streamjson.Event)) (ChildRunResult, error) { + launched = append(launched, strings.Join(args, " ")) + // A child that reports usage, exactly as a real provider stream does. + return ChildRunResult{ + Started: true, + Events: []streamjson.Event{ + {Type: "assistant", Text: "done"}, + {Type: "usage", TotalTokens: intPtrForTest(500)}, + }, + }, nil + }, + } + runner := NewPlanRunner(PlanTaskContext{Executor: executor, Cwd: t.TempDir(), SpecialistName: "explorer"}) + + budget := okBudget() + budget["max_tokens"] = float64(1) // one token: the first task alone blows it + plan := mustPlan(t, []any{task("a", "x"), task("b", "y"), task("c", "z")}, budget, readOnlyLimits()) + + report := ExecutePlan(context.Background(), plan, []string{"read_file"}, runner, nil) + + // The REAL runner must have reported the child's tokens... + if report.TokensUsed <= 0 { + t.Fatalf("the real runner reported %d tokens; the budget meter never moves and enforcement can never fire", + report.TokensUsed) + } + // ...so exactly one task ran and the rest were skipped for budget. + if len(launched) != 1 { + t.Fatalf("a 1-token budget must stop after the first task, launched %d children", len(launched)) + } + byID := map[string]TaskResult{} + for _, task := range report.Tasks { + byID[task.ID] = task + } + for _, id := range []string{"b", "c"} { + if byID[id].Outcome != TaskSkippedBudget { + t.Fatalf("%s must be skipped for budget, got %q", id, byID[id].Outcome) + } + } + if report.Status != PlanPartial { + t.Fatalf("status = %q, want partial", report.Status) + } +} + +// The runner uses the ctx handed to it PER TASK, not one captured at +// construction — a captured context is how the prototype's goroutine ignored +// cancellation. +func TestRealRunnerHonoursThePerCallContext(t *testing.T) { + launched := 0 + executor := Executor{ + BinaryPath: "/bin/true", + NewSessionID: func() (string, error) { return "specialist_00000000000000000000000a", nil }, + Load: func(LoadOptions) (LoadResult, error) { return LoadResult{}, nil }, + RunChild: func(context.Context, string, []string, func(streamjson.Event)) (ChildRunResult, error) { + launched++ + return ChildRunResult{Started: true}, nil + }, + } + runner := NewPlanRunner(PlanTaskContext{Executor: executor, Cwd: t.TempDir(), SpecialistName: "explorer"}) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancelled BEFORE the plan runs + plan := mustPlan(t, []any{task("a", "x"), task("b", "y")}, okBudget(), readOnlyLimits()) + report := ExecutePlan(ctx, plan, []string{"read_file"}, runner, nil) + + if launched != 0 { + t.Fatalf("a cancelled context must launch no children, launched %d", launched) + } + if report.Status != PlanFailed { + t.Fatalf("a cancelled plan must not report success, got %q", report.Status) + } +} + +func intPtrForTest(v int) *int { return &v } diff --git a/internal/tui/model.go b/internal/tui/model.go index e5beee70f..bf9a3f09e 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -30,6 +30,7 @@ import ( "github.com/Gitlawb/zero/internal/sandbox" "github.com/Gitlawb/zero/internal/sessions" "github.com/Gitlawb/zero/internal/skills" + "github.com/Gitlawb/zero/internal/specialist" "github.com/Gitlawb/zero/internal/streamjson" "github.com/Gitlawb/zero/internal/tools" "github.com/Gitlawb/zero/internal/usage" @@ -158,6 +159,9 @@ type model struct { // zeromaxingDisabled mirrors resolved config's profiles.disableZeromaxing so // /effort and /profile consult the same rule the headless path applies. zeromaxingDisabled bool + // zeromaxingGate is the shared flag the orchestrate tool reads. Written on + // every posture transition so a run started afterwards sees it. + zeromaxingGate *specialist.PostureGate // execProfileEffortUnraised names the effort level a profile asked for but // could not apply on the active model, so the status output can say what it // did not raise instead of silently pretending it did. @@ -896,6 +900,7 @@ func newModel(ctx context.Context, options Options) model { sandboxStore: sandboxStore, mcpConfig: options.MCPConfig, zeromaxingDisabled: options.ZeromaxingDisabled, + zeromaxingGate: options.ZeromaxingGate, mcpPermissionStore: options.MCPPermissionStore, mcpTokenStore: options.MCPTokenStore, mcpCommand: options.MCPCommand, diff --git a/internal/tui/options.go b/internal/tui/options.go index b2e58a0a3..37acf0ee1 100644 --- a/internal/tui/options.go +++ b/internal/tui/options.go @@ -14,6 +14,7 @@ import ( "github.com/Gitlawb/zero/internal/sandbox" "github.com/Gitlawb/zero/internal/sessions" "github.com/Gitlawb/zero/internal/skills" + "github.com/Gitlawb/zero/internal/specialist" "github.com/Gitlawb/zero/internal/tools" "github.com/Gitlawb/zero/internal/usage" "github.com/Gitlawb/zero/internal/zeroruntime" @@ -50,7 +51,13 @@ type Options struct { // headless exec path applies. Resolved config already folded the // project-scope tighten-only merge, so a project .zero/config.json can set // it but never clear it. - ZeromaxingDisabled bool + ZeromaxingDisabled bool + // ZeromaxingGate is the SHARED posture flag the orchestrate tool reads. A + // pointer, not a bool: the tool is registered once and the registry is + // cloned per run copying tool POINTERS, so the flip has to be visible + // through shared state rather than through re-registration or a closure + // over this value-typed model. nil disables the posture for the tool. + ZeromaxingGate *specialist.PostureGate MCPPermissionStore *mcp.PermissionStore MCPTokenStore *mcp.TokenStore MCPCommand func(context.Context, []string) MCPCommandResult diff --git a/internal/tui/session_controls.go b/internal/tui/session_controls.go index 0e78f51b2..3b6369883 100644 --- a/internal/tui/session_controls.go +++ b/internal/tui/session_controls.go @@ -597,6 +597,10 @@ func (m model) handleProfileCommand(args string) (model, string) { m.zeromaxing = agent.ZeromaxingEntering } m.agentOptions.Zeromaxing = m.zeromaxing + // The orchestrate tool reads this. Written through the SHARED gate pointer, + // which is what a run's cloned registry also holds — see + // TestClonedRegistrySharesTheGatePointer. + m.zeromaxingGate.Set(m.zeromaxingActive()) return m, m.profileText() } @@ -638,6 +642,9 @@ func (m model) revertExecProfile() model { m.zeromaxing = agent.ZeromaxingExiting } m.agentOptions.Zeromaxing = m.zeromaxing + // Leaving the posture takes the tool away with it. Exiting is already + // "off" for zeromaxingActive, so this clears the gate. + m.zeromaxingGate.Set(m.zeromaxingActive()) m.agentOptions.Profile = nil m.execProfileName = "" m.execProfileDisplacedMaxTurns = 0 diff --git a/internal/tui/zeromaxing_test.go b/internal/tui/zeromaxing_test.go index f9a47b857..8dacd341c 100644 --- a/internal/tui/zeromaxing_test.go +++ b/internal/tui/zeromaxing_test.go @@ -10,6 +10,8 @@ import ( "github.com/Gitlawb/zero/internal/config" "github.com/Gitlawb/zero/internal/execprofile" "github.com/Gitlawb/zero/internal/modelregistry" + "github.com/Gitlawb/zero/internal/specialist" + "github.com/Gitlawb/zero/internal/tools" "github.com/Gitlawb/zero/internal/zeroruntime" ) @@ -731,3 +733,142 @@ func forwardedEffortForTest(registry modelregistry.Registry, modelID, requested } return string(effective) } + +// gateModel builds a session holding a real shared gate. +func gateModel(t *testing.T, gate *specialist.PostureGate) model { + t.Helper() + return newModel(context.Background(), Options{ + ProviderName: "anthropic", ModelName: "claude-sonnet-4.5", Provider: &fakeProvider{}, + ProviderProfile: config.ProviderProfile{Name: "anthropic", CatalogID: "anthropic", Model: "claude-sonnet-4.5", APIKey: "k"}, + ZeromaxingGate: gate, + NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { return &fakeProvider{}, nil }, + }) +} + +// The gate is written on posture ON and OFF, asserted through the REAL handlers +// rather than a helper — a helper test is what missed the wiring four times in +// this feature. +func TestPostureTransitionsWriteTheSharedGate(t *testing.T) { + for _, route := range []struct { + name string + on func(model) (model, string) + off func(model) (model, string) + }{ + {"/effort", + func(m model) (model, string) { return m.handleEffortCommand(execprofile.Name) }, + func(m model) (model, string) { return m.handleEffortCommand("auto") }}, + {"/profile", + func(m model) (model, string) { return m.handleProfileCommand(execprofile.Name) }, + func(m model) (model, string) { return m.handleProfileCommand("balanced") }}, + } { + t.Run(route.name, func(t *testing.T) { + gate := &specialist.PostureGate{} + m := gateModel(t, gate) + if gate.Active() { + t.Fatal("a fresh session must leave the gate off") + } + m, _ = route.on(m) + if !gate.Active() { + t.Fatalf("%s must turn the gate ON", route.name) + } + m, _ = route.off(m) + if gate.Active() { + t.Fatalf("%s must turn the gate OFF", route.name) + } + _ = m + }) + } +} + +// A REFUSED selection must not arm the gate — a disabled workspace must not end +// up with the tool live. +func TestRefusedSelectionDoesNotArmTheGate(t *testing.T) { + gate := &specialist.PostureGate{} + m := newModel(context.Background(), Options{ + ProviderName: "anthropic", ModelName: "claude-sonnet-4.5", Provider: &fakeProvider{}, + ProviderProfile: config.ProviderProfile{Name: "anthropic", CatalogID: "anthropic", Model: "claude-sonnet-4.5", APIKey: "k"}, + ZeromaxingGate: gate, + ZeromaxingDisabled: true, + NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { return &fakeProvider{}, nil }, + }) + if _, text := m.handleEffortCommand(execprofile.Name); !strings.Contains(text, "Cannot use") { + t.Fatalf("setup: the selection should have been refused:\n%s", text) + } + if gate.Active() { + t.Fatal("a refused selection must leave the gate off") + } +} + +// A nil gate must not panic — a caller that never wires one simply has no tool. +func TestNilGateIsSafe(t *testing.T) { + m := gateModel(t, nil) + m, _ = m.handleEffortCommand(execprofile.Name) + m, _ = m.handleEffortCommand("auto") + _ = m +} + +// THE cloneToolRegistry HAZARD, proved rather than assumed. +// +// The TUI registers the tool once and clones the registry per run; the clone +// copies tool POINTERS. If the gate were a value or a closure over the model, +// the clone's tool would read a stale posture. This asserts the tool reachable +// from a CLONE observes a flip written after the clone was taken. +func TestClonedRegistrySharesTheGatePointer(t *testing.T) { + gate := &specialist.PostureGate{} + registry := tools.NewRegistry() + registry.Register(&specialist.OrchestrateTool{PostureActive: gate.Active}) + + // Clone FIRST, flip the posture AFTER — the order that would break a + // captured copy. + clone := cloneToolRegistry(registry) + raw, ok := clone.Get(specialist.OrchestrateToolName) + if !ok { + t.Fatal("the clone must carry the tool") + } + // Read Deferred through the same interface the partition uses, so this + // exercises the real path rather than a concrete type assertion. + deferred := func() bool { + d, ok := raw.(interface{ Deferred() bool }) + if !ok { + t.Fatal("the cloned tool must still implement Deferred") + } + return d.Deferred() + } + cloned := raw + if !deferred() || cloned.Safety().Permission != tools.PermissionDeny { + t.Fatal("before the flip the cloned tool must be off") + } + + gate.Set(true) + + if deferred() { + t.Fatal("the CLONED tool must observe a posture flip written after cloning") + } + if got := cloned.Safety().Permission; got != tools.PermissionAllow { + t.Fatalf("cloned tool permission = %v, want Allow after the flip", got) + } + // ...and back off again. + gate.Set(false) + if !deferred() || cloned.Safety().Permission != tools.PermissionDeny { + t.Fatal("the cloned tool must observe the posture being turned off too") + } +} + +// Concurrent write/read, for -race: the TUI writes the gate from its update +// loop while a run's tool dispatch reads it from the agent goroutine. +func TestGateIsSafeUnderConcurrentAccess(t *testing.T) { + gate := &specialist.PostureGate{} + tool := &specialist.OrchestrateTool{PostureActive: gate.Active} + done := make(chan struct{}) + go func() { + defer close(done) + for i := 0; i < 2000; i++ { + gate.Set(i%2 == 0) + } + }() + for i := 0; i < 2000; i++ { + _ = tool.Deferred() + _ = tool.Safety().Permission + } + <-done +} From e682cb615f4cba53afc9b65eb94c221122b9c8b4 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:18:31 +0530 Subject: [PATCH 10/86] fix(specialist): make the plan tool's parent grant real and empty mean empty The rule "a plan task may narrow the parent's grant, never widen it" was declared, documented and validated against -- and both production call sites left ParentTools nil, so neither the validation check nor the dispatch-time intersection ever ran. A run restricted to grep handed a plan task read_file. The empty case was worse. planToolGrant returned an empty slice when a task declared no tools and the parent grant was empty; resolvedToolAllowlist read an empty tool list as UNSPECIFIED and expanded it to the default read-only category. The narrower the parent, the wider the child -- on the default path, where a task declares no tools at all. Three changes: - The grant is now DERIVED inside registerSpecialistTools from specialist's own exported read-only list, narrowed by the registry and the run's operator filters. The filters are explicit parameters rather than wiring fields, so a call site cannot omit them without failing to compile. - Both intersections are unconditional. The "skip the check when the parent list is empty" escape hatch is what made the rule inert; an unsupplied grant is now a wiring bug that fails closed. - Manifest gains ToolsResolved, so a resolved tool list can say "deliberately nothing". A bare []string cannot: absence and emptiness are both len()==0, and omitempty erases an empty slice entirely, so a nil check would not survive a round trip. Same shape as audit finding M24. The flag's meaningful value is true, so omitempty only drops the meaningless false. planToolGrant now refuses an empty result rather than returning one, and ExecutePlan records that as a task failure with a reason without dispatching it, so an ungrantable plan reports partial/failed and exits 4. --- internal/cli/app.go | 38 ++++- internal/cli/exec.go | 26 ++-- internal/cli/plan_grant_test.go | 100 +++++++++++++ internal/specialist/exec.go | 10 ++ internal/specialist/manifest.go | 26 +++- internal/specialist/plan.go | 22 ++- internal/specialist/plan_exec.go | 70 ++++++--- internal/specialist/plan_grant_test.go | 188 +++++++++++++++++++++++++ internal/specialist/plan_runner.go | 9 +- 9 files changed, 449 insertions(+), 40 deletions(-) create mode 100644 internal/cli/plan_grant_test.go create mode 100644 internal/specialist/plan_grant_test.go diff --git a/internal/cli/app.go b/internal/cli/app.go index 6209d0fa4..ae6dcf27b 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -708,7 +708,9 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a executionRunner.SetPreparer(sandboxEngine) // One gate shared by the registered tool and the TUI that flips it. zeromaxingGate := &specialist.PostureGate{} - specialistRuntime, err := registerSpecialistTools(registry, workspaceRoot, resolved.Swarm.MaxTeamSize, + // nil filters: the TUI has no --enabled-tools/--disabled-tools equivalent, + // so the run's grant is every read-only tool the registry holds. + specialistRuntime, err := registerSpecialistTools(registry, workspaceRoot, resolved.Swarm.MaxTeamSize, nil, nil, orchestrateWiring{ Gate: zeromaxingGate, PlanContext: specialist.PlanTaskContext{Cwd: workspaceRoot}, @@ -1073,16 +1075,42 @@ type orchestrateWiring struct { Gate *specialist.PostureGate // Recorder receives plan lifecycle events; nil disables recording. Recorder specialist.PlanRecorder - // ParentTools is the run's grant. A plan task may narrow it, never widen. - ParentTools []string // PlanContext supplies the run-invariant state a plan task inherits. PlanContext specialist.PlanTaskContext } +// planParentTools is the run's grant: the tools a plan task may inherit. +// +// Derived, never hand-written. The candidate set is specialist's own exported +// list — nothing outside it can ever be granted — narrowed to what this +// registry actually holds and what the run's operator filters allow. Keeping +// one authoritative list rather than a second copy here is invariant 5; the +// previous field was a hand-supplied []string that BOTH production call sites +// left nil, which silently disabled the narrowing rule entirely. +func planParentTools(registry *tools.Registry, enabledTools, disabledTools []string) []string { + grant := []string{} + for _, name := range specialist.PlanReadOnlyToolNames() { + if _, found := registry.Get(name); !found { + continue + } + if !agent.ToolAllowedByFilters(name, enabledTools, disabledTools) { + continue + } + grant = append(grant, name) + } + return grant +} + // registerSpecialistTools registers the specialist, swarm and orchestrate tools. // The wiring argument carries what the orchestrate tool needs; a zero value // leaves it registered but permanently off, which is the posture-off behaviour. -func registerSpecialistTools(registry *tools.Registry, workspaceRoot string, maxTeamSize int, wiring orchestrateWiring) (*agentToolRuntime, error) { +// +// enabledTools/disabledTools are the run's operator filters, taken as explicit +// PARAMETERS rather than wiring fields on purpose: the plan tool's parent grant +// is computed from them here, so a call site cannot forget to supply them +// without failing to compile. The nil-nil case (the TUI, which has no such +// filters) is then a stated choice rather than an omission. +func registerSpecialistTools(registry *tools.Registry, workspaceRoot string, maxTeamSize int, enabledTools, disabledTools []string, wiring orchestrateWiring) (*agentToolRuntime, error) { paths, err := specialist.DefaultPaths(workspaceRoot) if err != nil { return nil, err @@ -1124,7 +1152,7 @@ func registerSpecialistTools(registry *tools.Registry, workspaceRoot string, max PostureActive: wiring.Gate.Active, RunTask: specialist.NewPlanRunner(planContext), Recorder: wiring.Recorder, - ParentTools: wiring.ParentTools, + ParentTools: planParentTools(registry, enabledTools, disabledTools), Depth: planContext.Depth, }) return &agentToolRuntime{specialist: runtime, swarm: sw, specialists: specialistSummaries(paths)}, nil diff --git a/internal/cli/exec.go b/internal/cli/exec.go index d9d6b2724..72858a87f 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -252,17 +252,21 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in // TUI needs one: the tool holds it for the process's life. planGate = &specialist.PostureGate{} planGate.Set(execProfile.IsZeromaxing()) - specialistRuntime, err = registerSpecialistTools(registry, workspaceRoot, maxTeamSize, orchestrateWiring{ - Gate: planGate, - Recorder: planRecorder, - PlanContext: specialist.PlanTaskContext{ - Cwd: workspaceRoot, - // Resolved permission mode is not available this early; the - // executor applies its own fail-safe mapping from an empty mode - // (never unsafe), so a plan task can never exceed the parent. - PermissionMode: "", - }, - }) + // The run's operator filters are final here: applyExecMode has already + // expanded any --mode preset onto them. They bound the plan tool's + // parent grant, so a task can never hold a tool this run was denied. + specialistRuntime, err = registerSpecialistTools(registry, workspaceRoot, maxTeamSize, + options.enabledTools, options.disabledTools, orchestrateWiring{ + Gate: planGate, + Recorder: planRecorder, + PlanContext: specialist.PlanTaskContext{ + Cwd: workspaceRoot, + // Resolved permission mode is not available this early; the + // executor applies its own fail-safe mapping from an empty mode + // (never unsafe), so a plan task can never exceed the parent. + PermissionMode: "", + }, + }) if err != nil { return writeExecProviderError(stdout, stderr, options.outputFormat, "specialist_error", err.Error()) } diff --git a/internal/cli/plan_grant_test.go b/internal/cli/plan_grant_test.go new file mode 100644 index 000000000..4f869ee67 --- /dev/null +++ b/internal/cli/plan_grant_test.go @@ -0,0 +1,100 @@ +package cli + +import ( + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/specialist" +) + +// The plan tool's parent grant was declared, documented and validated against — +// and left nil by BOTH production call sites, which made the "a task may narrow +// the parent's grant, never widen it" rule inert. These tests drive +// registerSpecialistTools, the function both call sites go through, rather than +// the helper it uses: asserting the helper would have passed throughout the +// period the wiring was missing. + +func registeredOrchestrateTool(t *testing.T, enabled, disabled []string) *specialist.OrchestrateTool { + t.Helper() + workspace := t.TempDir() + registry := newCoreRegistry(workspace) + runtime, err := registerSpecialistTools(registry, workspace, 0, enabled, disabled, orchestrateWiring{ + Gate: &specialist.PostureGate{}, + }) + if err != nil { + t.Fatalf("registerSpecialistTools: %v", err) + } + t.Cleanup(func() { closeSpecialistRuntime(nil, runtime) }) + + registered, found := registry.Get(specialist.OrchestrateToolName) + if !found { + t.Fatal("orchestrate was not registered") + } + tool, ok := registered.(*specialist.OrchestrateTool) + if !ok { + t.Fatalf("orchestrate is %T, not *specialist.OrchestrateTool", registered) + } + return tool +} + +// An unfiltered run grants every read-only tool a plan task may hold. The +// failure this pins is a grant of length zero, which is what both call sites +// produced. +func TestRegisteredOrchestrateToolCarriesTheRunsGrant(t *testing.T) { + tool := registeredOrchestrateTool(t, nil, nil) + if len(tool.ParentTools) == 0 { + t.Fatal("the registered orchestrate tool holds no parent grant, so the narrowing rule cannot fire") + } + want := specialist.PlanReadOnlyToolNames() + if strings.Join(tool.ParentTools, ",") != strings.Join(want, ",") { + t.Fatalf("unfiltered grant = %v, want the full read-only set %v", tool.ParentTools, want) + } +} + +// --enabled-tools narrows the grant. This is the reproduction from the audit, +// at the registration boundary: a parent holding only grep must not hand a plan +// task read_file. +func TestRegisteredOrchestrateGrantHonoursEnabledTools(t *testing.T) { + tool := registeredOrchestrateTool(t, []string{"grep", specialist.OrchestrateToolName}, nil) + if strings.Join(tool.ParentTools, ",") != "grep" { + t.Fatalf("grant = %v, want exactly [grep]: a plan task must not hold what --enabled-tools denied the run", tool.ParentTools) + } +} + +// --disabled-tools narrows it too, through the same filter gate the agent loop +// uses, so the two cannot disagree about what the run holds. +func TestRegisteredOrchestrateGrantHonoursDisabledTools(t *testing.T) { + tool := registeredOrchestrateTool(t, nil, []string{"read_file", "glob"}) + for _, name := range tool.ParentTools { + if name == "read_file" || name == "glob" { + t.Fatalf("grant %v still contains a tool --disabled-tools removed from the run", tool.ParentTools) + } + } + if len(tool.ParentTools) == 0 { + t.Fatal("grant collapsed to empty; only the two disabled tools should have been removed") + } +} + +// A run holding no read-only tools grants nothing. The grant must be EMPTY +// rather than falling back to a default set — an empty grant is refused +// downstream, and the bug was that it expanded instead. +func TestRegisteredOrchestrateGrantIsEmptyWhenTheRunHoldsNoReadTools(t *testing.T) { + tool := registeredOrchestrateTool(t, []string{specialist.OrchestrateToolName}, nil) + if len(tool.ParentTools) != 0 { + t.Fatalf("grant = %v, want empty: this run holds no read-only tools at all", tool.ParentTools) + } +} + +// The candidate set comes from specialist's exported list, not a second copy +// maintained here. Two duplicated lists drift (invariant 5). +func TestPlanParentToolsNeverExceedsThePlanReadOnlySet(t *testing.T) { + allowed := map[string]bool{} + for _, name := range specialist.PlanReadOnlyToolNames() { + allowed[name] = true + } + for _, name := range planParentTools(newCoreRegistry(t.TempDir()), nil, nil) { + if !allowed[name] { + t.Fatalf("grant contains %q, which is not a tool a plan task may ever hold", name) + } + } +} diff --git a/internal/specialist/exec.go b/internal/specialist/exec.go index e70017105..07529105c 100644 --- a/internal/specialist/exec.go +++ b/internal/specialist/exec.go @@ -717,7 +717,17 @@ func appendModelArgs(args []string, manifest Manifest, parentModel string, paren return args } +// resolvedToolAllowlist returns the tools a child may hold. +// +// ToolsResolved is checked FIRST and on its own: when the producer says the +// list is authoritative, an empty list means the child gets nothing and the +// caller's "resolved no enabled tools" check refuses the run. Testing only +// len(ResolvedTools) > 0 conflated "deliberately nothing" with "unspecified" +// and expanded the empty case to the default read-only category. func resolvedToolAllowlist(manifest Manifest) ([]string, error) { + if manifest.ToolsResolved { + return append([]string(nil), manifest.ResolvedTools...), nil + } if len(manifest.ResolvedTools) > 0 { return append([]string(nil), manifest.ResolvedTools...), nil } diff --git a/internal/specialist/manifest.go b/internal/specialist/manifest.go index 445c48e07..932794937 100644 --- a/internal/specialist/manifest.go +++ b/internal/specialist/manifest.go @@ -33,9 +33,28 @@ type Metadata struct { } type Manifest struct { - Metadata Metadata `json:"metadata"` - SystemPrompt string `json:"systemPrompt"` - ResolvedTools []string `json:"resolvedTools,omitempty"` + Metadata Metadata `json:"metadata"` + SystemPrompt string `json:"systemPrompt"` + ResolvedTools []string `json:"resolvedTools,omitempty"` + // ToolsResolved reports that ResolvedTools is AUTHORITATIVE — including + // when it is empty, which then means "deliberately nothing" rather than + // "not resolved yet". + // + // A bare []string cannot express that difference. Absence and emptiness are + // both len()==0, and `omitempty` erases the distinction outright: an empty + // ResolvedTools does not survive marshalling at all, so `!= nil` is not a + // usable test either. That is the same defect shape as audit finding M24 (a + // meaningful zero that omitempty deletes), and it let an empty grant fall + // through to the default read-only category — a narrower parent producing a + // wider child. + // + // A flag rather than a sentinel string or a distinct type: the meaningful + // value is TRUE, so omitempty only ever drops the meaningless false and the + // field round-trips correctly. A sentinel would be a magic tool name every + // consumer had to know to exclude (deny-list shaped, invariant 2), and a + // distinct type would touch every reader of ResolvedTools for no additional + // safety. + ToolsResolved bool `json:"toolsResolved,omitempty"` Location Location `json:"location"` FilePath string `json:"filePath"` LastModified time.Time `json:"lastModified,omitempty"` @@ -270,6 +289,7 @@ func Validate(manifest *Manifest) error { return fmt.Errorf("specialist %q: %w", manifest.Metadata.Name, err) } manifest.ResolvedTools = resolved + manifest.ToolsResolved = true return nil } diff --git a/internal/specialist/plan.go b/internal/specialist/plan.go index ce6c4725a..f6b536a2c 100644 --- a/internal/specialist/plan.go +++ b/internal/specialist/plan.go @@ -70,6 +70,14 @@ type Limits struct { MaxTokens int // ParentTools is the grant the parent run holds. A task's Tools must be a // subset; anything outside it is rejected. + // + // EMPTY MEANS EMPTY, not "unset". The intersection below is unconditional: + // a caller that does not supply this grants nothing, and every task is + // rejected. That is deliberate — the previous "skip the check when the + // list is empty" escape hatch made the rule inert at both production call + // sites, which supplied no grant at all, and a narrower parent produced a + // wider child. Fail closed (invariant 3): an unsupplied grant is a wiring + // bug, and the run must stop rather than assume authority. ParentTools []string // CurrentDepth is the depth of the run issuing the plan. Its tasks run one // level deeper, so the check is against maxSpecialistDepth. @@ -267,7 +275,11 @@ func validateTaskTools(task Task, limits Limits) error { return fmt.Errorf("task %q requests tool %q; plan tasks are read-only in this phase and may only use: %s", task.ID, name, strings.Join(sortedReadOnlyTools(), ", ")) } - if len(limits.ParentTools) > 0 && !parent[name] { + // UNCONDITIONAL. Guarding this on len(limits.ParentTools) > 0 is what + // made the rule inert: neither production call site supplied a grant, + // so the check never ran and a task could name any read-only tool the + // parent did not hold. + if !parent[name] { return fmt.Errorf("task %q requests tool %q, which this run does not hold; a task may narrow the parent's grant, never widen it", task.ID, name) } @@ -284,6 +296,14 @@ func sortedReadOnlyTools() []string { return names } +// PlanReadOnlyToolNames is the sorted set of tools a plan task may ever hold. +// +// Exported so a caller building Limits.ParentTools intersects against the SAME +// list this package validates against, rather than maintaining its own copy — +// two duplicated lists drift (invariant 5). Nothing outside this set can be +// granted, so a caller need only consider these names. +func PlanReadOnlyToolNames() []string { return sortedReadOnlyTools() } + func planBudget(args map[string]any, limits Limits) (Budget, error) { raw, ok := args["budget"].(map[string]any) if !ok { diff --git a/internal/specialist/plan_exec.go b/internal/specialist/plan_exec.go index 878511ff1..e9003ea2a 100644 --- a/internal/specialist/plan_exec.go +++ b/internal/specialist/plan_exec.go @@ -154,12 +154,24 @@ func ExecutePlan(ctx context.Context, plan Plan, parentTools []string, run PlanR continue } - recordDispatched(recorder, task) - started := time.Now() // BELT AND BRACES with the validator: the grant is intersected again // here, so a validation bug cannot widen a task's authority. Same shape - // as Phase 1's forwardedReasoningEffort guard. - result, err := run(ctx, task, planToolGrant(task, parentTools)) + // as Phase 1's forwardedReasoningEffort guard. Computed BEFORE dispatch + // is recorded: a task that cannot be granted anything was never + // dispatched, and the event log must not say it was. + granted, grantErr := planToolGrant(task, parentTools) + if grantErr != nil { + result := TaskResult{ID: id, Outcome: TaskFailed, Err: grantErr.Error()} + results[id] = result + failed[id] = true + report.Failed++ + recordFailed(recorder, result) + continue + } + + recordDispatched(recorder, task) + started := time.Now() + result, err := run(ctx, task, granted) result.ID = id if result.Duration == 0 { result.Duration = time.Since(started) @@ -222,33 +234,55 @@ func firstFailedDependency(task Task, failed map[string]bool) (string, bool) { // planToolGrant intersects a task's requested tools with the parent's grant. // An empty request inherits the parent's read-only grant; it never widens it. -func planToolGrant(task Task, parentTools []string) []string { +// +// It REFUSES an empty result rather than returning one. An empty grant used to +// be handed on to the manifest, where an empty tool list read as "unspecified" +// and expanded to the default read-only category — so the narrower the parent, +// the wider the child. Returning an error keeps the empty case from ever +// reaching a place that has to guess what it meant. See Manifest.ToolsResolved +// for the other half of that fix. +func planToolGrant(task Task, parentTools []string) ([]string, error) { parent := map[string]bool{} for _, name := range parentTools { parent[name] = true } + out := []string{} if len(task.Tools) == 0 { - out := []string{} for _, name := range parentTools { if planReadOnlyTools[name] { out = append(out, name) } } - sort.Strings(out) - return out - } - out := []string{} - for _, name := range task.Tools { - if !planReadOnlyTools[name] { - continue - } - if len(parentTools) > 0 && !parent[name] { - continue + } else { + for _, name := range task.Tools { + // UNCONDITIONAL on both sides: read-only AND held by the parent. + // The old "only check the parent when it supplied a list" form was + // what let a task widen its authority whenever the grant was + // unwired. + if !planReadOnlyTools[name] || !parent[name] { + continue + } + out = append(out, name) } - out = append(out, name) + } + if len(out) == 0 { + return nil, fmt.Errorf( + "task %q resolved no tools it may use: this run holds no read-only tools a plan task can inherit (parent grant: %s)", + task.ID, describeGrant(parentTools)) } sort.Strings(out) - return out + return out, nil +} + +// describeGrant renders a parent grant for an error message, naming the empty +// case explicitly so the reason is never a blank space. +func describeGrant(parentTools []string) string { + if len(parentTools) == 0 { + return "none" + } + sorted := append([]string(nil), parentTools...) + sort.Strings(sorted) + return strings.Join(sorted, ", ") } // criticalPath is the longest dependency-weighted path through the DAG: for diff --git a/internal/specialist/plan_grant_test.go b/internal/specialist/plan_grant_test.go new file mode 100644 index 000000000..ed429450f --- /dev/null +++ b/internal/specialist/plan_grant_test.go @@ -0,0 +1,188 @@ +package specialist + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +func mustParsePlan(t *testing.T, args map[string]any, limits Limits) Plan { + t.Helper() + plan, err := ParsePlan(args, limits) + if err != nil { + t.Fatalf("ParsePlan: %v", err) + } + return plan +} + +func singleTaskPlanArgs(task map[string]any) map[string]any { + return map[string]any{ + "name": "p", + "tasks": []any{task}, + "budget": map[string]any{"max_workers": 1, "max_tokens": 1000}, + } +} + +// THE EXPRESSIBILITY FIX. An empty ResolvedTools used to be indistinguishable +// from an unset one, so a deliberately-empty grant expanded to the default +// read-only category — the narrower the parent, the wider the child. +func TestResolvedToolAllowlistTreatsADeliberatelyEmptyGrantAsEmpty(t *testing.T) { + resolved, err := resolvedToolAllowlist(Manifest{ResolvedTools: []string{}, ToolsResolved: true}) + if err != nil { + t.Fatalf("resolvedToolAllowlist: %v", err) + } + if len(resolved) != 0 { + t.Fatalf("an authoritative empty tool list expanded to %v; empty must mean empty", resolved) + } +} + +// The other side of the same flag: a manifest that never resolved its tools +// still gets the default expansion, so no existing caller changes behaviour. +func TestResolvedToolAllowlistStillExpandsAnUnresolvedManifest(t *testing.T) { + resolved, err := resolvedToolAllowlist(Manifest{Metadata: Metadata{Name: "x"}}) + if err != nil { + t.Fatalf("resolvedToolAllowlist: %v", err) + } + if len(resolved) == 0 { + t.Fatal("an unresolved manifest must still expand to the default selection") + } +} + +// A bare []string cannot carry this distinction across a round trip: the field +// is omitempty, so a deliberately-empty list marshals away entirely and comes +// back nil. This is why the fix is a flag and not a nil check. +func TestToolsResolvedSurvivesAJSONRoundTrip(t *testing.T) { + manifest := Manifest{ResolvedTools: []string{}, ToolsResolved: true} + encoded, err := json.Marshal(manifest) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var round Manifest + if err := json.Unmarshal(encoded, &round); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if round.ResolvedTools != nil { + t.Fatalf("omitempty was expected to erase the empty slice, got %v", round.ResolvedTools) + } + if !round.ToolsResolved { + t.Fatalf("the authoritative flag did not survive the round trip: %s", encoded) + } + resolved, err := resolvedToolAllowlist(round) + if err != nil { + t.Fatalf("resolvedToolAllowlist: %v", err) + } + if len(resolved) != 0 { + t.Fatalf("after a round trip the empty grant expanded to %v", resolved) + } +} + +// THE SECOND LAYER. ExecutePlan refuses an empty grant before a manifest is +// ever built, but if that check were bypassed the manifest itself must still +// not expand. This is the belt to planToolGrant's braces. +func TestPlanTaskManifestMarksItsGrantAuthoritative(t *testing.T) { + manifest := planTaskManifest("explorer", []string{}) + if !manifest.ToolsResolved { + t.Fatal("a plan task's manifest carries an already-intersected grant; it must be marked authoritative") + } + resolved, err := resolvedToolAllowlist(manifest) + if err != nil { + t.Fatalf("resolvedToolAllowlist: %v", err) + } + if len(resolved) != 0 { + t.Fatalf("an empty plan grant expanded to %v instead of refusing", resolved) + } +} + +// planToolGrant refuses rather than returning an empty slice, so the empty case +// never reaches a caller that has to guess what it meant. +func TestPlanToolGrantRefusesWhenTheParentHoldsNothing(t *testing.T) { + _, err := planToolGrant(Task{ID: "a"}, nil) + if err == nil { + t.Fatal("an empty parent grant must be refused, not returned as an empty list") + } + if !strings.Contains(err.Error(), "parent grant: none") { + t.Fatalf("the refusal must name the empty grant explicitly, got: %v", err) + } +} + +// The intersection is unconditional on both sides. Guarding it on a non-empty +// parent list is what made the rule inert. +func TestPlanToolGrantNarrowsToTheParentsHoldings(t *testing.T) { + granted, err := planToolGrant(Task{ID: "a", Tools: []string{"read_file", "grep"}}, []string{"grep"}) + if err != nil { + t.Fatalf("planToolGrant: %v", err) + } + if strings.Join(granted, ",") != "grep" { + t.Fatalf("granted %v, want [grep]: read_file is not held by the parent", granted) + } +} + +// An empty request inherits the parent's grant — narrowed, never widened. +func TestPlanToolGrantInheritsOnlyWhatTheParentHolds(t *testing.T) { + granted, err := planToolGrant(Task{ID: "a"}, []string{"grep", "write_file"}) + if err != nil { + t.Fatalf("planToolGrant: %v", err) + } + if strings.Join(granted, ",") != "grep" { + t.Fatalf("granted %v, want [grep]: write_file is not a read-only plan tool", granted) + } +} + +// Validation rejects a widening even when the caller supplied a grant that does +// not contain the requested tool. Previously this only fired when the grant was +// non-empty, which no production caller ever made it. +func TestParsePlanRejectsAToolTheParentDoesNotHold(t *testing.T) { + _, err := ParsePlan( + singleTaskPlanArgs(map[string]any{"id": "a", "prompt": "p", "tools": []any{"read_file"}}), + Limits{MaxTasks: 20, MaxTokens: 200000, ParentTools: []string{"grep"}}, + ) + if err == nil { + t.Fatal("a task requesting a tool the run does not hold must be rejected") + } + if !strings.Contains(err.Error(), "never widen it") { + t.Fatalf("unexpected rejection reason: %v", err) + } +} + +// With no grant supplied at all, every request is rejected. Fail closed: an +// unsupplied grant is a wiring bug, and the run must stop rather than assume +// authority — which is exactly what the old escape hatch did. +func TestParsePlanRejectsEveryToolWhenNoGrantWasSupplied(t *testing.T) { + _, err := ParsePlan( + singleTaskPlanArgs(map[string]any{"id": "a", "prompt": "p", "tools": []any{"read_file"}}), + Limits{MaxTasks: 20, MaxTokens: 200000}, + ) + if err == nil { + t.Fatal("an unwired parent grant must reject, not silently allow") + } +} + +// End to end through ExecutePlan: an ungrantable task is a recorded FAILURE +// with a reason, never a dispatch. A dispatched-then-failed task would put a +// task_dispatched event in the log for work that never started. +func TestExecutePlanFailsAnUngrantableTaskWithoutDispatchingIt(t *testing.T) { + plan := mustParsePlan(t, + singleTaskPlanArgs(map[string]any{"id": "a", "prompt": "p"}), + Limits{MaxTasks: 20, MaxTokens: 200000, ParentTools: []string{"grep"}}, + ) + dispatched := 0 + report := ExecutePlan(context.Background(), plan, nil, + func(context.Context, Task, []string) (TaskResult, error) { + dispatched++ + return TaskResult{Outcome: TaskSucceeded}, nil + }, nil) + + if dispatched != 0 { + t.Fatalf("the runner was invoked %d times for a task that could be granted nothing", dispatched) + } + if report.Status != PlanFailed { + t.Fatalf("status = %q, want %q", report.Status, PlanFailed) + } + if len(report.Tasks) != 1 || report.Tasks[0].Outcome != TaskFailed { + t.Fatalf("outcome = %+v, want a recorded failure", report.Tasks) + } + if !strings.Contains(report.Tasks[0].Err, "resolved no tools") { + t.Fatalf("the failure must say why, got %q", report.Tasks[0].Err) + } +} diff --git a/internal/specialist/plan_runner.go b/internal/specialist/plan_runner.go index 81dc177a8..20ab6e593 100644 --- a/internal/specialist/plan_runner.go +++ b/internal/specialist/plan_runner.go @@ -111,8 +111,13 @@ func planTaskManifest(name string, grantedTools []string) Manifest { }, SystemPrompt: "You are executing one task of a larger plan. You have read-only tools. " + "Complete exactly the task described and report what you found; do not attempt to modify anything.", - Location: LocationBuiltin, - FilePath: "(plan)", + Location: LocationBuiltin, + FilePath: "(plan)", + // AUTHORITATIVE, not a hint: this is the already-intersected grant, and + // an empty one must refuse the child rather than expand to the default + // read-only category. ExecutePlan refuses before reaching here, so this + // is the second layer. ResolvedTools: grantedTools, + ToolsResolved: true, } } From 7274af3f320617ad9a68dd9811518660752ee454 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:25:56 +0530 Subject: [PATCH 11/86] fix(tui): make both effort doors ask the same question reconcileProfileAfterModelSwitch called reasoningEffortAllowed directly while the fill site called profileEffortApplies, added in 009f12b precisely to stop treating an uncatalogued model's empty ring as a refusal. The two disagreed on exactly that case, which is both of this user's configured models (glm-5.2 and grok-4.5 are catalogued=false with an empty ring): selecting the posture on one of them filled high, and switching to one with the posture already active dropped the effort to auto and reported it as a level the model would not take. e782470 was meant to fix this class at the cause across all consumers. It updated one door and not the other -- the seventeenth instance of the pattern, introduced by the fix for the sixteenth. The rule is now ONE function, profileEffortAppliesOn, that both doors call, and the ring's authority is passed through rather than inferred from its emptiness. reconcileProfileAfterModelSwitch takes ringKnown, which its caller already had and did not forward. The comment at command_center.go described the old conservatism as deliberate ("only ever applies where support is known"). That is now the bug's description, so it is replaced rather than left for the next reader to restore. TestProfileEffortDoorsAgree asserts the doors against EACH OTHER across catalogued / catalogued-with-no-ring / uncatalogued / no-model. Both doors had tests before and both passed; nothing compared them, which is why the disagreement shipped. Three existing tests used kimi-k2.7-code:cloud as their "unsupported" destination -- an UNCATALOGUED model, so they were asserting the defect. They now use gpt-4o, which the catalog vouches has no reasoning controls, so the genuine drop case stays covered. --- internal/tui/command_center.go | 14 ++- internal/tui/profile_command_test.go | 42 ++++++--- internal/tui/profile_effort_doors_test.go | 100 ++++++++++++++++++++++ internal/tui/session_controls.go | 33 +++++-- internal/tui/zeromaxing_test.go | 7 +- 5 files changed, 171 insertions(+), 25 deletions(-) create mode 100644 internal/tui/profile_effort_doors_test.go diff --git a/internal/tui/command_center.go b/internal/tui/command_center.go index d3449a538..52c79e55c 100644 --- a/internal/tui/command_center.go +++ b/internal/tui/command_center.go @@ -563,9 +563,17 @@ func (m model) switchProviderModel(providerName, modelID string) (model, string, // destination, exactly like handleModelCommand does. No generic // unsupported-drop here: cross-provider targets are often custom models // the catalog cannot vouch for either way, so an explicit preference is - // carried (pre-existing behavior) while the profile's own fill stays - // conservative — it only ever applies where support is known. - m = m.reconcileProfileAfterModelSwitch(m.availableReasoningEfforts()) + // carried (pre-existing behavior). + // + // The fill itself follows the SAME rule selecting the profile would apply + // on the destination — which is why the ring's authority is passed through + // rather than inferred from its emptiness. It is not "conservative, only + // where support is known": an uncatalogued model is filled, because the + // catalog cannot vouch either way and the headless path forwards it too. + // The earlier wording described the behaviour this call had before that + // rule was unified, and following it would restore the disagreement. + efforts, ringKnown := m.availableReasoningEffortsKnown() + m = m.reconcileProfileAfterModelSwitch(efforts, ringKnown) // Record the outgoing pair too — see the matching comment in // handleModelCommand for why (keeps the session's starting model from // silently dropping out of "Recent" on the first switch away from it). diff --git a/internal/tui/profile_command_test.go b/internal/tui/profile_command_test.go index f9884c7f9..65d5d46a5 100644 --- a/internal/tui/profile_command_test.go +++ b/internal/tui/profile_command_test.go @@ -23,7 +23,18 @@ func profileSwitchModel(t *testing.T) model { Provider: &fakeProvider{}, ProviderProfile: config.ProviderProfile{Name: "anthropic", CatalogID: "anthropic", Model: "claude-sonnet-4.5", APIKey: "k"}, SavedProviders: []config.ProviderProfile{ + // Three destinations, one per ring case the effort rule + // distinguishes: + // anthropic — catalogued, ring [low medium high]: supports the level. + // openai — catalogued, ring []: the catalog VOUCHES that it has + // no reasoning controls, so a fill genuinely must not + // apply. This is the drop case. + // ollama — NOT catalogued: the catalog cannot vouch either way, + // so the fill applies exactly as it would if the + // profile were selected while already on this model. + // Conflating this with the drop case was the defect. {Name: "anthropic", CatalogID: "anthropic", Model: "claude-sonnet-4.5", APIKey: "k"}, + {Name: "openai", CatalogID: "openai", Model: "gpt-4o", APIKey: "k"}, {Name: "ollama", CatalogID: "ollama", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "http://localhost:11434/v1", Model: "kimi-k2.7-code:cloud"}, }, NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { @@ -45,10 +56,11 @@ func TestProfileEffortReconciledOnModelSwitch(t *testing.T) { } // Supported -> unsupported: the profile-applied level must not survive - // onto a model with no effort ring. - m, text, ok, _ := m.switchProviderModel("ollama", "kimi-k2.7-code:cloud") + // onto a model the CATALOG VOUCHES has no effort ring. An uncatalogued + // destination is not this case — see TestProfileEffortDoorsAgree. + m, text, ok, _ := m.switchProviderModel("openai", "gpt-4o") if !ok { - t.Fatalf("switch to ollama failed: %q", text) + t.Fatalf("switch to openai failed: %q", text) } if m.reasoningEffort != "" || m.execProfileAppliedEffort != "" { t.Fatalf("effort = %q applied = %q, want both cleared on an unsupported destination", m.reasoningEffort, m.execProfileAppliedEffort) @@ -145,8 +157,8 @@ func TestProfileEffortTouchedSurvivesSupportedModelSwitch(t *testing.T) { // An UNKNOWN ring (live-discovered/custom target with no catalog entry) is // not evidence of unsupported: an explicit preference survives, exactly as it -// does on the cross-provider picker path, while the profile's own fill still -// retreats to models where support is known. +// does on the cross-provider picker path — and so does the profile's own fill, +// because selecting the profile on that same model would apply it. func TestProfileEffortUnknownRingPreservesExplicitChoice(t *testing.T) { m := profileSwitchModel(t) @@ -160,19 +172,25 @@ func TestProfileEffortUnknownRingPreservesExplicitChoice(t *testing.T) { t.Fatalf("effort = %q touched %v, an explicit choice must survive an unknown ring", m.reasoningEffort, m.execProfileEffortTouched) } - // Untouched profile fill on an unknown ring: the fill retreats (the - // profile only governs where support is known), with clean bookkeeping. + // Untouched profile fill on an unknown ring: the fill SURVIVES. The + // catalog cannot vouch either way, the headless path forwards the level in + // exactly this case, and selecting the profile here would fill it — so a + // switch that dropped it would make the two doors disagree about the same + // model. m2 := profileSwitchModel(t) m2, _ = m2.handleProfileCommand("fast") m2, reset = m2.reconcileEffortForModelSwitch(nil, false) if reset { - t.Fatal("a profile-fill retreat is not an unsupported-preference reset") + t.Fatal("a profile fill on an unknown ring is not an unsupported-preference reset") + } + if m2.reasoningEffort != "low" || m2.execProfileAppliedEffort != "low" { + t.Fatalf("effort = %q applied = %q, the profile fill must survive an unknown ring", m2.reasoningEffort, m2.execProfileAppliedEffort) } - if m2.reasoningEffort != "" || m2.execProfileAppliedEffort != "" { - t.Fatalf("effort = %q applied = %q, the profile fill must retreat on an unknown ring", m2.reasoningEffort, m2.execProfileAppliedEffort) + if !m2.agentOptions.Profile.Escalate.RestoreDefaultEffort { + t.Fatal("the profile still governs the effort, so the restore must stay armed") } - if m2.agentOptions.Profile.Escalate.RestoreDefaultEffort { - t.Fatal("no profile-governed effort, so the restore must be disarmed") + if m2.execProfileEffortUnraised != "" { + t.Fatalf("nothing was skipped, so nothing may be recorded as unraised: %q", m2.execProfileEffortUnraised) } } diff --git a/internal/tui/profile_effort_doors_test.go b/internal/tui/profile_effort_doors_test.go new file mode 100644 index 000000000..8117fd8a3 --- /dev/null +++ b/internal/tui/profile_effort_doors_test.go @@ -0,0 +1,100 @@ +package tui + +import ( + "testing" + + "github.com/Gitlawb/zero/internal/execprofile" + "github.com/Gitlawb/zero/internal/modelregistry" +) + +// THE TEST THAT WOULD HAVE CAUGHT IT. +// +// There are two doors onto the same decision — "does this profile's effort +// apply on this model?": +// +// door 1 select the profile while already on the model (handleProfileCommand) +// door 2 switch to the model with the profile active (reconcileProfileAfterModelSwitch) +// +// Each door already had tests. Both passed. They disagreed anyway, because each +// asserted its OWN expectation and nothing compared them: door 1 was fixed to +// stop treating an uncatalogued model's empty ring as a refusal, and door 2 was +// left calling the old predicate. The result was that the same user on the same +// model got a different effort depending on which door they came through. +// +// This test asserts the doors against EACH OTHER, so a future change to one of +// them fails here rather than shipping a second disagreement. +func TestProfileEffortDoorsAgree(t *testing.T) { + const want = modelregistry.ReasoningEffortHigh + + cases := []struct { + name string + model string + }{ + // Catalogued and supports the level. + {"catalogued, ring includes the level", "claude-sonnet-4.5"}, + // Catalogued with an empty ring: the catalog VOUCHES that there are no + // reasoning controls, so neither door may fill. + {"catalogued, ring is empty", "gpt-4o"}, + // Not catalogued: the catalog cannot vouch either way. This is the case + // the two doors disagreed on, and it is both of this user's models. + {"uncatalogued, empty ring", "glm-5.2"}, + {"uncatalogued, name-inferred ring", "gpt-5-mini"}, + // No model at all: nothing to make a support claim about. + {"no model selected", ""}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Door 1: would selecting the profile here fill the level? + selecting := model{modelName: tc.model} + viaSelection := selecting.profileEffortApplies(want) + + // Door 2: the profile is active with its fill applied, and the + // session switches to this model. Does the fill survive? + switching := model{ + modelName: tc.model, + execProfileName: execprofile.Name, + execProfileAppliedEffort: want, + reasoningEffort: want, + } + efforts, ringKnown := switching.availableReasoningEffortsKnown() + after := switching.reconcileProfileAfterModelSwitch(efforts, ringKnown) + viaSwitch := after.reasoningEffort == want + + if viaSelection != viaSwitch { + t.Fatalf("the doors disagree on %q: selecting the profile here fills=%v, "+ + "but switching here leaves effort %q (applied %q, unraised %q)", + tc.model, viaSelection, after.reasoningEffort, + after.execProfileAppliedEffort, after.execProfileEffortUnraised) + } + + // The bookkeeping must agree too: a level that was never skipped + // must not be reported as unraised, or the status line claims the + // model refused something it was never asked for. + if viaSelection && after.execProfileEffortUnraised != "" { + t.Fatalf("%q takes the fill, but the switch recorded it as unraised (%q)", + tc.model, after.execProfileEffortUnraised) + } + }) + } +} + +// The shared rule, stated directly. profileEffortAppliesOn is the single +// definition both doors call; if it ever grows a second copy, the door +// agreement test above is what fails. +func TestProfileEffortRuleDistinguishesVouchedFromUnknown(t *testing.T) { + const want = modelregistry.ReasoningEffortHigh + + if profileEffortAppliesOn("gpt-4o", nil, true, want) { + t.Error("a catalogued model with an empty ring genuinely has no reasoning controls; the fill must not apply") + } + if !profileEffortAppliesOn("glm-5.2", nil, false, want) { + t.Error("an uncatalogued model cannot be vouched for either way; declining silently drops the posture's effort") + } + if profileEffortAppliesOn("", nil, false, want) { + t.Error("with no model selected there is nothing to make a support claim about") + } + if !profileEffortAppliesOn("claude-sonnet-4.5", []modelregistry.ReasoningEffort{want}, true, want) { + t.Error("a ring that contains the level must apply") + } +} diff --git a/internal/tui/session_controls.go b/internal/tui/session_controls.go index 3b6369883..fc9fb06b6 100644 --- a/internal/tui/session_controls.go +++ b/internal/tui/session_controls.go @@ -258,7 +258,7 @@ func (m model) reconcileEffortForModelSwitch(efforts []modelregistry.ReasoningEf m = m.setProfileEffortRestore(false) } } - m = m.reconcileProfileAfterModelSwitch(efforts) + m = m.reconcileProfileAfterModelSwitch(efforts, ringKnown) return m, dropped && m.reasoningEffort == "" } @@ -271,7 +271,7 @@ func (m model) reconcileEffortForModelSwitch(efforts []modelregistry.ReasoningEf // explicitly touched effort is the user's choice and is never reconciled; the // escalation's RestoreDefaultEffort tracks whether the profile currently // governs the effort. -func (m model) reconcileProfileAfterModelSwitch(efforts []modelregistry.ReasoningEffort) model { +func (m model) reconcileProfileAfterModelSwitch(efforts []modelregistry.ReasoningEffort, ringKnown bool) model { if m.execProfileName == "" || m.execProfileEffortTouched { return m } @@ -280,7 +280,11 @@ func (m model) reconcileProfileAfterModelSwitch(efforts []modelregistry.Reasonin return m } want := modelregistry.ReasoningEffort(profile.ReasoningEffort) - supported := reasoningEffortAllowed(efforts, want) + // The SAME rule the selection door uses. Calling reasoningEffortAllowed + // directly here treated an uncatalogued model's empty ring as a refusal, so + // switching to a model that selection would have filled silently dropped + // the posture's effort and reported it as unraised. + supported := profileEffortAppliesOn(m.modelName, efforts, ringKnown, want) filled := m.execProfileAppliedEffort != "" switch { case supported && !filled && m.reasoningEffort == "": @@ -341,20 +345,26 @@ func (m model) availableReasoningEffortsKnown() ([]modelregistry.ReasoningEffort return registry.ReasoningEfforts(name), known } -// profileEffortApplies reports whether a profile may fill `want` on the active +// profileEffortAppliesOn is THE rule for whether a profile may fill `want` on a // model. It fills when the model is KNOWN to support the level, and also when // the ring is not authoritative: an unknown endpoint may well support it, and // declining would be a false negative that silently drops the posture's effort. // Only a model the catalog vouches for as lacking the level blocks the fill. -func (m model) profileEffortApplies(want modelregistry.ReasoningEffort) bool { +// +// ONE function, called by BOTH doors — selecting a profile on a model, and +// switching to a model with a profile already active. They previously used +// different predicates and disagreed on exactly the uncatalogued case, so the +// same user on the same model got a different effort depending on which door +// they came through. Two copies of a rule drift (invariant 5); +// TestProfileEffortDoorsAgree pins that these cannot. +func profileEffortAppliesOn(modelName string, efforts []modelregistry.ReasoningEffort, ringKnown bool, want modelregistry.ReasoningEffort) bool { // No model selected: there is nothing to make a support claim about, so the // profile does not fill. Distinct from an UNKNOWN model, which is a real // endpoint that may well accept the level — conflating the two was the // over-reach the pre-existing fast-posture test caught. - if strings.TrimSpace(m.modelName) == "" { + if strings.TrimSpace(modelName) == "" { return false } - efforts, known := m.availableReasoningEffortsKnown() if reasoningEffortAllowed(efforts, want) { return true } @@ -363,7 +373,14 @@ func (m model) profileEffortApplies(want modelregistry.ReasoningEffort) bool { // catalog cannot vouch either way, and declining would be a false negative // that silently drops the posture's effort — the headless path forwards it // in exactly this case ("no support claim can be made for it"). - return !known + return !ringKnown +} + +// profileEffortApplies is the selection door: it resolves the active model's +// ring and applies the shared rule. +func (m model) profileEffortApplies(want modelregistry.ReasoningEffort) bool { + efforts, known := m.availableReasoningEffortsKnown() + return profileEffortAppliesOn(m.modelName, efforts, known, want) } func (m model) effortDisplay() string { diff --git a/internal/tui/zeromaxing_test.go b/internal/tui/zeromaxing_test.go index 8dacd341c..3c473b353 100644 --- a/internal/tui/zeromaxing_test.go +++ b/internal/tui/zeromaxing_test.go @@ -338,9 +338,12 @@ func TestZeromaxingUnraisedEffortIsRecordedOnModelSwitch(t *testing.T) { t.Fatalf("nothing was skipped, so nothing should be recorded: %q", m.execProfileEffortUnraised) } - m, text, ok, _ := m.switchProviderModel("ollama", "kimi-k2.7-code:cloud") + // A destination the CATALOG VOUCHES has no reasoning controls. An + // uncatalogued destination keeps the fill instead — see + // TestProfileEffortDoorsAgree. + m, text, ok, _ := m.switchProviderModel("openai", "gpt-4o") if !ok { - t.Fatalf("switch to ollama failed: %q", text) + t.Fatalf("switch to openai failed: %q", text) } if m.reasoningEffort != "" { t.Fatalf("the unsupported level must be dropped, got %q", m.reasoningEffort) From e170c6fdcba555bfb3bd9632f89584f5a6029cc7 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:28:44 +0530 Subject: [PATCH 12/86] fix(cli): record the failed plan task's session and spend task_completed carried session_id and tokens; task_failed carried neither. So the one task a user needs to open was the one task the event log could not point them at -- and the per-task token records did not add up to the plan's own total. Executor.Run already goes out of its way to preserve the child session id on a post-start failure, with a comment saying it exists so a failed child stays drillable. The recorder discarded it one layer up. A dependency or budget skip records an empty session id rather than omitting the field: that task never started a child, and present-and-empty says so, where an absent field would look like the same drop being fixed here. The regression test compares the two events against EACH OTHER. Both already had tests and both passed; nothing compared them, which is how the asymmetry survived. --- internal/cli/plan_recorder.go | 11 +++ internal/cli/plan_recorder_test.go | 134 +++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 internal/cli/plan_recorder_test.go diff --git a/internal/cli/plan_recorder.go b/internal/cli/plan_recorder.go index 99a216d55..34a7168d6 100644 --- a/internal/cli/plan_recorder.go +++ b/internal/cli/plan_recorder.go @@ -93,6 +93,17 @@ func (bridge *planSessionRecorder) TaskFailed(result specialist.TaskResult) { "outcome": string(result.Outcome), "reason": result.Err, "duration_ms": result.Duration.Milliseconds(), + // THE SAME FIELDS AS TaskCompleted, and for the failure they matter + // more. Executor.Run carries the child session id even on a post-start + // failure specifically so a failed child stays drillable; recording it + // only on success made the one task a user needs to open the one task + // they could not find. Empty for a dependency or budget skip, which + // never started a child — that is the honest value, not a gap. + "session_id": result.SessionID, + // Tokens the failed task spent. They are already counted in the plan's + // aggregate, so omitting them here made the per-task records fail to + // add up to the total. + "tokens": result.Tokens, }) } diff --git a/internal/cli/plan_recorder_test.go b/internal/cli/plan_recorder_test.go new file mode 100644 index 000000000..ac79aa976 --- /dev/null +++ b/internal/cli/plan_recorder_test.go @@ -0,0 +1,134 @@ +package cli + +import ( + "encoding/json" + "testing" + "time" + + "github.com/Gitlawb/zero/internal/sessions" + "github.com/Gitlawb/zero/internal/specialist" +) + +// planEventPayloads runs the recorder against a REAL session store and returns +// the payload of each plan/task event, keyed by event type. Reading the events +// back is the point: asserting the map the recorder builds would not prove they +// reached the log a user actually opens. +func planEventPayloads(t *testing.T, record func(*planSessionRecorder)) map[sessions.EventType]map[string]any { + t.Helper() + store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()}) + session, err := store.Create(sessions.CreateInput{Cwd: t.TempDir()}) + if err != nil { + t.Fatalf("create session: %v", err) + } + inner := execSessionRecorder{prepared: sessions.PreparedExec{ + Mode: sessions.ModeNew, + Session: session, + Store: store, + }} + bridge := &planSessionRecorder{recorder: &inner} + + record(bridge) + if inner.err != nil { + t.Fatalf("recording failed: %v", inner.err) + } + + events, err := store.ReadEvents(session.SessionID) + if err != nil { + t.Fatalf("read events: %v", err) + } + payloads := map[sessions.EventType]map[string]any{} + for _, event := range events { + raw, err := json.Marshal(event.Payload) + if err != nil { + t.Fatalf("marshal %s payload: %v", event.Type, err) + } + decoded := map[string]any{} + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("decode %s payload: %v", event.Type, err) + } + payloads[event.Type] = decoded + } + return payloads +} + +// A FAILED task must be as drillable as a successful one. +// +// Executor.Run carries the child session id even on a post-start failure, +// specifically so a failed child can still be opened. Recording it only on +// task_completed meant the one task a user needs to investigate was the one +// task the event log could not point them at. +func TestTaskFailedRecordsTheChildSessionAndItsSpend(t *testing.T) { + payloads := planEventPayloads(t, func(bridge *planSessionRecorder) { + bridge.TaskFailed(specialist.TaskResult{ + ID: "b", + Outcome: specialist.TaskFailed, + Err: "Subagent failed (exit 3)", + Duration: 1500 * time.Millisecond, + SessionID: "specialist_0fd7e5a5b5e5c31063516119", + Tokens: 1500, + }) + }) + + failed, ok := payloads[sessions.EventTaskFailed] + if !ok { + t.Fatal("no task_failed event was recorded") + } + if failed["session_id"] != "specialist_0fd7e5a5b5e5c31063516119" { + t.Fatalf("the failed child's session id was dropped: %v", failed["session_id"]) + } + if failed["tokens"] != float64(1500) { + t.Fatalf("the failed task's spend was dropped: %v", failed["tokens"]) + } +} + +// The two task events are compared against EACH OTHER rather than each against +// its own expectation. That is how the asymmetry survived: task_completed had a +// test, task_failed had a test, and neither noticed the missing fields. +func TestTaskEventsCarryTheSameIdentityFields(t *testing.T) { + completed := planEventPayloads(t, func(bridge *planSessionRecorder) { + bridge.TaskCompleted(specialist.TaskResult{ + ID: "a", Outcome: specialist.TaskSucceeded, + Duration: time.Second, SessionID: "specialist_aaa", Tokens: 150, + }) + })[sessions.EventTaskCompleted] + + failed := planEventPayloads(t, func(bridge *planSessionRecorder) { + bridge.TaskFailed(specialist.TaskResult{ + ID: "b", Outcome: specialist.TaskFailed, Err: "boom", + Duration: time.Second, SessionID: "specialist_bbb", Tokens: 150, + }) + })[sessions.EventTaskFailed] + + for _, field := range []string{"id", "duration_ms", "session_id", "tokens"} { + if _, ok := completed[field]; !ok { + t.Fatalf("task_completed is missing %q", field) + } + if _, ok := failed[field]; !ok { + t.Fatalf("task_failed is missing %q, which task_completed records; "+ + "a failure must be at least as recoverable as a success", field) + } + } +} + +// A dependency or budget skip never started a child, so an empty session id is +// the honest value there — the field is present and empty, not absent. +func TestSkippedTaskRecordsAnEmptySessionRatherThanOmittingIt(t *testing.T) { + payloads := planEventPayloads(t, func(bridge *planSessionRecorder) { + bridge.TaskFailed(specialist.TaskResult{ + ID: "d", + Outcome: specialist.TaskSkippedDependency, + Err: `skipped: dependency "b" did not succeed`, + }) + }) + failed := payloads[sessions.EventTaskFailed] + value, ok := failed["session_id"] + if !ok { + t.Fatal("session_id must be present even for a task that never started") + } + if value != "" { + t.Fatalf("a skipped task started no child, so its session id must be empty, got %v", value) + } + if failed["outcome"] != string(specialist.TaskSkippedDependency) { + t.Fatalf("the skip outcome must survive: %v", failed["outcome"]) + } +} From 3ee471dabdfe1f7f07d24fe781190f9740f1ea71 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:33:33 +0530 Subject: [PATCH 13/86] fix(cli): measure the run's real depth for plan admission PlanContext.Depth was unset at both call sites, so Limits.CurrentDepth and the executor's own nesting check both measured a constant zero. The admission headroom check could never fire. It was only harmless because plan tasks run at --auto low, where specialist tools do not register, so nothing could re-enter -- an inert guard that reads like a live one, which is the shape someone later trusts. Wired rather than deleted: the value exists (--depth), it costs two lines, and the check it feeds is the one thing standing between a nested run and maxSpecialistDepth if a future phase ever lets a plan task spawn. The TUI passes an explicit 0 with a comment: it is always a root session, so zero is the measured value there, not an unset one. Proven at the process boundary rather than by a unit test, since the defect was the call site and not the helper: before, `exec --depth 7 --exec-profile zeromaxing` ran a four-task plan; after, it refuses with "a plan's tasks would run at depth 8, which reaches the maximum nesting depth of 8", while --depth 6 still runs. --- internal/cli/app.go | 7 +++++-- internal/cli/exec.go | 5 +++++ internal/cli/plan_grant_test.go | 26 ++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/internal/cli/app.go b/internal/cli/app.go index ae6dcf27b..109ecd23f 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -712,8 +712,11 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a // so the run's grant is every read-only tool the registry holds. specialistRuntime, err := registerSpecialistTools(registry, workspaceRoot, resolved.Swarm.MaxTeamSize, nil, nil, orchestrateWiring{ - Gate: zeromaxingGate, - PlanContext: specialist.PlanTaskContext{Cwd: workspaceRoot}, + Gate: zeromaxingGate, + // Depth stays 0: the TUI is always a root session — it has no + // --depth and is never launched as a child — so zero is the + // measured value here, not an unset one. + PlanContext: specialist.PlanTaskContext{Cwd: workspaceRoot, Depth: 0}, }) if err != nil { return writeAppError(stderr, "failed to initialize specialist tools: "+err.Error(), 1) diff --git a/internal/cli/exec.go b/internal/cli/exec.go index 72858a87f..906b5485b 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -265,6 +265,11 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in // executor applies its own fail-safe mapping from an empty mode // (never unsafe), so a plan task can never exceed the parent. PermissionMode: "", + // This run's nesting depth. Left unset, the admission + // headroom check and the executor's own depth check both + // measured a depth that was always zero, so neither could + // ever fire — an inert guard someone would later trust. + Depth: options.depth, }, }) if err != nil { diff --git a/internal/cli/plan_grant_test.go b/internal/cli/plan_grant_test.go index 4f869ee67..9310665ac 100644 --- a/internal/cli/plan_grant_test.go +++ b/internal/cli/plan_grant_test.go @@ -98,3 +98,29 @@ func TestPlanParentToolsNeverExceedsThePlanReadOnlySet(t *testing.T) { } } } + +// The depth the plan tool measures must be the RUN's depth, not a constant. +// Left unset it was always 0, so the admission headroom check and the +// executor's own nesting check could never fire — an inert guard that reads +// like a live one. +func TestRegisteredOrchestrateToolCarriesTheRunsDepth(t *testing.T) { + workspace := t.TempDir() + registry := newCoreRegistry(workspace) + runtime, err := registerSpecialistTools(registry, workspace, 0, nil, nil, orchestrateWiring{ + Gate: &specialist.PostureGate{}, + PlanContext: specialist.PlanTaskContext{Cwd: workspace, Depth: 3}, + }) + if err != nil { + t.Fatalf("registerSpecialistTools: %v", err) + } + t.Cleanup(func() { closeSpecialistRuntime(nil, runtime) }) + + registered, _ := registry.Get(specialist.OrchestrateToolName) + tool, ok := registered.(*specialist.OrchestrateTool) + if !ok { + t.Fatalf("orchestrate is %T", registered) + } + if tool.Depth != 3 { + t.Fatalf("Depth = %d, want the run's depth 3: an always-zero depth makes the headroom check inert", tool.Depth) + } +} From 9c17a162fa24f6ad79df8590adaf0f6600907def Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:47:04 +0530 Subject: [PATCH 14/86] test(specialist): compare the two grant enforcement points against each other MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RULES.md §3: for every rule you touch, find its sibling and prove by test that both agree -- one test comparing the paths on the same input, not two tests each asserting their own expectation. The authority rule has two enforcement points and Workstream A changed both; two sites fixed together is exactly the shape that drifts apart later. The relationship asserted is SUBSET, not equality, and the direction is the point. Admission is stricter: it rejects a task naming anything outside the grant. Dispatch narrows: it drops such a tool and grants the rest. Requiring them to refuse identically would assert a symmetry the design does not want -- dispatch only runs on an already-admitted task, so when it disagrees its job is to hand over less, never more. Mutation-checked: dropping the parent check from the dispatch intersection is caught with "the second layer widened authority", which is the defect the "BELT AND BRACES" comment promises cannot happen. --- internal/specialist/plan_grant_test.go | 91 ++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/internal/specialist/plan_grant_test.go b/internal/specialist/plan_grant_test.go index ed429450f..e4e50b94f 100644 --- a/internal/specialist/plan_grant_test.go +++ b/internal/specialist/plan_grant_test.go @@ -186,3 +186,94 @@ func TestExecutePlanFailsAnUngrantableTaskWithoutDispatchingIt(t *testing.T) { t.Fatalf("the failure must say why, got %q", report.Tasks[0].Err) } } + +// THE SIBLING COMPARISON for the tool grant. +// +// The authority rule has two enforcement points: validateTaskTools at admission +// and planToolGrant at dispatch, the second commented "BELT AND BRACES … so a +// validation bug cannot widen a task's authority". Both were fixed together — +// and two sites fixed together is exactly the shape that drifts apart later, +// because each ends up with its own test asserting its own expectation. +// +// This asserts them AGAINST EACH OTHER on the same inputs: whatever validation +// admits, dispatch must grant, and dispatch must never grant a tool validation +// would have rejected. +func TestBothGrantEnforcementPointsAgree(t *testing.T) { + cases := []struct { + name string + tools []any + parent []string + }{ + {"no request, parent holds reads", nil, []string{"read_file", "grep"}}, + {"no request, parent holds one read", nil, []string{"grep"}}, + {"no request, parent holds only mutators", nil, []string{"write_file", "bash"}}, + {"no request, parent holds nothing", nil, nil}, + {"request inside the grant", []any{"grep"}, []string{"read_file", "grep"}}, + {"request outside the grant", []any{"read_file"}, []string{"grep"}}, + {"request partly outside the grant", []any{"read_file", "grep"}, []string{"grep"}}, + {"request a mutator", []any{"write_file"}, []string{"read_file", "write_file"}}, + {"request with no grant at all", []any{"read_file"}, nil}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fields := map[string]any{"id": "a", "prompt": "p"} + if tc.tools != nil { + fields["tools"] = tc.tools + } + limits := Limits{MaxTasks: 20, MaxTokens: 200000, ParentTools: tc.parent} + + _, parseErr := ParsePlan(singleTaskPlanArgs(fields), limits) + + task := Task{ID: "a", Prompt: "p"} + for _, name := range tc.tools { + task.Tools = append(task.Tools, name.(string)) + } + granted, grantErr := planToolGrant(task, tc.parent) + + // THE RELATIONSHIP IS SUBSET, NOT EQUALITY, and the direction is + // what matters. Admission is the stricter layer: it REJECTS a task + // naming anything outside the grant. Dispatch is the narrowing + // layer: it DROPS such a tool and grants the rest. Those differ, + // and they should — dispatch only ever runs on an already-admitted + // task, so its job when it disagrees is to hand over less, never + // more. Requiring the two to refuse identically would be asserting + // a symmetry the design does not want. + // + // So the invariant tested here is one-directional: whatever + // dispatch grants must be something admission would also have + // allowed. A violation means the second layer widened authority, + // which is the defect the "belt and braces" comment promises + // cannot happen. + if grantErr != nil { + if len(granted) != 0 { + t.Fatalf("a refused grant must hand over nothing, got %v", granted) + } + return + } + for _, name := range granted { + if !planReadOnlyTools[name] { + t.Fatalf("dispatch granted %q, which is not read-only; admission would have rejected it", name) + } + if !containsGrant(tc.parent, name) { + t.Fatalf("dispatch granted %q, which the parent does not hold %v; "+ + "the second layer widened authority", name, tc.parent) + } + } + // And the converse direction: when admission ACCEPTS, dispatch must + // have something to hand over, or an admitted task could never run. + if parseErr == nil && len(granted) == 0 { + t.Fatal("admission accepted the task but dispatch granted nothing") + } + }) + } +} + +func containsGrant(grant []string, name string) bool { + for _, held := range grant { + if held == name { + return true + } + } + return false +} From 8199c430dc7d39b031b090e28ac8ceeb436307dc Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:26:59 +0530 Subject: [PATCH 15/86] feat(tui): make a running plan visible A plan ran silently: sequential, synchronous, no output until it finished. Four things stood between the existing live-progress machinery and the plan tool, all of them wiring rather than missing capability. loop.go gated the progress callback on `call.Name == "Task"`, so the second sub-agent-spawning tool got nil. Keyed now on the TOOL'S OWN DECLARATION (tools.ChildProgressStreamer) rather than its name: `|| call.Name == "orchestrate"` would have been the same defect one name later, which is this codebase's most repeated shape. Every tool that does not declare keeps the nil callback it has today -- swarm tools included, deliberately. NewPlanRunner built TaskRunOptions without Progress and RunWithOptions never read options.Progress. Either half alone leaves a plan's children streaming to nobody, so they are ONE change with one end-to-end test. This is finding 7's class again: a second construction path that does not carry what the first one did. PlanRunner now takes a PlanTaskRequest struct rather than a widening parameter list, because each new positional argument is another chance for that omission. app.go never set Recorder, so the TUI recorded NONE of the five plan lifecycle events (finding 9). The recorder is now a required POSITIONAL parameter of registerSpecialistTools, like the operator filters: forgetting it is a compile error rather than a silent nil. TaskCancelled is a distinct outcome. Cancellation marked every remaining task failed with "context canceled", so a deliberate Ctrl-C on a twenty-task plan read as nineteen defects. PlanCancelled joins it as a terminal status, and terminalStatus counts it -- without that a plan with two successes and two cancellations reported "completed", which is RC-F exactly. Also fixes a live render-cache collision this change would have made certain. A rowSpecialist row carries no id and no text; every distinguishing field lives behind row.specialistInfo, and none of it was in the cache key. Two specialist cards in one run hashed identically, so a failed sub-agent rendered as a successful one. Rare with Task (one child per run is typical), guaranteed with a plan. Proven by test before fixing, then asserted with four cards including a failure and a cancellation. Additivity re-proven: posture off, all five permission modes byte-identical against an origin/main binary with DeferThreshold unset, hashes unchanged. --- internal/agent/child_progress_test.go | 135 +++++++++++++++ internal/agent/loop.go | 13 +- internal/cli/app.go | 24 +-- internal/cli/exec.go | 5 +- internal/cli/plan_grant_test.go | 37 ++++- internal/specialist/plan_exec.go | 85 +++++++++- internal/specialist/plan_exec_test.go | 123 ++++++++++++-- internal/specialist/plan_grant_test.go | 14 +- internal/specialist/plan_progress_test.go | 111 +++++++++++++ internal/specialist/plan_runner.go | 10 +- internal/specialist/plan_test.go | 13 +- internal/specialist/plan_tool.go | 33 +++- internal/specialist/task_tool.go | 6 + internal/tools/registry.go | 26 +++ internal/tui/model.go | 108 ++++++++++-- internal/tui/options.go | 5 + internal/tui/plan_messages.go | 94 +++++++++++ internal/tui/plan_progress.go | 192 ++++++++++++++++++++++ internal/tui/plan_progress_test.go | 185 +++++++++++++++++++++ internal/tui/render_cache.go | 35 ++++ internal/tui/sidebar.go | 3 + internal/tui/specialist_card.go | 6 + 22 files changed, 1208 insertions(+), 55 deletions(-) create mode 100644 internal/agent/child_progress_test.go create mode 100644 internal/specialist/plan_progress_test.go create mode 100644 internal/tui/plan_messages.go create mode 100644 internal/tui/plan_progress.go create mode 100644 internal/tui/plan_progress_test.go diff --git a/internal/agent/child_progress_test.go b/internal/agent/child_progress_test.go new file mode 100644 index 000000000..e849f4f37 --- /dev/null +++ b/internal/agent/child_progress_test.go @@ -0,0 +1,135 @@ +package agent + +import ( + "context" + "testing" + + "github.com/Gitlawb/zero/internal/streamjson" + "github.com/Gitlawb/zero/internal/tools" +) + +// progressProbeTool records whether the loop handed it a progress callback. +// streams mirrors what a real tool declares via tools.ChildProgressStreamer. +type progressProbeTool struct { + name string + streams bool + got *bool +} + +func (t *progressProbeTool) Name() string { return t.name } +func (t *progressProbeTool) Description() string { return "probe" } +func (t *progressProbeTool) Parameters() tools.Schema { + return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}} +} +func (t *progressProbeTool) Safety() tools.Safety { + return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow} +} +func (t *progressProbeTool) Run(context.Context, map[string]any) tools.Result { + *t.got = false + return tools.Result{Status: tools.StatusOK, Output: "ok"} +} +func (t *progressProbeTool) RunWithOptions(_ context.Context, _ map[string]any, options tools.RunOptions) tools.Result { + *t.got = options.Progress != nil + return tools.Result{Status: tools.StatusOK, Output: "ok"} +} + +// StreamsChildProgress makes this type implement tools.ChildProgressStreamer; +// the flag drives the answer. A tool that never declares at all is modelled by +// silentProbeTool below, which genuinely does not implement the interface. +func (t *progressProbeTool) StreamsChildProgress() bool { return t.streams } + +// silentProbeTool does NOT implement tools.ChildProgressStreamer at all — the +// state every tool in the tree is in today except Task and orchestrate. +type silentProbeTool struct { + name string + got *bool +} + +func (t *silentProbeTool) Name() string { return t.name } +func (t *silentProbeTool) Description() string { return "probe" } +func (t *silentProbeTool) Parameters() tools.Schema { + return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}} +} +func (t *silentProbeTool) Safety() tools.Safety { + return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow} +} +func (t *silentProbeTool) Run(context.Context, map[string]any) tools.Result { + *t.got = false + return tools.Result{Status: tools.StatusOK, Output: "ok"} +} +func (t *silentProbeTool) RunWithOptions(_ context.Context, _ map[string]any, options tools.RunOptions) tools.Result { + *t.got = options.Progress != nil + return tools.Result{Status: tools.StatusOK, Output: "ok"} +} + +func runProbe(t *testing.T, tool tools.Tool, got *bool, withSink bool) bool { + t.Helper() + registry := tools.NewRegistry() + registry.Register(tool) + options := Options{} + if withSink { + options.OnToolProgress = func(string, streamjson.Event) {} + } + result, err := executeToolCall(context.Background(), + registry, + ToolCall{ID: "call_1", Name: tool.Name(), Arguments: "{}"}, + PermissionModeUnsafe, + options) + if err != nil { + t.Fatalf("executeToolCall: %v", err) + } + if result.Status == "" { + t.Fatalf("probe produced no result") + } + return *got +} + +// THE PARITY OBLIGATION for un-gating the progress path. +// +// The relationship asserted here is EQUALITY, per RULES.md §3: a tool's +// progress wiring before and after this change must be the same for every tool +// that already had it and every tool that already lacked it. Only a tool that +// newly DECLARES the interface may change. +// +// The name gate is gone, so the guarantee cannot be "Task still works" — it has +// to be stated in terms of the declaration, which is what these cases do. +func TestProgressCallbackFollowsTheDeclarationNotTheName(t *testing.T) { + t.Run("a declaring tool receives it (Task's behaviour, preserved)", func(t *testing.T) { + var got bool + // Named something other than "Task" ON PURPOSE: under the old name gate + // this case failed, which is the whole point of the change. + if !runProbe(t, &progressProbeTool{name: "spawner", streams: true, got: &got}, &got, true) { + t.Fatal("a tool declaring StreamsChildProgress must receive the callback") + } + }) + + t.Run("a tool named Task that does NOT declare gets nothing", func(t *testing.T) { + var got bool + // The inverse of the old behaviour, and the reason a name is the wrong + // key: identity is not capability. + if runProbe(t, &silentProbeTool{name: "Task", got: &got}, &got, true) { + t.Fatal("the callback must follow the declaration, not the name") + } + }) + + t.Run("a non-declaring tool receives nothing (every other tool, unchanged)", func(t *testing.T) { + var got bool + if runProbe(t, &silentProbeTool{name: "read_file", got: &got}, &got, true) { + t.Fatal("un-gating must not start handing a callback to tools that never had one") + } + }) + + t.Run("a declaring tool that answers false gets nothing", func(t *testing.T) { + var got bool + if runProbe(t, &progressProbeTool{name: "spawner", streams: false, got: &got}, &got, true) { + t.Fatal("StreamsChildProgress() == false must be honoured") + } + }) + + t.Run("no sink means no callback, whatever the tool declares", func(t *testing.T) { + var got bool + if runProbe(t, &progressProbeTool{name: "spawner", streams: true, got: &got}, &got, false) { + t.Fatal("without OnToolProgress there is nothing to forward to") + } + }) +} diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 8015062de..4227de086 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -1343,10 +1343,17 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal } args = shellExecutionArgsForApproval(call.Name, args, decisionAction, options) - // Task tool: wire progress callback so the TUI sees live tool-call events - // from the specialist child process. + // Wire the progress callback so the TUI sees live tool-call events from a + // child agent process. + // + // Keyed on the TOOL'S OWN DECLARATION (tools.ChildProgressStreamer), not on + // its name. This was `call.Name == "Task"`, which made the second + // sub-agent-spawning tool run invisibly; `|| call.Name == "orchestrate"` + // would have been the same defect one name later. A tool that spawns + // children declares it, and every tool that does not keeps the nil callback + // it has today. var progressCallback func(streamjson.Event) - if call.Name == "Task" && options.OnToolProgress != nil { + if options.OnToolProgress != nil && tools.StreamsChildProgress(tool) { toolCallID := call.ID onProgress := options.OnToolProgress progressCallback = func(event streamjson.Event) { diff --git a/internal/cli/app.go b/internal/cli/app.go index 109ecd23f..6d1e6e5f7 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -708,9 +708,13 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a executionRunner.SetPreparer(sandboxEngine) // One gate shared by the registered tool and the TUI that flips it. zeromaxingGate := &specialist.PostureGate{} + // The plan recorder. Registered once with the tool and re-attached to each + // run by the model — without it the TUI recorded NONE of the five plan + // lifecycle events, so a plan ran completely invisibly (audit finding 9). + planProgress := tui.NewPlanProgressBridge() // nil filters: the TUI has no --enabled-tools/--disabled-tools equivalent, // so the run's grant is every read-only tool the registry holds. - specialistRuntime, err := registerSpecialistTools(registry, workspaceRoot, resolved.Swarm.MaxTeamSize, nil, nil, + specialistRuntime, err := registerSpecialistTools(registry, workspaceRoot, resolved.Swarm.MaxTeamSize, nil, nil, planProgress, orchestrateWiring{ Gate: zeromaxingGate, // Depth stays 0: the TUI is always a root session — it has no @@ -850,6 +854,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a MCPConfig: mcpConfig, ZeromaxingDisabled: resolved.Profiles.DisableZeromaxing, ZeromaxingGate: zeromaxingGate, + PlanProgress: planProgress, MCPPermissionStore: mcpPermissionStore, MCPTokenStore: mcpTokenStore, MCPCommand: func(ctx context.Context, args []string) tui.MCPCommandResult { @@ -1076,8 +1081,6 @@ type orchestrateWiring struct { // state: the TUI model is a value type copied on every update, so a closure // would freeze the posture as it was at registration. Gate *specialist.PostureGate - // Recorder receives plan lifecycle events; nil disables recording. - Recorder specialist.PlanRecorder // PlanContext supplies the run-invariant state a plan task inherits. PlanContext specialist.PlanTaskContext } @@ -1108,12 +1111,13 @@ func planParentTools(registry *tools.Registry, enabledTools, disabledTools []str // The wiring argument carries what the orchestrate tool needs; a zero value // leaves it registered but permanently off, which is the posture-off behaviour. // -// enabledTools/disabledTools are the run's operator filters, taken as explicit -// PARAMETERS rather than wiring fields on purpose: the plan tool's parent grant -// is computed from them here, so a call site cannot forget to supply them -// without failing to compile. The nil-nil case (the TUI, which has no such -// filters) is then a stated choice rather than an omission. -func registerSpecialistTools(registry *tools.Registry, workspaceRoot string, maxTeamSize int, enabledTools, disabledTools []string, wiring orchestrateWiring) (*agentToolRuntime, error) { +// enabledTools/disabledTools are the run's operator filters, and recorder is the +// plan lifecycle sink. All three are explicit PARAMETERS rather than wiring +// fields on purpose: a call site cannot forget them without failing to compile. +// As a field, Recorder was simply never set by the TUI, so a plan there recorded +// none of its five lifecycle events (finding 9) — the same omission the parent +// grant suffered. An explicit nil is a stated choice; an absent field is not. +func registerSpecialistTools(registry *tools.Registry, workspaceRoot string, maxTeamSize int, enabledTools, disabledTools []string, recorder specialist.PlanRecorder, wiring orchestrateWiring) (*agentToolRuntime, error) { paths, err := specialist.DefaultPaths(workspaceRoot) if err != nil { return nil, err @@ -1154,7 +1158,7 @@ func registerSpecialistTools(registry *tools.Registry, workspaceRoot string, max registry.Register(&specialist.OrchestrateTool{ PostureActive: wiring.Gate.Active, RunTask: specialist.NewPlanRunner(planContext), - Recorder: wiring.Recorder, + Recorder: recorder, ParentTools: planParentTools(registry, enabledTools, disabledTools), Depth: planContext.Depth, }) diff --git a/internal/cli/exec.go b/internal/cli/exec.go index 906b5485b..f0debfcee 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -256,9 +256,8 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in // expanded any --mode preset onto them. They bound the plan tool's // parent grant, so a task can never hold a tool this run was denied. specialistRuntime, err = registerSpecialistTools(registry, workspaceRoot, maxTeamSize, - options.enabledTools, options.disabledTools, orchestrateWiring{ - Gate: planGate, - Recorder: planRecorder, + options.enabledTools, options.disabledTools, planRecorder, orchestrateWiring{ + Gate: planGate, PlanContext: specialist.PlanTaskContext{ Cwd: workspaceRoot, // Resolved permission mode is not available this early; the diff --git a/internal/cli/plan_grant_test.go b/internal/cli/plan_grant_test.go index 9310665ac..a4e45fac4 100644 --- a/internal/cli/plan_grant_test.go +++ b/internal/cli/plan_grant_test.go @@ -18,7 +18,7 @@ func registeredOrchestrateTool(t *testing.T, enabled, disabled []string) *specia t.Helper() workspace := t.TempDir() registry := newCoreRegistry(workspace) - runtime, err := registerSpecialistTools(registry, workspace, 0, enabled, disabled, orchestrateWiring{ + runtime, err := registerSpecialistTools(registry, workspace, 0, enabled, disabled, nil, orchestrateWiring{ Gate: &specialist.PostureGate{}, }) if err != nil { @@ -106,7 +106,7 @@ func TestPlanParentToolsNeverExceedsThePlanReadOnlySet(t *testing.T) { func TestRegisteredOrchestrateToolCarriesTheRunsDepth(t *testing.T) { workspace := t.TempDir() registry := newCoreRegistry(workspace) - runtime, err := registerSpecialistTools(registry, workspace, 0, nil, nil, orchestrateWiring{ + runtime, err := registerSpecialistTools(registry, workspace, 0, nil, nil, nil, orchestrateWiring{ Gate: &specialist.PostureGate{}, PlanContext: specialist.PlanTaskContext{Cwd: workspace, Depth: 3}, }) @@ -124,3 +124,36 @@ func TestRegisteredOrchestrateToolCarriesTheRunsDepth(t *testing.T) { t.Fatalf("Depth = %d, want the run's depth 3: an always-zero depth makes the headroom check inert", tool.Depth) } } + +// The plan recorder is a REQUIRED parameter, not a wiring field. As a field the +// TUI simply never set it, so a plan run there recorded none of its five +// lifecycle events — finding 9, and the same omission the parent grant +// suffered. Making it positional turns forgetting it into a compile error; +// this test pins that a supplied recorder actually reaches the tool. +func TestRegisteredOrchestrateToolCarriesTheSuppliedRecorder(t *testing.T) { + workspace := t.TempDir() + registry := newCoreRegistry(workspace) + recorder := &countingPlanRecorder{} + runtime, err := registerSpecialistTools(registry, workspace, 0, nil, nil, recorder, orchestrateWiring{ + Gate: &specialist.PostureGate{}, + }) + if err != nil { + t.Fatalf("registerSpecialistTools: %v", err) + } + t.Cleanup(func() { closeSpecialistRuntime(nil, runtime) }) + + registered, _ := registry.Get(specialist.OrchestrateToolName) + tool, ok := registered.(*specialist.OrchestrateTool) + if !ok { + t.Fatalf("orchestrate is %T", registered) + } + if tool.Recorder != recorder { + t.Fatal("the supplied recorder did not reach the tool, so plan events go nowhere") + } +} + +type countingPlanRecorder struct{ dispatched int } + +func (r *countingPlanRecorder) TaskDispatched(specialist.Task) { r.dispatched++ } +func (r *countingPlanRecorder) TaskCompleted(specialist.TaskResult) {} +func (r *countingPlanRecorder) TaskFailed(specialist.TaskResult) {} diff --git a/internal/specialist/plan_exec.go b/internal/specialist/plan_exec.go index e9003ea2a..a5a74d84a 100644 --- a/internal/specialist/plan_exec.go +++ b/internal/specialist/plan_exec.go @@ -2,10 +2,13 @@ package specialist import ( "context" + "errors" "fmt" "sort" "strings" "time" + + "github.com/Gitlawb/zero/internal/streamjson" ) // PlanStatus is a plan's terminal state. @@ -23,6 +26,9 @@ const ( PlanPartial PlanStatus = "partial" // PlanFailed: no task succeeded. PlanFailed PlanStatus = "failed" + // PlanCancelled: the run was stopped and nothing had succeeded yet. Its own + // status so a deliberate stop is never reported as a failure. + PlanCancelled PlanStatus = "cancelled" ) // TaskOutcome is why a task ended the way it did. @@ -38,6 +44,12 @@ const ( // TaskSkippedBudget: the plan's token budget was exhausted before this // task could be dispatched. TaskSkippedBudget TaskOutcome = "budget_exhausted" + // TaskCancelled: the run was cancelled before this task finished, or before + // it started. ITS OWN OUTCOME, not a failure — cancelling a twenty-task + // plan used to mark every remaining task "failed" with "context canceled", + // so a deliberate Ctrl-C read as nineteen defects. Nothing failed; the user + // stopped it. + TaskCancelled TaskOutcome = "cancelled" ) // TaskResult is one task's record. @@ -60,6 +72,9 @@ type PlanReport struct { Succeeded int Failed int Skipped int + // Cancelled counts tasks stopped by the user rather than broken. Kept + // separate from Failed so the summary and the panel can say so. + Cancelled int // SequentialTotal is the sum of task durations — what this run actually // spent. SequentialTotal time.Duration @@ -81,10 +96,29 @@ type PlanReport struct { TokensUsed int } +// PlanTaskRequest is everything one task needs to run. +// +// A STRUCT, not a widening parameter list. The runner's inputs are the thing +// that grows every stage — 2a adds Progress, 2b will add background wiring — +// and each addition through a positional parameter is another chance for a +// second construction path to omit a field the first one carried. That is +// exactly the class that produced findings 1 and 7: a caller that forgets a +// field compiles fine and fails silently. +type PlanTaskRequest struct { + // Task is the validated task to run. + Task Task + // Tools is the already-intersected grant. Never widened downstream. + Tools []string + // Progress, when set, receives each stream-json event the task's child + // emits. nil is a no-op — the behaviour for every caller that does not wire + // live progress. + Progress func(streamjson.Event) +} + // PlanRunner runs one task. The executor depends on this seam rather than on // Executor directly so the budget, ordering and failure semantics are testable // without launching child processes. -type PlanRunner func(ctx context.Context, task Task, tools []string) (TaskResult, error) +type PlanRunner func(ctx context.Context, req PlanTaskRequest) (TaskResult, error) // PlanRecorder receives plan lifecycle events. Recording is BEST-EFFORT and // must never fail the run — it mirrors execSessionRecorder.append's contract, @@ -121,9 +155,27 @@ func ExecutePlan(ctx context.Context, plan Plan, parentTools []string, run PlanR deadline = time.Now().Add(wall) } + cancelled := false for _, id := range plan.Order() { task := tasks[id] + // Cancellation is checked FIRST and recorded as its own outcome. Once + // the run is cancelled every remaining task is cancelled too — they are + // not blocked by a dependency and the budget did not run out. + if cancelled || ctx.Err() != nil { + cancelled = true + result := TaskResult{ + ID: id, + Outcome: TaskCancelled, + Err: "cancelled: the run was stopped before this task ran", + } + results[id] = result + failed[id] = true + report.Cancelled++ + recordFailed(recorder, result) + continue + } + if blocker, blocked := firstFailedDependency(task, failed); blocked { result := TaskResult{ ID: id, @@ -171,7 +223,7 @@ func ExecutePlan(ctx context.Context, plan Plan, parentTools []string, run PlanR recordDispatched(recorder, task) started := time.Now() - result, err := run(ctx, task, granted) + result, err := run(ctx, PlanTaskRequest{Task: task, Tools: granted}) result.ID = id if result.Duration == 0 { result.Duration = time.Since(started) @@ -181,6 +233,21 @@ func ExecutePlan(ctx context.Context, plan Plan, parentTools []string, run PlanR report.TokensUsed += result.Tokens if err != nil || result.Outcome == TaskFailed { + // A task cut short by cancellation is CANCELLED, not failed. The + // distinction survives all the way to the terminal status, so a + // stopped plan never reports as a broken one. + if ctx.Err() != nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + cancelled = true + result.Outcome = TaskCancelled + if result.Err == "" { + result.Err = "cancelled: the run was stopped while this task was running" + } + results[id] = result + failed[id] = true + report.Cancelled++ + recordFailed(recorder, result) + continue + } result.Outcome = TaskFailed if result.Err == "" && err != nil { result.Err = err.Error() @@ -210,11 +277,17 @@ func ExecutePlan(ctx context.Context, plan Plan, parentTools []string, run PlanR // status precisely so a mostly-failed plan can never be reported as success. func terminalStatus(report PlanReport) PlanStatus { switch { + case report.Succeeded == 0 && report.Cancelled > 0: + // Stopped before anything finished. Not a failure — nothing broke. + return PlanCancelled case report.Succeeded == 0: return PlanFailed - case report.Failed == 0 && report.Skipped == 0: + case report.Failed == 0 && report.Skipped == 0 && report.Cancelled == 0: return PlanCompleted default: + // Cancelled MUST be part of this condition. Without it a plan with two + // successes and two cancellations reported "completed" — work that + // never ran, reported as done, which is RC-F exactly. return PlanPartial } } @@ -324,7 +397,11 @@ func speedup(sequential, critical time.Duration) float64 { // number the Phase 3 decision rests on, surfaced rather than buried in an event. func (report PlanReport) Summary() string { var b strings.Builder - fmt.Fprintf(&b, "Plan %s: %d succeeded, %d failed, %d skipped.\n", report.Status, report.Succeeded, report.Failed, report.Skipped) + fmt.Fprintf(&b, "Plan %s: %d succeeded, %d failed, %d skipped", report.Status, report.Succeeded, report.Failed, report.Skipped) + if report.Cancelled > 0 { + fmt.Fprintf(&b, ", %d cancelled", report.Cancelled) + } + b.WriteString(".\n") fmt.Fprintf(&b, "sequential total: %s · critical path: %s · max_speedup: %.2fx\n", report.SequentialTotal.Round(time.Millisecond), report.CriticalPath.Round(time.Millisecond), report.MaxSpeedup) for _, task := range report.Tasks { diff --git a/internal/specialist/plan_exec_test.go b/internal/specialist/plan_exec_test.go index 8b12f2443..6061f9eff 100644 --- a/internal/specialist/plan_exec_test.go +++ b/internal/specialist/plan_exec_test.go @@ -48,7 +48,8 @@ func TestDependencyFailureSkipsDependentsAndRecordsThem(t *testing.T) { recorder := &recordingRecorder{} report := ExecutePlan(context.Background(), plan, []string{"read_file"}, - func(_ context.Context, task Task, _ []string) (TaskResult, error) { + func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + task := req.Task if task.ID == "a" { return TaskResult{Outcome: TaskFailed, Err: "boom"}, errors.New("boom") } @@ -100,7 +101,8 @@ func TestMostlyFailedPlanIsPartialNotSuccess(t *testing.T) { } plan := mustPlan(t, raw, okBudget(), readOnlyLimits()) report := ExecutePlan(context.Background(), plan, []string{"read_file"}, - func(_ context.Context, task Task, _ []string) (TaskResult, error) { + func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + task := req.Task if task.ID == "t00" { return TaskResult{Outcome: TaskSucceeded, Output: "ok"}, nil } @@ -127,7 +129,7 @@ func TestMostlyFailedPlanIsPartialNotSuccess(t *testing.T) { func TestAllFailedPlanIsFailed(t *testing.T) { plan := mustPlan(t, []any{task("a", "x"), task("b", "y")}, okBudget(), readOnlyLimits()) report := ExecutePlan(context.Background(), plan, []string{"read_file"}, - func(context.Context, Task, []string) (TaskResult, error) { + func(context.Context, PlanTaskRequest) (TaskResult, error) { return TaskResult{Outcome: TaskFailed}, errors.New("no") }, nil) if report.Status != PlanFailed { @@ -143,7 +145,8 @@ func TestBudgetExhaustionMidPlanIsPartial(t *testing.T) { dispatched := []string{} report := ExecutePlan(context.Background(), plan, []string{"read_file"}, - func(_ context.Context, task Task, _ []string) (TaskResult, error) { + func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + task := req.Task dispatched = append(dispatched, task.ID) return TaskResult{Outcome: TaskSucceeded, Tokens: 60, Output: "ok"}, nil }, nil) @@ -179,7 +182,8 @@ func TestToolGrantIsIntersectedAtDispatch(t *testing.T) { var granted []string ExecutePlan(context.Background(), plan, []string{"read_file"}, - func(_ context.Context, _ Task, tools []string) (TaskResult, error) { + func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + tools := req.Tools granted = tools return TaskResult{Outcome: TaskSucceeded}, nil }, nil) @@ -202,7 +206,8 @@ func TestEmptyToolsInheritsOnlyReadOnlyParentTools(t *testing.T) { plan := mustPlan(t, []any{task("a", "x")}, okBudget(), Limits{MaxTasks: 5, MaxTokens: 1000}) var granted []string ExecutePlan(context.Background(), plan, []string{"read_file", "grep", "write_file", "bash"}, - func(_ context.Context, _ Task, tools []string) (TaskResult, error) { + func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + tools := req.Tools granted = tools return TaskResult{Outcome: TaskSucceeded}, nil }, nil) @@ -232,7 +237,8 @@ func TestMaxSpeedupOnAKnownDAG(t *testing.T) { "c": 30 * time.Millisecond, "d": 10 * time.Millisecond, } report := ExecutePlan(context.Background(), plan, []string{"read_file"}, - func(_ context.Context, task Task, _ []string) (TaskResult, error) { + func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + task := req.Task return TaskResult{Outcome: TaskSucceeded, Duration: durations[task.ID]}, nil }, nil) @@ -254,7 +260,7 @@ func TestMaxSpeedupOnAKnownDAG(t *testing.T) { // chain has speedup 1. Those are the bounds the kill criterion is read against, // so they must be exactly right. func TestMaxSpeedupBounds(t *testing.T) { - runner := func(_ context.Context, _ Task, _ []string) (TaskResult, error) { + runner := func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { return TaskResult{Outcome: TaskSucceeded, Duration: 10 * time.Millisecond}, nil } independent := mustPlan(t, []any{task("a", "x"), task("b", "y"), task("c", "z")}, okBudget(), readOnlyLimits()) @@ -275,7 +281,8 @@ func TestTaskResultsArriveVerbatim(t *testing.T) { const body = "Summary\n-------\nline one\n\n indented\n\ndiff --git a/x b/x\n+added\n-removed\n\nConclusion: done." plan := mustPlan(t, []any{task("a", "x"), task("b", "y", "a")}, okBudget(), readOnlyLimits()) report := ExecutePlan(context.Background(), plan, []string{"read_file"}, - func(_ context.Context, task Task, _ []string) (TaskResult, error) { + func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + task := req.Task return TaskResult{Outcome: TaskSucceeded, Output: task.ID + ":" + body}, nil }, nil) @@ -303,7 +310,8 @@ func TestExecutionFollowsTheValidatedOrder(t *testing.T) { }, okBudget(), readOnlyLimits()) seen := []string{} ExecutePlan(context.Background(), plan, []string{"read_file"}, - func(_ context.Context, task Task, _ []string) (TaskResult, error) { + func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + task := req.Task seen = append(seen, task.ID) return TaskResult{Outcome: TaskSucceeded}, nil }, nil) @@ -326,7 +334,7 @@ func TestExecutionFollowsTheValidatedOrder(t *testing.T) { func TestRecordingIsOptional(t *testing.T) { plan := mustPlan(t, []any{task("a", "x")}, okBudget(), readOnlyLimits()) report := ExecutePlan(context.Background(), plan, []string{"read_file"}, - func(context.Context, Task, []string) (TaskResult, error) { + func(context.Context, PlanTaskRequest) (TaskResult, error) { return TaskResult{Outcome: TaskSucceeded}, nil }, nil) if report.Status != PlanCompleted { @@ -420,9 +428,98 @@ func TestRealRunnerHonoursThePerCallContext(t *testing.T) { if launched != 0 { t.Fatalf("a cancelled context must launch no children, launched %d", launched) } - if report.Status != PlanFailed { - t.Fatalf("a cancelled plan must not report success, got %q", report.Status) + // CANCELLED, not failed. The guarantee this test was written for — a + // cancelled plan never reports success — still holds; the status is simply + // no longer conflated with a plan that broke. Marking a stopped run as + // failed is what made a cancelled twenty-task plan read as nineteen + // defects. + if report.Status == PlanCompleted { + t.Fatalf("a cancelled plan must never report success, got %q", report.Status) + } + if report.Status != PlanCancelled { + t.Fatalf("a plan cancelled before any task ran must report %q, got %q", PlanCancelled, report.Status) + } + if report.Cancelled != 2 { + t.Fatalf("both tasks must be recorded as cancelled, got %d", report.Cancelled) + } + for _, task := range report.Tasks { + if task.Outcome != TaskCancelled { + t.Fatalf("task %q outcome = %q, want %q", task.ID, task.Outcome, TaskCancelled) + } } } func intPtrForTest(v int) *int { return &v } + +// A plan cancelled PART WAY through is partial, not failed: work that finished +// still counts, and the tasks that never ran are cancelled rather than broken. +// The whole point of the outcome is that a user who pressed Ctrl-C does not get +// a wall of failures. +func TestCancellationMidPlanIsPartialAndNamesTheCancelledTasks(t *testing.T) { + plan := mustPlan(t, []any{task("a", "x"), task("b", "y"), task("c", "z")}, okBudget(), readOnlyLimits()) + ctx, cancel := context.WithCancel(context.Background()) + + ran := 0 + report := ExecutePlan(ctx, plan, []string{"read_file"}, + func(runCtx context.Context, req PlanTaskRequest) (TaskResult, error) { + ran++ + if req.Task.ID == "a" { + return TaskResult{Outcome: TaskSucceeded, Output: "done"}, nil + } + // b is in flight when the user stops the run. + cancel() + return TaskResult{Outcome: TaskFailed, Err: "context canceled"}, runCtx.Err() + }, nil) + + if ran != 2 { + t.Fatalf("only a and b should have been dispatched, ran %d", ran) + } + if report.Status != PlanPartial { + t.Fatalf("status = %q, want %q: one task succeeded, so this is not a wholesale cancellation", report.Status, PlanPartial) + } + if report.Succeeded != 1 { + t.Fatalf("succeeded = %d, want 1: finished work still counts", report.Succeeded) + } + if report.Failed != 0 { + t.Fatalf("failed = %d, want 0: nothing broke, the user stopped it", report.Failed) + } + if report.Cancelled != 2 { + t.Fatalf("cancelled = %d, want 2 (b in flight, c never dispatched)", report.Cancelled) + } + byID := map[string]TaskOutcome{} + for _, task := range report.Tasks { + byID[task.ID] = task.Outcome + } + if byID["a"] != TaskSucceeded || byID["b"] != TaskCancelled || byID["c"] != TaskCancelled { + t.Fatalf("outcomes = %v, want a succeeded, b and c cancelled", byID) + } +} + +// The summary a user reads must name cancellation rather than burying it in the +// failure count. +func TestSummaryNamesCancelledTasks(t *testing.T) { + report := PlanReport{Status: PlanPartial, Succeeded: 1, Cancelled: 2} + summary := report.Summary() + if !strings.Contains(summary, "2 cancelled") { + t.Fatalf("the summary must state the cancelled count:\n%s", summary) + } + if strings.Contains(summary, "2 failed") { + t.Fatalf("cancelled tasks must not be reported as failures:\n%s", summary) + } +} + +// A genuine failure is still a failure — the cancellation branch must not +// swallow real errors just because the run finished afterwards. +func TestARealFailureIsNotReclassifiedAsCancelled(t *testing.T) { + plan := mustPlan(t, []any{task("a", "x")}, okBudget(), readOnlyLimits()) + report := ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(context.Context, PlanTaskRequest) (TaskResult, error) { + return TaskResult{Outcome: TaskFailed, Err: "the child exited 3"}, nil + }, nil) + if report.Failed != 1 || report.Cancelled != 0 { + t.Fatalf("failed=%d cancelled=%d, want a real failure counted as one", report.Failed, report.Cancelled) + } + if report.Status != PlanFailed { + t.Fatalf("status = %q, want %q", report.Status, PlanFailed) + } +} diff --git a/internal/specialist/plan_grant_test.go b/internal/specialist/plan_grant_test.go index e4e50b94f..f800ce85a 100644 --- a/internal/specialist/plan_grant_test.go +++ b/internal/specialist/plan_grant_test.go @@ -168,7 +168,7 @@ func TestExecutePlanFailsAnUngrantableTaskWithoutDispatchingIt(t *testing.T) { ) dispatched := 0 report := ExecutePlan(context.Background(), plan, nil, - func(context.Context, Task, []string) (TaskResult, error) { + func(context.Context, PlanTaskRequest) (TaskResult, error) { dispatched++ return TaskResult{Outcome: TaskSucceeded}, nil }, nil) @@ -277,3 +277,15 @@ func containsGrant(grant []string, name string) bool { } return false } + +// The real tools must actually declare what the loop now keys on. The agent +// package proves the MECHANISM with probes; this proves the two production +// tools opt in, which is what makes their children visible. +func TestSubagentToolsDeclareChildProgress(t *testing.T) { + if !(&TaskTool{}).StreamsChildProgress() { + t.Error("the Task tool must declare child progress; its sub-agents streamed before this change") + } + if !(&OrchestrateTool{}).StreamsChildProgress() { + t.Error("orchestrate must declare child progress; without it a plan runs invisibly") + } +} diff --git a/internal/specialist/plan_progress_test.go b/internal/specialist/plan_progress_test.go new file mode 100644 index 000000000..9c464ce23 --- /dev/null +++ b/internal/specialist/plan_progress_test.go @@ -0,0 +1,111 @@ +package specialist + +import ( + "context" + "testing" + + "github.com/Gitlawb/zero/internal/streamjson" + "github.com/Gitlawb/zero/internal/tools" +) + +// progressExecutor is an Executor whose child immediately emits one stream-json +// event, so a test can observe whether the progress callback reached it. +func progressExecutor(t *testing.T) Executor { + t.Helper() + return Executor{ + BinaryPath: "/bin/true", + NewSessionID: func() (string, error) { return "specialist_00000000000000000000000a", nil }, + Load: func(LoadOptions) (LoadResult, error) { return LoadResult{}, nil }, + RunChild: func(_ context.Context, _ string, _ []string, progress func(streamjson.Event)) (ChildRunResult, error) { + if progress != nil { + progress(streamjson.Event{Type: streamjson.EventToolCall, Name: "read_file"}) + } + return ChildRunResult{Started: true}, nil + }, + } +} + +// THE SECOND CONSTRUCTION PATH, both halves, asserted end to end. +// +// The Task tool forwards its caller's progress callback into TaskRunOptions; +// the plan runner built the same struct and omitted the field, and the +// orchestrate tool never read options.Progress at all. Either half alone leaves +// a plan's children streaming to nobody, which is why they are one change and +// one test: a test that only covered the runner would have passed while the +// tool dropped the callback on the floor, and vice versa. +func TestOrchestrateForwardsProgressToEveryTasksChild(t *testing.T) { + var events int + gate := &PostureGate{} + gate.Set(true) + + tool := &OrchestrateTool{ + PostureActive: gate.Active, + ParentTools: []string{"read_file"}, + RunTask: NewPlanRunner(PlanTaskContext{ + Executor: progressExecutor(t), + Cwd: t.TempDir(), + SpecialistName: "explorer", + }), + } + + result := tool.RunWithOptions(context.Background(), map[string]any{ + "name": "p", + "tasks": []any{ + map[string]any{"id": "a", "prompt": "one"}, + map[string]any{"id": "b", "prompt": "two"}, + }, + "budget": map[string]any{"max_workers": float64(1), "max_tokens": float64(100000)}, + }, tools.RunOptions{ + Progress: func(streamjson.Event) { events++ }, + }) + + if result.Status == tools.StatusError { + t.Fatalf("plan failed: %s", result.Output) + } + if events != 2 { + t.Fatalf("saw %d progress events, want one per task: a plan task's child must stream exactly as a Task sub-agent's does", events) + } +} + +// The runner half on its own: a request carrying a callback must put it on +// TaskRunOptions. Pinned separately so a regression names which half broke. +func TestPlanRunnerForwardsProgress(t *testing.T) { + var got bool + runner := NewPlanRunner(PlanTaskContext{ + Executor: progressExecutor(t), + Cwd: t.TempDir(), + SpecialistName: "explorer", + }) + if _, err := runner(context.Background(), PlanTaskRequest{ + Task: Task{ID: "a", Prompt: "x"}, + Tools: []string{"read_file"}, + Progress: func(streamjson.Event) { got = true }, + }); err != nil { + t.Fatalf("runner: %v", err) + } + if !got { + t.Fatal("the runner dropped the progress callback; its child streamed to nobody") + } +} + +// A caller that wires no progress is unaffected: nil stays nil rather than +// becoming a non-nil no-op, so the executor's own behaviour is unchanged. +func TestPlanRunnerWithoutProgressPassesNil(t *testing.T) { + var sawCallback bool + executor := Executor{ + BinaryPath: "/bin/true", + NewSessionID: func() (string, error) { return "specialist_00000000000000000000000a", nil }, + Load: func(LoadOptions) (LoadResult, error) { return LoadResult{}, nil }, + RunChild: func(_ context.Context, _ string, _ []string, progress func(streamjson.Event)) (ChildRunResult, error) { + sawCallback = progress != nil + return ChildRunResult{Started: true}, nil + }, + } + runner := NewPlanRunner(PlanTaskContext{Executor: executor, Cwd: t.TempDir(), SpecialistName: "explorer"}) + if _, err := runner(context.Background(), PlanTaskRequest{Task: Task{ID: "a", Prompt: "x"}, Tools: []string{"read_file"}}); err != nil { + t.Fatalf("runner: %v", err) + } + if sawCallback { + t.Fatal("an unwired plan must hand the executor a nil progress callback, as it always did") + } +} diff --git a/internal/specialist/plan_runner.go b/internal/specialist/plan_runner.go index 20ab6e593..76633c87a 100644 --- a/internal/specialist/plan_runner.go +++ b/internal/specialist/plan_runner.go @@ -41,7 +41,8 @@ type PlanTaskContext struct { // synchronous, returns before ExecutePlan moves to the next task, and holds no // goroutine of its own. func NewPlanRunner(planCtx PlanTaskContext) PlanRunner { - return func(ctx context.Context, task Task, grantedTools []string) (TaskResult, error) { + return func(ctx context.Context, req PlanTaskRequest) (TaskResult, error) { + task, grantedTools := req.Task, req.Tools if ctx == nil { ctx = context.Background() } @@ -64,6 +65,13 @@ func NewPlanRunner(planCtx PlanTaskContext) PlanRunner { CurrentDepth: planCtx.Depth, Cwd: planCtx.Cwd, PermissionMode: planCtx.PermissionMode, + // THE SECOND HALF of the same defect. The Task tool forwards its + // caller's progress callback here (task_tool.go); this path built + // the same struct and omitted the field, so a plan task's child + // streamed to nobody. Same class as finding 7 (ParentModel): a + // second construction path that does not carry what the first one + // did. Fixed with the tool-side half, not separately. + Progress: req.Progress, // Explicitly NOT MemberAutonomy: Phase 2 tasks are read-only, and // granting it here would be the authority widening that was ruled // out as needing its own decision. diff --git a/internal/specialist/plan_test.go b/internal/specialist/plan_test.go index 638ea7a46..10f48244a 100644 --- a/internal/specialist/plan_test.go +++ b/internal/specialist/plan_test.go @@ -42,7 +42,7 @@ func TestParsePlanIsTheOnlyConstructor(t *testing.T) { t.Fatal("a zero Plan must carry no tasks and no order") } // Executing one runs nothing and reports failed — never success. - report := ExecutePlan(context.Background(), zero, nil, func(context.Context, Task, []string) (TaskResult, error) { + report := ExecutePlan(context.Background(), zero, nil, func(context.Context, PlanTaskRequest) (TaskResult, error) { t.Fatal("a zero Plan must not dispatch anything") return TaskResult{}, nil }, nil) @@ -214,7 +214,7 @@ func TestTaskCountAgreesBetweenAdmissionAndExecution(t *testing.T) { t.Fatalf("TaskCount = %d, want %d", plan.TaskCount(), size) } dispatched := 0 - ExecutePlan(context.Background(), plan, []string{"read_file"}, func(context.Context, Task, []string) (TaskResult, error) { + ExecutePlan(context.Background(), plan, []string{"read_file"}, func(context.Context, PlanTaskRequest) (TaskResult, error) { dispatched++ return TaskResult{Outcome: TaskSucceeded}, nil }, nil) @@ -363,7 +363,7 @@ func TestOrchestrateRunRejectsAnInvalidPlan(t *testing.T) { tool := &OrchestrateTool{ PostureActive: func() bool { return true }, ParentTools: []string{"read_file"}, - RunTask: func(context.Context, Task, []string) (TaskResult, error) { + RunTask: func(context.Context, PlanTaskRequest) (TaskResult, error) { ran = true return TaskResult{Outcome: TaskSucceeded}, nil }, @@ -393,16 +393,17 @@ func TestOrchestrateToolStatusFollowsThePlanStatus(t *testing.T) { want tools.Status wantSub string }{ - {"all succeed", func(context.Context, Task, []string) (TaskResult, error) { + {"all succeed", func(context.Context, PlanTaskRequest) (TaskResult, error) { return TaskResult{Outcome: TaskSucceeded, Output: "ok"}, nil }, tools.StatusOK, "completed"}, - {"one fails -> partial", func(_ context.Context, task Task, _ []string) (TaskResult, error) { + {"one fails -> partial", func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + task := req.Task if task.ID == "b" { return TaskResult{Outcome: TaskFailed, Err: "no"}, errors.New("no") } return TaskResult{Outcome: TaskSucceeded, Output: "ok"}, nil }, tools.StatusError, "partial"}, - {"all fail -> failed", func(context.Context, Task, []string) (TaskResult, error) { + {"all fail -> failed", func(context.Context, PlanTaskRequest) (TaskResult, error) { return TaskResult{Outcome: TaskFailed, Err: "no"}, errors.New("no") }, tools.StatusError, "failed"}, } diff --git a/internal/specialist/plan_tool.go b/internal/specialist/plan_tool.go index 778b61f04..b10127e88 100644 --- a/internal/specialist/plan_tool.go +++ b/internal/specialist/plan_tool.go @@ -85,6 +85,12 @@ func (tool *OrchestrateTool) postureActive() bool { return tool != nil && tool.PostureActive != nil && tool.PostureActive() } +// StreamsChildProgress declares that a plan's tasks are child agent runs whose +// stream-json events should reach the parent's UI, exactly as a Task +// sub-agent's do. Declaring it is what un-gates the progress path — the loop +// used to key on the tool name, so this tool ran invisibly. +func (tool *OrchestrateTool) StreamsChildProgress() bool { return true } + func (tool *OrchestrateTool) Parameters() tools.Schema { return tools.Schema{ Type: "object", @@ -158,6 +164,31 @@ func (tool *OrchestrateTool) Run(ctx context.Context, args map[string]any) tools return tool.RunWithOptions(ctx, args, tools.RunOptions{}) } +// runnerWithProgress attaches the caller's progress callback to every task +// request. +// +// PER CALL, not captured at construction: RunTask is built once at +// registration and holds only run-invariant state, while the progress callback +// belongs to one tool call. Capturing it in NewPlanRunner is precisely the +// mistake the runner's own doc comment warns about. +// +// The callback is shared by every task rather than keyed per task, because the +// loop's callback carries only the parent's tool-call id and has no room for a +// sub-key. That is sound HERE and only here: MaxWorkers is validated to be 1, +// so exactly one task is in flight at any moment and the consumer can attribute +// events to the task it last saw dispatched. Stage 2d must revisit this the +// moment two tasks can run at once — see the note on the TUI plan recorder. +func (tool *OrchestrateTool) runnerWithProgress(options tools.RunOptions) PlanRunner { + run := tool.RunTask + if run == nil || options.Progress == nil { + return run + } + return func(ctx context.Context, req PlanTaskRequest) (TaskResult, error) { + req.Progress = options.Progress + return run(ctx, req) + } +} + // Limits supplies the hard caps a plan must fit inside. nil means the defaults. func (tool *OrchestrateTool) limits(options tools.RunOptions) Limits { limits := Limits{ @@ -194,7 +225,7 @@ func (tool *OrchestrateTool) RunWithOptions(ctx context.Context, args map[string } recordPlanAdmitted(tool.Recorder, plan) - report := ExecutePlan(ctx, plan, tool.ParentTools, tool.RunTask, tool.Recorder) + report := ExecutePlan(ctx, plan, tool.ParentTools, tool.runnerWithProgress(options), tool.Recorder) recordPlanCompleted(tool.Recorder, plan, report) result := tools.Result{ diff --git a/internal/specialist/task_tool.go b/internal/specialist/task_tool.go index 339f2adcc..e6a811a5c 100644 --- a/internal/specialist/task_tool.go +++ b/internal/specialist/task_tool.go @@ -57,6 +57,12 @@ func (tool *TaskTool) Parameters() tools.Schema { } } +// StreamsChildProgress declares that this tool spawns a child agent run whose +// stream-json events should reach the parent's UI. Previously the agent loop +// inferred this from the tool's NAME; the declaration moves the knowledge to +// the tool that has it. +func (tool *TaskTool) StreamsChildProgress() bool { return true } + func (tool *TaskTool) Safety() tools.Safety { return tools.Safety{ SideEffect: tools.SideEffectShell, diff --git a/internal/tools/registry.go b/internal/tools/registry.go index 5d52667af..6f68404f1 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -64,6 +64,32 @@ type deferredTool interface { Deferred() bool } +// ChildProgressStreamer is an optional interface a tool implements to declare +// that it spawns child agent runs whose stream-json events should surface to +// the parent's UI. The agent loop supplies RunOptions.Progress only to tools +// that declare it. +// +// A DECLARATION, NOT A NAME. The loop previously gated this on +// `call.Name == "Task"`, so a second sub-agent-spawning tool (orchestrate) got +// a nil callback and ran invisibly. Adding `|| call.Name == "orchestrate"` +// would be the same defect one name later — this codebase's most repeated +// shape. A tool that spawns children says so itself, and the loop asks. +// +// Tools that do not implement this keep receiving a nil Progress, which is +// exactly their behaviour today: swarm tools included, deliberately, since the +// swarm launcher does not forward a progress callback either. +type ChildProgressStreamer interface { + StreamsChildProgress() bool +} + +// StreamsChildProgress reports whether a tool opts into receiving +// RunOptions.Progress. Tools that do not implement ChildProgressStreamer are +// silent, which is the default. +func StreamsChildProgress(t Tool) bool { + streamer, ok := t.(ChildProgressStreamer) + return ok && streamer.StreamsChildProgress() +} + func NewRegistry() *Registry { return &Registry{tools: make(map[string]Tool)} } diff --git a/internal/tui/model.go b/internal/tui/model.go index bf9a3f09e..715a44fe1 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -116,17 +116,27 @@ type model struct { titledSessions map[string]bool // retitle* drive the sequential /retitle backfill: queued session ids still // awaiting a title, whether a backfill is running, and its progress counters. - retitleQueue []string - retitleActive bool - retitleTotal int - retitleDone int - retitleOK int - usageTracker *usage.Tracker - sessionCompactor SessionCompactor - prService *PrService - prState PrState - prWatcherStop func() - runtimeMessageSink func(tea.Msg) + retitleQueue []string + retitleActive bool + retitleTotal int + retitleDone int + retitleOK int + usageTracker *usage.Tracker + sessionCompactor SessionCompactor + prService *PrService + prState PrState + prWatcherStop func() + runtimeMessageSink func(tea.Msg) + // planRunningCardKey is the card key of the plan task currently in flight. + // A plan's child progress events arrive keyed by the ORCHESTRATE tool call + // (the loop's callback carries only the parent's tool-call id), so they are + // attributed to this. Sound only because MaxWorkers is validated to be 1 — + // exactly one task runs at a time. Stage 2d must revisit it. + planRunningCardKey string + // planProgress is the shared recorder the orchestrate tool holds. A POINTER + // for the same reason PostureGate is one: the TUI model is a value type + // copied on every update, so a closure over it would freeze the first run. + planProgress *PlanProgressBridge prepareRunCompletionWarning func() runCompletionWarning func() string agentOptions agent.Options @@ -908,6 +918,7 @@ func newModel(ctx context.Context, options Options) model { agentOptions: options.AgentOptions, sessionCompactor: options.SessionCompactor, runtimeMessageSink: options.RuntimeMessageSink, + planProgress: options.PlanProgress, permissionMode: permissionMode, reasoningEffort: options.ReasoningEffort, responseStyle: defaultedResponseStyle(options.ResponseStyle), @@ -2628,10 +2639,79 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m.transcript = appendTranscriptRow(m.transcript, cardRow) } return m, nil + case planAdmittedMsg: + if msg.runID != m.activeRunID { + return m, nil + } + m.transcript = appendTranscriptRow(m.transcript, transcriptRow{ + kind: rowSystem, + runID: msg.runID, + id: fmt.Sprintf("plan-admitted-%s", msg.name), + text: planAdmittedLine(msg.name, msg.taskCount), + }) + return m, nil + case planTaskStartMsg: + if msg.runID != m.activeRunID { + return m, nil + } + m.specialists.start(msg.taskID, msg.summary, msg.cardKey, m.now()) + // Remember which task is in flight so the plan's progress events — which + // arrive keyed by the ORCHESTRATE tool call, not by task — can be + // attributed. Sound only because MaxWorkers is validated to be 1, so + // exactly one task runs at a time. Stage 2d must revisit this. + m.planRunningCardKey = msg.cardKey + return m, nil + case planTaskDoneMsg: + if msg.runID != m.activeRunID { + return m, nil + } + if m.planRunningCardKey == msg.cardKey { + m.planRunningCardKey = "" + } + cardKey := msg.cardKey + if !msg.dispatched { + // Never started, so it has no card. Give it its own key and open one + // now, so a skipped or cancelled task is still SHOWN rather than + // closing the previously dispatched task's card. + cardKey = "planskipped_" + msg.taskID + m.specialists.start(msg.taskID, msg.reason, cardKey, m.now()) + } + m.specialists.complete(cardKey, msg.status, 0, msg.reason, m.now()) + if msg.sessionID != "" && msg.sessionID != cardKey { + m.specialists.reconcileSessionID(cardKey, msg.sessionID) + cardKey = msg.sessionID + } + if info, ok := m.specialists.getBySessionID(cardKey); ok { + m.transcript = appendTranscriptRow(m.transcript, transcriptRow{ + kind: rowSpecialist, + runID: msg.runID, + specialistInfo: &info, + }) + } + return m, nil + case planCompletedMsg: + if msg.runID != m.activeRunID { + return m, nil + } + m.planRunningCardKey = "" + m.transcript = appendTranscriptRow(m.transcript, transcriptRow{ + kind: rowSystem, + runID: msg.runID, + id: fmt.Sprintf("plan-completed-%s", msg.name), + text: planCompletedLine(msg), + }) + return m, nil case specialistProgressMsg: if msg.runID != m.activeRunID { return m, nil } + // A plan's progress arrives keyed by the orchestrate tool call, which has + // no card of its own; attribute it to the task currently in flight. + if _, ok := m.specialists.getBySessionID(msg.toolCallID); !ok && m.planRunningCardKey != "" { + m.specialists.incrementToolCount(m.planRunningCardKey) + m.specialists.setCurrentTool(m.planRunningCardKey, msg.toolName, msg.detail) + return m, nil + } // Each progress message is one specialist tool call (OnToolProgress fires only // for EventToolCall); bump the card's tool-call counter so it stops showing a // permanent "0 tool calls" (M18). The tracker is still keyed by the tool-call @@ -4869,6 +4949,12 @@ func (m model) beginRun(cancel context.CancelFunc) model { // previous turn don't bleed into the new one. m.specialists.clear() m.plan.clear() + m.planRunningCardKey = "" + // Re-bind the plan recorder to THIS run. The orchestrate tool holds the + // bridge for the process's life (the registry is built once per session), + // so the run id has to be pushed in per run — the PostureGate problem, same + // solution. + m.planProgress.Attach(m.runtimeMessageSink, m.runID) m.stepWork = nil m.stepNarration = nil m.stepExplanation = nil diff --git a/internal/tui/options.go b/internal/tui/options.go index 37acf0ee1..6473f9ea8 100644 --- a/internal/tui/options.go +++ b/internal/tui/options.go @@ -40,6 +40,11 @@ type Options struct { DiscoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error) DiscoverOllamaContextWindow func(ctx context.Context, baseURL string, model string) (int, error) RuntimeMessageSink func(tea.Msg) + // PlanProgress is the recorder the orchestrate tool was registered with. + // The model re-attaches it to each run so plan lifecycle events become + // transcript cards. nil disables the live plan view without affecting + // execution — recording is best-effort everywhere on this path. + PlanProgress *PlanProgressBridge PrepareRunCompletionWarning func() RunCompletionWarning func() string Registry *tools.Registry diff --git a/internal/tui/plan_messages.go b/internal/tui/plan_messages.go new file mode 100644 index 000000000..08bc5be74 --- /dev/null +++ b/internal/tui/plan_messages.go @@ -0,0 +1,94 @@ +package tui + +import ( + "fmt" + "strings" +) + +// Plan lifecycle messages, posted from the tool goroutine by +// PlanProgressBridge and consumed on the Bubble Tea event loop. Each carries +// runID so the stale-run guard can drop messages from a superseded run, exactly +// like specialistStartMsg. + +// planAdmittedMsg announces a validated plan before its first task runs, so a +// plan does not read as a frozen session until the first task finishes. +type planAdmittedMsg struct { + runID int + name string + taskCount int +} + +// planTaskStartMsg opens a card for a dispatched task. cardKey is a temporary +// id (the child session does not exist yet) reconciled on completion. +type planTaskStartMsg struct { + runID int + taskID string + summary string + cardKey string +} + +// planTaskDoneMsg closes a task's card. +// +// dispatched distinguishes a task that ran from one that never started +// (dependency-skipped, budget-skipped, cancelled before dispatch). A task that +// never started has no card, and closing the last dispatched task's card for it +// would mark the wrong task — the specialist-card collision defect in a new +// costume. +type planTaskDoneMsg struct { + runID int + taskID string + cardKey string + dispatched bool + sessionID string + status specialistStatus + outcome string + reason string +} + +// planCompletedMsg carries the plan's terminal record. +type planCompletedMsg struct { + runID int + name string + status string + succeeded int + failed int + skipped int + cancelled int + tokensUsed int + tokenLimit int + maxSpeedup float64 +} + +// planNoticeLine renders the one-line plan notices shown in the transcript. +// Kept here beside the messages so the wording and the data stay together. +func planAdmittedLine(name string, taskCount int) string { + label := "tasks" + if taskCount == 1 { + label = "task" + } + if strings.TrimSpace(name) == "" { + return fmt.Sprintf("plan: %d %s", taskCount, label) + } + return fmt.Sprintf("plan %q: %d %s", name, taskCount, label) +} + +func planCompletedLine(msg planCompletedMsg) string { + var b strings.Builder + fmt.Fprintf(&b, "plan %s: %d succeeded", msg.status, msg.succeeded) + if msg.failed > 0 { + fmt.Fprintf(&b, ", %d failed", msg.failed) + } + if msg.skipped > 0 { + fmt.Fprintf(&b, ", %d skipped", msg.skipped) + } + if msg.cancelled > 0 { + fmt.Fprintf(&b, ", %d cancelled", msg.cancelled) + } + if msg.tokenLimit > 0 { + fmt.Fprintf(&b, " · %d/%d tokens", msg.tokensUsed, msg.tokenLimit) + } + if msg.maxSpeedup > 0 { + fmt.Fprintf(&b, " · max_speedup %.2fx", msg.maxSpeedup) + } + return b.String() +} diff --git a/internal/tui/plan_progress.go b/internal/tui/plan_progress.go new file mode 100644 index 000000000..0e178f622 --- /dev/null +++ b/internal/tui/plan_progress.go @@ -0,0 +1,192 @@ +package tui + +import ( + "fmt" + "strings" + "sync" + + tea "charm.land/bubbletea/v2" + + "github.com/Gitlawb/zero/internal/specialist" +) + +// PlanProgressBridge turns plan lifecycle events into TUI messages. +// +// LIFETIME, and it is the PostureGate problem again: the registry is built once +// per session and the orchestrate tool holds this recorder for the process's +// life, while the run id changes on every run. So this is a POINTER with +// mutable state that the model re-attaches per run, not a closure over a +// value-typed model that would freeze the first run's id forever. +// +// THREAD SAFETY: every method here is called from the tool's goroutine, never +// from the Bubble Tea event loop. It takes a mutex, builds a message, and hands +// it to the sink — which is the same asynchronous path OnToolProgress already +// uses. Nothing here renders, blocks, or touches model state. +// +// BEST EFFORT, like every other recorder on this path: a nil bridge, a nil sink +// or an unattached run is a silent no-op. Recording must never be the thing +// that fails a plan (execSessionRecorder.append's contract). +type PlanProgressBridge struct { + mu sync.Mutex + sink func(tea.Msg) + runID int + // dispatched counts tasks so each gets a unique temporary card key. The + // child's real session id is not known until the child process creates it, + // so the card is keyed by this and reconciled on completion — exactly how + // the Task tool's card works. + dispatched int +} + +// NewPlanProgressBridge returns a bridge that is inert until Attach is called. +func NewPlanProgressBridge() *PlanProgressBridge { return &PlanProgressBridge{} } + +// Attach binds the bridge to the run that is about to start. Called on every +// run so a plan's cards belong to the run that produced them; the stale-run +// guard in the message handlers does the rest. +func (bridge *PlanProgressBridge) Attach(sink func(tea.Msg), runID int) { + if bridge == nil { + return + } + bridge.mu.Lock() + defer bridge.mu.Unlock() + bridge.sink = sink + bridge.runID = runID + bridge.dispatched = 0 +} + +// send posts a message if the bridge is attached. Nil-safe at every level. +func (bridge *PlanProgressBridge) send(build func(runID int) tea.Msg) { + if bridge == nil { + return + } + bridge.mu.Lock() + sink, runID := bridge.sink, bridge.runID + bridge.mu.Unlock() + if sink == nil { + return + } + sink(build(runID)) +} + +// planTaskKey is the temporary card key for the nth dispatched task. Namespaced +// so it can never collide with a tool call id. +func planTaskKey(n int) string { return fmt.Sprintf("plantask_%d", n) } + +// PlanAdmitted announces the plan so the transcript can show that N tasks are +// about to run rather than going silent until the first one finishes. +func (bridge *PlanProgressBridge) PlanAdmitted(plan specialist.Plan) { + name := plan.Name() + count := plan.TaskCount() + bridge.send(func(runID int) tea.Msg { + return planAdmittedMsg{runID: runID, name: name, taskCount: count} + }) +} + +// TaskDispatched opens a card for the task that is about to run. +func (bridge *PlanProgressBridge) TaskDispatched(task specialist.Task) { + if bridge == nil { + return + } + bridge.mu.Lock() + bridge.dispatched++ + key := planTaskKey(bridge.dispatched) + bridge.mu.Unlock() + + id, summary := task.ID, planTaskSummary(task) + bridge.send(func(runID int) tea.Msg { + return planTaskStartMsg{runID: runID, taskID: id, summary: summary, cardKey: key} + }) +} + +// TaskCompleted closes the card and reconciles it to the child's real session +// id so the user can drill into it. +func (bridge *PlanProgressBridge) TaskCompleted(result specialist.TaskResult) { + bridge.finish(result, specialistCompleted) +} + +// TaskFailed closes the card with the outcome's own status. A cancelled or +// skipped task is NOT rendered as an error: nothing broke. +func (bridge *PlanProgressBridge) TaskFailed(result specialist.TaskResult) { + bridge.finish(result, planOutcomeStatus(result.Outcome)) +} + +func (bridge *PlanProgressBridge) finish(result specialist.TaskResult, status specialistStatus) { + if bridge == nil { + return + } + bridge.mu.Lock() + key := planTaskKey(bridge.dispatched) + bridge.mu.Unlock() + + // A task that was never dispatched (dependency-skipped, budget-skipped, + // cancelled before it ran) has no card. Reporting it against the LAST + // dispatched task's key would close the wrong card, so those carry their + // own key and the handler creates the card on demand. + dispatched := result.Outcome == specialist.TaskSucceeded || result.Outcome == specialist.TaskFailed + taskID, sessionID, reason := result.ID, result.SessionID, result.Err + outcome := result.Outcome + bridge.send(func(runID int) tea.Msg { + return planTaskDoneMsg{ + runID: runID, + taskID: taskID, + cardKey: key, + dispatched: dispatched, + sessionID: sessionID, + status: status, + outcome: string(outcome), + reason: reason, + } + }) +} + +// PlanCompleted reports the plan's terminal state. +func (bridge *PlanProgressBridge) PlanCompleted(plan specialist.Plan, report specialist.PlanReport) { + name := plan.Name() + status := string(report.Status) + succeeded, failed := report.Succeeded, report.Failed + skipped, cancelled := report.Skipped, report.Cancelled + tokens, limit := report.TokensUsed, plan.Budget().MaxTokens + speedup := report.MaxSpeedup + bridge.send(func(runID int) tea.Msg { + return planCompletedMsg{ + runID: runID, name: name, status: status, + succeeded: succeeded, failed: failed, skipped: skipped, cancelled: cancelled, + tokensUsed: tokens, tokenLimit: limit, maxSpeedup: speedup, + } + }) +} + +// planOutcomeStatus maps a task outcome onto the card status. Cancelled and +// skipped are deliberately NOT specialistError: a user who stopped a plan did +// not break it, and a task skipped because its dependency failed is not itself +// a failure. +func planOutcomeStatus(outcome specialist.TaskOutcome) specialistStatus { + switch outcome { + case specialist.TaskSucceeded: + return specialistCompleted + case specialist.TaskFailed: + return specialistError + default: + // Cancelled, dependency-skipped, budget-skipped: ended without running + // to completion, but not an error. + return specialistCancelled + } +} + +// planTaskSummary is a SHORT label for the card. The full prompt stays in the +// tool output; a display surface never becomes the data path. +func planTaskSummary(task specialist.Task) string { + summary := strings.TrimSpace(task.Prompt) + if summary == "" { + return "" + } + if index := strings.IndexAny(summary, "\r\n"); index >= 0 { + summary = summary[:index] + } + return truncateRunes(summary, planTaskSummaryWidth) +} + +// planTaskSummaryWidth bounds the card label. truncateRunes (view.go) does the +// cut on RUNE boundaries — reusing it rather than reimplementing keeps one +// truncation rule, since slicing by byte index produces mojibake. +const planTaskSummaryWidth = 60 diff --git a/internal/tui/plan_progress_test.go b/internal/tui/plan_progress_test.go new file mode 100644 index 000000000..c6ab27207 --- /dev/null +++ b/internal/tui/plan_progress_test.go @@ -0,0 +1,185 @@ +package tui + +import ( + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/Gitlawb/zero/internal/specialist" +) + +// THE RENDER-CACHE COLLISION, asserted with N cards where one has failed. +// +// A rowSpecialist row carries no id and no text — everything that distinguishes +// one card from another lives behind row.specialistInfo, and none of it was in +// the cache key. Two specialist cards in one run therefore hashed identically, +// so the first one rendered was reused for the second: a failed sub-agent shown +// as a successful one. One child per run made that rare; a plan produces one +// card per task, which makes it certain. +func TestSpecialistCardsInOneRunDoNotShareACacheKey(t *testing.T) { + m := model{} + now := time.Now() + cards := []specialistInfo{ + {name: "a", childSessionID: "s1", status: specialistCompleted, startedAt: now, completedAt: now}, + {name: "b", childSessionID: "s2", status: specialistError, errorMsg: "boom", startedAt: now, completedAt: now}, + {name: "c", childSessionID: "s3", status: specialistCancelled, startedAt: now, completedAt: now}, + {name: "d", childSessionID: "s4", status: specialistCompleted, startedAt: now, completedAt: now}, + } + + seen := map[string]string{} + for index := range cards { + row := transcriptRow{kind: rowSpecialist, runID: 7, specialistInfo: &cards[index]} + key, _ := m.renderRowCacheKey(row, 80, rowContext{}, cardRenderOptions{}, false) + if other, clash := seen[key]; clash { + t.Fatalf("cards %q and %q share a cache key, so one renders as the other", other, cards[index].name) + } + seen[key] = cards[index].name + } +} + +// Every field that changes what a card shows must be in the key. Checked field +// by field so one added to specialistInfo and forgotten here fails loudly. +func TestSpecialistCacheKeyCoversEveryVaryingField(t *testing.T) { + m := model{} + base := specialistInfo{ + name: "a", description: "d", childSessionID: "s1", status: specialistRunning, + exitCode: 0, errorMsg: "", toolCount: 1, currentTool: "grep", currentDetail: "x", + startedAt: time.Unix(1, 0), completedAt: time.Unix(2, 0), + } + keyOf := func(info specialistInfo) string { + key, _ := m.renderRowCacheKey( + transcriptRow{kind: rowSpecialist, runID: 1, specialistInfo: &info}, 80, rowContext{}, cardRenderOptions{}, false) + return key + } + original := keyOf(base) + + mutations := map[string]func(*specialistInfo){ + "name": func(i *specialistInfo) { i.name = "z" }, + "description": func(i *specialistInfo) { i.description = "z" }, + "childSessionID": func(i *specialistInfo) { i.childSessionID = "z" }, + "status": func(i *specialistInfo) { i.status = specialistError }, + "exitCode": func(i *specialistInfo) { i.exitCode = 3 }, + "errorMsg": func(i *specialistInfo) { i.errorMsg = "z" }, + "toolCount": func(i *specialistInfo) { i.toolCount = 9 }, + "currentTool": func(i *specialistInfo) { i.currentTool = "z" }, + "currentDetail": func(i *specialistInfo) { i.currentDetail = "z" }, + "startedAt": func(i *specialistInfo) { i.startedAt = time.Unix(99, 0) }, + "completedAt": func(i *specialistInfo) { i.completedAt = time.Unix(99, 0) }, + } + for field, mutate := range mutations { + changed := base + mutate(&changed) + if keyOf(changed) == original { + t.Errorf("changing %s does not change the cache key, so a stale render survives it", field) + } + } +} + +// The bridge must never touch the event loop and must be inert until attached. +func TestPlanProgressBridgeIsInertUntilAttached(t *testing.T) { + bridge := NewPlanProgressBridge() + // No sink: every method is a silent no-op rather than a panic. Recording is + // best-effort and must never be the thing that fails a plan. + bridge.TaskDispatched(specialist.Task{ID: "a"}) + bridge.TaskCompleted(specialist.TaskResult{ID: "a", Outcome: specialist.TaskSucceeded}) + bridge.TaskFailed(specialist.TaskResult{ID: "b", Outcome: specialist.TaskFailed}) + + var nilBridge *PlanProgressBridge + nilBridge.TaskDispatched(specialist.Task{ID: "a"}) + nilBridge.Attach(nil, 1) +} + +func TestPlanProgressBridgeEmitsACardPerTask(t *testing.T) { + var got []tea.Msg + bridge := NewPlanProgressBridge() + bridge.Attach(func(msg tea.Msg) { got = append(got, msg) }, 42) + + bridge.TaskDispatched(specialist.Task{ID: "a", Prompt: "first"}) + bridge.TaskCompleted(specialist.TaskResult{ID: "a", Outcome: specialist.TaskSucceeded, SessionID: "specialist_aaa"}) + bridge.TaskDispatched(specialist.Task{ID: "b", Prompt: "second"}) + bridge.TaskFailed(specialist.TaskResult{ID: "b", Outcome: specialist.TaskFailed, Err: "boom"}) + + if len(got) != 4 { + t.Fatalf("expected one message per transition, got %d", len(got)) + } + first, ok := got[0].(planTaskStartMsg) + if !ok || first.taskID != "a" || first.runID != 42 { + t.Fatalf("first message = %#v, want a start for task a on run 42", got[0]) + } + second, ok := got[2].(planTaskStartMsg) + if !ok || second.cardKey == first.cardKey { + t.Fatalf("each task needs its OWN card key, got %q twice", first.cardKey) + } + done, ok := got[3].(planTaskDoneMsg) + if !ok || done.status != specialistError || !done.dispatched { + t.Fatalf("last message = %#v, want a dispatched failure", got[3]) + } +} + +// A task that never started has no card, so closing the LAST dispatched task's +// card for it would mark the wrong task — the collision defect in a new shape. +func TestSkippedTaskDoesNotCloseTheDispatchedTasksCard(t *testing.T) { + var got []tea.Msg + bridge := NewPlanProgressBridge() + bridge.Attach(func(msg tea.Msg) { got = append(got, msg) }, 1) + + bridge.TaskDispatched(specialist.Task{ID: "b", Prompt: "runs"}) + bridge.TaskFailed(specialist.TaskResult{ID: "b", Outcome: specialist.TaskFailed, Err: "boom"}) + bridge.TaskFailed(specialist.TaskResult{ID: "d", Outcome: specialist.TaskSkippedDependency, Err: "skipped"}) + + skipped, ok := got[2].(planTaskDoneMsg) + if !ok { + t.Fatalf("expected a done message for the skipped task, got %#v", got[2]) + } + if skipped.dispatched { + t.Fatal("a dependency-skipped task never ran; marking it dispatched closes another task's card") + } + if skipped.status == specialistError { + t.Fatal("a task skipped because its dependency failed is not itself an error") + } +} + +// Cancelled and skipped are not errors. A user who stopped a plan did not break +// it, and a wall of red is exactly what the outcome exists to prevent. +func TestCancelledAndSkippedRenderAsNeitherSuccessNorError(t *testing.T) { + for _, outcome := range []specialist.TaskOutcome{ + specialist.TaskCancelled, + specialist.TaskSkippedDependency, + specialist.TaskSkippedBudget, + } { + if status := planOutcomeStatus(outcome); status != specialistCancelled { + t.Errorf("outcome %q mapped to status %v, want the neutral cancelled status", outcome, status) + } + } + if planOutcomeStatus(specialist.TaskFailed) != specialistError { + t.Error("a real failure must still render as an error") + } + if planOutcomeStatus(specialist.TaskSucceeded) != specialistCompleted { + t.Error("a success must still render as a success") + } +} + +// The card label is a SUMMARY. The full prompt stays in the tool output — a +// display formatter on the data path is how a 583-rune work product became 200 +// mangled runes. +func TestPlanTaskSummaryIsShortAndCutsOnRuneBoundaries(t *testing.T) { + multiline := specialist.Task{Prompt: "first line\nsecond line"} + if got := planTaskSummary(multiline); got != "first line" { + t.Fatalf("summary = %q, want only the first line", got) + } + long := specialist.Task{Prompt: strings.Repeat("日", 200)} + summary := planTaskSummary(long) + if len([]rune(summary)) > planTaskSummaryWidth { + t.Fatalf("summary is %d runes, over the %d cap", len([]rune(summary)), planTaskSummaryWidth) + } + if !strings.HasSuffix(summary, "…") { + t.Fatalf("a truncated summary must say so: %q", summary) + } + for _, r := range summary { + if r == '�' { + t.Fatalf("summary was cut mid-rune: %q", summary) + } + } +} diff --git a/internal/tui/render_cache.go b/internal/tui/render_cache.go index a5ba02059..3243e6c50 100644 --- a/internal/tui/render_cache.go +++ b/internal/tui/render_cache.go @@ -156,6 +156,13 @@ func (m model) renderRowCacheKey(row transcriptRow, width int, rc rowContext, op appendRenderCacheField(&b, strconv.FormatBool(rc.auto[key])) appendRenderCacheField(&b, permissionCacheFingerprint(row.permission)) appendRenderCacheField(&b, askUserCacheFingerprint(row.askUser)) + // A specialist row carries NO id and NO text — every distinguishing field + // lives behind row.specialistInfo, and none of it was in this key. Two + // specialist cards in one run therefore produced an identical key, so the + // first one rendered was reused for the second: a failed sub-agent shown as + // a successful one. Rare with the Task tool (one child per run is typical), + // guaranteed with a plan, which produces one card per task. + appendRenderCacheField(&b, specialistCacheFingerprint(row.specialistInfo)) return b.String(), stable } @@ -166,6 +173,34 @@ func appendRenderCacheField(b *strings.Builder, value string) { b.WriteByte('|') } +// specialistCacheFingerprint covers every field of a specialist card that can +// change what it renders. A field added to specialistInfo and not added here is +// the same defect returning, which is why the test asserts on distinctness +// across cards rather than on this string's shape. +func specialistCacheFingerprint(info *specialistInfo) string { + if info == nil { + return "" + } + fields := []string{ + info.childSessionID, + info.name, + info.description, + strconv.Itoa(int(info.status)), + strconv.Itoa(info.exitCode), + info.errorMsg, + strconv.Itoa(info.toolCount), + info.currentTool, + info.currentDetail, + strconv.FormatInt(info.startedAt.UnixNano(), 10), + strconv.FormatInt(info.completedAt.UnixNano(), 10), + } + var b strings.Builder + for _, field := range fields { + appendRenderCacheField(&b, field) + } + return b.String() +} + func permissionCacheFingerprint(event *agent.PermissionEvent) string { if event == nil { return "" diff --git a/internal/tui/sidebar.go b/internal/tui/sidebar.go index 13b87a14b..b6ec023f1 100644 --- a/internal/tui/sidebar.go +++ b/internal/tui/sidebar.go @@ -423,6 +423,9 @@ func (m model) sidebarAgentRows(width int) ([]string, []sidebarAgentHit) { icon = zeroTheme.accent.Render(m.spinnerGlyph()) case specialistError: icon = zeroTheme.red.Render("✗") + case specialistCancelled: + // Neutral, not red: a stopped or skipped task is not a defect. + icon = zeroTheme.faint.Render("⊘") default: // completed icon = zeroTheme.green.Render("✓") } diff --git a/internal/tui/specialist_card.go b/internal/tui/specialist_card.go index 2bd7160fd..267cbd8a4 100644 --- a/internal/tui/specialist_card.go +++ b/internal/tui/specialist_card.go @@ -25,6 +25,12 @@ const ( specialistRunning specialistStatus = iota specialistCompleted specialistError + // specialistCancelled: ended without running to completion, but nothing + // broke — a plan task the user stopped, or one skipped because a dependency + // failed. Appended AFTER the existing values so their ordinals are + // unchanged; a Task sub-agent never produces this, so its rendering is + // untouched. + specialistCancelled ) // specialistInfo is the rendered view of one specialist invocation. From 06c4a06c2fbe78dbd195d2c6c5c60f5d76b0ebd6 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:25:15 +0530 Subject: [PATCH 16/86] feat(tui): add the /plans panel with the plan's dependency shape Gate 1a made a plan's tasks visible as cards. Cards cannot show the one thing that makes a plan a plan: its shape. This adds the panel. The shape is carried on planAdmittedMsg, not assembled as tasks finish. ParsePlan already proved the graph acyclic and computed the topological order, so the panel is complete before the first task starts rather than growing a node at a time. Each task is ranked by dependency depth -- one more than its deepest dependency -- and indented by it, so a diamond reads as one node, two beside each other, one node. A chain steps in once per link; independent tasks share a rung. All three are asserted. Mounted above the pinned update_plan panel, since it describes work running right now. It renders NOTHING when no plan has been admitted -- not an empty box, not a blank line -- which is what keeps a posture-off session unchanged: the panel never runs, rather than running and producing nothing. Named orchestrate throughout. /plan (update_plan's TODO list) and /plans (this) are genuinely different things with near-identical names, so every type, field and function here says orchestrate and only the user-facing command says plans. The command lists every task including any the pinned panel had to drop, and names each task's dependencies explicitly, so the shape survives copied text and narrow terminals where indentation does not. Bounded at 12 rows and it states what it hid: a silently truncated list reads as a complete one. Summaries cut on rune boundaries. The full result stays in the tool output -- the panel summarises, and a display formatter on a data path is how a 583-rune work product became 200 mangled runes. The header clock stops when the plan ends, and when the run ends without a terminal plan event. It previously kept counting while the turn that produced the plan carried on, so a 20-second plan read as minutes. TestSuggestionOverlayCapsRowsWithoutMoreText pinned the first and last entries of the command palette's first window; inserting /plans pushed /ps out of it. The window's start and size are unchanged, so the tail anchor moves to /permissions and the comment says why it is incidental. --- internal/tui/autocomplete_test.go | 5 +- internal/tui/commands.go | 12 + internal/tui/model.go | 32 +- internal/tui/orchestrate_panel.go | 426 +++++++++++++++++++++++++ internal/tui/orchestrate_panel_test.go | 420 ++++++++++++++++++++++++ internal/tui/plan_messages.go | 21 +- internal/tui/plan_progress.go | 22 +- 7 files changed, 930 insertions(+), 8 deletions(-) create mode 100644 internal/tui/orchestrate_panel.go create mode 100644 internal/tui/orchestrate_panel_test.go diff --git a/internal/tui/autocomplete_test.go b/internal/tui/autocomplete_test.go index e8fdae52e..bb8261c2e 100644 --- a/internal/tui/autocomplete_test.go +++ b/internal/tui/autocomplete_test.go @@ -395,7 +395,10 @@ func TestSuggestionOverlayCapsRowsWithoutMoreText(t *testing.T) { if strings.Contains(plain, "more") { t.Fatalf("bare slash palette should not render a more-count row, got %q", plain) } - if !strings.Contains(plain, "│ ❯ provider") || !strings.Contains(plain, "│ ps") { + // First and last entries of the first visible window. The tail anchor moves + // whenever a command is inserted above it — /plans pushed /ps out — so what + // this pins is the window's start and size, not those two names. + if !strings.Contains(plain, "│ ❯ provider") || !strings.Contains(plain, "│ permissions") { t.Fatalf("top of palette should render first visible command window, got %q", plain) } if strings.Contains(plain, "compact") { diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 3ee9cf7e8..876fda773 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -25,6 +25,7 @@ const ( commandDebug commandDoctor commandPlan + commandPlans commandSearch commandResume commandRetitle @@ -117,6 +118,17 @@ var commandDefinitions = []commandDefinition{ description: "Show planning mode status.", kind: commandPlan, }, + { + name: "/plans", + usage: "/plans", + group: commandGroupSession, + // Deliberately close to /plan, and they are different things: /plan is + // planning-mode status (the update_plan TODO list), /plans is the + // orchestrate plan's task graph. The description says so, because the + // names alone do not. + description: "Show the running orchestrate plan: tasks, dependency shape, budget.", + kind: commandPlans, + }, { name: "/permissions", usage: "/permissions", diff --git a/internal/tui/model.go b/internal/tui/model.go index 715a44fe1..d501decc7 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -136,7 +136,11 @@ type model struct { // planProgress is the shared recorder the orchestrate tool holds. A POINTER // for the same reason PostureGate is one: the TUI model is a value type // copied on every update, so a closure over it would freeze the first run. - planProgress *PlanProgressBridge + planProgress *PlanProgressBridge + // orchestrate is the live view of the running orchestrate plan. Distinct + // from m.plan, which is the update_plan tool's TODO list — two different + // things called "plan", kept apart by name everywhere but the command. + orchestrate orchestratePanelState prepareRunCompletionWarning func() runCompletionWarning func() string agentOptions agent.Options @@ -2402,6 +2406,10 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m.runCancel = nil m.activeRunID = 0 m.plan.frozenAt = m.now() // freeze the plan clock while idle (no run in flight) + // Same for the orchestrate panel: a plan left mid-flight when the run + // ends (interrupt, crash, a turn that yielded) must stop counting rather + // than tick forever against a turn that is gone. + m.orchestrate.frozenAt = m.now() // A fully successful turn means the task is done. Weaker models often // forget the final update_plan, leaving the panel stuck mid-progress; // reconcile it to complete here. Read pendingAskUser/pendingPermission @@ -2643,6 +2651,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.runID != m.activeRunID { return m, nil } + m.orchestrate.admit(msg, m.now()) m.transcript = appendTranscriptRow(m.transcript, transcriptRow{ kind: rowSystem, runID: msg.runID, @@ -2654,6 +2663,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.runID != m.activeRunID { return m, nil } + m.orchestrate.markStarted(msg.taskID, msg.summary, m.now()) m.specialists.start(msg.taskID, msg.summary, msg.cardKey, m.now()) // Remember which task is in flight so the plan's progress events — which // arrive keyed by the ORCHESTRATE tool call, not by task — can be @@ -2665,6 +2675,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.runID != m.activeRunID { return m, nil } + m.orchestrate.markDone(msg.taskID, msg.outcome, m.now()) if m.planRunningCardKey == msg.cardKey { m.planRunningCardKey = "" } @@ -2694,6 +2705,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.planRunningCardKey = "" + m.orchestrate.complete(msg, m.now()) m.transcript = appendTranscriptRow(m.transcript, transcriptRow{ kind: rowSystem, runID: msg.runID, @@ -3075,6 +3087,15 @@ func (m model) footerView(width int) string { // run, not the subagent/swarm child session being viewed there, so pinning it // above that composer would show unrelated state. if !m.subchat.active { + // The ORCHESTRATE plan panel sits above the update_plan panel: it + // describes work currently running, so it is the more urgent of the + // two. It renders nothing at all when no plan has been admitted (see + // orchestratePanelState.visible), which is what keeps a posture-off + // session byte-identical rather than merely visually unchanged. + if orchestrate := m.renderOrchestratePanel(width); orchestrate != "" { + footer.WriteString(orchestrate) + footer.WriteString("\n") + } if plan := m.renderPinnedPlanPanel(width, m.pinnedPlanMaxHeight()); plan != "" { footer.WriteString(plan) footer.WriteString("\n") @@ -4568,6 +4589,9 @@ func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) { return m.openSTTModelPicker() case commandVoice: return m.toggleVoiceMode() + case commandPlans: + m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.orchestratePlansText()}) + return m, nil case commandContext: m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.contextText()}) return m, nil @@ -4950,6 +4974,7 @@ func (m model) beginRun(cancel context.CancelFunc) model { m.specialists.clear() m.plan.clear() m.planRunningCardKey = "" + m.orchestrate.clear() // Re-bind the plan recorder to THIS run. The orchestrate tool holds the // bridge for the process's life (the registry is built once per session), // so the run id has to be pushed in per run — the PostureGate problem, same @@ -5110,8 +5135,9 @@ func (m *model) cancelRun() { *m = m.advanceZeromaxing() // a cancelled run spends the one-shot notices too m.runCancel = nil m.activeRunID = 0 - m.cancelConfirmActive = false // whatever path got here, there's nothing left to confirm cancelling - m.plan.frozenAt = m.now() // freeze the plan clock while idle (no run in flight) + m.cancelConfirmActive = false // whatever path got here, there's nothing left to confirm cancelling + m.plan.frozenAt = m.now() // freeze the plan clock while idle (no run in flight) + m.orchestrate.frozenAt = m.now() // and the orchestrate panel's, for the same reason m.pendingPermission = nil m.pendingAskUser = nil // The interim block renders streamingText live; a cancelled run's partial diff --git a/internal/tui/orchestrate_panel.go b/internal/tui/orchestrate_panel.go new file mode 100644 index 000000000..e7e1c4cc7 --- /dev/null +++ b/internal/tui/orchestrate_panel.go @@ -0,0 +1,426 @@ +package tui + +import ( + "fmt" + "strings" + "time" +) + +// orchestrateTaskStatus is a plan task's state in the panel. +type orchestrateTaskStatus int + +const ( + orchestratePending orchestrateTaskStatus = iota + orchestrateRunning + orchestrateDone + orchestrateFailed + orchestrateSkipped + orchestrateCancelled +) + +func (s orchestrateTaskStatus) label() string { + switch s { + case orchestrateRunning: + return "running" + case orchestrateDone: + return "done" + case orchestrateFailed: + return "failed" + case orchestrateSkipped: + return "skipped" + case orchestrateCancelled: + return "cancelled" + default: + return "pending" + } +} + +// orchestrateTask is one row of the panel. +type orchestrateTask struct { + id string + dependsOn []string + phase string + status orchestrateTaskStatus + summary string + startedAt time.Time + endedAt time.Time + // depth is the task's rank in the dependency graph: 0 for a task with no + // dependencies, otherwise one more than its deepest dependency. This is what + // makes a diamond LOOK like a diamond — tasks at the same depth ran from the + // same fan-out and are drawn on the same rung. + depth int +} + +// elapsed is the task's duration: live while running, frozen once ended. +func (t orchestrateTask) elapsed(now time.Time) time.Duration { + if t.startedAt.IsZero() { + return 0 + } + if t.endedAt.IsZero() { + return now.Sub(t.startedAt) + } + return t.endedAt.Sub(t.startedAt) +} + +// orchestratePanelState is the live view of one orchestrate plan. +// +// Distinct from planPanelState, which belongs to the update_plan tool (the +// model's own TODO list). Two different things called "plan" is a real +// collision risk, so this type, its command and its state are named +// ORCHESTRATE throughout, and only the user-facing command says "plans". +type orchestratePanelState struct { + name string + tasks []orchestrateTask + byID map[string]int + status string + tokensUsed int + tokenLimit int + maxSpeedup float64 + startedAt time.Time + completedAt time.Time + // frozenAt stops the header clock when the run ends without a terminal plan + // event — an interrupt or a crash. Mirrors planPanelState.frozenAt. + frozenAt time.Time + expanded bool +} + +func (s *orchestratePanelState) clear() { *s = orchestratePanelState{} } + +func (s orchestratePanelState) isEmpty() bool { return len(s.tasks) == 0 } + +func (s orchestratePanelState) isComplete() bool { return s.status != "" } + +// admit installs the shape. Called once per plan, before any task runs. +func (s *orchestratePanelState) admit(msg planAdmittedMsg, now time.Time) { + s.clear() + s.name = msg.name + s.tokenLimit = msg.tokenLimit + s.startedAt = now + s.byID = make(map[string]int, len(msg.tasks)) + s.tasks = make([]orchestrateTask, 0, len(msg.tasks)) + for _, task := range msg.tasks { + s.byID[task.id] = len(s.tasks) + s.tasks = append(s.tasks, orchestrateTask{ + id: task.id, + dependsOn: task.dependsOn, + phase: task.phase, + status: orchestratePending, + }) + } + s.computeDepths() +} + +// computeDepths ranks tasks by dependency depth. The message carries them in +// the validated topological order, so every dependency is already ranked when a +// task is reached — the same property criticalPath relies on. +func (s *orchestratePanelState) computeDepths() { + for index := range s.tasks { + depth := 0 + for _, dep := range s.tasks[index].dependsOn { + position, ok := s.byID[dep] + if !ok || position >= index { + continue + } + if next := s.tasks[position].depth + 1; next > depth { + depth = next + } + } + s.tasks[index].depth = depth + } +} + +func (s *orchestratePanelState) markStarted(taskID, summary string, now time.Time) { + index, ok := s.byID[taskID] + if !ok { + return + } + s.tasks[index].status = orchestrateRunning + s.tasks[index].summary = summary + s.tasks[index].startedAt = now +} + +func (s *orchestratePanelState) markDone(taskID, outcome string, now time.Time) { + index, ok := s.byID[taskID] + if !ok { + return + } + task := &s.tasks[index] + task.status = orchestrateStatusFromOutcome(outcome) + task.endedAt = now + if task.startedAt.IsZero() { + // Never dispatched: it has no duration, and pretending otherwise would + // put time against work that did not happen. + task.startedAt = now + } +} + +// orchestrateStatusFromOutcome maps the executor's terminal outcomes onto panel +// statuses. The string values are specialist's TaskOutcome constants; matching +// on them here rather than importing keeps the panel free of the executor. +func orchestrateStatusFromOutcome(outcome string) orchestrateTaskStatus { + switch outcome { + case "succeeded": + return orchestrateDone + case "failed": + return orchestrateFailed + case "cancelled": + return orchestrateCancelled + case "dependency_failed", "budget_exhausted": + return orchestrateSkipped + default: + return orchestrateFailed + } +} + +func (s *orchestratePanelState) complete(msg planCompletedMsg, now time.Time) { + s.status = msg.status + s.tokensUsed = msg.tokensUsed + if msg.tokenLimit > 0 { + s.tokenLimit = msg.tokenLimit + } + s.maxSpeedup = msg.maxSpeedup + s.completedAt = now +} + +// visible mirrors planPanelState.visible: nothing to show when empty, and a +// finished plan stops pinning itself after a grace period unless expanded. +// +// THIS IS THE MOUNT-POINT ANSWER. With no plan admitted the state is empty and +// this returns false, so the panel contributes nothing to the view — the +// posture-off, no-plan TUI is unchanged because the panel never renders, not +// because it renders something empty. +func (s orchestratePanelState) visible(now time.Time) bool { + if s.isEmpty() { + return false + } + if s.isComplete() && !s.expanded && !s.completedAt.IsZero() && now.Sub(s.completedAt) > completedHideAfter { + return false + } + return true +} + +func (s orchestratePanelState) counts() (done, failed, skipped, cancelled, running int) { + for _, task := range s.tasks { + switch task.status { + case orchestrateDone: + done++ + case orchestrateFailed: + failed++ + case orchestrateSkipped: + skipped++ + case orchestrateCancelled: + cancelled++ + case orchestrateRunning: + running++ + } + } + return +} + +const ( + // orchestrateMaxRows bounds what the pinned panel draws. A plan may hold up + // to defaultPlanMaxTasks tasks and the panel must never push the composer + // off screen; beyond this the panel says how many it is not showing rather + // than silently dropping them. + orchestrateMaxRows = 12 + // orchestrateSummaryWidth bounds a task's one-line summary. + orchestrateSummaryWidth = 48 + // orchestrateMinWidth is the narrowest sensible render. + orchestrateMinWidth = 24 +) + +// renderOrchestratePanel draws the plan: one row per task, indented by +// dependency depth so the shape is legible. +func (m model) renderOrchestratePanel(width int) string { + state := m.orchestrate + now := m.orchestrateNow() + if !state.visible(now) { + return "" + } + if width < orchestrateMinWidth { + width = orchestrateMinWidth + } + + var b strings.Builder + b.WriteString(zeroTheme.ink.Render(orchestrateHeaderLine(state, now))) + + rows := state.tasks + hidden := 0 + if len(rows) > orchestrateMaxRows { + hidden = len(rows) - orchestrateMaxRows + rows = rows[:orchestrateMaxRows] + } + for _, task := range rows { + b.WriteString("\n") + b.WriteString(m.renderOrchestrateTaskLine(task, now, width)) + } + if hidden > 0 { + b.WriteString("\n") + b.WriteString(zeroTheme.faint.Render(fmt.Sprintf(" … %d more task(s) not shown", hidden))) + } + if footer := orchestrateFooterLine(state); footer != "" { + b.WriteString("\n") + b.WriteString(zeroTheme.faint.Render(footer)) + } + return b.String() +} + +func orchestrateHeaderLine(state orchestratePanelState, now time.Time) string { + done, failed, skipped, cancelled, running := state.counts() + var b strings.Builder + b.WriteString("PLAN") + if name := strings.TrimSpace(state.name); name != "" { + fmt.Fprintf(&b, " %s", truncateRunes(name, 24)) + } + fmt.Fprintf(&b, " %d/%d done", done, len(state.tasks)) + if running > 0 { + fmt.Fprintf(&b, " · %d running", running) + } + if failed > 0 { + fmt.Fprintf(&b, " · %d failed", failed) + } + if skipped > 0 { + fmt.Fprintf(&b, " · %d skipped", skipped) + } + if cancelled > 0 { + fmt.Fprintf(&b, " · %d cancelled", cancelled) + } + if !state.startedAt.IsZero() { + fmt.Fprintf(&b, " · %s", formatElapsedSeconds(now.Sub(state.startedAt))) + } + return b.String() +} + +func orchestrateFooterLine(state orchestratePanelState) string { + var parts []string + if state.tokenLimit > 0 { + parts = append(parts, fmt.Sprintf("budget %d/%d tokens", state.tokensUsed, state.tokenLimit)) + } + if state.isComplete() { + parts = append(parts, "status "+state.status) + if state.maxSpeedup > 0 { + parts = append(parts, fmt.Sprintf("max_speedup %.2fx", state.maxSpeedup)) + } + } + if len(parts) == 0 { + return "" + } + return " " + strings.Join(parts, " · ") +} + +func (m model) renderOrchestrateTaskLine(task orchestrateTask, now time.Time, width int) string { + // Indent by dependency depth: tasks that fan out from the same parent share + // a rung, so a diamond reads as one node, two beside each other, one node. + indent := strings.Repeat(" ", task.depth+1) + + glyph := orchestrateGlyph(task.status) + if task.status == orchestrateRunning { + glyph = zeroTheme.accent.Render(m.spinnerGlyph()) + } + + head := fmt.Sprintf("%s%s %s", indent, glyph, truncateRunes(task.id, 24)) + meta := task.status.label() + if elapsed := task.elapsed(now); elapsed > 0 { + meta += " " + formatElapsedSeconds(elapsed) + } + + // The summary is what is left after the fixed parts, and it is a SUMMARY: + // the task's full result stays in the tool output. A display formatter on + // the data path is how a 583-rune work product became 200 mangled runes. + remaining := width - len([]rune(head)) - len([]rune(meta)) - 3 + line := head + " " + zeroTheme.faint.Render(meta) + if remaining > 8 && strings.TrimSpace(task.summary) != "" { + limit := remaining + if limit > orchestrateSummaryWidth { + limit = orchestrateSummaryWidth + } + line += " " + zeroTheme.faint.Render(truncateRunes(task.summary, limit)) + } + return line +} + +func orchestrateGlyph(status orchestrateTaskStatus) string { + switch status { + case orchestrateDone: + return zeroTheme.green.Render("✓") + case orchestrateFailed: + return zeroTheme.red.Render("✗") + case orchestrateSkipped, orchestrateCancelled: + // Neutral: a skipped or stopped task is not a defect. + return zeroTheme.faint.Render("⊘") + default: + return zeroTheme.faint.Render("·") + } +} + +// orchestrateNow freezes the plan clock while no run is in flight, mirroring +// planNow — a task left mid-flight when the agent yields must stop ticking up +// against a turn that is no longer running. +func (m model) orchestrateNow() time.Time { + // The plan's own completion stops its clock, whatever the run is doing: a + // finished plan inside a turn that continues must not keep counting. + if !m.orchestrate.completedAt.IsZero() { + return m.orchestrate.completedAt + } + // Otherwise the run ending stops it — a plan left mid-flight by an + // interrupt would tick forever against a turn that is no longer running. + if m.activeRunID == 0 && !m.orchestrate.frozenAt.IsZero() { + return m.orchestrate.frozenAt + } + return m.now() +} + +// orchestratePlansText answers /plans. It reports the plan whether or not the +// pinned panel is currently showing it — a finished plan stops pinning itself +// after a grace period, and asking for it explicitly should still work. +func (m model) orchestratePlansText() string { + state := m.orchestrate + if state.isEmpty() { + return "No plan has run this session. Under the zeromaxing posture, ask for one and " + + "the orchestrate tool will run it; tasks appear here as they go." + } + + now := m.orchestrateNow() + var b strings.Builder + b.WriteString(orchestrateHeaderLine(state, now)) + for _, task := range state.tasks { + b.WriteString("\n") + b.WriteString(orchestratePlainTaskLine(task, now)) + } + if footer := orchestrateFooterLine(state); footer != "" { + b.WriteString("\n") + b.WriteString(footer) + } + return b.String() +} + +// orchestratePlainTaskLine is the /plans row: no spinner and no colour, since +// this is a static transcript entry rather than a live panel. Every task is +// listed — the command is where a plan larger than the panel's row budget can +// be read in full. +func orchestratePlainTaskLine(task orchestrateTask, now time.Time) string { + indent := strings.Repeat(" ", task.depth+1) + line := fmt.Sprintf("%s%s %s [%s]", indent, orchestratePlainGlyph(task.status), task.id, task.status.label()) + if elapsed := task.elapsed(now); elapsed > 0 { + line += " " + formatElapsedSeconds(elapsed) + } + if len(task.dependsOn) > 0 { + line += " ← " + strings.Join(task.dependsOn, ", ") + } + return line +} + +func orchestratePlainGlyph(status orchestrateTaskStatus) string { + switch status { + case orchestrateDone: + return "✓" + case orchestrateFailed: + return "✗" + case orchestrateSkipped, orchestrateCancelled: + return "⊘" + case orchestrateRunning: + return "▸" + default: + return "·" + } +} diff --git a/internal/tui/orchestrate_panel_test.go b/internal/tui/orchestrate_panel_test.go new file mode 100644 index 000000000..7361dd7c5 --- /dev/null +++ b/internal/tui/orchestrate_panel_test.go @@ -0,0 +1,420 @@ +package tui + +import ( + "context" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" +) + +// diamondAdmitted is the plan the whole feature is measured against: a -> (b,c) +// -> d. It has to LOOK like a diamond, not like a flat list. +func diamondAdmitted() planAdmittedMsg { + return planAdmittedMsg{ + runID: 1, + name: "diamond", + taskCount: 4, + tokenLimit: 100000, + tasks: []planGraphTask{ + {id: "a"}, + {id: "b", dependsOn: []string{"a"}}, + {id: "c", dependsOn: []string{"a"}}, + {id: "d", dependsOn: []string{"b", "c"}}, + }, + } +} + +func admittedModel(t *testing.T, msg planAdmittedMsg) model { + t.Helper() + m := model{now: func() time.Time { return time.Unix(1000, 0) }} + m.orchestrate.admit(msg, m.now()) + return m +} + +// THE POINT OF THE PANEL. A dependency graph rendered as a flat list is just +// the cards again; the shape is the thing cards cannot show. +func TestDiamondRendersAsADiamond(t *testing.T) { + m := admittedModel(t, diamondAdmitted()) + + depth := map[string]int{} + for _, task := range m.orchestrate.tasks { + depth[task.id] = task.depth + } + if depth["a"] != 0 { + t.Fatalf("the root must sit at depth 0, got %d", depth["a"]) + } + if depth["b"] != 1 || depth["c"] != 1 { + t.Fatalf("b and c fan out from the same parent and must share a rung: b=%d c=%d", depth["b"], depth["c"]) + } + if depth["d"] != 2 { + t.Fatalf("the join must sit below both branches, got %d", depth["d"]) + } + + rendered := m.renderOrchestratePanel(100) + indent := func(id string) int { + for _, line := range strings.Split(rendered, "\n") { + if strings.Contains(line, " "+id+" ") { + return len(line) - len(strings.TrimLeft(line, " ")) + } + } + t.Fatalf("task %q missing from the panel:\n%s", id, rendered) + return -1 + } + if indent("b") != indent("c") { + t.Fatalf("b and c must be drawn on the same rung:\n%s", rendered) + } + if indent("a") >= indent("b") || indent("d") <= indent("a") { + t.Fatalf("the diamond is drawn flat:\n%s", rendered) + } +} + +// A chain is not a diamond: each task must step in one further, or the panel +// draws every shape the same way. +func TestAChainStepsInOncePerLink(t *testing.T) { + m := admittedModel(t, planAdmittedMsg{ + runID: 1, name: "chain", taskCount: 3, + tasks: []planGraphTask{ + {id: "a"}, + {id: "b", dependsOn: []string{"a"}}, + {id: "c", dependsOn: []string{"b"}}, + }, + }) + got := []int{} + for _, task := range m.orchestrate.tasks { + got = append(got, task.depth) + } + if len(got) != 3 || got[0] != 0 || got[1] != 1 || got[2] != 2 { + t.Fatalf("chain depths = %v, want 0,1,2", got) + } +} + +// Independent tasks all sit on the same rung — a fan-out reads as a fan-out. +func TestIndependentTasksShareOneRung(t *testing.T) { + m := admittedModel(t, planAdmittedMsg{ + runID: 1, name: "fan", taskCount: 3, + tasks: []planGraphTask{{id: "a"}, {id: "b"}, {id: "c"}}, + }) + for _, task := range m.orchestrate.tasks { + if task.depth != 0 { + t.Fatalf("task %q has no dependencies but sits at depth %d", task.id, task.depth) + } + } +} + +// THE MOUNT-POINT INVARIANT. With no plan the panel contributes nothing — +// not an empty box, not a blank line. That is what keeps a posture-off session +// unchanged. +func TestPanelRendersNothingWithoutAPlan(t *testing.T) { + m := model{now: func() time.Time { return time.Unix(1000, 0) }} + if got := m.renderOrchestratePanel(100); got != "" { + t.Fatalf("with no plan the panel must render nothing, got %q", got) + } + if m.orchestrate.visible(m.now()) { + t.Fatal("an empty plan must not be visible") + } +} + +// A finished plan stops pinning itself, matching the update_plan panel, so a +// completed plan does not occupy the footer forever. +func TestCompletedPlanStopsPinningItself(t *testing.T) { + m := admittedModel(t, diamondAdmitted()) + m.orchestrate.complete(planCompletedMsg{status: "completed", maxSpeedup: 1.33}, m.now()) + + if !m.orchestrate.visible(m.now()) { + t.Fatal("a just-completed plan should still be visible") + } + later := m.now().Add(completedHideAfter + time.Second) + if m.orchestrate.visible(later) { + t.Fatal("a long-finished plan must stop pinning itself") + } + m.orchestrate.expanded = true + if !m.orchestrate.visible(later) { + t.Fatal("an expanded plan stays visible however long ago it finished") + } +} + +// Status transitions must survive to the panel, and a skipped or cancelled task +// must not be shown as a failure. +func TestPanelTracksEveryOutcome(t *testing.T) { + m := admittedModel(t, diamondAdmitted()) + now := m.now() + + m.orchestrate.markStarted("a", "root", now) + m.orchestrate.markDone("a", "succeeded", now.Add(time.Second)) + m.orchestrate.markStarted("b", "left", now.Add(time.Second)) + m.orchestrate.markDone("b", "failed", now.Add(2*time.Second)) + m.orchestrate.markDone("c", "cancelled", now.Add(2*time.Second)) + m.orchestrate.markDone("d", "dependency_failed", now.Add(2*time.Second)) + + want := map[string]orchestrateTaskStatus{ + "a": orchestrateDone, "b": orchestrateFailed, + "c": orchestrateCancelled, "d": orchestrateSkipped, + } + for _, task := range m.orchestrate.tasks { + if task.status != want[task.id] { + t.Errorf("task %q status = %v, want %v", task.id, task.status, want[task.id]) + } + } + done, failed, skipped, cancelled, running := m.orchestrate.counts() + if done != 1 || failed != 1 || skipped != 1 || cancelled != 1 || running != 0 { + t.Fatalf("counts = done %d failed %d skipped %d cancelled %d running %d", done, failed, skipped, cancelled, running) + } +} + +// A cancelled plan must not read as a wall of failures — the whole reason +// TaskCancelled exists. +func TestCancelledPlanIsNotShownAsFailures(t *testing.T) { + m := admittedModel(t, diamondAdmitted()) + now := m.now() + m.orchestrate.markStarted("a", "root", now) + m.orchestrate.markDone("a", "succeeded", now) + for _, id := range []string{"b", "c", "d"} { + m.orchestrate.markDone(id, "cancelled", now) + } + m.orchestrate.complete(planCompletedMsg{status: "partial", succeeded: 1, cancelled: 3}, now) + + _, failed, _, cancelled, _ := m.orchestrate.counts() + if failed != 0 { + t.Fatalf("a cancelled plan reported %d failures", failed) + } + if cancelled != 3 { + t.Fatalf("cancelled = %d, want 3", cancelled) + } + rendered := m.renderOrchestratePanel(100) + if strings.Contains(rendered, "failed") { + t.Fatalf("the panel calls a cancelled plan failed:\n%s", rendered) + } + if !strings.Contains(rendered, "cancelled") { + t.Fatalf("the panel must say what actually happened:\n%s", rendered) + } +} + +// A plan at the task cap must not push the composer off screen, and what it +// leaves out has to be stated — a silently truncated list reads as a complete +// one. +func TestALargePlanIsBoundedAndSaysWhatItHid(t *testing.T) { + msg := planAdmittedMsg{runID: 1, name: "big", taskCount: 20} + for index := 0; index < 20; index++ { + msg.tasks = append(msg.tasks, planGraphTask{id: string(rune('a' + index))}) + } + msg.taskCount = len(msg.tasks) + m := admittedModel(t, msg) + + rendered := m.renderOrchestratePanel(100) + lines := strings.Count(rendered, "\n") + 1 + if lines > orchestrateMaxRows+3 { + t.Fatalf("the panel drew %d lines for a 20-task plan; it must stay bounded", lines) + } + if !strings.Contains(rendered, "more task(s) not shown") { + t.Fatalf("a truncated panel must say so:\n%s", rendered) + } + // /plans is where the whole plan can still be read. + full := m.orchestratePlansText() + for _, task := range msg.tasks { + if !strings.Contains(full, task.id) { + t.Fatalf("/plans omitted task %q; it is the surface that shows everything", task.id) + } + } +} + +// Narrow terminals must not panic or produce garbage. +func TestPanelSurvivesNarrowWidths(t *testing.T) { + m := admittedModel(t, diamondAdmitted()) + m.orchestrate.markStarted("a", strings.Repeat("verbose ", 40), m.now()) + for _, width := range []int{0, 1, 10, 24, 40, 200} { + rendered := m.renderOrchestratePanel(width) + if rendered == "" { + t.Fatalf("width %d rendered nothing for a live plan", width) + } + for _, r := range rendered { + if r == '�' { + t.Fatalf("width %d cut mid-rune:\n%s", width, rendered) + } + } + } +} + +// A task with no output must still render — the panel shows status, not results. +func TestATaskWithNoSummaryStillRenders(t *testing.T) { + m := admittedModel(t, diamondAdmitted()) + m.orchestrate.markStarted("a", "", m.now()) + rendered := m.renderOrchestratePanel(100) + if !strings.Contains(rendered, " a ") { + t.Fatalf("a task with no summary vanished from the panel:\n%s", rendered) + } +} + +// The panel SUMMARISES. The full result stays in the tool output; a display +// formatter on the data path is how a 583-rune work product became 200 mangled +// runes. +func TestPanelTruncatesSummariesOnRuneBoundaries(t *testing.T) { + m := admittedModel(t, diamondAdmitted()) + m.orchestrate.markStarted("a", strings.Repeat("日", 300), m.now()) + rendered := m.renderOrchestratePanel(100) + for _, line := range strings.Split(rendered, "\n") { + if len([]rune(line)) > 140 { + t.Fatalf("a panel line ran to %d runes:\n%s", len([]rune(line)), line) + } + } + if strings.Contains(rendered, "�") { + t.Fatalf("summary was cut mid-rune:\n%s", rendered) + } +} + +// /plans answers even with no plan, and never claims one ran. +func TestPlansCommandWithNoPlan(t *testing.T) { + m := model{now: func() time.Time { return time.Unix(1000, 0) }} + text := m.orchestratePlansText() + if !strings.Contains(text, "No plan has run") { + t.Fatalf("/plans must say plainly that nothing ran: %q", text) + } +} + +// /plans shows the dependency edges explicitly, so the shape survives even +// where indentation does not (copied text, narrow terminals, screen readers). +func TestPlansCommandNamesTheEdges(t *testing.T) { + m := admittedModel(t, diamondAdmitted()) + text := m.orchestratePlansText() + if !strings.Contains(text, "d [pending] ← b, c") { + t.Fatalf("/plans must name each task's dependencies:\n%s", text) + } +} + +// A finished plan's clock stops. It used to keep counting while the turn that +// produced it carried on, so a plan that took 20 seconds read as minutes. +func TestTheHeaderClockStopsWhenThePlanEnds(t *testing.T) { + m := admittedModel(t, diamondAdmitted()) + start := m.now() + m.activeRunID = 7 // the run continues after the plan finishes + m.orchestrate.complete(planCompletedMsg{status: "completed"}, start.Add(20*time.Second)) + + m.now = func() time.Time { return start.Add(10 * time.Minute) } + if got := m.orchestrateNow(); !got.Equal(start.Add(20 * time.Second)) { + t.Fatalf("the clock kept running after the plan ended: %v", got.Sub(start)) + } +} + +// A plan left mid-flight by an interrupt stops counting when the run ends, +// rather than ticking against a turn that is gone. +func TestAnInterruptedPlansClockFreezesWithTheRun(t *testing.T) { + m := admittedModel(t, diamondAdmitted()) + start := m.now() + m.orchestrate.markStarted("a", "root", start) + m.orchestrate.frozenAt = start.Add(5 * time.Second) + m.activeRunID = 0 + + m.now = func() time.Time { return start.Add(10 * time.Minute) } + if got := m.orchestrateNow(); !got.Equal(start.Add(5 * time.Second)) { + t.Fatalf("an interrupted plan kept counting: %v", got.Sub(start)) + } +} + +// THE WIRING, not the state machine. Every test above drives +// orchestratePanelState directly; this one pushes the four plan messages +// through model.Update, which is the only path a real run takes. A panel whose +// state machine is perfect and whose handlers never call it is exactly the +// defect class this feature keeps producing. +func TestPlanMessagesDriveThePanel(t *testing.T) { + m := model{now: func() time.Time { return time.Unix(1000, 0) }} + m.activeRunID = 1 + + step := func(msg tea.Msg) { + t.Helper() + updated, _ := m.Update(msg) + next, ok := updated.(model) + if !ok { + t.Fatalf("Update returned %T", updated) + } + m = next + } + + step(diamondAdmitted()) + if len(m.orchestrate.tasks) != 4 { + t.Fatalf("planAdmittedMsg did not reach the panel: %d tasks", len(m.orchestrate.tasks)) + } + if m.orchestrate.tokenLimit != 100000 { + t.Fatalf("the budget limit did not reach the panel: %d", m.orchestrate.tokenLimit) + } + + step(planTaskStartMsg{runID: 1, taskID: "a", summary: "root", cardKey: "plantask_1"}) + if m.orchestrate.tasks[0].status != orchestrateRunning { + t.Fatal("planTaskStartMsg did not mark the task running in the panel") + } + + step(planTaskDoneMsg{runID: 1, taskID: "a", cardKey: "plantask_1", dispatched: true, + status: specialistCompleted, outcome: "succeeded"}) + if m.orchestrate.tasks[0].status != orchestrateDone { + t.Fatal("planTaskDoneMsg did not mark the task done in the panel") + } + + step(planCompletedMsg{runID: 1, name: "diamond", status: "partial", + succeeded: 1, tokensUsed: 150, tokenLimit: 100000, maxSpeedup: 1.33}) + if m.orchestrate.status != "partial" || m.orchestrate.tokensUsed != 150 { + t.Fatalf("planCompletedMsg did not reach the panel: %+v", m.orchestrate.status) + } + if m.orchestrate.maxSpeedup != 1.33 { + t.Fatalf("max_speedup did not reach the panel: %v", m.orchestrate.maxSpeedup) + } +} + +// A message from a superseded run must not touch the panel, or a cancelled +// turn's plan overwrites the current one. +func TestStalePlanMessagesAreDropped(t *testing.T) { + m := model{now: func() time.Time { return time.Unix(1000, 0) }} + m.activeRunID = 2 + + stale := diamondAdmitted() // runID 1 + updated, _ := m.Update(stale) + next := updated.(model) + if !next.orchestrate.isEmpty() { + t.Fatal("a plan from a superseded run reached the panel") + } +} + +// THE MOUNT. The panel has to be reachable from the real View, not just +// renderable in isolation — a renderer nothing calls is the same defect as a +// state machine nothing feeds. +func TestOrchestratePanelMountsInTheFooter(t *testing.T) { + m := newModel(context.Background(), Options{}) + m.width, m.height = 96, 30 + m.activeRunID = 1 + + before := plainRender(t, m.View()) + if strings.Contains(before, "PLAN diamond") { + t.Fatal("the panel must not appear before a plan is admitted") + } + + updated, _ := m.Update(diamondAdmitted()) + m = updated.(model) + m.width, m.height = 96, 30 + after := plainRender(t, m.View()) + + if !strings.Contains(after, "PLAN diamond") { + t.Fatalf("the panel is not mounted in the view:\n%s", after) + } + for _, id := range []string{"a", "b", "c", "d"} { + if !strings.Contains(after, " "+id+" ") { + t.Fatalf("task %q missing from the rendered view:\n%s", id, after) + } + } +} + +// /plans reaches orchestratePlansText through the real command dispatch. +func TestPlansCommandIsWired(t *testing.T) { + m := newModel(context.Background(), Options{}) + m.width, m.height = 96, 30 + m.activeRunID = 1 // set BEFORE the message, or the stale-run guard drops it + updated, _ := m.Update(diamondAdmitted()) + m = updated.(model) + + result, _ := m.executeSlash("/plans") + m = result.(model) + if !transcriptContains(m.transcript, "PLAN diamond") { + t.Fatal("/plans did not report the admitted plan") + } + if !transcriptContains(m.transcript, "← b, c") { + t.Fatal("/plans did not report the dependency shape") + } +} diff --git a/internal/tui/plan_messages.go b/internal/tui/plan_messages.go index 08bc5be74..c68a8f5c8 100644 --- a/internal/tui/plan_messages.go +++ b/internal/tui/plan_messages.go @@ -12,10 +12,25 @@ import ( // planAdmittedMsg announces a validated plan before its first task runs, so a // plan does not read as a frozen session until the first task finishes. +// +// It carries the SHAPE, not just the count: the panel's whole reason to exist +// beyond a stream of cards is showing that a diamond is a diamond. The shape is +// known at admission — ParsePlan already proved the graph acyclic and computed +// the order — so sending it here means the panel is complete before the first +// task starts rather than assembling itself as tasks finish. type planAdmittedMsg struct { - runID int - name string - taskCount int + runID int + name string + taskCount int + tasks []planGraphTask + tokenLimit int +} + +// planGraphTask is one node of the admitted graph, in execution order. +type planGraphTask struct { + id string + dependsOn []string + phase string } // planTaskStartMsg opens a card for a dispatched task. cardKey is a temporary diff --git a/internal/tui/plan_progress.go b/internal/tui/plan_progress.go index 0e178f622..245fa1963 100644 --- a/internal/tui/plan_progress.go +++ b/internal/tui/plan_progress.go @@ -77,8 +77,28 @@ func planTaskKey(n int) string { return fmt.Sprintf("plantask_%d", n) } func (bridge *PlanProgressBridge) PlanAdmitted(plan specialist.Plan) { name := plan.Name() count := plan.TaskCount() + limit := plan.Budget().MaxTokens + + // Copied into the message in EXECUTION ORDER, with the dependency edges, so + // the panel can draw the graph without reaching back into the plan — which + // it could not do anyway: Plan's fields are unexported and it lives in + // another package on the tool's goroutine. + byID := map[string]specialist.Task{} + for _, task := range plan.Tasks() { + byID[task.ID] = task + } + graph := make([]planGraphTask, 0, count) + for _, id := range plan.Order() { + task := byID[id] + graph = append(graph, planGraphTask{ + id: id, + dependsOn: append([]string(nil), task.DependsOn...), + phase: task.Phase, + }) + } + bridge.send(func(runID int) tea.Msg { - return planAdmittedMsg{runID: runID, name: name, taskCount: count} + return planAdmittedMsg{runID: runID, name: name, taskCount: count, tasks: graph, tokenLimit: limit} }) } From 86db276e55c81096214c8caace54fd48dbfa956c Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:08:48 +0530 Subject: [PATCH 17/86] fix(specialist): a plan task inherits the parent's model The struct comment always claimed a plan task "inherits exactly the parent's policy". It did not: both production call sites left PlanTaskContext.ParentModel empty, so appendModelArgs received no parent model and passed no --model, and the child fell back to whatever its own config resolved. After a /model switch or a --model flag, plan tasks ran on a different model than the run that launched them. Decided: the documentation states the safer behaviour, so the code moves to match the doc. Explicitly NOT a router decision -- choosing a cheaper model per task is a real future feature and would arrive as an explicit plan field on top of correct inheritance. Silently failing to propagate is not that feature. The fix is not "populate the field at registration", which would have been wrong for the TUI: its registry is built once per session while /model changes the model between runs, so a captured value is stale by design. That is why the fields were empty rather than merely unset -- they were in the wrong home. ParentSessionID/ParentModel/ParentReasoningEffort now travel per call on PlanTaskRequest, read from the same tools.RunOptions fields the Task tool reads, and are gone from PlanTaskContext so there is no second source to drift from. Before: parent ran --model parent-chose-this, child reported stub-model. After: child reports parent-chose-this. Asserted per call, so a second orchestrate call with a different model launches on that model. The parent session id travels with it, which is what links a plan task back to the run that spawned it rather than leaving it an orphan. --- internal/specialist/plan_exec.go | 11 +++ internal/specialist/plan_progress_test.go | 92 +++++++++++++++++++++++ internal/specialist/plan_runner.go | 30 +++++--- internal/specialist/plan_tool.go | 20 +++-- 4 files changed, 136 insertions(+), 17 deletions(-) diff --git a/internal/specialist/plan_exec.go b/internal/specialist/plan_exec.go index a5a74d84a..d22c886fd 100644 --- a/internal/specialist/plan_exec.go +++ b/internal/specialist/plan_exec.go @@ -113,6 +113,17 @@ type PlanTaskRequest struct { // emits. nil is a no-op — the behaviour for every caller that does not wire // live progress. Progress func(streamjson.Event) + // ParentSessionID / ParentModel / ParentReasoningEffort identify the run + // issuing the plan, so a task runs on the SAME model its parent is running + // on rather than whatever the child's own config resolves to. + // + // Per call, not per registration: the TUI's registry is built once while + // /model can change the model between runs. Attached by the orchestrate + // tool from tools.RunOptions, which is where the Task tool reads the same + // three values. + ParentSessionID string + ParentModel string + ParentReasoningEffort string } // PlanRunner runs one task. The executor depends on this seam rather than on diff --git a/internal/specialist/plan_progress_test.go b/internal/specialist/plan_progress_test.go index 9c464ce23..f09b413c5 100644 --- a/internal/specialist/plan_progress_test.go +++ b/internal/specialist/plan_progress_test.go @@ -2,6 +2,7 @@ package specialist import ( "context" + "strings" "testing" "github.com/Gitlawb/zero/internal/streamjson" @@ -109,3 +110,94 @@ func TestPlanRunnerWithoutProgressPassesNil(t *testing.T) { t.Fatal("an unwired plan must hand the executor a nil progress callback, as it always did") } } + +// Q9: a plan task inherits the parent's model. +// +// The struct comment always claimed it did; the code did not. Both production +// call sites left PlanTaskContext.ParentModel empty, so appendModelArgs got no +// parent model and passed no --model — a plan task ran on whatever the CHILD's +// config resolved, which after a /model switch is a different model entirely. +// +// The fix is not "populate the field at registration": the TUI's registry is +// built once per session while /model changes the model between runs, so a +// value captured there would be stale by design. The three values arrive per +// call, from the same tools.RunOptions the Task tool reads. +func TestPlanTaskInheritsTheParentsModel(t *testing.T) { + var childArgs []string + executor := Executor{ + BinaryPath: "/bin/true", + NewSessionID: func() (string, error) { return "specialist_00000000000000000000000a", nil }, + Load: func(LoadOptions) (LoadResult, error) { return LoadResult{}, nil }, + RunChild: func(_ context.Context, _ string, args []string, _ func(streamjson.Event)) (ChildRunResult, error) { + childArgs = args + return ChildRunResult{Started: true}, nil + }, + } + gate := &PostureGate{} + gate.Set(true) + tool := &OrchestrateTool{ + PostureActive: gate.Active, + ParentTools: []string{"read_file"}, + RunTask: NewPlanRunner(PlanTaskContext{Executor: executor, Cwd: t.TempDir(), SpecialistName: "explorer"}), + } + + tool.RunWithOptions(context.Background(), map[string]any{ + "name": "p", + "tasks": []any{map[string]any{"id": "a", "prompt": "x"}}, + "budget": map[string]any{"max_workers": float64(1), "max_tokens": float64(100000)}, + }, tools.RunOptions{ + Model: "parent-chose-this", + SessionID: "zero_parent_session", + ReasoningEffort: "high", + }) + + joined := strings.Join(childArgs, " ") + if !strings.Contains(joined, "--model parent-chose-this") { + t.Fatalf("the plan task did not inherit the parent's model:\n%s", joined) + } + // The parent SESSION travels with the model: it is what links the child + // back to the run that spawned it, so a plan task stays drillable from its + // parent rather than looking like an orphan. + if !strings.Contains(joined, "zero_parent_session") { + t.Fatalf("the plan task did not inherit the parent's session id:\n%s", joined) + } +} + +// The parent identity is read per CALL. A second call with a different model +// must launch its task on that model — the whole reason these values do not +// live on the registration-time context. +func TestASecondCallUsesTheNewParentModel(t *testing.T) { + var models []string + executor := Executor{ + BinaryPath: "/bin/true", + NewSessionID: func() (string, error) { return "specialist_00000000000000000000000a", nil }, + Load: func(LoadOptions) (LoadResult, error) { return LoadResult{}, nil }, + RunChild: func(_ context.Context, _ string, args []string, _ func(streamjson.Event)) (ChildRunResult, error) { + for index, arg := range args { + if arg == "--model" && index+1 < len(args) { + models = append(models, args[index+1]) + } + } + return ChildRunResult{Started: true}, nil + }, + } + gate := &PostureGate{} + gate.Set(true) + tool := &OrchestrateTool{ + PostureActive: gate.Active, + ParentTools: []string{"read_file"}, + RunTask: NewPlanRunner(PlanTaskContext{Executor: executor, Cwd: t.TempDir(), SpecialistName: "explorer"}), + } + args := map[string]any{ + "name": "p", + "tasks": []any{map[string]any{"id": "a", "prompt": "x"}}, + "budget": map[string]any{"max_workers": float64(1), "max_tokens": float64(100000)}, + } + + tool.RunWithOptions(context.Background(), args, tools.RunOptions{Model: "first-model"}) + tool.RunWithOptions(context.Background(), args, tools.RunOptions{Model: "second-model"}) + + if len(models) != 2 || models[0] != "first-model" || models[1] != "second-model" { + t.Fatalf("models = %v; the parent model must be read per call, not captured at registration", models) + } +} diff --git a/internal/specialist/plan_runner.go b/internal/specialist/plan_runner.go index 76633c87a..b666a3a69 100644 --- a/internal/specialist/plan_runner.go +++ b/internal/specialist/plan_runner.go @@ -17,12 +17,18 @@ import ( type PlanTaskContext struct { Executor Executor Cwd string - // ParentSessionID / ParentModel / PermissionMode / Depth describe the run - // issuing the plan, so a task inherits exactly the parent's policy. - ParentSessionID string - ParentModel string - PermissionMode string - Depth int + // PermissionMode / Depth describe the run issuing the plan, so a task + // inherits exactly the parent's policy. + // + // ParentSessionID and ParentModel are DELIBERATELY NOT HERE. They looked + // run-invariant and are not: the TUI builds this once per session while the + // user can change model with /model between runs, so a value captured here + // would be whatever was active at startup — and in practice both call sites + // left them empty, so a plan task inherited no model at all. They arrive + // per call on PlanTaskRequest instead, from the same tools.RunOptions the + // Task tool reads. + PermissionMode string + Depth int // SpecialistName is the read-only specialist each plan task runs as. SpecialistName string } @@ -60,11 +66,13 @@ func NewPlanRunner(planCtx PlanTaskContext) PlanRunner { Description: "plan task " + task.ID, Manifest: &manifest, }, TaskRunOptions{ - ParentSessionID: planCtx.ParentSessionID, - ParentModel: planCtx.ParentModel, - CurrentDepth: planCtx.Depth, - Cwd: planCtx.Cwd, - PermissionMode: planCtx.PermissionMode, + // Per-call, from the tool's RunOptions — see PlanTaskRequest. + ParentSessionID: req.ParentSessionID, + ParentModel: req.ParentModel, + ParentReasoningEffort: req.ParentReasoningEffort, + CurrentDepth: planCtx.Depth, + Cwd: planCtx.Cwd, + PermissionMode: planCtx.PermissionMode, // THE SECOND HALF of the same defect. The Task tool forwards its // caller's progress callback here (task_tool.go); this path built // the same struct and omitted the field, so a plan task's child diff --git a/internal/specialist/plan_tool.go b/internal/specialist/plan_tool.go index b10127e88..096f20f53 100644 --- a/internal/specialist/plan_tool.go +++ b/internal/specialist/plan_tool.go @@ -164,8 +164,8 @@ func (tool *OrchestrateTool) Run(ctx context.Context, args map[string]any) tools return tool.RunWithOptions(ctx, args, tools.RunOptions{}) } -// runnerWithProgress attaches the caller's progress callback to every task -// request. +// runnerForCall attaches everything that belongs to THIS tool call — the +// progress callback and the parent's identity — to every task request. // // PER CALL, not captured at construction: RunTask is built once at // registration and holds only run-invariant state, while the progress callback @@ -178,13 +178,21 @@ func (tool *OrchestrateTool) Run(ctx context.Context, args map[string]any) tools // so exactly one task is in flight at any moment and the consumer can attribute // events to the task it last saw dispatched. Stage 2d must revisit this the // moment two tasks can run at once — see the note on the TUI plan recorder. -func (tool *OrchestrateTool) runnerWithProgress(options tools.RunOptions) PlanRunner { +func (tool *OrchestrateTool) runnerForCall(options tools.RunOptions) PlanRunner { run := tool.RunTask - if run == nil || options.Progress == nil { - return run + if run == nil { + return nil } return func(ctx context.Context, req PlanTaskRequest) (TaskResult, error) { req.Progress = options.Progress + // The parent's identity, read from the same RunOptions fields the Task + // tool reads (task_tool.go). A plan task inherits the model its parent + // is running on; without this it fell back to the child's own config, + // so switching model with /model or --model left plan tasks running + // somewhere else entirely. + req.ParentSessionID = options.SessionID + req.ParentModel = options.Model + req.ParentReasoningEffort = options.ReasoningEffort return run(ctx, req) } } @@ -225,7 +233,7 @@ func (tool *OrchestrateTool) RunWithOptions(ctx context.Context, args map[string } recordPlanAdmitted(tool.Recorder, plan) - report := ExecutePlan(ctx, plan, tool.ParentTools, tool.runnerWithProgress(options), tool.Recorder) + report := ExecutePlan(ctx, plan, tool.ParentTools, tool.runnerForCall(options), tool.Recorder) recordPlanCompleted(tool.Recorder, plan, report) result := tools.Result{ From 126ed00e0f12a488fab999868bfb86a2c1c6d103 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:26:23 +0530 Subject: [PATCH 18/86] fix(specialist): a resumed specialist keeps the run's model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Launching a specialist and resuming one are two doors onto the same question -- which model does this specialist run on -- and they answered it differently. BuildResumeArgsInput carried no model fields at all and BuildResumeArgs never called appendModelArgs, so a resumed child was launched with no --model and fell back to whatever its own config resolved. The same specialist could change model between its first turn and its second. Verified by building both argument sets side by side rather than by reading: FRESH args: exec --init-session-id … --model parent-chose-this … RESUME args: exec --resume … (no --model) The fix calls the SHARED helper rather than repeating its rules, so the manifest-pins-its-own-model precedence is applied once and cannot drift between the two paths. The relationship the regression test asserts is EQUALITY (RULES.md §3): fresh and resumed launches of the same specialist must produce the same model and the same reasoning effort. A second test drives Executor.Run with Resume set, since the builder having the fields proves nothing if the call site never fills them -- which is exactly how the two paths diverged. PRE-EXISTING main defect, not ZeroMaxing: it affects every resumed specialist. Separate commit so it can carry its own issue. Note for Stage 2c: runResume also resolves the manifest by session.AgentName and ignores params.Manifest, which is why inline-manifest children are unresumable. Same function, still open, deliberately not fixed here. --- internal/specialist/exec.go | 28 +++- internal/specialist/resume_model_test.go | 161 +++++++++++++++++++++++ 2 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 internal/specialist/resume_model_test.go diff --git a/internal/specialist/exec.go b/internal/specialist/exec.go index 07529105c..f24cf9652 100644 --- a/internal/specialist/exec.go +++ b/internal/specialist/exec.go @@ -82,6 +82,16 @@ type BuildResumeArgsInput struct { Manifest Manifest Cwd string PermissionMode string + // ParentModel / ParentReasoningEffort are the run's model and effort, and + // they belong here for exactly the reason they belong on BuildArgsInput: a + // resumed child is still a child of THIS run. + // + // They were absent, and BuildResumeArgs never called appendModelArgs, so a + // resumed specialist was launched with no --model at all and fell back to + // whatever its own config resolved. A fresh launch and a resume of the same + // specialist could therefore run on different models. + ParentModel string + ParentReasoningEffort string } type BuildArgsResult struct { @@ -331,6 +341,10 @@ func (executor Executor) BuildResumeArgs(input BuildResumeArgsInput) (BuildArgsR } args = append(args, "--enabled-tools", strings.Join(toolAllowlist, ",")) args = append(args, "--depth", strconv.Itoa(input.CurrentDepth+1), "--tag", sessionTagSpecialist) + // The SAME model resolution the fresh-launch path uses. Calling the shared + // helper rather than repeating its rules is what keeps the two paths from + // disagreeing about which model a specialist runs on. + args = appendModelArgs(args, input.Manifest, input.ParentModel, input.ParentReasoningEffort) if cwd := strings.TrimSpace(input.Cwd); cwd != "" { args = append(args, "--cwd", cwd) } @@ -396,12 +410,14 @@ func (executor Executor) runResume(ctx context.Context, params TaskParameters, o return ExecResult{}, err } built, err := executor.BuildResumeArgs(BuildResumeArgsInput{ - SessionID: params.Resume, - Prompt: params.Prompt, - CurrentDepth: options.CurrentDepth, - Manifest: manifest, - Cwd: options.Cwd, - PermissionMode: options.PermissionMode, + SessionID: params.Resume, + Prompt: params.Prompt, + CurrentDepth: options.CurrentDepth, + Manifest: manifest, + Cwd: options.Cwd, + PermissionMode: options.PermissionMode, + ParentModel: options.ParentModel, + ParentReasoningEffort: options.ParentReasoningEffort, }) if err != nil { return ExecResult{}, err diff --git a/internal/specialist/resume_model_test.go b/internal/specialist/resume_model_test.go new file mode 100644 index 000000000..75d910dba --- /dev/null +++ b/internal/specialist/resume_model_test.go @@ -0,0 +1,161 @@ +package specialist + +import ( + "context" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/sessions" + "github.com/Gitlawb/zero/internal/streamjson" +) + +func modelArgOf(t *testing.T, args []string) string { + t.Helper() + for index, arg := range args { + if arg == "--model" && index+1 < len(args) { + return args[index+1] + } + } + return "" +} + +func effortArgOf(t *testing.T, args []string) string { + t.Helper() + for index, arg := range args { + if arg == "--reasoning-effort" && index+1 < len(args) { + return args[index+1] + } + } + return "" +} + +func resumeTestManifest() Manifest { + return Manifest{ + Metadata: Metadata{Name: "explorer"}, + ResolvedTools: []string{"read_file"}, + ToolsResolved: true, + } +} + +// THE SIBLING COMPARISON, and the relationship is EQUALITY (RULES.md §3): +// launching a specialist fresh and resuming it are two doors onto the same +// question — which model does this specialist run on? — so they must answer it +// identically. +// +// They did not. BuildResumeArgsInput carried no model fields and +// BuildResumeArgs never called appendModelArgs, so a resumed specialist ran on +// whatever its own config resolved while a fresh one ran on the parent's model. +func TestFreshAndResumedLaunchesAgreeOnTheModel(t *testing.T) { + executor := Executor{BinaryPath: "/bin/true"} + manifest := resumeTestManifest() + + fresh, err := executor.BuildArgs(BuildArgsInput{ + Manifest: manifest, Prompt: "x", Cwd: t.TempDir(), + ParentModel: "parent-chose-this", ParentReasoningEffort: "high", + }) + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + resumed, err := executor.BuildResumeArgs(BuildResumeArgsInput{ + SessionID: "specialist_00000000000000000000000a", Prompt: "x", + Manifest: manifest, Cwd: t.TempDir(), + ParentModel: "parent-chose-this", ParentReasoningEffort: "high", + }) + if err != nil { + t.Fatalf("BuildResumeArgs: %v", err) + } + + freshModel, resumedModel := modelArgOf(t, fresh.Args), modelArgOf(t, resumed.Args) + if freshModel != resumedModel { + t.Fatalf("fresh launches on %q and resume on %q; the same specialist must not change model on resume", + freshModel, resumedModel) + } + if resumedModel != "parent-chose-this" { + t.Fatalf("resume model = %q, want the parent's", resumedModel) + } + if got := effortArgOf(t, resumed.Args); got != effortArgOf(t, fresh.Args) { + t.Fatalf("reasoning effort differs between fresh (%q) and resume (%q)", effortArgOf(t, fresh.Args), got) + } +} + +// A manifest that pins its own model still wins on resume, exactly as it does +// on a fresh launch — appendModelArgs' rule, applied once rather than twice. +func TestAManifestModelStillWinsOnResume(t *testing.T) { + executor := Executor{BinaryPath: "/bin/true"} + manifest := resumeTestManifest() + manifest.Metadata.Model = "manifest-pinned" + + resumed, err := executor.BuildResumeArgs(BuildResumeArgsInput{ + SessionID: "specialist_00000000000000000000000a", Prompt: "x", + Manifest: manifest, Cwd: t.TempDir(), ParentModel: "parent-chose-this", + }) + if err != nil { + t.Fatalf("BuildResumeArgs: %v", err) + } + if got := modelArgOf(t, resumed.Args); got != "manifest-pinned" { + t.Fatalf("model = %q, want the manifest's own pin", got) + } +} + +// With no parent model supplied nothing is forced, so a caller that wires +// neither is unchanged — the resume path stays exactly as permissive as the +// fresh one. +func TestResumeWithoutAParentModelPassesNoFlag(t *testing.T) { + executor := Executor{BinaryPath: "/bin/true"} + resumed, err := executor.BuildResumeArgs(BuildResumeArgsInput{ + SessionID: "specialist_00000000000000000000000a", Prompt: "x", + Manifest: resumeTestManifest(), Cwd: t.TempDir(), + }) + if err != nil { + t.Fatalf("BuildResumeArgs: %v", err) + } + if strings.Contains(strings.Join(resumed.Args, " "), "--model") { + t.Fatal("with no parent model the resume path must force nothing") + } +} + +// THE CALL SITE, not the builder. BuildResumeArgs having the fields proves +// nothing if runResume never fills them — which is precisely how the fresh path +// ended up carrying a model the resume path did not. This drives Executor.Run +// with Resume set and reads the argv the child would have been launched with. +func TestResumeCarriesTheRunsModelThroughExecutorRun(t *testing.T) { + store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()}) + session, err := store.Create(sessions.CreateInput{ + SessionID: "specialist_00000000000000000000000a", + SessionKind: sessions.SessionKindChild, + Cwd: t.TempDir(), + AgentName: "explorer", + Tag: sessionTagSpecialist, + }) + if err != nil { + t.Fatalf("create session: %v", err) + } + + var childArgs []string + executor := Executor{ + BinaryPath: "/bin/true", + SessionStore: store, + Load: func(LoadOptions) (LoadResult, error) { + return LoadResult{Specialists: []Manifest{resumeTestManifest()}}, nil + }, + RunChild: func(_ context.Context, _ string, args []string, _ func(streamjson.Event)) (ChildRunResult, error) { + childArgs = args + return ChildRunResult{Started: true}, nil + }, + } + + if _, err := executor.Run(context.Background(), + TaskParameters{Name: "explorer", Prompt: "carry on", Resume: session.SessionID}, + TaskRunOptions{ + Cwd: t.TempDir(), + ParentModel: "parent-chose-this", + ParentReasoningEffort: "high", + }); err != nil { + t.Fatalf("Run: %v", err) + } + + if got := modelArgOf(t, childArgs); got != "parent-chose-this" { + t.Fatalf("the resumed child was launched with model %q, not the run's:\n%s", + got, strings.Join(childArgs, " ")) + } +} From 96439f839b1cb372fdd0321d9c3f88e7e47f308b Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:29:18 +0530 Subject: [PATCH 19/86] fix(specialist): the two read-only tool sets agree again planReadOnlyTools bounds what a plan task may hold; readOnlySpecialistTools decides whether a specialist counts as read-only for the Task tool's permission gate. Different questions, same answer -- and they disagreed: lsp_navigate was in the first and not the second, so a manifest holding exactly the plan grant failed the read-only check. The omission was chronology rather than a decision. readOnlySpecialistTools was written in #243 (2026-06-18); lsp_navigate arrived in #276 two days later and nobody went back. The tool declares EffectReadOnly with the safety reason "reads files, modifies nothing" and resolves its path through the same scoped confinement its read-only siblings use, so it belongs in the set. Pinned as a SUBSET, not equality, and the direction is the point: anything a plan task may hold must be something the wider gate also calls read-only. The reverse is not required -- update_plan is read-only for a specialist and deliberately not a plan tool. NOT derived from the tools package's declared capabilities, which would be the real class fix: update_plan is EffectInteractive, so deriving the set would silently drop it and change the Task tool's permission gate. That is its own change with its own reasoning, not a drive-by. --- internal/specialist/exec.go | 8 +++++++ internal/specialist/plan_grant_test.go | 31 ++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/internal/specialist/exec.go b/internal/specialist/exec.go index f24cf9652..1fe77c311 100644 --- a/internal/specialist/exec.go +++ b/internal/specialist/exec.go @@ -181,6 +181,14 @@ var readOnlySpecialistTools = map[string]bool{ "grep": true, "glob": true, "update_plan": true, + // lsp_navigate declares EffectReadOnly with the safety reason "reads files, + // modifies nothing", and resolves its path through the same scoped + // confinement its read-only siblings use. Its absence was chronology, not a + // decision: this list was written in #243 (2026-06-18) and the tool arrived + // in #276 two days later. planReadOnlyTools already listed it, so the two + // sets disagreed about what "read-only" means — + // TestReadOnlyToolSetsAgree now makes that fail rather than ship. + "lsp_navigate": true, } // IsReadOnlySpecialist reports whether the named specialist resolves to a diff --git a/internal/specialist/plan_grant_test.go b/internal/specialist/plan_grant_test.go index f800ce85a..9cc9109aa 100644 --- a/internal/specialist/plan_grant_test.go +++ b/internal/specialist/plan_grant_test.go @@ -289,3 +289,34 @@ func TestSubagentToolsDeclareChildProgress(t *testing.T) { t.Error("orchestrate must declare child progress; without it a plan runs invisibly") } } + +// TWO LISTS THAT MUST NOT DRIFT (invariant 5). +// +// planReadOnlyTools bounds what a plan task may hold; readOnlySpecialistTools +// decides whether a specialist counts as read-only for the Task tool's +// permission gate. They are different questions with the same answer, and they +// drifted: lsp_navigate was in the first and not the second, so a plan task +// could hold a tool that made its own manifest fail the read-only check. +// +// The relationship is SUBSET, not equality, and the direction matters: anything +// a plan task may hold must be something the wider gate also calls read-only. +// The reverse is not required — update_plan is read-only for a specialist and +// deliberately not a plan tool. +func TestReadOnlyToolSetsAgree(t *testing.T) { + for name := range planReadOnlyTools { + if !readOnlySpecialistTools[name] { + t.Errorf("planReadOnlyTools allows %q but readOnlySpecialistTools does not call it read-only; "+ + "a plan task holding it would fail the specialist read-only check", name) + } + } +} + +// A manifest holding the full plan grant must pass the read-only check. This is +// the consequence the drift produced, asserted at the behaviour rather than at +// the two maps. +func TestAFullPlanGrantIsAReadOnlyManifest(t *testing.T) { + manifest := planTaskManifest("explorer", PlanReadOnlyToolNames()) + if !manifestIsReadOnly(manifest) { + t.Fatalf("a manifest holding exactly the plan grant %v is not considered read-only", PlanReadOnlyToolNames()) + } +} From a7f54454f357d7ee14369986972ab8256d93806d Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:44:03 +0530 Subject: [PATCH 20/86] perf(agent): drop the confirmation policy from a run that cannot act A read-only run carried ~5 KB of confirmation policy governing actions it could not take. Measured on a real plan-task child: 8,365 bytes of the 17,903-byte system prompt -- 47% of it -- were policy for capabilities the child provably lacked (no write, no edit, no shell, no ask_user), and the system prompt is 71% of that request. Multiplied by every task in a plan and every specialist sub-agent. Keyed on the RESOLVED TOOL SET, never a name or a manifest flag, and fail closed at every step: a nil registry, a run holding nothing, and any tool whose effect is not positively read-only all keep the policy. EffectUnknown -- MCP tools, plugin tools, the specialist tools -- keeps it, which is why an ordinary session is untouched. Two things this got wrong first, both caught rather than reasoned away: It used ToolVisible, which applies the permission mode's ADVERTISING rules on top of the operator filters. A write tool auto mode does not advertise is still held and still callable once approved, so a run launched with --enabled-tools read_file,grep,glob,write_file counted as read-only and lost its policy -- a fail-OPEN exactly inverse to the purpose. Found by driving the binary; every unit test passed at the time. It now asks ToolAllowedByFilters, which answers "does this run hold the tool". Reading the registered tool set made registering a posture-gated tool change the prompt with the posture OFF, breaking the additive-only guarantee. TestPostureOffPrefixUnchangedByRegisteringTheTool caught it, which is what that test exists for. A tool the run can never call now says nothing about what the run can do -- and a tool that varies its permission by argument is never ruled out from its static safety, however that reads. Verified on the wire: --enabled-tools read_file,grep,glob drops it (12,310-byte prompt); adding write_file or bash keeps it (18,789). Additivity re-proven byte-identical across all five permission modes. Only the confirmation policy is gated. The remaining ~3.3 KB (Editing discipline, Testing gate, Permission and safety) lives inside system_prompt.md and needs that file split, which touches the prompt every run shares. Separate change. --- internal/agent/prompt_capability_test.go | 224 +++++++++++++++++++++++ internal/agent/system_prompt.go | 64 +++++++ 2 files changed, 288 insertions(+) create mode 100644 internal/agent/prompt_capability_test.go diff --git a/internal/agent/prompt_capability_test.go b/internal/agent/prompt_capability_test.go new file mode 100644 index 000000000..68be99ac9 --- /dev/null +++ b/internal/agent/prompt_capability_test.go @@ -0,0 +1,224 @@ +package agent + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/tools" +) + +// readOnlyRegistry is the tool set a PLAN TASK actually holds — pure reads, no +// ask_user and no request_permissions. +// +// Deliberately not CoreReadOnlyToolsScoped, which despite its name also carries +// ask_user and request_permissions (both EffectInteractive). A run holding +// those genuinely can prompt, so the policy applies to it and the gate keeps +// it; that is why this helper enumerates the plan grant instead. +func readOnlyRegistry(t *testing.T) *tools.Registry { + t.Helper() + planTools := map[string]bool{ + "read_file": true, "read_minified_file": true, "list_directory": true, + "grep": true, "glob": true, "lsp_navigate": true, + } + registry := tools.NewRegistry() + for _, tool := range tools.CoreReadOnlyToolsScoped(t.TempDir(), nil) { + if planTools[tool.Name()] { + registry.Register(tool) + } + } + return registry +} + +func mutatingRegistry(t *testing.T) *tools.Registry { + t.Helper() + registry := readOnlyRegistry(t) + for _, tool := range tools.CoreWriteToolsScoped(t.TempDir(), nil) { + registry.Register(tool) + } + return registry +} + +// An interactive tool is not a read: a run that can prompt keeps the policy. +func TestAnInteractiveToolKeepsTheConfirmationPolicy(t *testing.T) { + registry := tools.NewRegistry() + for _, tool := range tools.CoreReadOnlyToolsScoped(t.TempDir(), nil) { + registry.Register(tool) + } + prompt := buildSystemPrompt(Options{ + Registry: registry, PermissionMode: PermissionModeAuto, Cwd: t.TempDir(), + }) + if !strings.Contains(prompt, "Confirmation Policy") { + t.Fatal("ask_user and request_permissions can prompt, so the policy still governs this run") + } +} + +// A run that cannot change anything carries no confirmation policy. Measured at +// ~2,527 of a plan child's 7,648 prompt tokens — policy for capabilities the +// child provably lacks, multiplied by every task in a plan. +func TestAReadOnlyRunDropsTheConfirmationPolicy(t *testing.T) { + prompt := buildSystemPrompt(Options{ + Registry: readOnlyRegistry(t), + PermissionMode: PermissionModeAuto, + Cwd: t.TempDir(), + }) + for _, marker := range []string{"Confirmation Policy", "ALWAYS CONFIRM", "PRE-APPROVAL"} { + if strings.Contains(prompt, marker) { + t.Errorf("a read-only run still carries %q", marker) + } + } +} + +// THE FAIL-CLOSED DIRECTION, which is the one that matters: anything that can +// change something keeps the policy. +func TestAnyMutatingCapabilityKeepsTheConfirmationPolicy(t *testing.T) { + cases := map[string]Options{ + "write tools present": {Registry: mutatingRegistry(t), PermissionMode: PermissionModeAuto}, + "no registry at all": {PermissionMode: PermissionModeAuto}, + "empty registry": {Registry: tools.NewRegistry(), PermissionMode: PermissionModeAuto}, + } + for name, options := range cases { + options.Cwd = t.TempDir() + if !strings.Contains(buildSystemPrompt(options), "Confirmation Policy") { + t.Errorf("%s: the confirmation policy was dropped; this path must fail closed", name) + } + } +} + +// A tool whose effect is not positively read-only keeps the policy, even +// alongside read tools. MCP tools, plugin tools and the specialist tools are all +// EffectUnknown, so an ordinary session is unaffected by this change. +func TestAnUnknownEffectToolKeepsTheConfirmationPolicy(t *testing.T) { + registry := readOnlyRegistry(t) + registry.Register(&silentProbeTool{name: "mystery", got: new(bool)}) + + prompt := buildSystemPrompt(Options{ + Registry: registry, PermissionMode: PermissionModeAuto, Cwd: t.TempDir(), + }) + if !strings.Contains(prompt, "Confirmation Policy") { + t.Fatal("a tool of unknown effect must keep the policy — unknown is not read-only") + } +} + +// A tool hidden by the run's operator filters does not count: what matters is +// what the run can actually reach, judged by the same gate the loop advertises +// through. +func TestFilteredOutMutatorsDoNotKeepThePolicy(t *testing.T) { + registry := mutatingRegistry(t) + var mutators []string + for _, tool := range registry.All() { + if tools.CapabilitiesOf(tool).Effect != tools.EffectReadOnly { + mutators = append(mutators, tool.Name()) + } + } + if len(mutators) == 0 { + t.Fatal("setup: expected the write tools to register") + } + + prompt := buildSystemPrompt(Options{ + Registry: registry, + DisabledTools: mutators, + Cwd: t.TempDir(), + }) + if strings.Contains(prompt, "Confirmation Policy") { + t.Fatal("every mutator is filtered out of this run, so the policy has nothing to govern") + } +} + +// THE FAIL-OPEN THIS FUNCTION FIRST SHIPPED WITH, pinned. +// +// The gate originally used ToolVisible, which applies the permission mode's +// ADVERTISING rules on top of the operator filters. A write tool that auto mode +// does not advertise is still held and still callable once approved — so a run +// launched with --enabled-tools read_file,grep,glob,write_file counted as +// read-only and lost its confirmation policy. Exactly inverse to the point. +// +// Found by driving the binary; every unit test at the time passed. +func TestAnUnadvertisedMutatorStillKeepsThePolicy(t *testing.T) { + registry := mutatingRegistry(t) + for _, mode := range []PermissionMode{PermissionModeAuto, PermissionModeAsk, "", PermissionModeSpecDraft} { + prompt := buildSystemPrompt(Options{ + Registry: registry, PermissionMode: mode, Cwd: t.TempDir(), + }) + if !strings.Contains(prompt, "Confirmation Policy") { + t.Errorf("permission mode %q: a held-but-unadvertised mutator dropped the policy", mode) + } + } +} + +// The gate asks whether the run HOLDS a mutator, so the permission mode must +// not change the answer. A mode-dependent system prompt would also move the +// cache prefix between modes for no reason. +func TestThePolicyDecisionIsIndependentOfPermissionMode(t *testing.T) { + for _, registry := range []*tools.Registry{readOnlyRegistry(t), mutatingRegistry(t)} { + var first string + for index, mode := range []PermissionMode{PermissionModeAuto, PermissionModeAsk, PermissionModeUnsafe, ""} { + prompt := buildSystemPrompt(Options{Registry: registry, PermissionMode: mode, Cwd: t.TempDir()}) + has := strings.Contains(prompt, "Confirmation Policy") + if index == 0 { + first = fmt.Sprint(has) + continue + } + if fmt.Sprint(has) != first { + t.Fatalf("permission mode %q changed the policy decision", mode) + } + } + } +} + +// THE INTERACTION between this gate and the additivity guarantee. +// +// The gate reads the registered tool set, so registering a posture-gated tool +// changed the prompt even with the posture OFF — breaking the guarantee that +// registering it is invisible. Caught by +// TestPostureOffPrefixUnchangedByRegisteringTheTool, which is what that test is +// for. A tool the run can never call now says nothing about what the run can +// do. +func TestAPermanentlyDeniedToolDoesNotChangeThePrompt(t *testing.T) { + base := readOnlyRegistry(t) + withDenied := readOnlyRegistry(t) + withDenied.Register(&deniedProbeTool{name: "gated"}) + + // ONE cwd for both: the prompt embeds the working directory, so two + // t.TempDir() calls would differ for a reason that has nothing to do with + // the tool set. + cwd := t.TempDir() + before := buildSystemPrompt(Options{Registry: base, Cwd: cwd}) + after := buildSystemPrompt(Options{Registry: withDenied, Cwd: cwd}) + if before != after { + t.Fatalf("registering a permanently denied tool changed the prompt (%d -> %d bytes)", + len(before), len(after)) + } +} + +// ...but a tool that varies its permission by argument is never ruled out from +// its static safety, however that reads. Fail closed. +func TestAnArgsPermissionedToolIsNeverTreatedAsDenied(t *testing.T) { + registry := readOnlyRegistry(t) + registry.Register(&argsPermissionedProbeTool{deniedProbeTool{name: "varies"}}) + + if !strings.Contains(buildSystemPrompt(Options{Registry: registry, Cwd: t.TempDir()}), "Confirmation Policy") { + t.Fatal("a tool whose permission depends on its arguments must never be ruled out statically") + } +} + +type deniedProbeTool struct{ name string } + +func (t *deniedProbeTool) Name() string { return t.name } +func (t *deniedProbeTool) Description() string { return "gated" } +func (t *deniedProbeTool) Parameters() tools.Schema { + return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}} +} +func (t *deniedProbeTool) Safety() tools.Safety { + return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionDeny} +} +func (t *deniedProbeTool) Run(context.Context, map[string]any) tools.Result { + return tools.Result{Status: tools.StatusOK} +} + +type argsPermissionedProbeTool struct{ deniedProbeTool } + +func (t *argsPermissionedProbeTool) PermissionForArgs(map[string]any) tools.Permission { + return tools.PermissionAllow +} diff --git a/internal/agent/system_prompt.go b/internal/agent/system_prompt.go index d8f5f8341..d69be0314 100644 --- a/internal/agent/system_prompt.go +++ b/internal/agent/system_prompt.go @@ -66,6 +66,63 @@ const ( workspaceSeedWidth = 100 ) +// runCanMutate reports whether this run holds any tool that could change +// something — and therefore whether the confirmation policy applies to it. +// +// FAIL CLOSED at every step. A nil registry, a run with no visible tools, and +// any tool whose effect is not explicitly read-only all answer TRUE, so the +// policy is dropped ONLY when every visible tool is positively known to be a +// pure read. EffectUnknown (MCP tools, plugin tools, the specialist tools) +// keeps it, which is what makes an ordinary session byte-identical. +// +// Keyed on the RESOLVED TOOL SET, not on a specialist name or a manifest flag: +// what a run can do is decided by the tools it actually holds, and a name is +// not a capability. +// +// Filters, NOT advertising. ToolAllowedByFilters answers "does this run hold +// the tool"; ToolVisible also applies the permission mode's advertising rules, +// and a write tool that auto mode does not advertise is still held and still +// callable once approved. Using ToolVisible here dropped the policy from a run +// launched with --enabled-tools read_file,grep,glob,write_file — a fail-OPEN +// exactly inverse to this function's purpose, caught by driving the binary. +func runCanMutate(options Options) bool { + if options.Registry == nil { + return true + } + held := 0 + for _, tool := range options.Registry.All() { + if !ToolAllowedByFilters(tool.Name(), options.EnabledTools, options.DisabledTools) { + continue + } + if permanentlyDenied(tool) { + // A tool the run can never call cannot act, so it says nothing + // about what the run can do. This is what keeps registering a + // posture-gated tool from changing the prompt while the posture is + // off — the additivity guarantee, which its identity test caught + // this function breaking. + continue + } + held++ + if tools.CapabilitiesOf(tool).Effect != tools.EffectReadOnly { + return true + } + } + return held == 0 +} + +// permanentlyDenied reports whether a tool can never be invoked in this run. +// +// A tool that varies its permission by arguments is NEVER treated as denied, +// however its static safety reads: the question is whether any call could +// succeed, and only a tool with no per-argument override can be ruled out from +// its static permission alone. Fail closed. +func permanentlyDenied(tool tools.Tool) bool { + if _, varies := tool.(tools.ArgsPermissioner); varies { + return false + } + return tool.Safety().Permission == tools.PermissionDeny +} + // buildSystemPrompt assembles the full system prompt for a run: the core // coding-craft instructions, dynamic workspace context (cwd, git branch, project // guidelines), and the safety confirmation policy. It is built once per run so @@ -129,6 +186,13 @@ func buildSystemPromptParts(options Options) systemPromptParts { sections = append(sections, style) } policy := strings.TrimSpace(confirmationPolicy) + if !runCanMutate(options) { + // A run that cannot mutate anything has nothing to confirm. Carrying + // ~5 KB of confirmation policy into such a run is pure cost: measured + // at 2,527 of a plan child's 7,648 prompt tokens, multiplied by every + // task in a plan and by every specialist sub-agent. + policy = "" + } if policy != "" { sections = append(sections, policy) } From 13d02de36422d3c592be08833904a04916640502 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:26:01 +0530 Subject: [PATCH 21/86] feat(specialist,tui): unbounded plans by default, collapsed plan panel Two changes from driving a real six-task chain. THE TOKEN BUDGET IS NOW OPTIONAL, AND OMITTED MEANS UNBOUNDED. max_tokens was required and capped at 200_000, and a chain asking for exactly that spent 469,555 -- 2.3x over -- and still lost its last task. The check runs only BETWEEN tasks: a task is dispatched whenever any budget remains and then spends whatever it spends, so the cap bounded nothing while stopping real work from finishing. A number that neither bounds spend nor lets a heavy plan complete is worse than no number, because it reads like a guarantee. Spend is still METERED and reported everywhere it was -- PlanReport.TokensUsed, the plan_completed event, the panel footer -- so an unbounded plan is not an unmeasured one. A caller that wants a bound still sets max_tokens and gets the same dispatch-time behaviour as before, asserted by test in both directions. This removes the only ceiling on what one orchestrate call can spend. Under the zeromaxing posture a twenty-task plan authorises 6,400 child turns. That is the user's decision, taken after seeing the cap fail to do the job it existed for. THE PANEL COLLAPSES. A six-task chain took seven footer lines for the whole run, pushing the conversation up for detail that is one keypress away. The header alone is the state; the tasks are detail. Click the header line to open it -- the interaction that was actually asked for -- or Ctrl+G. NOT Ctrl+O, which already toggles the detailed transcript view: the first attempt bound it there and the existing handler silently won. Not Ctrl+P either, which belongs to the update_plan panel. The dependency indent is capped at five levels. A twenty-task chain adds a rung per link, which would indent forty columns and run off a narrow terminal; the depths still reflect the real graph, only the drawing stops. Past the cap every task sits at one depth, which is honest for a chain. --- internal/specialist/plan.go | 20 +++- internal/specialist/plan_exec.go | 5 +- internal/specialist/plan_test.go | 71 +++++++++++-- internal/specialist/plan_tool.go | 14 ++- internal/tui/keybinding_help.go | 2 +- internal/tui/model.go | 12 +++ internal/tui/mouse.go | 7 ++ internal/tui/orchestrate_panel.go | 65 +++++++++++- internal/tui/orchestrate_panel_test.go | 136 +++++++++++++++++++++++++ 9 files changed, 310 insertions(+), 22 deletions(-) diff --git a/internal/specialist/plan.go b/internal/specialist/plan.go index f6b536a2c..b64c21333 100644 --- a/internal/specialist/plan.go +++ b/internal/specialist/plan.go @@ -307,7 +307,7 @@ func PlanReadOnlyToolNames() []string { return sortedReadOnlyTools() } func planBudget(args map[string]any, limits Limits) (Budget, error) { raw, ok := args["budget"].(map[string]any) if !ok { - return Budget{}, fmt.Errorf("plan requires a budget object with max_tokens and max_workers") + return Budget{}, fmt.Errorf("plan requires a budget object with max_workers") } budget := Budget{ MaxWorkers: planInt(raw, "max_workers"), @@ -324,8 +324,22 @@ func planBudget(args map[string]any, limits Limits) (Budget, error) { "budget.max_workers must be 1: this phase executes plan tasks sequentially, and a plan asking for %d is rejected rather than quietly run with one worker", budget.MaxWorkers) } - if budget.MaxTokens <= 0 { - return Budget{}, fmt.Errorf("budget.max_tokens is required and must be positive") + // max_tokens is OPTIONAL and unbounded by default. + // + // It was required, and capped at 200k. Both went, because the bound did not + // work and got in the way of real work. It was checked only BETWEEN tasks: + // a task was dispatched whenever any budget remained and then spent + // whatever it spent, so a six-task chain asking for the 200k maximum + // actually spent 469,555 — 2.3x over — and the last task was cut anyway. + // A number that neither bounds spend nor lets a heavy plan finish is worse + // than no number, because it reads like a guarantee. + // + // Spend is still METERED and reported (PlanReport.TokensUsed, the + // plan_completed event, the panel), so what a plan cost is always visible. + // A caller that wants a bound sets max_tokens and gets the same + // dispatch-time behaviour as before. + if budget.MaxTokens < 0 { + return Budget{}, fmt.Errorf("budget.max_tokens must not be negative") } if limits.MaxTokens > 0 && budget.MaxTokens > limits.MaxTokens { return Budget{}, fmt.Errorf("budget.max_tokens %d exceeds the limit of %d for this run", budget.MaxTokens, limits.MaxTokens) diff --git a/internal/specialist/plan_exec.go b/internal/specialist/plan_exec.go index d22c886fd..1b85b4380 100644 --- a/internal/specialist/plan_exec.go +++ b/internal/specialist/plan_exec.go @@ -158,7 +158,10 @@ func ExecutePlan(ctx context.Context, plan Plan, parentTools []string, run PlanR // posture every child inherits a 320-turn ceiling, so a twenty-task plan // authorises 6,400 child turns from a single tool call; this is what stands // between that number and the user's bill. + // budgetLeft is only a bound when the plan asked for one. Zero means + // unbounded: spend is metered and reported, not gated. budgetLeft := plan.Budget().MaxTokens + bounded := budgetLeft > 0 budgetExhausted := false deadline := time.Time{} @@ -203,7 +206,7 @@ func ExecutePlan(ctx context.Context, plan Plan, parentTools []string, run PlanR // A budget-exhausted or timed-out plan SKIPS the rest rather than // aborting: independent work already done still counts, and the record // must show what was not attempted. - if budgetExhausted || budgetLeft <= 0 || (!deadline.IsZero() && time.Now().After(deadline)) { + if budgetExhausted || (bounded && budgetLeft <= 0) || (!deadline.IsZero() && time.Now().After(deadline)) { budgetExhausted = true result := TaskResult{ ID: id, diff --git a/internal/specialist/plan_test.go b/internal/specialist/plan_test.go index 10f48244a..0dc63cc3d 100644 --- a/internal/specialist/plan_test.go +++ b/internal/specialist/plan_test.go @@ -158,21 +158,70 @@ func TestParsePlanRejectsMoreThanOneWorker(t *testing.T) { } } -// (j) A budget is required, and max_tokens with it. -func TestParsePlanRequiresABudget(t *testing.T) { +// (j) A budget object is required — max_workers must be stated — but +// max_tokens is OPTIONAL and unbounded when omitted. +// +// It used to be required and capped at 200k. Both went: the check ran only +// BETWEEN tasks, so a six-task chain asking for exactly 200k spent 469,555 and +// was cut short anyway. A number that neither bounds spend nor lets heavy work +// finish is worse than none, because it reads like a guarantee. Spend is still +// metered and reported; a caller that wants a bound still sets one. +func TestParsePlanRequiresABudgetButNotATokenCap(t *testing.T) { if _, err := ParsePlan(map[string]any{"tasks": []any{task("a", "x")}}, readOnlyLimits()); err == nil { - t.Fatal("a plan with no budget must be rejected") + t.Fatal("a plan with no budget object must be rejected") + } + + unbounded := okBudget() + delete(unbounded, "max_tokens") + plan, err := ParsePlan(planArgs([]any{task("a", "x")}, unbounded), readOnlyLimits()) + if err != nil { + t.Fatalf("an omitted max_tokens must be accepted as unbounded, got %v", err) + } + if plan.Budget().MaxTokens != 0 { + t.Fatalf("an omitted max_tokens must read as 0 (unbounded), got %d", plan.Budget().MaxTokens) + } + + negative := okBudget() + negative["max_tokens"] = float64(-1) + if _, err := ParsePlan(planArgs([]any{task("a", "x")}, negative), readOnlyLimits()); err == nil { + t.Fatal("a negative max_tokens is meaningless and must be rejected") + } +} + +// A caller that DOES want a bound still gets one, enforced exactly as before. +func TestAnExplicitTokenBoundStillStopsThePlan(t *testing.T) { + budget := okBudget() + budget["max_tokens"] = float64(1) + plan, err := ParsePlan(planArgs([]any{task("a", "x"), task("b", "y")}, budget), readOnlyLimits()) + if err != nil { + t.Fatalf("ParsePlan: %v", err) + } + report := ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + return TaskResult{ID: req.Task.ID, Outcome: TaskSucceeded, Tokens: 100}, nil + }, nil) + if report.Skipped != 1 { + t.Fatalf("an explicit bound must still cut the plan short: %+v", report) } +} + +// ...and an unbounded plan runs every task, however much it spends. +func TestAnUnboundedPlanRunsEveryTask(t *testing.T) { budget := okBudget() delete(budget, "max_tokens") - _, err := ParsePlan(planArgs([]any{task("a", "x")}, budget), readOnlyLimits()) - if err == nil || !strings.Contains(err.Error(), "max_tokens") { - t.Fatalf("a missing max_tokens must be rejected, got %v", err) - } - over := okBudget() - over["max_tokens"] = float64(999_999_999) - if _, err := ParsePlan(planArgs([]any{task("a", "x")}, over), readOnlyLimits()); err == nil { - t.Fatal("a budget above the run's limit must be rejected") + plan, err := ParsePlan(planArgs([]any{task("a", "x"), task("b", "y"), task("c", "z")}, budget), readOnlyLimits()) + if err != nil { + t.Fatalf("ParsePlan: %v", err) + } + report := ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + return TaskResult{ID: req.Task.ID, Outcome: TaskSucceeded, Tokens: 1_000_000}, nil + }, nil) + if report.Succeeded != 3 || report.Skipped != 0 { + t.Fatalf("an unbounded plan must run every task: %+v", report) + } + if report.TokensUsed != 3_000_000 { + t.Fatalf("spend must still be METERED when it is not bounded, got %d", report.TokensUsed) } } diff --git a/internal/specialist/plan_tool.go b/internal/specialist/plan_tool.go index 096f20f53..a47414f6c 100644 --- a/internal/specialist/plan_tool.go +++ b/internal/specialist/plan_tool.go @@ -49,9 +49,15 @@ const ( // defaultPlanMaxTasks bounds plan size. Deliberately small: Phase 2 exists // to measure whether fan-out would pay, not to run large plans. defaultPlanMaxTasks = 20 - // defaultPlanMaxTokens caps what one orchestrate call may spend across every - // child it launches. - defaultPlanMaxTokens = 200_000 + // defaultPlanMaxTokens is 0: NO ceiling on what a plan may request. + // + // It was 200_000, and a six-task chain asking for exactly that spent + // 469,555 — the check ran only between tasks, so the last task dispatched + // overshot without limit and the plan was cut short anyway. Capping the + // REQUEST while not bounding the SPEND is the worst of both: heavy work + // cannot finish and the number means nothing. Spend is metered and + // reported either way; a caller that wants a bound still sets max_tokens. + defaultPlanMaxTokens = 0 ) func (tool *OrchestrateTool) Name() string { return OrchestrateToolName } @@ -103,7 +109,7 @@ func (tool *OrchestrateTool) Parameters() tools.Schema { }, "budget": { Type: "object", - Description: "Required. max_tokens is required; max_workers must be 1 (this phase executes sequentially); max_wall_seconds is optional.", + Description: "Required. max_workers must be 1 (this phase executes sequentially). max_tokens and max_wall_seconds are optional bounds; omit them to run unbounded — spend is reported either way.", }, }, Required: []string{"tasks", "budget"}, diff --git a/internal/tui/keybinding_help.go b/internal/tui/keybinding_help.go index f1e53ed73..3497e4d54 100644 --- a/internal/tui/keybinding_help.go +++ b/internal/tui/keybinding_help.go @@ -53,7 +53,7 @@ func (m model) buildKeybindingGroups() []keybindingGroup { bindings: []keybinding{ {labelOr(m.keyBindings.cycleReasoning, "Ctrl+T"), "cycle reasoning effort (auto \u2192 low \u2192 medium \u2192 high)"}, {"Shift+Tab", "cycle permission mode (auto \u2194 ask)"}, - {labelOr(m.keyBindings.togglePlan, "Ctrl+P"), "expand / collapse the plan panel (when no menu is open)"}, + {labelOr(m.keyBindings.togglePlan, "Ctrl+P") + " / Ctrl+G", "expand / collapse the plan / orchestrate panel"}, }, }, { diff --git a/internal/tui/model.go b/internal/tui/model.go index d501decc7..e36783f02 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1717,6 +1717,18 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m.plan.expanded = !m.plan.expanded return m, nil } + case keyCtrl(msg, 'g'): + // Ctrl+G expands/collapses the ORCHESTRATE plan panel — the + // keyboard equivalent of clicking its header line. + // + // NOT Ctrl+O, which already toggles the detailed transcript view, + // and not Ctrl+P, which belongs to the update_plan panel: /plan and + // /plans are already two different things, and one key toggling + // whichever happened to be present would make that worse. + if m.noBlockingModal() && !m.orchestrate.isEmpty() { + m.orchestrate.expanded = !m.orchestrate.expanded + return m, nil + } case m.keyMatch(m.keyBindings.toggleSidebar, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'b') }) && canFireComposerGatedToggle(m.keyBindings.toggleSidebar, defaultToggleSidebarChord, m.composerValue() == ""): // Ctrl+B collapses / restores the right context sidebar. The composer-empty // requirement only applies when the binding resolves to the conflicting diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go index de8c2df63..e6a29be0c 100644 --- a/internal/tui/mouse.go +++ b/internal/tui/mouse.go @@ -87,6 +87,13 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { if mouseRightPress(msg) { return m, pasteFromClipboardCmd() } + // Clicking the orchestrate plan's header line opens or closes it. Checked + // before the surface switch below because the panel lives in the FOOTER, + // outside every transcript/sidebar region those cases test. + if mouseLeftPress(msg) && m.clickedOrchestrateHeader(msg) { + m.orchestrate.expanded = !m.orchestrate.expanded + return m, nil + } if mouseLeftPress(msg) { switch { case m.providerWizard != nil: diff --git a/internal/tui/orchestrate_panel.go b/internal/tui/orchestrate_panel.go index e7e1c4cc7..4a853a699 100644 --- a/internal/tui/orchestrate_panel.go +++ b/internal/tui/orchestrate_panel.go @@ -4,6 +4,9 @@ import ( "fmt" "strings" "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" ) // orchestrateTaskStatus is a plan task's state in the panel. @@ -227,6 +230,8 @@ const ( orchestrateSummaryWidth = 48 // orchestrateMinWidth is the narrowest sensible render. orchestrateMinWidth = 24 + // orchestrateMaxIndentDepth bounds the dependency indent. + orchestrateMaxIndentDepth = 5 ) // renderOrchestratePanel draws the plan: one row per task, indented by @@ -244,6 +249,14 @@ func (m model) renderOrchestratePanel(width int) string { var b strings.Builder b.WriteString(zeroTheme.ink.Render(orchestrateHeaderLine(state, now))) + // COLLAPSED BY DEFAULT. A plan's tasks are detail; the header is the state. + // A six-task chain otherwise takes seven lines of the footer for the whole + // run, pushing the conversation up for information that is one keypress + // away. Ctrl+O expands. + if !state.expanded { + return b.String() + } + rows := state.tasks hidden := 0 if len(rows) > orchestrateMaxRows { @@ -268,6 +281,13 @@ func (m model) renderOrchestratePanel(width int) string { func orchestrateHeaderLine(state orchestratePanelState, now time.Time) string { done, failed, skipped, cancelled, running := state.counts() var b strings.Builder + // The affordance says which way the keypress goes, so the collapsed state + // does not look like the whole panel. + if state.expanded { + b.WriteString("▾ ") + } else { + b.WriteString("▸ ") + } b.WriteString("PLAN") if name := strings.TrimSpace(state.name); name != "" { fmt.Fprintf(&b, " %s", truncateRunes(name, 24)) @@ -288,13 +308,23 @@ func orchestrateHeaderLine(state orchestratePanelState, now time.Time) string { if !state.startedAt.IsZero() { fmt.Fprintf(&b, " · %s", formatElapsedSeconds(now.Sub(state.startedAt))) } + if !state.expanded { + b.WriteString(" ") + b.WriteString("click or ctrl+g to expand") + } return b.String() } func orchestrateFooterLine(state orchestratePanelState) string { var parts []string - if state.tokenLimit > 0 { + switch { + case state.tokenLimit > 0: parts = append(parts, fmt.Sprintf("budget %d/%d tokens", state.tokensUsed, state.tokenLimit)) + case state.tokensUsed > 0: + // No bound was asked for, so there is no denominator to show. The spend + // is still reported — an unbounded plan must not also be an unmeasured + // one. + parts = append(parts, fmt.Sprintf("%d tokens", state.tokensUsed)) } if state.isComplete() { parts = append(parts, "status "+state.status) @@ -311,7 +341,12 @@ func orchestrateFooterLine(state orchestratePanelState) string { func (m model) renderOrchestrateTaskLine(task orchestrateTask, now time.Time, width int) string { // Indent by dependency depth: tasks that fan out from the same parent share // a rung, so a diamond reads as one node, two beside each other, one node. - indent := strings.Repeat(" ", task.depth+1) + // + // CAPPED. A strict chain adds a rung per link, so a twenty-task chain would + // indent forty columns and run off a narrow terminal — the shape stops being + // legible long before that. Past the cap every task sits at the same depth, + // which is honest for a chain: they are all one-after-another anyway. + indent := strings.Repeat(" ", minInt(task.depth, orchestrateMaxIndentDepth)+1) glyph := orchestrateGlyph(task.status) if task.status == orchestrateRunning { @@ -424,3 +459,29 @@ func orchestratePlainGlyph(status orchestrateTaskStatus) string { return "·" } } + +// orchestrateHeaderMarker identifies the panel's header line inside the footer. +// Both affordance glyphs are checked so the line is findable in either state. +const orchestrateHeaderMarker = "PLAN" + +// clickedOrchestrateHeader reports whether a left-click landed on the plan +// panel's header line. +// +// Located by CONTENT rather than by a remembered row index: the footer's height +// varies with the composer, the pinned update_plan panel and the status line, so +// an index captured at render time is stale by the time a click arrives. The +// arrow glyph is what distinguishes this header from update_plan's. +func (m model) clickedOrchestrateHeader(msg tea.MouseMsg) bool { + if m.orchestrate.isEmpty() || !m.altScreen || m.subchat.active { + return false + } + width := m.chatColumnWidth() + frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width)) + _, row, ok := frame.footerRect.local(mouseX(msg), mouseY(msg)) + if !ok || row < 0 || row >= len(frame.footerLines) { + return false + } + line := ansi.Strip(frame.footerLines[row]) + return strings.HasPrefix(strings.TrimSpace(line), "▸ "+orchestrateHeaderMarker) || + strings.HasPrefix(strings.TrimSpace(line), "▾ "+orchestrateHeaderMarker) +} diff --git a/internal/tui/orchestrate_panel_test.go b/internal/tui/orchestrate_panel_test.go index 7361dd7c5..2bffb230e 100644 --- a/internal/tui/orchestrate_panel_test.go +++ b/internal/tui/orchestrate_panel_test.go @@ -7,6 +7,7 @@ import ( "time" tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" ) // diamondAdmitted is the plan the whole feature is measured against: a -> (b,c) @@ -26,10 +27,14 @@ func diamondAdmitted() planAdmittedMsg { } } +// admittedModel returns a model with the plan admitted AND EXPANDED. The panel +// collapses by default (the header is the state; the tasks are detail), so a +// test about task rows has to open it — the collapsed default has its own test. func admittedModel(t *testing.T, msg planAdmittedMsg) model { t.Helper() m := model{now: func() time.Time { return time.Unix(1000, 0) }} m.orchestrate.admit(msg, m.now()) + m.orchestrate.expanded = true return m } @@ -126,9 +131,11 @@ func TestCompletedPlanStopsPinningItself(t *testing.T) { t.Fatal("a just-completed plan should still be visible") } later := m.now().Add(completedHideAfter + time.Second) + m.orchestrate.expanded = false if m.orchestrate.visible(later) { t.Fatal("a long-finished plan must stop pinning itself") } + // An expanded plan is one the user deliberately opened, so it stays. m.orchestrate.expanded = true if !m.orchestrate.visible(later) { t.Fatal("an expanded plan stays visible however long ago it finished") @@ -389,6 +396,16 @@ func TestOrchestratePanelMountsInTheFooter(t *testing.T) { updated, _ := m.Update(diamondAdmitted()) m = updated.(model) m.width, m.height = 96, 30 + + // Collapsed by default: the header is there, the tasks are not. + collapsed := plainRender(t, m.View()) + if !strings.Contains(collapsed, "PLAN diamond") { + t.Fatalf("the collapsed panel must still show the plan header:\n%s", collapsed) + } + if strings.Contains(collapsed, "ctrl+g") == false { + t.Fatalf("the collapsed panel must say how to open it:\n%s", collapsed) + } + m.orchestrate.expanded = true after := plainRender(t, m.View()) if !strings.Contains(after, "PLAN diamond") { @@ -418,3 +435,122 @@ func TestPlansCommandIsWired(t *testing.T) { t.Fatal("/plans did not report the dependency shape") } } + +// COLLAPSED BY DEFAULT, and the header says how to open it. A six-task chain +// otherwise takes seven footer lines for the whole run, pushing the +// conversation up for detail that is one keypress away. +func TestThePanelIsCollapsedUntilAsked(t *testing.T) { + m := model{now: func() time.Time { return time.Unix(1000, 0) }} + m.orchestrate.admit(diamondAdmitted(), m.now()) + + collapsed := m.renderOrchestratePanel(100) + if strings.Count(collapsed, "\n") != 0 { + t.Fatalf("the collapsed panel must be exactly one line:\n%s", collapsed) + } + if !strings.Contains(collapsed, "PLAN diamond") { + t.Fatalf("the collapsed panel must still name the plan:\n%s", collapsed) + } + if !strings.Contains(collapsed, "ctrl+g") { + t.Fatalf("a collapsed panel that does not say how to open it reads as the whole panel:\n%s", collapsed) + } + for _, id := range []string{" b ", " c ", " d "} { + if strings.Contains(collapsed, id) { + t.Fatalf("task%q leaked into the collapsed panel:\n%s", id, collapsed) + } + } + + m.orchestrate.expanded = true + expanded := m.renderOrchestratePanel(100) + if strings.Count(expanded, "\n") < 4 { + t.Fatalf("the expanded panel must show every task:\n%s", expanded) + } + if strings.Contains(expanded, "to expand") { + t.Fatal("an already-open panel must not still offer to open") + } +} + +// Ctrl+O toggles it through the real key handler, and does nothing when there +// is no plan — a keypress that silently mutates hidden state is worse than one +// that does nothing. +func TestCtrlGTogglesTheOrchestratePanel(t *testing.T) { + m := newModel(context.Background(), Options{}) + m.width, m.height = 96, 30 + + press := func(m model) model { + t.Helper() + updated, _ := m.Update(tea.KeyPressMsg{Code: 'g', Mod: tea.ModCtrl}) + return updated.(model) + } + + m = press(m) + if m.orchestrate.expanded { + t.Fatal("with no plan admitted the toggle must do nothing") + } + + m.activeRunID = 1 + updated, _ := m.Update(diamondAdmitted()) + m = updated.(model) + if m.orchestrate.expanded { + t.Fatal("a freshly admitted plan starts collapsed") + } + m = press(m) + if !m.orchestrate.expanded { + t.Fatal("ctrl+g did not expand the panel") + } + m = press(m) + if m.orchestrate.expanded { + t.Fatal("ctrl+g did not collapse the panel again") + } +} + +// An unbounded plan still reports what it spent. Removing the cap must not also +// remove the measurement. +func TestAnUnboundedPlanStillReportsItsSpend(t *testing.T) { + m := admittedModel(t, planAdmittedMsg{runID: 1, name: "p", taskCount: 1, + tasks: []planGraphTask{{id: "a"}}}) + m.orchestrate.complete(planCompletedMsg{status: "completed", tokensUsed: 469555}, m.now()) + + rendered := m.renderOrchestratePanel(100) + if !strings.Contains(rendered, "469555 tokens") { + t.Fatalf("an unbounded plan must still report its spend:\n%s", rendered) + } + if strings.Contains(rendered, "/0 tokens") { + t.Fatalf("no bound was asked for, so there is no denominator to show:\n%s", rendered) + } +} + +// A long chain must not indent itself off the screen. A twenty-task chain adds +// a rung per link, which would be forty columns of indent — the shape stops +// being legible long before that. +func TestADeepChainStopsIndenting(t *testing.T) { + msg := planAdmittedMsg{runID: 1, name: "chain"} + for index := 0; index < 20; index++ { + task := planGraphTask{id: string(rune('a' + index))} + if index > 0 { + task.dependsOn = []string{string(rune('a' + index - 1))} + } + msg.tasks = append(msg.tasks, task) + } + msg.taskCount = len(msg.tasks) + m := admittedModel(t, msg) + + // The DEPTHS still reflect the real graph — only the drawing is capped. + last := m.orchestrate.tasks[len(m.orchestrate.tasks)-1] + if last.depth != 19 { + t.Fatalf("the last link of a 20-task chain is at depth %d, want 19", last.depth) + } + + rendered := m.renderOrchestratePanel(60) + for _, styled := range strings.Split(rendered, "\n") { + // VISIBLE width: the rendered line carries colour escapes, and counting + // those would measure the wrong thing entirely. + line := ansi.Strip(styled) + indent := len(line) - len(strings.TrimLeft(line, " ")) + if indent > 2*(orchestrateMaxIndentDepth+1) { + t.Fatalf("indent ran to %d columns:\n%s", indent, rendered) + } + if len([]rune(line)) > 60 { + t.Fatalf("a line ran to %d visible columns past a 60-wide panel:\n%s", len([]rune(line)), line) + } + } +} From b904c3f75120e18edc365d5bb98e501385dbdef6 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:52:41 +0530 Subject: [PATCH 22/86] feat(tui): fade finished plan tasks, and say what /effort accepts TWO SEPARATE THINGS FROM DRIVING A REAL PLAN. Finished tasks now drop out of the panel after a 5s linger, so it tracks live work instead of accumulating a transcript of it. Long enough to see a task land, short enough that a six-task chain does not fill the footer with history. Matches how the AGENTS sidebar already retires finished agents. A faded task is hidden, not forgotten: the header still counts it, and /plans still lists the whole plan. That matters, because the dependency shape fades with the tasks -- a diamond stops looking like a diamond once its first task goes. /plans is where the shape stays readable for the whole run, and it names each task's dependencies explicitly rather than relying on indentation. /effort NOW SAYS WHAT IT ACCEPTS. On glm-5.2 the card reported "available: not listed - model is not in Zero's catalog" and stopped. Nothing typeable, on the model this user actually runs. And on a catalogued model it listed low/medium/high while omitting zeromaxing -- which is selected through this very command, and which the actions line already offered. "available" (what the CATALOG vouches for) and "you can set" (what the command accepts) are different questions and were conflated into one line. They are now two fields. The distinction is enforced, not decorative: - catalogued with a ring -> exactly that ring - catalogued with NO ring -> nothing but the posture. gpt-4o genuinely has no reasoning controls and the command refuses every level there, so offering them would advertise what the command rejects - not catalogued -> low/medium/high, because Zero cannot vouch either way and the headless path forwards them - posture disabled -> the posture is dropped from BOTH the list and the actions line, since suggesting a command the run will refuse is worse than saying nothing The card and the command are two doors onto "which efforts can I set", so the parity test feeds every advertised value back through the real command rather than comparing two lists -- a list comparison would pass while the command rejected all of them. --- internal/tui/commands.go | 2 +- internal/tui/commands_test.go | 2 +- internal/tui/effort_options_test.go | 93 ++++++++++++++++++++++++++ internal/tui/orchestrate_panel.go | 41 ++++++++++-- internal/tui/orchestrate_panel_test.go | 63 +++++++++++++++++ internal/tui/session_controls.go | 57 +++++++++++++++- 6 files changed, 250 insertions(+), 8 deletions(-) create mode 100644 internal/tui/effort_options_test.go diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 876fda773..51171f04d 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -282,7 +282,7 @@ var commandDefinitions = []commandDefinition{ }, { name: "/effort", - usage: "/effort [list|low|medium|high|auto]", + usage: "/effort [list|low|medium|high|zeromaxing|auto]", group: commandGroupModel, description: "Show or set reasoning effort for supported models.", kind: commandEffort, diff --git a/internal/tui/commands_test.go b/internal/tui/commands_test.go index ea3a37323..87ba7b455 100644 --- a/internal/tui/commands_test.go +++ b/internal/tui/commands_test.go @@ -47,7 +47,7 @@ func TestFormatCommandHelpLinesGroupsCommandsByStableOrder(t *testing.T) { "model:", " /provider [add|status] - Manage providers: activate, add, edit, delete.", " /model [list|id] - Show or switch the active model.", - " /effort [list|low|medium|high|auto] - Show or set reasoning effort for supported models.", + " /effort [list|low|medium|high|zeromaxing|auto] - Show or set reasoning effort for supported models.", "session:", " /plan - Show planning mode status.", "runtime:", diff --git a/internal/tui/effort_options_test.go b/internal/tui/effort_options_test.go new file mode 100644 index 000000000..c2ea50bb5 --- /dev/null +++ b/internal/tui/effort_options_test.go @@ -0,0 +1,93 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/execprofile" +) + +// THE CARD AND THE COMMAND ARE TWO DOORS onto "which efforts can I set?", so +// the relationship is EQUALITY (RULES.md §3): every value /effort offers must +// be one /effort accepts, and refusing something it just advertised is the same +// disagreement in the other direction. +// +// Asserted by feeding each advertised value back through the real command +// rather than by comparing two lists — a list comparison would pass while the +// command rejected them all. +func TestEveryAdvertisedEffortIsAccepted(t *testing.T) { + for _, name := range []string{"glm-5.2", "claude-sonnet-4.5", "gpt-4o", "gpt-5-mini"} { + t.Run(name, func(t *testing.T) { + m := model{modelName: name} + for _, value := range m.settableEfforts() { + _, out := m.handleEffortCommand(value) + for _, refusal := range []string{"Unknown reasoning effort", "is not supported by", "does not expose reasoning effort"} { + if strings.Contains(out, refusal) { + t.Errorf("the card offers %q on %s but the command refuses it: %s", value, name, out) + } + } + } + }) + } +} + +// A model the catalog VOUCHES has no reasoning controls must not be offered +// any. gpt-4o is catalogued with an empty ring, and offering low/medium/high +// there would advertise exactly what the command rejects. +func TestACataloguedModelWithNoControlsOffersOnlyThePosture(t *testing.T) { + m := model{modelName: "gpt-4o"} + settable := m.settableEfforts() + if len(settable) != 1 || settable[0] != execprofile.Name { + t.Fatalf("settable = %v, want only the posture: the catalog says this model has no reasoning controls", settable) + } +} + +// An UNCATALOGUED model is a different case: Zero cannot vouch either way, the +// levels are forwarded, and telling the user "not listed" while showing nothing +// typeable leaves them with no way to act. +func TestAnUncataloguedModelStillOffersTheLevels(t *testing.T) { + m := model{modelName: "glm-5.2"} + card := m.effortText() + if !strings.Contains(card, "you can set") { + t.Fatalf("an uncatalogued model must still say what can be set:\n%s", card) + } + for _, want := range []string{"low", "medium", "high", execprofile.Name} { + if !strings.Contains(card, want) { + t.Errorf("the card does not offer %q on an uncatalogued model:\n%s", want, card) + } + } +} + +// The posture is selected through this namespace, so it belongs in the list on +// every model — it was missing from all of them while the actions line offered +// it. +func TestThePostureIsOfferedOnEveryModel(t *testing.T) { + for _, name := range []string{"glm-5.2", "claude-sonnet-4.5", "gpt-4o"} { + m := model{modelName: name} + if !strings.Contains(strings.Join(m.settableEfforts(), ","), execprofile.Name) { + t.Errorf("%s does not offer %q, which /effort accepts", name, execprofile.Name) + } + } +} + +// ...and NOT when the workspace disabled it. Advertising a command the run will +// refuse is worse than saying nothing. +func TestADisabledPostureIsNotOffered(t *testing.T) { + m := model{modelName: "glm-5.2", zeromaxingDisabled: true} + if strings.Contains(strings.Join(m.settableEfforts(), ","), execprofile.Name) { + t.Error("the posture is disabled for this workspace and must not be offered") + } + if strings.Contains(m.effortText(), "for the maximal posture") { + t.Error("the actions line still suggests a command the run will refuse") + } +} + +// With no model selected there is nothing to claim support for, but the posture +// is a Zero-side setting and stays available. +func TestWithNoModelOnlyThePostureIsOffered(t *testing.T) { + m := model{} + settable := m.settableEfforts() + if len(settable) != 1 || settable[0] != execprofile.Name { + t.Fatalf("settable = %v, want only the posture when no model is selected", settable) + } +} diff --git a/internal/tui/orchestrate_panel.go b/internal/tui/orchestrate_panel.go index 4a853a699..290987614 100644 --- a/internal/tui/orchestrate_panel.go +++ b/internal/tui/orchestrate_panel.go @@ -234,6 +234,34 @@ const ( orchestrateMaxIndentDepth = 5 ) +// orchestrateTaskLinger is how long a finished task stays on screen before it +// drops out. Long enough to see it land, short enough that the panel tracks +// what is actually happening rather than accumulating history. +// +// Matches the AGENTS sidebar's treatment of finished agents, which retires them +// the same way and for the same reason. +const orchestrateTaskLinger = 5 * time.Second + +// liveTasks returns the tasks the panel should draw: everything not yet +// finished, plus anything that finished within the linger window. +// +// The header's counts still cover EVERY task — a faded task is hidden, not +// forgotten — and /plans still lists the whole plan. This is the panel choosing +// to show live work rather than accumulate a transcript. +// +// TRADE-OFF, stated because it cost something real: the dependency shape goes +// with them. A diamond stops looking like a diamond once its first task fades. +// /plans is where the shape stays readable for the whole run. +func (s orchestratePanelState) liveTasks(now time.Time) []orchestrateTask { + live := make([]orchestrateTask, 0, len(s.tasks)) + for _, task := range s.tasks { + if task.endedAt.IsZero() || now.Sub(task.endedAt) < orchestrateTaskLinger { + live = append(live, task) + } + } + return live +} + // renderOrchestratePanel draws the plan: one row per task, indented by // dependency depth so the shape is legible. func (m model) renderOrchestratePanel(width int) string { @@ -257,19 +285,22 @@ func (m model) renderOrchestratePanel(width int) string { return b.String() } - rows := state.tasks - hidden := 0 + rows := state.liveTasks(now) + // Hidden by the ROW CAP is worth saying; hidden by the linger is not. A + // truncated list reads as a complete one, but a faded task is already + // accounted for in the header's done count. + capped := 0 if len(rows) > orchestrateMaxRows { - hidden = len(rows) - orchestrateMaxRows + capped = len(rows) - orchestrateMaxRows rows = rows[:orchestrateMaxRows] } for _, task := range rows { b.WriteString("\n") b.WriteString(m.renderOrchestrateTaskLine(task, now, width)) } - if hidden > 0 { + if capped > 0 { b.WriteString("\n") - b.WriteString(zeroTheme.faint.Render(fmt.Sprintf(" … %d more task(s) not shown", hidden))) + b.WriteString(zeroTheme.faint.Render(fmt.Sprintf(" … %d more task(s) not shown", capped))) } if footer := orchestrateFooterLine(state); footer != "" { b.WriteString("\n") diff --git a/internal/tui/orchestrate_panel_test.go b/internal/tui/orchestrate_panel_test.go index 2bffb230e..9bfca31d1 100644 --- a/internal/tui/orchestrate_panel_test.go +++ b/internal/tui/orchestrate_panel_test.go @@ -554,3 +554,66 @@ func TestADeepChainStopsIndenting(t *testing.T) { } } } + +// FINISHED TASKS FADE OUT. The panel tracks live work rather than accumulating +// a transcript of it. +func TestFinishedTasksFadeOutOfThePanel(t *testing.T) { + m := admittedModel(t, diamondAdmitted()) + start := m.now() + + m.orchestrate.markStarted("a", "root", start) + m.orchestrate.markDone("a", "succeeded", start) + m.orchestrate.markStarted("b", "left", start) + + // Immediately after finishing, a is still there — you have to be able to + // SEE it land. + if !strings.Contains(m.renderOrchestratePanel(90), " a ") { + t.Fatal("a task that just finished must linger long enough to be seen") + } + + m.now = func() time.Time { return start.Add(orchestrateTaskLinger + time.Second) } + faded := m.renderOrchestratePanel(90) + if strings.Contains(faded, " a ") { + t.Fatalf("a long-finished task must drop out of the panel:\n%s", faded) + } + for _, id := range []string{" b ", " c ", " d "} { + if !strings.Contains(faded, id) { + t.Fatalf("task%q is not finished and must stay:\n%s", id, faded) + } + } +} + +// A faded task is hidden, not forgotten: the header still counts it, and +// /plans still lists the whole plan with its shape intact. +func TestAFadedTaskIsStillCountedAndStillInPlans(t *testing.T) { + m := admittedModel(t, diamondAdmitted()) + start := m.now() + m.orchestrate.markStarted("a", "root", start) + m.orchestrate.markDone("a", "succeeded", start) + m.now = func() time.Time { return start.Add(orchestrateTaskLinger + time.Second) } + + if done, _, _, _, _ := m.orchestrate.counts(); done != 1 { + t.Fatalf("done count = %d, want 1: a faded task is hidden, not forgotten", done) + } + if !strings.Contains(m.renderOrchestratePanel(90), "1/4 done") { + t.Fatal("the header must still report the faded task as done") + } + full := m.orchestratePlansText() + if !strings.Contains(full, "a [done]") { + t.Fatalf("/plans must still list every task, faded or not:\n%s", full) + } + if !strings.Contains(full, "← b, c") { + t.Fatalf("/plans is where the dependency shape survives the fade:\n%s", full) + } +} + +// Pending tasks never fade — they have not finished, so there is nothing to +// retire. +func TestPendingTasksNeverFade(t *testing.T) { + m := admittedModel(t, diamondAdmitted()) + m.now = func() time.Time { return time.Unix(1000, 0).Add(time.Hour) } + live := m.orchestrate.liveTasks(m.now()) + if len(live) != 4 { + t.Fatalf("no task has finished, so all four must still show, got %d", len(live)) + } +} diff --git a/internal/tui/session_controls.go b/internal/tui/session_controls.go index fc9fb06b6..66458e3cb 100644 --- a/internal/tui/session_controls.go +++ b/internal/tui/session_controls.go @@ -166,7 +166,12 @@ func (m model) effortText() string { {Key: "model", Value: displayValue(m.modelName, "none")}, } _, ringKnown := m.availableReasoningEffortsKnown() - actions := []string{"use /effort to switch", "/effort " + execprofile.Name + " for the maximal posture", "/effort auto to clear"} + actions := []string{"use /effort to switch", "/effort auto to clear"} + if !m.zeromaxingDisabled { + // Not offered when the workspace disabled it: an action line that + // suggests a command the run will refuse is worse than no line. + actions = append(actions, "/effort "+execprofile.Name+" for the maximal posture") + } // The SAME resolved-state line /profile status renders, so the two surfaces // cannot disagree about what actually reaches the provider. stateLines := append([]string{m.resolvedPostureLine()}, m.zeromaxingNotes()...) @@ -181,6 +186,9 @@ func (m model) effortText() string { summary = "levels are not listed for this model; they can still be set and are forwarded, but the provider may reject them" } fields = append(fields, commandField{Key: "available", Value: available}) + if settable := m.settableEfforts(); len(settable) > 0 { + fields = append(fields, commandField{Key: "you can set", Value: strings.Join(settable, ", ")}) + } return renderCommandCardTranscript(commandCard{ Title: "Effort", Summary: []string{"active effort: " + m.effortDisplay(), summary}, @@ -189,6 +197,9 @@ func (m model) effortText() string { }) } fields = append(fields, commandField{Key: "available", Value: joinReasoningEfforts(efforts)}) + if settable := m.settableEfforts(); len(settable) > 0 { + fields = append(fields, commandField{Key: "you can set", Value: strings.Join(settable, ", ")}) + } return renderCommandCardTranscript(commandCard{ Title: "Effort", Summary: []string{"active effort: " + m.effortDisplay(), fmt.Sprintf("%d supported level(s)", len(efforts))}, @@ -440,6 +451,50 @@ func reasoningEffortIndex(efforts []modelregistry.ReasoningEffort, want modelreg return -1 } +// settableEfforts lists what /effort will actually accept right now. +// +// DISTINCT from "available", which reports what the CATALOG vouches for. The +// two answer different questions and used to be conflated into one line, so a +// user on a model Zero has no entry for was told "not listed" and shown nothing +// they could type — even though low/medium/high are settable there and ARE +// forwarded, which is the whole point of the catalog-authority rule. +// +// zeromaxing belongs here because it is selected through this namespace, and it +// was missing from every model's list even though the actions line offered it. +// It is omitted when the workspace disabled the posture, so the card never +// advertises something that will be refused. +func (m model) settableEfforts() []string { + efforts, known := m.availableReasoningEffortsKnown() + var values []string + switch { + case known: + // The catalog is AUTHORITATIVE, including when its ring is empty: + // gpt-4o genuinely has no reasoning controls, and handleEffortCommand + // refuses every level there. Offering low/medium/high would advertise + // something the command rejects — the card and the command answering + // the same question differently, which is the shape this codebase keeps + // producing. + values = append(values, splitReasoningEfforts(efforts)...) + case strings.TrimSpace(m.modelName) != "": + // No catalog entry: Zero cannot vouch either way, and the headless path + // forwards these in exactly this case. Offering them is honest; the + // summary line already says the provider may reject them. + values = append(values, "low", "medium", "high") + } + if !m.zeromaxingDisabled { + values = append(values, execprofile.Name) + } + return values +} + +func splitReasoningEfforts(efforts []modelregistry.ReasoningEffort) []string { + values := make([]string, 0, len(efforts)) + for _, effort := range efforts { + values = append(values, string(effort)) + } + return values +} + func joinReasoningEfforts(efforts []modelregistry.ReasoningEffort) string { values := make([]string, 0, len(efforts)) for _, effort := range efforts { From 3189641833a4676d112101f5ee5ad459f682f612 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:59:42 +0530 Subject: [PATCH 23/86] fix(tui): the effort picker offers what /effort actually accepts The popup that /effort opens showed a single "auto" row on glm-5.2. Reported from use, with a screenshot; I had just told the user no such picker existed, which was wrong -- newEffortPicker has been there all along. It read availableReasoningEfforts(), the CATALOG's answer. A model with no catalog entry has an empty ring, so the picker offered nothing settable -- on the model this user runs. And it never offered the zeromaxing posture on ANY model, even though picking one routes straight into handleEffortCommand, which accepts it. The picker now shares settableEfforts() with the /effort card, so the three surfaces that answer "which efforts can I set" -- card, picker, command -- cannot disagree. Catalog authority is preserved: gpt-4o, which the catalog vouches has no reasoning controls, still offers only auto and the posture. It also preselects the active posture. Under zeromaxing m.reasoningEffort holds "high" -- the level the posture FILLED -- so preselecting from that alone would highlight the wrong row. The parity test now feeds every value from BOTH surfaces back through the real command, and asserts the two offer the same set. The picker is the surface that actually failed in use while the card's own test passed, which is the argument for testing them against each other rather than each against its own expectation. Two existing tests encoded the old behaviour. One asserted "[auto] as the only effort option on an unsupported model" -- the reported bug, written down as an expectation; an uncatalogued model is not an unsupported one, and its levels are forwarded. The other pressed enter expecting to land on auto, which only held while auto was the sole row; it now selects auto deliberately and asserts the preselection instead. --- internal/tui/effort_options_test.go | 80 ++++++++++++++++++++++++----- internal/tui/picker.go | 37 ++++++++----- internal/tui/picker_test.go | 26 +++++++--- 3 files changed, 112 insertions(+), 31 deletions(-) diff --git a/internal/tui/effort_options_test.go b/internal/tui/effort_options_test.go index c2ea50bb5..a651bef48 100644 --- a/internal/tui/effort_options_test.go +++ b/internal/tui/effort_options_test.go @@ -7,30 +7,86 @@ import ( "github.com/Gitlawb/zero/internal/execprofile" ) -// THE CARD AND THE COMMAND ARE TWO DOORS onto "which efforts can I set?", so -// the relationship is EQUALITY (RULES.md §3): every value /effort offers must -// be one /effort accepts, and refusing something it just advertised is the same -// disagreement in the other direction. +// THREE DOORS onto "which efforts can I set?" — the /effort card, the popup +// picker, and the command itself. The relationship is EQUALITY (RULES.md §3): +// every value either surface offers must be one the command accepts. // -// Asserted by feeding each advertised value back through the real command -// rather than by comparing two lists — a list comparison would pass while the -// command rejected them all. +// Asserted by feeding each offered value back through the REAL command rather +// than by comparing lists — a list comparison would pass while the command +// rejected all of them. The picker is included because it was the surface that +// actually failed in use: it read the catalog directly, so on a model with no +// catalog entry it offered nothing but "auto". func TestEveryAdvertisedEffortIsAccepted(t *testing.T) { for _, name := range []string{"glm-5.2", "claude-sonnet-4.5", "gpt-4o", "gpt-5-mini"} { t.Run(name, func(t *testing.T) { m := model{modelName: name} - for _, value := range m.settableEfforts() { - _, out := m.handleEffortCommand(value) - for _, refusal := range []string{"Unknown reasoning effort", "is not supported by", "does not expose reasoning effort"} { - if strings.Contains(out, refusal) { - t.Errorf("the card offers %q on %s but the command refuses it: %s", value, name, out) + + offered := map[string][]string{ + "card": m.settableEfforts(), + } + var fromPicker []string + for _, item := range m.newEffortPicker().items { + fromPicker = append(fromPicker, item.Value) + } + offered["picker"] = fromPicker + + for surface, values := range offered { + for _, value := range values { + _, out := m.handleEffortCommand(value) + for _, refusal := range []string{"Unknown reasoning effort", "is not supported by", "does not expose reasoning effort"} { + if strings.Contains(out, refusal) { + t.Errorf("the %s offers %q on %s but the command refuses it: %s", surface, value, name, out) + } } } } + + // And the two surfaces must offer the same set, "auto" aside — one + // listing a level the other omits is the disagreement this test + // exists to prevent. + for _, value := range m.settableEfforts() { + if !strings.Contains(strings.Join(fromPicker, ","), value) { + t.Errorf("the card offers %q on %s but the picker does not", value, name) + } + } }) } } +// The picker must offer the posture on every model, and preselect it when it is +// active — under zeromaxing m.reasoningEffort holds "high", the level the +// posture FILLED, so preselecting from that would highlight the wrong row. +func TestThePickerOffersAndPreselectsThePosture(t *testing.T) { + for _, name := range []string{"glm-5.2", "claude-sonnet-4.5", "gpt-4o"} { + items := model{modelName: name}.newEffortPicker().items + var labels []string + for _, item := range items { + labels = append(labels, item.Value) + } + if !strings.Contains(strings.Join(labels, ","), execprofile.Name) { + t.Errorf("%s: the picker does not offer %q, which it routes straight into handleEffortCommand", name, execprofile.Name) + } + } + + active := model{modelName: "glm-5.2", execProfileName: execprofile.Name, reasoningEffort: "high"} + picker := active.newEffortPicker() + if got := picker.items[picker.selected].Value; got != execprofile.Name { + t.Fatalf("under the posture the picker preselects %q, want %q", got, execprofile.Name) + } +} + +// A model the catalog says has no controls offers only auto and the posture — +// the picker's own version of the card's rule. +func TestThePickerRespectsCatalogAuthority(t *testing.T) { + var labels []string + for _, item := range (model{modelName: "gpt-4o"}).newEffortPicker().items { + labels = append(labels, item.Value) + } + if strings.Join(labels, ",") != "auto,"+execprofile.Name { + t.Fatalf("picker = %v, want only auto and the posture on a model with no reasoning controls", labels) + } +} + // A model the catalog VOUCHES has no reasoning controls must not be offered // any. gpt-4o is catalogued with an empty ring, and offering low/medium/high // there would advertise exactly what the command rejects. diff --git a/internal/tui/picker.go b/internal/tui/picker.go index 1e7580bbf..b90e881c4 100644 --- a/internal/tui/picker.go +++ b/internal/tui/picker.go @@ -10,6 +10,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/execprofile" "github.com/Gitlawb/zero/internal/modelregistry" "github.com/Gitlawb/zero/internal/providercatalog" "github.com/Gitlawb/zero/internal/providermodelcatalog" @@ -974,25 +975,35 @@ func (m *model) selectPickerValue(value string) { } } -// newEffortPicker lists the reasoning efforts the active model supports plus an -// "auto" option, preselecting the current preference. When the model exposes no -// effort controls, still returns a single "auto" picker so the user gets the -// popup affordance on /effort instead of a static status card; handleEffortCommand -// reports "Active model does not expose reasoning effort controls" if they pick -// anything other than auto. +// newEffortPicker lists everything /effort accepts on the active model, plus +// "auto", preselecting the current preference. +// +// THE THIRD DOOR onto "which efforts can I set?", after the /effort card and +// the command itself. It used to read availableReasoningEfforts() — the +// CATALOG's answer — so on a model with no catalog entry it offered nothing but +// "auto", and it never offered the zeromaxing posture on any model even though +// picking it routes straight into handleEffortCommand, which accepts it. +// +// It now shares settableEfforts() with the card, so the three cannot disagree. func (m model) newEffortPicker() *commandPicker { - efforts := m.availableReasoningEfforts() items := []pickerItem{{Label: "auto", Value: "auto"}} selected := 0 - if m.reasoningEffort == "" { - selected = 0 - } - for _, effort := range efforts { - items = append(items, pickerItem{Label: string(effort), Value: string(effort)}) - if m.reasoningEffort != "" && effort == m.reasoningEffort { + for _, value := range m.settableEfforts() { + items = append(items, pickerItem{Label: value, Value: value}) + if m.reasoningEffort != "" && value == string(m.reasoningEffort) { selected = len(items) - 1 } } + // The posture is not a reasoning level, so it is preselected from the + // active PROFILE rather than from m.reasoningEffort — which under + // zeromaxing holds "high", the level the posture filled. + if m.execProfileName == execprofile.Name { + for index, item := range items { + if item.Value == execprofile.Name { + selected = index + } + } + } return &commandPicker{kind: pickerEffort, title: "select reasoning effort", items: items, selected: selected} } diff --git a/internal/tui/picker_test.go b/internal/tui/picker_test.go index e9532ef54..ec8a378bb 100644 --- a/internal/tui/picker_test.go +++ b/internal/tui/picker_test.go @@ -1089,10 +1089,12 @@ func readTUIConfigFixture(t *testing.T, path string) config.FileConfig { return cfg } -func TestEffortPickerOpensForModelWithoutEffortControls(t *testing.T) { - // glm-5.1 is not in the hard-coded registry, so availableReasoningEfforts is - // empty. /effort should still open a picker (offering auto only) instead of - // rendering a static "Effort / available: none for active model" status card. +func TestEffortPickerOffersSettableLevelsOnAnUncataloguedModel(t *testing.T) { + // glm-5.1 has no catalog entry, so availableReasoningEfforts is empty. That + // means Zero CANNOT VOUCH either way — not that the model has no controls. + // The levels are settable there and are forwarded, so the picker offers + // them; it used to offer "auto" alone, which left a user on a custom + // endpoint with a popup containing one useless row. m := newModel(context.Background(), Options{ModelName: "glm-5.1"}) m.input.SetValue("/effort") @@ -1101,8 +1103,12 @@ func TestEffortPickerOpensForModelWithoutEffortControls(t *testing.T) { if m.picker == nil || m.picker.kind != pickerEffort { t.Fatalf("expected an open effort picker, got %#v", m.picker) } - if len(m.picker.items) != 1 || m.picker.items[0].Value != "auto" { - t.Fatalf("expected [auto] as the only effort option on an unsupported model, got %#v", m.picker.items) + var values []string + for _, item := range m.picker.items { + values = append(values, item.Value) + } + if strings.Join(values, ",") != "auto,low,medium,high,zeromaxing" { + t.Fatalf("picker = %v, want auto plus the settable levels and the posture", values) } if m.picker.title != "select reasoning effort" { t.Fatalf("picker title = %q, want %q", m.picker.title, "select reasoning effort") @@ -1121,6 +1127,14 @@ func TestEffortPickerAutoSelectionKeepsEffortUnset(t *testing.T) { if m.picker == nil { t.Fatal("expected the effort picker to open") } + // The picker preselects the ACTIVE effort, so "auto" has to be chosen + // deliberately rather than by pressing enter on whatever happened to be + // first. That preselection is the point: opening the picker should show you + // where you are. + if got := m.picker.items[m.picker.selected].Value; got != "high" { + t.Fatalf("the picker preselected %q, want the active effort %q", got, "high") + } + m.selectPickerValue("auto") updated, _ = m.Update(testKey(tea.KeyEnter)) m = updated.(model) From e59394c16b12b68bc073049e442770c3e56ddeca Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:13:16 +0530 Subject: [PATCH 24/86] fix(tui): five reporting defects a real plan run exposed All five from one screenshot of a six-task chain, all in what the run REPORTED rather than in what it did. "error (exit code 0)" on a failed task, directly above a body reading "Subagent failed (exit 4)" -- the card contradicting its own detail. A plan task's failure carries no exit code, and rendering the zero value claimed one it did not have. Only shown when non-zero now. Dependency-skipped tasks rendered "error". specialistCancelled landed in specialistStatusString's default arm, so a plan with ONE real failure showed three errors: the failure plus every task skipped behind it. The default stays "error" so an unmapped status still fails closed. "0 tokens" in the specialists rollup while the plan reported 130,135. Nothing has ever populated specialistInfo.tokenCount -- not for plan tasks, not for Task sub-agents -- so the total was always zero. The segment is now omitted at zero, matching the per-card rule (M18): a number that looks measured and is not is worse than no number. A plan task's spend now reaches its card, so the rollup adds up to what the plan reports. Task sub-agents still bridge no usage; that gap is unchanged and now shows as an absent segment rather than a false zero. The sidebar said "no active plan" while the panel two lines below showed one mid-flight. Its PLAN section reads the update_plan TODO list and knew nothing about orchestrate plans. It now shows a one-line summary -- name, done/total, failures, the task in flight -- when an orchestrate plan is running. EXACTLY one line, because the FILES section below computes its click offsets from the lines above it. The sidebar test drives renderContextSidebar rather than the line helper: the first version called the helper directly, and the mutation that removes the section from the sidebar entirely still passed. --- internal/tui/model.go | 1 + internal/tui/orchestrate_panel.go | 34 ++++++ internal/tui/plan_card_regression_test.go | 127 ++++++++++++++++++++++ internal/tui/plan_messages.go | 3 + internal/tui/plan_progress.go | 3 +- internal/tui/sidebar.go | 16 ++- internal/tui/specialist_card.go | 35 +++++- 7 files changed, 213 insertions(+), 6 deletions(-) create mode 100644 internal/tui/plan_card_regression_test.go diff --git a/internal/tui/model.go b/internal/tui/model.go index e36783f02..3fd73aab3 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -2700,6 +2700,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m.specialists.start(msg.taskID, msg.reason, cardKey, m.now()) } m.specialists.complete(cardKey, msg.status, 0, msg.reason, m.now()) + m.specialists.setTokens(cardKey, msg.tokens) if msg.sessionID != "" && msg.sessionID != cardKey { m.specialists.reconcileSessionID(cardKey, msg.sessionID) cardKey = msg.sessionID diff --git a/internal/tui/orchestrate_panel.go b/internal/tui/orchestrate_panel.go index 290987614..159a87566 100644 --- a/internal/tui/orchestrate_panel.go +++ b/internal/tui/orchestrate_panel.go @@ -516,3 +516,37 @@ func (m model) clickedOrchestrateHeader(msg tea.MouseMsg) bool { return strings.HasPrefix(strings.TrimSpace(line), "▸ "+orchestrateHeaderMarker) || strings.HasPrefix(strings.TrimSpace(line), "▾ "+orchestrateHeaderMarker) } + +// sidebarOrchestrateLine is the one-line orchestrate summary the sidebar shows +// in place of "no active plan" while a plan is running. Kept to a single line +// because the FILES section below computes its click offsets from the lines +// above it. +func (m model) sidebarOrchestrateLine(width int) string { + state := m.orchestrate + done, failed, _, _, _ := state.counts() + + label := strings.TrimSpace(state.name) + if label == "" { + label = "plan" + } + summary := fmt.Sprintf("%s %d/%d", label, done, len(state.tasks)) + if failed > 0 { + summary += fmt.Sprintf(" · %d failed", failed) + } + if running := state.runningTask(); running != "" { + summary += " · " + running + } + // Room for the two-space indent the placeholder also carries. + return " " + zeroTheme.muted.Render(truncateRunes(summary, maxInt(1, width-2))) +} + +// runningTask names the task currently in flight, or "" when none is. Sound +// because MaxWorkers is validated to be 1 — Stage 2d must revisit it. +func (s orchestratePanelState) runningTask() string { + for _, task := range s.tasks { + if task.status == orchestrateRunning { + return task.id + } + } + return "" +} diff --git a/internal/tui/plan_card_regression_test.go b/internal/tui/plan_card_regression_test.go new file mode 100644 index 000000000..8891ccd98 --- /dev/null +++ b/internal/tui/plan_card_regression_test.go @@ -0,0 +1,127 @@ +package tui + +import ( + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" +) + +// A skipped or cancelled task is NOT an error. specialistCancelled fell into +// specialistStatusString's default arm, so every task skipped because its +// dependency failed rendered "error" — a plan with one real failure showed +// three. +func TestCancelledTasksDoNotRenderAsErrors(t *testing.T) { + if got := specialistStatusString(specialistCancelled); got != "cancelled" { + t.Fatalf("specialistCancelled renders as %q, want %q", got, "cancelled") + } + if got := specialistStatusString(specialistError); got != "error" { + t.Fatalf("a real error must still say so, got %q", got) + } + // Fail closed: an unmapped status is an error, not something benign. + if got := specialistStatusString(specialistStatus(99)); got != "error" { + t.Fatalf("an unknown status must fail closed to %q, got %q", "error", got) + } +} + +// The card claimed an exit code it did not have. A plan task's failure carries +// no exit code, so the zero value rendered "error (exit code 0)" directly above +// a body reading "Subagent failed (exit 4)" — the card contradicting its own +// detail. +func TestTheCardOnlyClaimsAnExitCodeItHas(t *testing.T) { + now := time.Now() + withoutCode := specialistInfo{ + name: "trace", status: specialistError, errorMsg: "Subagent failed (exit 4)", + startedAt: now, completedAt: now, + } + m := model{now: func() time.Time { return now }} + rendered := m.renderSpecialistCard(withoutCode, 80) + if strings.Contains(rendered, "exit code 0") { + t.Fatalf("a failure with no exit code must not claim one:\n%s", rendered) + } + if !strings.Contains(rendered, "error") { + t.Fatalf("it is still an error:\n%s", rendered) + } + + withCode := withoutCode + withCode.exitCode = 4 + if !strings.Contains(m.renderSpecialistCard(withCode, 80), "exit code 4") { + t.Fatal("a real exit code must still be shown") + } +} + +// The rollup always read "0 tokens" because nothing populated tokenCount. A +// number that looks measured and is not is worse than no number. +func TestTheSummaryOmitsATokenTotalNobodyMeasured(t *testing.T) { + now := time.Now() + unmeasured := []specialistInfo{{name: "a", status: specialistCompleted, startedAt: now, completedAt: now}} + if strings.Contains(renderSpecialistSummary(unmeasured, "*"), "0 tokens") { + t.Fatal("the rollup must omit a token total nobody populated") + } + + measured := []specialistInfo{{name: "a", status: specialistCompleted, tokenCount: 130135, startedAt: now, completedAt: now}} + if !strings.Contains(renderSpecialistSummary(measured, "*"), "tokens") { + t.Fatal("a measured total must still be reported") + } +} + +// A plan task's spend reaches its card, so the rollup adds up to what the plan +// reports rather than to zero. +func TestAPlanTasksSpendReachesItsCard(t *testing.T) { + m := model{now: func() time.Time { return time.Unix(1000, 0) }} + m.activeRunID = 1 + for _, msg := range []tea.Msg{ + planTaskStartMsg{runID: 1, taskID: "a", cardKey: "plantask_1"}, + planTaskDoneMsg{runID: 1, taskID: "a", cardKey: "plantask_1", dispatched: true, + status: specialistCompleted, outcome: "succeeded", sessionID: "specialist_aaa", tokens: 150}, + } { + updated, _ := m.Update(msg) + m = updated.(model) + } + info, ok := m.specialists.getBySessionID("specialist_aaa") + if !ok { + t.Fatal("the task's card is missing") + } + if info.tokenCount != 150 { + t.Fatalf("tokenCount = %d, want the task's real spend", info.tokenCount) + } +} + +// The sidebar said "no active plan" while the panel below it showed one +// mid-flight. Two surfaces contradicting each other about the same session. +func TestTheSidebarDoesNotDenyARunningPlan(t *testing.T) { + m := model{now: func() time.Time { return time.Unix(1000, 0) }} + m.orchestrate.admit(diamondAdmitted(), m.now()) + m.orchestrate.markStarted("a", "root", m.now()) + + // Drives the REAL sidebar assembly, not the line helper: the helper + // returning the right string proves nothing if the PLAN section never calls + // it, which is the shape this feature keeps producing. + rendered := stripANSILines(m.renderContextSidebar(40, 30)) + if strings.Contains(rendered, "no active plan") { + t.Fatalf("the sidebar denies a plan that is running:\n%s", rendered) + } + for _, want := range []string{"diamond", "0/4", "a"} { + if !strings.Contains(rendered, want) { + t.Errorf("the sidebar does not carry %q:\n%s", want, rendered) + } + } +} + +// ...and with no plan at all it still says so. +func TestTheSidebarStillSaysWhenThereIsNoPlan(t *testing.T) { + m := model{now: func() time.Time { return time.Unix(1000, 0) }} + if !strings.Contains(stripANSILines(m.renderContextSidebar(40, 30)), "no active plan") { + t.Fatal("with no plan the sidebar must still say so") + } +} + +func stripANSILines(lines []string) string { + out := make([]string, 0, len(lines)) + for _, line := range lines { + out = append(out, ansi.Strip(line)) + } + return strings.Join(out, "\n") +} diff --git a/internal/tui/plan_messages.go b/internal/tui/plan_messages.go index c68a8f5c8..3b4e87e4d 100644 --- a/internal/tui/plan_messages.go +++ b/internal/tui/plan_messages.go @@ -58,6 +58,9 @@ type planTaskDoneMsg struct { status specialistStatus outcome string reason string + // tokens is what the task actually spent. The card omits the segment when + // it is zero rather than reporting a total nobody measured. + tokens int } // planCompletedMsg carries the plan's terminal record. diff --git a/internal/tui/plan_progress.go b/internal/tui/plan_progress.go index 245fa1963..11a12962f 100644 --- a/internal/tui/plan_progress.go +++ b/internal/tui/plan_progress.go @@ -144,7 +144,7 @@ func (bridge *PlanProgressBridge) finish(result specialist.TaskResult, status sp // own key and the handler creates the card on demand. dispatched := result.Outcome == specialist.TaskSucceeded || result.Outcome == specialist.TaskFailed taskID, sessionID, reason := result.ID, result.SessionID, result.Err - outcome := result.Outcome + outcome, tokens := result.Outcome, result.Tokens bridge.send(func(runID int) tea.Msg { return planTaskDoneMsg{ runID: runID, @@ -155,6 +155,7 @@ func (bridge *PlanProgressBridge) finish(result specialist.TaskResult, status sp status: status, outcome: string(outcome), reason: reason, + tokens: tokens, } }) } diff --git a/internal/tui/sidebar.go b/internal/tui/sidebar.go index b6ec023f1..876d4361c 100644 --- a/internal/tui/sidebar.go +++ b/internal/tui/sidebar.go @@ -631,10 +631,20 @@ func (m model) renderContextSidebar(width, height int) []string { add("") add(m.sidebarPlanHeader(width)) planLines := m.sidebarPlanLines(width) - if len(planLines) == 0 { - add(sidebarPlaceholder("no active plan", width)) - } else { + switch { + case len(planLines) > 0: lines = append(lines, planLines...) + case !m.orchestrate.isEmpty(): + // An ORCHESTRATE plan is running. Without this the sidebar said "no + // active plan" while the panel two lines below showed one mid-flight — + // the two surfaces contradicting each other about the same session. + // + // EXACTLY ONE line, like the placeholder it replaces: the FILES + // section's click offsets are computed from the lines above it, so a + // different count here would misdirect every file hit. + add(m.sidebarOrchestrateLine(width)) + default: + add(sidebarPlaceholder("no active plan", width)) } // FILES section: the files this session has touched (files_panel.go). diff --git a/internal/tui/specialist_card.go b/internal/tui/specialist_card.go index 267cbd8a4..27dd4eed3 100644 --- a/internal/tui/specialist_card.go +++ b/internal/tui/specialist_card.go @@ -96,6 +96,21 @@ func (t *specialistTracker) complete(childSessionID string, status specialistSta // incrementToolCount bumps the tool-call counter for the specialist with // childSessionID. Unknown specialists are ignored. +// setTokens records a child's token spend against its card. Plan tasks know +// theirs (TaskResult.Tokens); the Task tool does not bridge usage yet, so its +// cards stay at zero and the display omits the segment rather than showing one. +func (t *specialistTracker) setTokens(childSessionID string, tokens int) { + if tokens <= 0 { + return + } + for index := range t.specialists { + if t.specialists[index].childSessionID == childSessionID { + t.specialists[index].tokenCount = tokens + return + } + } +} + func (t *specialistTracker) incrementToolCount(childSessionID string) { for index := range t.specialists { if t.specialists[index].childSessionID == childSessionID { @@ -165,7 +180,14 @@ func specialistStatusString(s specialistStatus) string { return "completed" case specialistError: return "error" + case specialistCancelled: + // Cancelled and dependency/budget-skipped tasks landed in the default + // arm and rendered as "error", so a plan the user stopped, and every + // task skipped because something upstream failed, read as a defect. + return "cancelled" default: + // Fail closed: an unmapped status is reported as an error rather than + // quietly as something benign. return "error" } } @@ -292,7 +314,11 @@ func (m model) renderSpecialistCard(info specialistInfo, width int) string { // Body line: " status · N tool calls · M,NNN tokens". toolLabel := "tool calls" statusLabel := specialistStatusString(info.status) - if info.status == specialistError { + // Only claim an exit code when there IS one. A plan task's failure arrives + // without one, and rendering the zero value produced "error (exit code 0)" + // directly above a body saying "Subagent failed (exit 4)" — the card + // contradicting its own detail. + if info.status == specialistError && info.exitCode != 0 { statusLabel = fmt.Sprintf("error (exit code %d)", info.exitCode) } // The token total is only populated when usage was bridged from the child; omit @@ -478,7 +504,12 @@ func renderSpecialistSummary(specialists []specialistInfo, spinnerView string) s summary += "s" } } - summary += " · " + formatTokenCount(totalTokens) + " tokens" + // Omitted at zero, matching the per-card rule (M18): nothing populated + // tokenCount for a Task sub-agent, so the rollup always read "0 tokens" — + // a number that looks measured and is not. + if totalTokens > 0 { + summary += " · " + formatTokenCount(totalTokens) + " tokens" + } // summary is " " + spinnerView + " N specialists ...". The spinner sits // at byte offset 2 (after the 2-space indent), so the muted tail must skip // both the indent and the spinner's bytes to avoid splitting a multi-byte From 5620403e6d9c1c294b23b5a8f54bfb68461650c2 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:20:13 +0530 Subject: [PATCH 25/86] fix(tui): the plan budget line counts while the plan runs The footer read "budget 0/200000 tokens" for an entire run while the cards directly above it showed 84,953 spent. tokensUsed was assigned in exactly one place -- from plan_completed, which arrives when the whole plan ends -- so the one number a user watches that line for was zero for as long as it mattered. Spend now accumulates as each task finishes. The executor's own total still wins at the end, since it counts every task including any whose message the panel dropped as stale, but a plan that reports no total no longer erases what was counted. --- internal/tui/model.go | 2 +- internal/tui/orchestrate_panel.go | 15 +++++- internal/tui/orchestrate_panel_test.go | 68 +++++++++++++++++++++++--- 3 files changed, 74 insertions(+), 11 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index 3fd73aab3..86b3150c9 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -2687,7 +2687,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.runID != m.activeRunID { return m, nil } - m.orchestrate.markDone(msg.taskID, msg.outcome, m.now()) + m.orchestrate.markDone(msg.taskID, msg.outcome, msg.tokens, m.now()) if m.planRunningCardKey == msg.cardKey { m.planRunningCardKey = "" } diff --git a/internal/tui/orchestrate_panel.go b/internal/tui/orchestrate_panel.go index 159a87566..2b25b5640 100644 --- a/internal/tui/orchestrate_panel.go +++ b/internal/tui/orchestrate_panel.go @@ -142,11 +142,17 @@ func (s *orchestratePanelState) markStarted(taskID, summary string, now time.Tim s.tasks[index].startedAt = now } -func (s *orchestratePanelState) markDone(taskID, outcome string, now time.Time) { +func (s *orchestratePanelState) markDone(taskID, outcome string, tokens int, now time.Time) { index, ok := s.byID[taskID] if !ok { return } + // ACCUMULATE AS TASKS FINISH. tokensUsed was only ever assigned from the + // plan_completed event, which arrives when the whole plan ends — so the + // footer read "budget 0/200000" for the entire run while the cards above it + // showed tens of thousands of tokens spent. A budget line that reports zero + // while spending is the one number a user is watching it for. + s.tokensUsed += tokens task := &s.tasks[index] task.status = orchestrateStatusFromOutcome(outcome) task.endedAt = now @@ -177,7 +183,12 @@ func orchestrateStatusFromOutcome(outcome string) orchestrateTaskStatus { func (s *orchestratePanelState) complete(msg planCompletedMsg, now time.Time) { s.status = msg.status - s.tokensUsed = msg.tokensUsed + // The executor's total is AUTHORITATIVE and replaces what the panel + // accumulated: it counts every task, including any whose message the panel + // dropped as stale. + if msg.tokensUsed > 0 { + s.tokensUsed = msg.tokensUsed + } if msg.tokenLimit > 0 { s.tokenLimit = msg.tokenLimit } diff --git a/internal/tui/orchestrate_panel_test.go b/internal/tui/orchestrate_panel_test.go index 9bfca31d1..03dccea73 100644 --- a/internal/tui/orchestrate_panel_test.go +++ b/internal/tui/orchestrate_panel_test.go @@ -149,11 +149,11 @@ func TestPanelTracksEveryOutcome(t *testing.T) { now := m.now() m.orchestrate.markStarted("a", "root", now) - m.orchestrate.markDone("a", "succeeded", now.Add(time.Second)) + m.orchestrate.markDone("a", "succeeded", 0, now.Add(time.Second)) m.orchestrate.markStarted("b", "left", now.Add(time.Second)) - m.orchestrate.markDone("b", "failed", now.Add(2*time.Second)) - m.orchestrate.markDone("c", "cancelled", now.Add(2*time.Second)) - m.orchestrate.markDone("d", "dependency_failed", now.Add(2*time.Second)) + m.orchestrate.markDone("b", "failed", 0, now.Add(2*time.Second)) + m.orchestrate.markDone("c", "cancelled", 0, now.Add(2*time.Second)) + m.orchestrate.markDone("d", "dependency_failed", 0, now.Add(2*time.Second)) want := map[string]orchestrateTaskStatus{ "a": orchestrateDone, "b": orchestrateFailed, @@ -176,9 +176,9 @@ func TestCancelledPlanIsNotShownAsFailures(t *testing.T) { m := admittedModel(t, diamondAdmitted()) now := m.now() m.orchestrate.markStarted("a", "root", now) - m.orchestrate.markDone("a", "succeeded", now) + m.orchestrate.markDone("a", "succeeded", 0, now) for _, id := range []string{"b", "c", "d"} { - m.orchestrate.markDone(id, "cancelled", now) + m.orchestrate.markDone(id, "cancelled", 0, now) } m.orchestrate.complete(planCompletedMsg{status: "partial", succeeded: 1, cancelled: 3}, now) @@ -562,7 +562,7 @@ func TestFinishedTasksFadeOutOfThePanel(t *testing.T) { start := m.now() m.orchestrate.markStarted("a", "root", start) - m.orchestrate.markDone("a", "succeeded", start) + m.orchestrate.markDone("a", "succeeded", 0, start) m.orchestrate.markStarted("b", "left", start) // Immediately after finishing, a is still there — you have to be able to @@ -589,7 +589,7 @@ func TestAFadedTaskIsStillCountedAndStillInPlans(t *testing.T) { m := admittedModel(t, diamondAdmitted()) start := m.now() m.orchestrate.markStarted("a", "root", start) - m.orchestrate.markDone("a", "succeeded", start) + m.orchestrate.markDone("a", "succeeded", 0, start) m.now = func() time.Time { return start.Add(orchestrateTaskLinger + time.Second) } if done, _, _, _, _ := m.orchestrate.counts(); done != 1 { @@ -617,3 +617,55 @@ func TestPendingTasksNeverFade(t *testing.T) { t.Fatalf("no task has finished, so all four must still show, got %d", len(live)) } } + +// THE BUDGET LINE MUST COUNT WHILE THE PLAN RUNS. +// +// tokensUsed was assigned only from plan_completed, which arrives when the +// whole plan ends — so the footer read "budget 0/200000" for the entire run +// while the cards above it showed tens of thousands of tokens spent. Live spend +// is the one number a user watches that line for. +func TestTheBudgetLineCountsDuringTheRun(t *testing.T) { + m := admittedModel(t, diamondAdmitted()) + now := m.now() + + m.orchestrate.markStarted("a", "root", now) + m.orchestrate.markDone("a", "succeeded", 16470, now) + if m.orchestrate.tokensUsed != 16470 { + t.Fatalf("tokensUsed = %d after one task, want it counted as it finishes", m.orchestrate.tokensUsed) + } + m.orchestrate.markStarted("b", "left", now) + m.orchestrate.markDone("b", "succeeded", 68483, now) + + rendered := m.renderOrchestratePanel(100) + if !strings.Contains(rendered, "budget 84953/100000 tokens") { + t.Fatalf("the footer must report live spend mid-run:\n%s", rendered) + } +} + +// The executor's total is authoritative and replaces what the panel +// accumulated: it counts every task, including any whose message the panel +// dropped as stale. +func TestThePlansOwnTotalWinsAtTheEnd(t *testing.T) { + m := admittedModel(t, diamondAdmitted()) + now := m.now() + m.orchestrate.markStarted("a", "root", now) + m.orchestrate.markDone("a", "succeeded", 100, now) + + m.orchestrate.complete(planCompletedMsg{status: "partial", tokensUsed: 379773}, now) + if m.orchestrate.tokensUsed != 379773 { + t.Fatalf("tokensUsed = %d, want the executor's authoritative total", m.orchestrate.tokensUsed) + } +} + +// ...but a plan that reports no total does not erase what was counted. +func TestAMissingFinalTotalDoesNotZeroTheCount(t *testing.T) { + m := admittedModel(t, diamondAdmitted()) + now := m.now() + m.orchestrate.markStarted("a", "root", now) + m.orchestrate.markDone("a", "succeeded", 4242, now) + + m.orchestrate.complete(planCompletedMsg{status: "completed"}, now) + if m.orchestrate.tokensUsed != 4242 { + t.Fatalf("tokensUsed = %d, want the accumulated count kept when no total arrives", m.orchestrate.tokensUsed) + } +} From f9ecde00a542919478e1260f096e30f0b41e7fd2 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:36:03 +0530 Subject: [PATCH 26/86] =?UTF-8?q?feat(tui):=20the=20plan=20detail=20view?= =?UTF-8?q?=20=E2=80=94=20phases=20left,=20live=20agent=20detail=20right?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pressing the PLAN header opens a two-pane drill-in: every phase down the left, the selected phase's live agent state on the right — status, elapsed, dependencies, tool count, spend, its prompt, what it is doing right now, and where it ended up. Up/down moves, enter drills into the child session, esc closes. An OVERLAY, in the same slot the help and picker overlays use, so it composites over the chat rather than replacing it. Nothing about the transcript, footer or composer changes, and it renders nothing when closed or when no plan has been admitted — which is what keeps a posture-off session unchanged. No separate posture gate: a plan only exists under the posture, so without one there is no PLAN line to press. The panel's own expand/collapse stays on ctrl+g, for a glance without leaving the conversation. Opening lands on the task worth reading: the one running, else the first that failed, else the first task. The selection clamps rather than wraps. Enter is offered ONLY when the selected task has a child session to open. A running task is still keyed by a temporary id, and a hint for a key that does nothing trains the user to ignore hints. The phase list marks the task in flight. Running and pending both fell into the default glyph, so the list could not show what was actually happening. Prompts are SUMMARISED and say when they were cut; the full text stays in the tool output. A task that never started shows what it is waiting on rather than a row of zeroes. Eight mutations, all caught. Two needed the tests fixed first: the running-task assertion compared whole rows, which differ by the selection marker anyway, so removing the in-flight glyph passed; and nothing drove the click path at all. --- internal/tui/model.go | 41 ++- internal/tui/mouse.go | 6 +- internal/tui/orchestrate_detail.go | 281 ++++++++++++++++++++ internal/tui/orchestrate_detail_test.go | 300 ++++++++++++++++++++++ internal/tui/orchestrate_panel.go | 20 +- internal/tui/orchestrate_panel_test.go | 32 +-- internal/tui/plan_card_regression_test.go | 2 +- 7 files changed, 660 insertions(+), 22 deletions(-) create mode 100644 internal/tui/orchestrate_detail.go create mode 100644 internal/tui/orchestrate_detail_test.go diff --git a/internal/tui/model.go b/internal/tui/model.go index 86b3150c9..72f5c508b 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -204,6 +204,10 @@ type model struct { transcript []transcriptRow transcriptDetailed bool helpOverlay bool // the `?` keyboard-shortcut overlay is open + // orchestrateDetail is the plan drill-in: phases left, the selected task's + // live agent detail right. Opened by pressing the PLAN header. + orchestrateDetail bool + orchestrateSelected int // leaderHelpOverlay is the Ctrl+X ? modal listing every leader slash chord. leaderHelpOverlay bool // leaderPending is true after Ctrl+X until a second key, Esc, or timeout @@ -1401,6 +1405,31 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { } // The `?` help overlay is modal: `?`, Esc, q, or Enter close it; every // other key is swallowed so nothing types into the hidden composer. + // The plan detail view owns esc and the arrows while it is open, so a + // keypress meant for it never falls through to the transcript. + if m.orchestrateDetailOpen() { + switch { + case keyIs(msg, tea.KeyEscape): + m.orchestrateDetail = false + return m, nil + case keyIs(msg, tea.KeyUp): + return m.moveOrchestrateSelection(-1), nil + case keyIs(msg, tea.KeyDown): + return m.moveOrchestrateSelection(1), nil + case keyIs(msg, tea.KeyEnter): + // Drill into the task's child session, reusing the same subchat + // the specialist cards open. Only when there IS one — a running + // task is still keyed by a temporary id. + if session := m.selectedTaskSession(); session != "" { + m.orchestrateDetail = false + if errMsg := m.subchat.enter(m.sessionStore, session, m.orchestrate.tasks[m.orchestrateSelected].id, m.chatScrollOffset); errMsg != "" { + m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: errMsg}) + } + return m, nil + } + return m, nil + } + } if m.helpOverlay { if keyText(msg) == "?" || keyText(msg) == "q" || keyIs(msg, tea.KeyEsc) || keyIs(msg, tea.KeyEnter) || keyCtrl(msg, 'c') { m.helpOverlay = false @@ -2675,7 +2704,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.runID != m.activeRunID { return m, nil } - m.orchestrate.markStarted(msg.taskID, msg.summary, m.now()) + m.orchestrate.markStarted(msg.taskID, msg.summary, msg.cardKey, m.now()) m.specialists.start(msg.taskID, msg.summary, msg.cardKey, m.now()) // Remember which task is in flight so the plan's progress events — which // arrive keyed by the ORCHESTRATE tool call, not by task — can be @@ -2705,6 +2734,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m.specialists.reconcileSessionID(cardKey, msg.sessionID) cardKey = msg.sessionID } + m.orchestrate.linkCard(msg.taskID, cardKey) if info, ok := m.specialists.getBySessionID(cardKey); ok { m.transcript = appendTranscriptRow(m.transcript, transcriptRow{ kind: rowSpecialist, @@ -2970,6 +3000,7 @@ func (m model) transcriptView() string { leaderHelpOverlayContent = m.renderLeaderHelpOverlay(width) } + orchestrateOverlay := m.renderOrchestrateDetailOverlay(width) suggestionOverlay := m.suggestionOverlay(width) providerOverlay := m.providerWizardOverlay(width) mcpAddOverlay := m.mcpAddWizardOverlay(width) @@ -2980,6 +3011,8 @@ func (m model) transcriptView() string { switch { case sttKeyOverlay != "": viewportOverlay = sttKeyOverlay + case orchestrateOverlay != "": + viewportOverlay = orchestrateOverlay case helpOverlayContent != "": viewportOverlay = helpOverlayContent case leaderHelpOverlayContent != "": @@ -3038,10 +3071,16 @@ func (m model) twoColumnTranscriptView() string { width := chatW + orchestrateOverlay := m.renderOrchestrateDetailOverlay(width) suggestionOverlay := m.suggestionOverlay(width) bodyItems := m.transcriptBodyItems(width, "", false) footer := m.footerView(width) + // The plan detail view wins over the suggestion popup: it is a deliberate + // drill-in, and the composer is not what the user is looking at. overlayForViewport := suggestionOverlay + if orchestrateOverlay != "" { + overlayForViewport = orchestrateOverlay + } if m.transcriptEmpty() && !m.pending { overlayForViewport = "" } diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go index e6a29be0c..de9b0b367 100644 --- a/internal/tui/mouse.go +++ b/internal/tui/mouse.go @@ -91,8 +91,10 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { // before the surface switch below because the panel lives in the FOOTER, // outside every transcript/sidebar region those cases test. if mouseLeftPress(msg) && m.clickedOrchestrateHeader(msg) { - m.orchestrate.expanded = !m.orchestrate.expanded - return m, nil + // Pressing PLAN opens the drill-in — phases left, the selected task's + // live agent detail right. The panel's own expand/collapse stays on + // ctrl+g for a quick look without leaving the conversation. + return m.openOrchestrateDetail(), nil } if mouseLeftPress(msg) { switch { diff --git a/internal/tui/orchestrate_detail.go b/internal/tui/orchestrate_detail.go new file mode 100644 index 000000000..19017fafd --- /dev/null +++ b/internal/tui/orchestrate_detail.go @@ -0,0 +1,281 @@ +package tui + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" +) + +// The plan detail view: the plan's phases down the left, the selected phase's +// live agent detail on the right. +// +// Opened by pressing the PLAN header, closed with Esc. It is an OVERLAY, using +// the same slot the help and picker overlays use, so it composites over the +// chat rather than replacing the view — the conversation stays visible behind +// it and nothing about the transcript, footer or composer changes. +// +// It renders NOTHING when no plan has been admitted, which is what keeps a +// posture-off session unchanged. No separate posture gate is needed: a plan +// only exists under the posture, so there is no PLAN line to press without one. + +const ( + // orchestrateDetailPhaseWidth is the left column. Wide enough for a task id + // and its status glyph, narrow enough to leave the detail room. + orchestrateDetailPhaseWidth = 22 + // orchestrateDetailMinWidth is the total below which the two-column layout + // stops making sense and the detail alone is shown. + orchestrateDetailMinWidth = 56 +) + +// orchestrateDetailOpen reports whether the view should render. +func (m model) orchestrateDetailOpen() bool { + return m.orchestrateDetail && !m.orchestrate.isEmpty() +} + +// openOrchestrateDetail opens the view on the task most worth looking at: the +// one running, else the first that failed, else the first task. +func (m model) openOrchestrateDetail() model { + if m.orchestrate.isEmpty() { + return m + } + m.orchestrateDetail = true + m.orchestrateSelected = m.orchestrate.defaultSelection() + return m +} + +// defaultSelection picks the task a user opening the view wants to see first. +func (s orchestratePanelState) defaultSelection() int { + for index, task := range s.tasks { + if task.status == orchestrateRunning { + return index + } + } + for index, task := range s.tasks { + if task.status == orchestrateFailed { + return index + } + } + return 0 +} + +// moveOrchestrateSelection walks the phase list, clamped at both ends so the +// selection never wraps onto an unrelated task. +func (m model) moveOrchestrateSelection(delta int) model { + if len(m.orchestrate.tasks) == 0 { + return m + } + next := m.orchestrateSelected + delta + if next < 0 { + next = 0 + } + if next >= len(m.orchestrate.tasks) { + next = len(m.orchestrate.tasks) - 1 + } + m.orchestrateSelected = next + return m +} + +// renderOrchestrateDetailOverlay draws the whole view, or "" when it is closed. +func (m model) renderOrchestrateDetailOverlay(width int) string { + if !m.orchestrateDetailOpen() { + return "" + } + if width < orchestrateDetailMinWidth { + // Too narrow for two columns: the detail is what the user came for, so + // the phase list is what goes. + return m.orchestrateDetailBox(width, m.detailPaneLines(width-4)) + } + + phaseWidth := orchestrateDetailPhaseWidth + detailWidth := width - phaseWidth - 7 + + phases := strings.Join(m.phasePaneLines(phaseWidth), "\n") + detail := strings.Join(m.detailPaneLines(detailWidth), "\n") + + columns := lipgloss.JoinHorizontal(lipgloss.Top, + lipgloss.NewStyle().Width(phaseWidth).Render(phases), + lipgloss.NewStyle().Width(2).Render(""), + lipgloss.NewStyle().Width(detailWidth).Render(detail), + ) + return m.orchestrateDetailBox(width, strings.Split(columns, "\n")) +} + +// orchestrateDetailBox wraps the content in the plan's title and footer hint. +func (m model) orchestrateDetailBox(width int, body []string) string { + state := m.orchestrate + title := strings.TrimSpace(state.name) + if title == "" { + title = "plan" + } + + var b strings.Builder + b.WriteString(zeroTheme.accent.Render(truncateRunes(title, maxInt(1, width-2)))) + done, failed, skipped, cancelled, _ := state.counts() + subtitle := fmt.Sprintf("%d/%d done", done, len(state.tasks)) + if failed > 0 { + subtitle += fmt.Sprintf(" · %d failed", failed) + } + if skipped > 0 { + subtitle += fmt.Sprintf(" · %d skipped", skipped) + } + if cancelled > 0 { + subtitle += fmt.Sprintf(" · %d cancelled", cancelled) + } + if state.tokensUsed > 0 { + subtitle += fmt.Sprintf(" · %d tokens", state.tokensUsed) + } + b.WriteString("\n") + b.WriteString(zeroTheme.faint.Render(truncateRunes(subtitle, maxInt(1, width-2)))) + b.WriteString("\n\n") + b.WriteString(strings.Join(body, "\n")) + b.WriteString("\n\n") + hint := "↑/↓ phase esc close" + if m.selectedTaskSession() != "" { + // Only offered when the selected task HAS a child session to open. A + // hint for a key that does nothing trains the user to ignore hints. + hint = "↑/↓ phase enter open agent esc close" + } + b.WriteString(zeroTheme.faint.Render(hint)) + return b.String() +} + +// selectedTaskSession returns the selected task's child session id, or "" when +// it has none yet. A running task is still keyed by its temporary card key, +// which is not a session and cannot be opened. +func (m model) selectedTaskSession() string { + if m.orchestrateSelected < 0 || m.orchestrateSelected >= len(m.orchestrate.tasks) { + return "" + } + key := m.orchestrate.tasks[m.orchestrateSelected].cardKey + if strings.HasPrefix(key, "specialist_") { + return key + } + return "" +} + +// phasePaneLines is the left column: every task, numbered, with the selected +// one marked. Numbered rather than indented — this is a list to navigate, and +// the dependency shape is on the panel and in /plans. +func (m model) phasePaneLines(width int) []string { + lines := []string{zeroTheme.muted.Render("Phases")} + for index, task := range m.orchestrate.tasks { + marker := " " + style := zeroTheme.faint + if index == m.orchestrateSelected { + marker = "❯ " + style = zeroTheme.ink + } + label := fmt.Sprintf("%s%d %s", marker, index+1, task.id) + row := style.Render(truncateRunes(label, maxInt(1, width-6))) + glyph := orchestrateGlyph(task.status) + if task.status == orchestrateRunning { + // The panel spins here; a static list should still distinguish the + // task in flight from one that has not started, which the default + // pending dot did not. + glyph = zeroTheme.accent.Render("▸") + } + lines = append(lines, row+" "+glyph) + } + return lines +} + +// detailPaneLines is the right column: what the selected task is doing. +func (m model) detailPaneLines(width int) []string { + if m.orchestrateSelected < 0 || m.orchestrateSelected >= len(m.orchestrate.tasks) { + return []string{zeroTheme.faint.Render("no task selected")} + } + task := m.orchestrate.tasks[m.orchestrateSelected] + now := m.orchestrateNow() + + lines := []string{zeroTheme.ink.Render(truncateRunes(task.id, maxInt(1, width)))} + + status := task.status.label() + if elapsed := task.elapsed(now); elapsed > 0 { + status += " · " + formatElapsedSeconds(elapsed) + } + if len(task.dependsOn) > 0 { + status += " · after " + strings.Join(task.dependsOn, ", ") + } + lines = append(lines, zeroTheme.faint.Render(truncateRunes(status, maxInt(1, width)))) + + // Live agent state, read from the specialist tracker through the task's + // card key. Absent for a task that never started, which is the honest + // answer rather than a row of zeroes. + info, hasCard := m.specialists.getBySessionID(task.cardKey) + if hasCard { + meta := fmt.Sprintf("%d tool calls", info.toolCount) + if info.toolCount == 1 { + meta = "1 tool call" + } + if info.tokenCount > 0 { + meta += fmt.Sprintf(" · %s tokens", formatTokenCount(info.tokenCount)) + } + lines = append(lines, zeroTheme.faint.Render(truncateRunes(meta, maxInt(1, width)))) + } + + if summary := strings.TrimSpace(task.summary); summary != "" { + lines = append(lines, "", zeroTheme.muted.Render("Prompt")) + lines = append(lines, wrapToWidth(summary, width, 3)...) + } + + if hasCard && strings.TrimSpace(info.currentTool) != "" { + lines = append(lines, "", zeroTheme.muted.Render("Activity")) + activity := info.currentTool + if detail := strings.TrimSpace(info.currentDetail); detail != "" { + activity += "(" + detail + ")" + } + lines = append(lines, " "+zeroTheme.faint.Render(truncateRunes(activity, maxInt(1, width-2)))) + } + + lines = append(lines, "", zeroTheme.muted.Render("Outcome")) + lines = append(lines, " "+zeroTheme.faint.Render(truncateRunes(m.orchestrateOutcomeLine(task, hasCard, info), maxInt(1, width-2)))) + return lines +} + +// orchestrateOutcomeLine states where the task ended up, in one line. +func (m model) orchestrateOutcomeLine(task orchestrateTask, hasCard bool, info specialistInfo) string { + switch task.status { + case orchestrateRunning: + return "still running…" + case orchestratePending: + return "waiting on " + strings.Join(task.dependsOn, ", ") + case orchestrateDone: + return "completed" + } + // Failed, skipped or cancelled: the reason is what matters, and the card's + // error text is more specific than the status word. + if hasCard && strings.TrimSpace(info.errorMsg) != "" { + return firstLineOf(info.errorMsg) + } + return task.status.label() +} + +func firstLineOf(text string) string { + if index := strings.IndexAny(text, "\r\n"); index >= 0 { + return strings.TrimSpace(text[:index]) + } + return strings.TrimSpace(text) +} + +// wrapToWidth breaks text onto at most maxLines lines of the given width, +// cutting on RUNE boundaries and saying when it truncated. +func wrapToWidth(text string, width, maxLines int) []string { + width = maxInt(4, width-2) + runes := []rune(strings.Join(strings.Fields(text), " ")) + var lines []string + for len(runes) > 0 && len(lines) < maxLines { + take := width + if take > len(runes) { + take = len(runes) + } + lines = append(lines, " "+zeroTheme.faint.Render(string(runes[:take]))) + runes = runes[take:] + } + if len(runes) > 0 && len(lines) > 0 { + // Say that it was cut. A silently truncated prompt reads as the whole + // prompt, and the full text is in the tool output either way. + lines = append(lines, " "+zeroTheme.faint.Render("…")) + } + return lines +} diff --git a/internal/tui/orchestrate_detail_test.go b/internal/tui/orchestrate_detail_test.go new file mode 100644 index 000000000..9c98a2dd0 --- /dev/null +++ b/internal/tui/orchestrate_detail_test.go @@ -0,0 +1,300 @@ +package tui + +import ( + "context" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" +) + +func detailModel(t *testing.T) model { + t.Helper() + m := admittedModel(t, diamondAdmitted()) + now := m.now() + m.orchestrate.markStarted("a", "survey the packages", "plantask_1", now) + m.orchestrate.markDone("a", "succeeded", 16470, now.Add(6*time.Second)) + m.orchestrate.linkCard("a", "specialist_aaa") + m.specialists.start("a", "survey the packages", "specialist_aaa", now) + m.specialists.complete("specialist_aaa", specialistCompleted, 0, "", now) + m.specialists.setTokens("specialist_aaa", 16470) + + m.orchestrate.markStarted("b", "read the left branch", "plantask_2", now.Add(6*time.Second)) + m.specialists.start("b", "read the left branch", "plantask_2", now) + m.specialists.incrementToolCount("plantask_2") + m.specialists.setCurrentTool("plantask_2", "read_file", "internal/agent/loop.go") + m.orchestrate.linkCard("b", "plantask_2") + return m +} + +// THE MOUNT-POINT INVARIANT. Closed, and with no plan at all, the view +// contributes nothing — which is what keeps a posture-off session unchanged. +func TestTheDetailViewRendersNothingWhenClosed(t *testing.T) { + m := detailModel(t) + if got := m.renderOrchestrateDetailOverlay(120); got != "" { + t.Fatalf("a closed view must render nothing, got %q", got) + } + + empty := model{now: func() time.Time { return time.Unix(1000, 0) }} + empty.orchestrateDetail = true + if got := empty.renderOrchestrateDetailOverlay(120); got != "" { + t.Fatalf("with no plan there is nothing to open, got %q", got) + } +} + +// Opening lands on the task worth looking at: the one running. +func TestOpeningSelectsTheRunningTask(t *testing.T) { + m := detailModel(t).openOrchestrateDetail() + if !m.orchestrateDetailOpen() { + t.Fatal("the view did not open") + } + if got := m.orchestrate.tasks[m.orchestrateSelected].id; got != "b" { + t.Fatalf("selected %q, want the running task", got) + } +} + +// ...and on the first failure when nothing is running, since that is what a +// user opens the view to read. +func TestOpeningSelectsTheFirstFailureWhenIdle(t *testing.T) { + m := admittedModel(t, diamondAdmitted()) + now := m.now() + m.orchestrate.markStarted("a", "root", "k1", now) + m.orchestrate.markDone("a", "succeeded", 0, now) + m.orchestrate.markStarted("b", "left", "k2", now) + m.orchestrate.markDone("b", "failed", 0, now) + + m = m.openOrchestrateDetail() + if got := m.orchestrate.tasks[m.orchestrateSelected].id; got != "b" { + t.Fatalf("selected %q, want the failed task", got) + } +} + +// Both panes render: the phase list on the left, the selected task's live +// detail on the right. +func TestTheViewShowsPhasesAndTheSelectedTasksDetail(t *testing.T) { + m := detailModel(t).openOrchestrateDetail() + rendered := ansi.Strip(m.renderOrchestrateDetailOverlay(120)) + + if !strings.Contains(rendered, "diamond") { + t.Fatalf("the plan's name is missing:\n%s", rendered) + } + for _, id := range []string{"a", "b", "c", "d"} { + if !strings.Contains(rendered, id) { + t.Fatalf("phase %q missing from the list:\n%s", id, rendered) + } + } + // The running task's live agent state. + for _, want := range []string{"Prompt", "read the left branch", "Activity", "read_file", "Outcome", "still running"} { + if !strings.Contains(rendered, want) { + t.Errorf("the detail pane does not show %q:\n%s", want, rendered) + } + } + if !strings.Contains(rendered, "1 tool call") { + t.Errorf("the detail pane does not show the live tool count:\n%s", rendered) + } +} + +// Moving the selection changes what the right pane shows — the whole point of +// the two panes. +func TestMovingTheSelectionChangesTheDetail(t *testing.T) { + m := detailModel(t).openOrchestrateDetail() + before := ansi.Strip(m.renderOrchestrateDetailOverlay(120)) + + m = m.moveOrchestrateSelection(-1) + after := ansi.Strip(m.renderOrchestrateDetailOverlay(120)) + if before == after { + t.Fatal("moving the selection did not change the detail pane") + } + if !strings.Contains(after, "survey the packages") { + t.Fatalf("the pane does not show the newly selected task:\n%s", after) + } + if !strings.Contains(after, "16,470 tokens") && !strings.Contains(after, "16470 tokens") { + t.Fatalf("the pane does not show the selected task's spend:\n%s", after) + } +} + +// The selection clamps at both ends rather than wrapping onto an unrelated +// task. +func TestTheSelectionClampsAtBothEnds(t *testing.T) { + m := detailModel(t).openOrchestrateDetail() + for range 10 { + m = m.moveOrchestrateSelection(-1) + } + if m.orchestrateSelected != 0 { + t.Fatalf("selection = %d, want it clamped at the first task", m.orchestrateSelected) + } + for range 20 { + m = m.moveOrchestrateSelection(1) + } + if want := len(m.orchestrate.tasks) - 1; m.orchestrateSelected != want { + t.Fatalf("selection = %d, want it clamped at %d", m.orchestrateSelected, want) + } +} + +// Esc closes it and the arrows move within it, through the real key handler — +// a keypress meant for the view must never fall through to the transcript. +func TestTheViewOwnsItsKeysWhileOpen(t *testing.T) { + m := detailModel(t).openOrchestrateDetail() + m.width, m.height = 120, 40 + + press := func(m model, key tea.KeyMsg) model { + t.Helper() + updated, _ := m.Update(key) + return updated.(model) + } + + selected := m.orchestrateSelected + m = press(m, tea.KeyPressMsg{Code: tea.KeyUp}) + if m.orchestrateSelected == selected { + t.Fatal("up did not move the selection") + } + m = press(m, tea.KeyPressMsg{Code: tea.KeyDown}) + if m.orchestrateSelected != selected { + t.Fatal("down did not move the selection back") + } + m = press(m, tea.KeyPressMsg{Code: tea.KeyEscape}) + if m.orchestrateDetailOpen() { + t.Fatal("esc did not close the view") + } +} + +// A task that never started has no agent card, so the pane says what it knows +// rather than showing a row of zeroes. +func TestAPendingTaskShowsWhatItIsWaitingOn(t *testing.T) { + m := detailModel(t).openOrchestrateDetail() + for m.orchestrate.tasks[m.orchestrateSelected].id != "d" { + m = m.moveOrchestrateSelection(1) + } + rendered := ansi.Strip(m.renderOrchestrateDetailOverlay(120)) + if !strings.Contains(rendered, "waiting on b, c") { + t.Fatalf("a pending task must say what it is waiting on:\n%s", rendered) + } + if strings.Contains(rendered, "0 tool calls") { + t.Fatalf("a task that never started has no tool count to report:\n%s", rendered) + } +} + +// Narrow terminals drop the phase list rather than crushing both columns, and +// never cut mid-rune. +func TestTheViewSurvivesNarrowWidths(t *testing.T) { + m := detailModel(t).openOrchestrateDetail() + for _, width := range []int{20, 40, 55, 56, 80, 200} { + rendered := m.renderOrchestrateDetailOverlay(width) + if rendered == "" { + t.Fatalf("width %d rendered nothing", width) + } + if strings.Contains(rendered, "\ufffd") { + t.Fatalf("width %d cut mid-rune:\n%s", width, rendered) + } + } +} + +// A long prompt is summarised and says it was cut. The full text stays in the +// tool output — a display surface is never the data path. +func TestALongPromptIsTruncatedAndSaysSo(t *testing.T) { + m := admittedModel(t, diamondAdmitted()) + m.orchestrate.markStarted("a", strings.Repeat("verbose ", 200), "k", m.now()) + m = m.openOrchestrateDetail() + + rendered := ansi.Strip(m.renderOrchestrateDetailOverlay(100)) + if !strings.Contains(rendered, "…") { + t.Fatalf("a truncated prompt must say so:\n%s", rendered) + } + if strings.Count(rendered, "verbose") > 60 { + t.Fatalf("the prompt was dumped rather than summarised:\n%s", rendered) + } +} + +// Enter is only advertised when it does something. A hint for a key that is a +// no-op trains the user to ignore hints. +func TestEnterIsOnlyOfferedWhenThereIsASessionToOpen(t *testing.T) { + m := detailModel(t).openOrchestrateDetail() // selects "b", still running + if got := m.selectedTaskSession(); got != "" { + t.Fatalf("a running task has no child session yet, got %q", got) + } + running := ansi.Strip(m.renderOrchestrateDetailOverlay(120)) + if strings.Contains(running, "enter open agent") { + t.Fatalf("enter is offered for a task with no session to open:\n%s", running) + } + + m = m.moveOrchestrateSelection(-1) // "a", finished, has a real session + if got := m.selectedTaskSession(); got != "specialist_aaa" { + t.Fatalf("selectedTaskSession = %q, want the child's real session id", got) + } + finished := ansi.Strip(m.renderOrchestrateDetailOverlay(120)) + if !strings.Contains(finished, "enter open agent") { + t.Fatalf("a task with a session must offer to open it:\n%s", finished) + } +} + +// The running task is distinguishable from one that has not started. Both fell +// into the pending glyph, so the phase list could not show what was in flight. +func TestThePhaseListMarksTheRunningTask(t *testing.T) { + m := detailModel(t).openOrchestrateDetail() + rendered := ansi.Strip(m.renderOrchestrateDetailOverlay(120)) + var runningRow, pendingRow string + for _, line := range strings.Split(rendered, "\n") { + if strings.Contains(line, " 2 b") { + runningRow = line + } + if strings.Contains(line, " 3 c") { + pendingRow = line + } + } + if runningRow == "" || pendingRow == "" { + t.Fatalf("phase rows missing:\n%s", rendered) + } + // Assert on the MARKER, not on the whole row: the columns are joined + // horizontally, so every line also carries the detail pane, and comparing + // rows let the running marker be removed without the test noticing. + phaseOnly := func(line string) string { + if index := strings.Index(line, " "); index > 0 { + return line[:index+1] + } + return line + } + if !strings.Contains(phaseOnly(runningRow), "▸") { + t.Fatalf("the running task carries no in-flight marker:\n%s", rendered) + } + if strings.Contains(phaseOnly(pendingRow), "▸") { + t.Fatalf("a pending task must not carry the in-flight marker:\n%s", rendered) + } +} + +// Pressing the PLAN header opens the view — the interaction that was asked for. +func TestClickingPlanOpensTheDetailView(t *testing.T) { + m := newModel(context.Background(), Options{}) + m.width, m.height = 120, 40 + m.altScreen = true + m.activeRunID = 1 + updated, _ := m.Update(diamondAdmitted()) + m = updated.(model) + + if m.orchestrateDetailOpen() { + t.Fatal("the view must start closed") + } + + // Locate the header row in the rendered footer and click it, rather than + // guessing a coordinate: the footer's height varies with the composer and + // the pinned plan panel. + frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(m.chatColumnWidth()), m.footerView(m.chatColumnWidth())) + row := -1 + for index, line := range frame.footerLines { + if strings.Contains(ansi.Strip(line), "PLAN diamond") { + row = index + break + } + } + if row < 0 { + t.Fatal("the plan header is not in the footer") + } + + click := tea.MouseClickMsg{X: frame.footerRect.x + 2, Y: frame.footerRect.y + row, Button: tea.MouseLeft} + updated, _ = m.Update(click) + m = updated.(model) + if !m.orchestrateDetailOpen() { + t.Fatal("clicking the PLAN header did not open the detail view") + } +} diff --git a/internal/tui/orchestrate_panel.go b/internal/tui/orchestrate_panel.go index 2b25b5640..0d965f6ee 100644 --- a/internal/tui/orchestrate_panel.go +++ b/internal/tui/orchestrate_panel.go @@ -52,6 +52,11 @@ type orchestrateTask struct { // makes a diamond LOOK like a diamond — tasks at the same depth ran from the // same fan-out and are drawn on the same rung. depth int + // cardKey links this task to its live agent state in the specialist + // tracker: the temporary key while it runs, the child's real session id + // once it finishes. The detail pane reads tool counts, the current tool and + // spend through it rather than duplicating that state here. + cardKey string } // elapsed is the task's duration: live while running, frozen once ended. @@ -132,7 +137,7 @@ func (s *orchestratePanelState) computeDepths() { } } -func (s *orchestratePanelState) markStarted(taskID, summary string, now time.Time) { +func (s *orchestratePanelState) markStarted(taskID, summary, cardKey string, now time.Time) { index, ok := s.byID[taskID] if !ok { return @@ -140,6 +145,17 @@ func (s *orchestratePanelState) markStarted(taskID, summary string, now time.Tim s.tasks[index].status = orchestrateRunning s.tasks[index].summary = summary s.tasks[index].startedAt = now + if cardKey != "" { + s.tasks[index].cardKey = cardKey + } +} + +// linkCard repoints a task at its child's real session id, so the detail pane +// keeps finding it after the tracker reconciles the temporary key. +func (s *orchestratePanelState) linkCard(taskID, cardKey string) { + if index, ok := s.byID[taskID]; ok && cardKey != "" { + s.tasks[index].cardKey = cardKey + } } func (s *orchestratePanelState) markDone(taskID, outcome string, tokens int, now time.Time) { @@ -352,7 +368,7 @@ func orchestrateHeaderLine(state orchestratePanelState, now time.Time) string { } if !state.expanded { b.WriteString(" ") - b.WriteString("click or ctrl+g to expand") + b.WriteString("click to open · ctrl+g to expand") } return b.String() } diff --git a/internal/tui/orchestrate_panel_test.go b/internal/tui/orchestrate_panel_test.go index 03dccea73..2c11b9a9b 100644 --- a/internal/tui/orchestrate_panel_test.go +++ b/internal/tui/orchestrate_panel_test.go @@ -148,9 +148,9 @@ func TestPanelTracksEveryOutcome(t *testing.T) { m := admittedModel(t, diamondAdmitted()) now := m.now() - m.orchestrate.markStarted("a", "root", now) + m.orchestrate.markStarted("a", "root", "", now) m.orchestrate.markDone("a", "succeeded", 0, now.Add(time.Second)) - m.orchestrate.markStarted("b", "left", now.Add(time.Second)) + m.orchestrate.markStarted("b", "left", "", now.Add(time.Second)) m.orchestrate.markDone("b", "failed", 0, now.Add(2*time.Second)) m.orchestrate.markDone("c", "cancelled", 0, now.Add(2*time.Second)) m.orchestrate.markDone("d", "dependency_failed", 0, now.Add(2*time.Second)) @@ -175,7 +175,7 @@ func TestPanelTracksEveryOutcome(t *testing.T) { func TestCancelledPlanIsNotShownAsFailures(t *testing.T) { m := admittedModel(t, diamondAdmitted()) now := m.now() - m.orchestrate.markStarted("a", "root", now) + m.orchestrate.markStarted("a", "root", "", now) m.orchestrate.markDone("a", "succeeded", 0, now) for _, id := range []string{"b", "c", "d"} { m.orchestrate.markDone(id, "cancelled", 0, now) @@ -229,7 +229,7 @@ func TestALargePlanIsBoundedAndSaysWhatItHid(t *testing.T) { // Narrow terminals must not panic or produce garbage. func TestPanelSurvivesNarrowWidths(t *testing.T) { m := admittedModel(t, diamondAdmitted()) - m.orchestrate.markStarted("a", strings.Repeat("verbose ", 40), m.now()) + m.orchestrate.markStarted("a", strings.Repeat("verbose ", 40), "", m.now()) for _, width := range []int{0, 1, 10, 24, 40, 200} { rendered := m.renderOrchestratePanel(width) if rendered == "" { @@ -246,7 +246,7 @@ func TestPanelSurvivesNarrowWidths(t *testing.T) { // A task with no output must still render — the panel shows status, not results. func TestATaskWithNoSummaryStillRenders(t *testing.T) { m := admittedModel(t, diamondAdmitted()) - m.orchestrate.markStarted("a", "", m.now()) + m.orchestrate.markStarted("a", "", "", m.now()) rendered := m.renderOrchestratePanel(100) if !strings.Contains(rendered, " a ") { t.Fatalf("a task with no summary vanished from the panel:\n%s", rendered) @@ -258,7 +258,7 @@ func TestATaskWithNoSummaryStillRenders(t *testing.T) { // runes. func TestPanelTruncatesSummariesOnRuneBoundaries(t *testing.T) { m := admittedModel(t, diamondAdmitted()) - m.orchestrate.markStarted("a", strings.Repeat("日", 300), m.now()) + m.orchestrate.markStarted("a", strings.Repeat("日", 300), "", m.now()) rendered := m.renderOrchestratePanel(100) for _, line := range strings.Split(rendered, "\n") { if len([]rune(line)) > 140 { @@ -308,7 +308,7 @@ func TestTheHeaderClockStopsWhenThePlanEnds(t *testing.T) { func TestAnInterruptedPlansClockFreezesWithTheRun(t *testing.T) { m := admittedModel(t, diamondAdmitted()) start := m.now() - m.orchestrate.markStarted("a", "root", start) + m.orchestrate.markStarted("a", "root", "", start) m.orchestrate.frozenAt = start.Add(5 * time.Second) m.activeRunID = 0 @@ -402,7 +402,7 @@ func TestOrchestratePanelMountsInTheFooter(t *testing.T) { if !strings.Contains(collapsed, "PLAN diamond") { t.Fatalf("the collapsed panel must still show the plan header:\n%s", collapsed) } - if strings.Contains(collapsed, "ctrl+g") == false { + if strings.Contains(collapsed, "click to open") == false { t.Fatalf("the collapsed panel must say how to open it:\n%s", collapsed) } m.orchestrate.expanded = true @@ -450,7 +450,7 @@ func TestThePanelIsCollapsedUntilAsked(t *testing.T) { if !strings.Contains(collapsed, "PLAN diamond") { t.Fatalf("the collapsed panel must still name the plan:\n%s", collapsed) } - if !strings.Contains(collapsed, "ctrl+g") { + if !strings.Contains(collapsed, "click to open") { t.Fatalf("a collapsed panel that does not say how to open it reads as the whole panel:\n%s", collapsed) } for _, id := range []string{" b ", " c ", " d "} { @@ -561,9 +561,9 @@ func TestFinishedTasksFadeOutOfThePanel(t *testing.T) { m := admittedModel(t, diamondAdmitted()) start := m.now() - m.orchestrate.markStarted("a", "root", start) + m.orchestrate.markStarted("a", "root", "", start) m.orchestrate.markDone("a", "succeeded", 0, start) - m.orchestrate.markStarted("b", "left", start) + m.orchestrate.markStarted("b", "left", "", start) // Immediately after finishing, a is still there — you have to be able to // SEE it land. @@ -588,7 +588,7 @@ func TestFinishedTasksFadeOutOfThePanel(t *testing.T) { func TestAFadedTaskIsStillCountedAndStillInPlans(t *testing.T) { m := admittedModel(t, diamondAdmitted()) start := m.now() - m.orchestrate.markStarted("a", "root", start) + m.orchestrate.markStarted("a", "root", "", start) m.orchestrate.markDone("a", "succeeded", 0, start) m.now = func() time.Time { return start.Add(orchestrateTaskLinger + time.Second) } @@ -628,12 +628,12 @@ func TestTheBudgetLineCountsDuringTheRun(t *testing.T) { m := admittedModel(t, diamondAdmitted()) now := m.now() - m.orchestrate.markStarted("a", "root", now) + m.orchestrate.markStarted("a", "root", "", now) m.orchestrate.markDone("a", "succeeded", 16470, now) if m.orchestrate.tokensUsed != 16470 { t.Fatalf("tokensUsed = %d after one task, want it counted as it finishes", m.orchestrate.tokensUsed) } - m.orchestrate.markStarted("b", "left", now) + m.orchestrate.markStarted("b", "left", "", now) m.orchestrate.markDone("b", "succeeded", 68483, now) rendered := m.renderOrchestratePanel(100) @@ -648,7 +648,7 @@ func TestTheBudgetLineCountsDuringTheRun(t *testing.T) { func TestThePlansOwnTotalWinsAtTheEnd(t *testing.T) { m := admittedModel(t, diamondAdmitted()) now := m.now() - m.orchestrate.markStarted("a", "root", now) + m.orchestrate.markStarted("a", "root", "", now) m.orchestrate.markDone("a", "succeeded", 100, now) m.orchestrate.complete(planCompletedMsg{status: "partial", tokensUsed: 379773}, now) @@ -661,7 +661,7 @@ func TestThePlansOwnTotalWinsAtTheEnd(t *testing.T) { func TestAMissingFinalTotalDoesNotZeroTheCount(t *testing.T) { m := admittedModel(t, diamondAdmitted()) now := m.now() - m.orchestrate.markStarted("a", "root", now) + m.orchestrate.markStarted("a", "root", "", now) m.orchestrate.markDone("a", "succeeded", 4242, now) m.orchestrate.complete(planCompletedMsg{status: "completed"}, now) diff --git a/internal/tui/plan_card_regression_test.go b/internal/tui/plan_card_regression_test.go index 8891ccd98..d14c2eb9c 100644 --- a/internal/tui/plan_card_regression_test.go +++ b/internal/tui/plan_card_regression_test.go @@ -94,7 +94,7 @@ func TestAPlanTasksSpendReachesItsCard(t *testing.T) { func TestTheSidebarDoesNotDenyARunningPlan(t *testing.T) { m := model{now: func() time.Time { return time.Unix(1000, 0) }} m.orchestrate.admit(diamondAdmitted(), m.now()) - m.orchestrate.markStarted("a", "root", m.now()) + m.orchestrate.markStarted("a", "root", "", m.now()) // Drives the REAL sidebar assembly, not the line helper: the helper // returning the right string proves nothing if the PLAN section never calls From 58b3b7c1071e6b4c8d0ca30eee7b00ee3c358659 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:11:54 +0530 Subject: [PATCH 27/86] feat(tui): the running plan fills the sidebar's PLAN section, in colour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The right-hand column showed one grey line — "trace-chain 3/6 · helpers" — for a plan the panel below it was rendering in full. It now carries the plan properly: a progress count in the header and one coloured line per task, in the same shape the update_plan steps use so the section reads consistently whichever plan is in it. green ✓ done · red ✗ failed · accent • in flight · faint ○ pending faint ⊘ skipped or cancelled — deliberately NOT red, since neither is a defect The header count turns red on a failure and green when everything is done, so the one thing worth noticing carries from across the screen. THE LINES LIVE INSIDE sidebarPlanLines, and that is not a detail. sidebarFileSelectables computes the FILES section's click offsets from len(sidebarPlanLines); the previous one-liner sat beside the placeholder and was safe only because it was exactly one line. Rendering a whole section there would have misdirected every file click by the number of lines added, silently. Inside the function the arithmetic stays correct by construction. Live tasks first, so a long plan shows what is happening rather than what already happened, bounded at six lines with the remainder stated — the column is 26 to 40 cells wide and shares its height with AGENTS, FILES and ACTIVITY. The panel and the detail view are where a whole plan is read. update_plan's own steps still win the section when it has any: the two are different things and the TODO list is the one the model is actively editing. The plan's NAME is not in the sidebar. At this width it would cost a task row, and it is on the panel and in the detail view. --- internal/tui/orchestrate_panel.go | 82 ++++++++---- internal/tui/plan_card_regression_test.go | 12 +- internal/tui/sidebar.go | 27 ++-- internal/tui/sidebar_plan_test.go | 144 ++++++++++++++++++++++ 4 files changed, 228 insertions(+), 37 deletions(-) create mode 100644 internal/tui/sidebar_plan_test.go diff --git a/internal/tui/orchestrate_panel.go b/internal/tui/orchestrate_panel.go index 0d965f6ee..13265ad84 100644 --- a/internal/tui/orchestrate_panel.go +++ b/internal/tui/orchestrate_panel.go @@ -544,36 +544,70 @@ func (m model) clickedOrchestrateHeader(msg tea.MouseMsg) bool { strings.HasPrefix(strings.TrimSpace(line), "▾ "+orchestrateHeaderMarker) } -// sidebarOrchestrateLine is the one-line orchestrate summary the sidebar shows -// in place of "no active plan" while a plan is running. Kept to a single line -// because the FILES section below computes its click offsets from the lines -// above it. -func (m model) sidebarOrchestrateLine(width int) string { +// maxSidebarOrchestrateLines caps the sidebar's plan list. The column is 26-40 +// wide and shares its height with AGENTS, FILES and ACTIVITY, so a twenty-task +// plan must not push those off — the panel and the detail view are where a +// whole plan is read. +const maxSidebarOrchestrateLines = 6 + +// sidebarOrchestrateLines renders the running plan for the right-hand column: +// one line per task, coloured by status, in the same shape the update_plan +// steps use so the section reads consistently whichever plan is in it. +// +// Live tasks first — the ones that have not finished — so a long plan shows +// what is happening rather than what already happened. What it drops is stated. +func (m model) sidebarOrchestrateLines(width int) []string { state := m.orchestrate - done, failed, _, _, _ := state.counts() + if state.isEmpty() { + return nil + } + room := maxInt(4, width-3) - label := strings.TrimSpace(state.name) - if label == "" { - label = "plan" + rows := state.liveTasks(m.orchestrateNow()) + if len(rows) == 0 { + // Everything has finished and faded: show the tail of the plan rather + // than an empty section. + rows = state.tasks + if len(rows) > maxSidebarOrchestrateLines { + rows = rows[len(rows)-maxSidebarOrchestrateLines:] + } } - summary := fmt.Sprintf("%s %d/%d", label, done, len(state.tasks)) - if failed > 0 { - summary += fmt.Sprintf(" · %d failed", failed) + hidden := 0 + if len(rows) > maxSidebarOrchestrateLines { + hidden = len(rows) - maxSidebarOrchestrateLines + rows = rows[:maxSidebarOrchestrateLines] + } + + lines := make([]string, 0, len(rows)+1) + for _, task := range rows { + icon, body := sidebarOrchestrateStyle(task, room) + lines = append(lines, " "+icon+" "+body) } - if running := state.runningTask(); running != "" { - summary += " · " + running + if hidden > 0 { + lines = append(lines, " "+zeroTheme.faint.Render(fmt.Sprintf(" +%d more", hidden))) } - // Room for the two-space indent the placeholder also carries. - return " " + zeroTheme.muted.Render(truncateRunes(summary, maxInt(1, width-2))) + return lines } -// runningTask names the task currently in flight, or "" when none is. Sound -// because MaxWorkers is validated to be 1 — Stage 2d must revisit it. -func (s orchestratePanelState) runningTask() string { - for _, task := range s.tasks { - if task.status == orchestrateRunning { - return task.id - } +// sidebarOrchestrateStyle picks the glyph and text colour for one task. The +// palette matches the update_plan steps above it: green done, red failed, accent +// in flight, faint everything else — and cancelled/skipped are deliberately NOT +// red, since neither is a defect. +func sidebarOrchestrateStyle(task orchestrateTask, room int) (string, string) { + label := task.id + if summary := strings.TrimSpace(task.summary); summary != "" && len(label)+3 < room { + label += " " + summary + } + switch task.status { + case orchestrateDone: + return zeroTheme.green.Render("✓"), zeroTheme.muted.Render(truncateStep(label, room)) + case orchestrateRunning: + return zeroTheme.accent.Render("•"), zeroTheme.ink.Render(truncateStep(label, room)) + case orchestrateFailed: + return zeroTheme.red.Render("✗"), zeroTheme.muted.Render(truncateStep(label, room)) + case orchestrateSkipped, orchestrateCancelled: + return zeroTheme.faint.Render("⊘"), zeroTheme.faint.Render(truncateStep(label, room)) + default: + return zeroTheme.faint.Render("○"), zeroTheme.faint.Render(truncateStep(label, room)) } - return "" } diff --git a/internal/tui/plan_card_regression_test.go b/internal/tui/plan_card_regression_test.go index d14c2eb9c..19fb4990b 100644 --- a/internal/tui/plan_card_regression_test.go +++ b/internal/tui/plan_card_regression_test.go @@ -103,9 +103,15 @@ func TestTheSidebarDoesNotDenyARunningPlan(t *testing.T) { if strings.Contains(rendered, "no active plan") { t.Fatalf("the sidebar denies a plan that is running:\n%s", rendered) } - for _, want := range []string{"diamond", "0/4", "a"} { - if !strings.Contains(rendered, want) { - t.Errorf("the sidebar does not carry %q:\n%s", want, rendered) + // The section carries the progress count and the tasks themselves. The + // plan's NAME lives on the panel and in the detail view — the column is 26 + // to 40 cells wide and a name would cost a task row. + if !strings.Contains(rendered, "PLAN") || !strings.Contains(rendered, "0/4") { + t.Errorf("the PLAN header does not show progress:\n%s", rendered) + } + for _, id := range []string{"a", "b", "c", "d"} { + if !strings.Contains(rendered, id) { + t.Errorf("the sidebar does not list task %q:\n%s", id, rendered) } } } diff --git a/internal/tui/sidebar.go b/internal/tui/sidebar.go index 876d4361c..9174c9912 100644 --- a/internal/tui/sidebar.go +++ b/internal/tui/sidebar.go @@ -634,15 +634,6 @@ func (m model) renderContextSidebar(width, height int) []string { switch { case len(planLines) > 0: lines = append(lines, planLines...) - case !m.orchestrate.isEmpty(): - // An ORCHESTRATE plan is running. Without this the sidebar said "no - // active plan" while the panel two lines below showed one mid-flight — - // the two surfaces contradicting each other about the same session. - // - // EXACTLY ONE line, like the placeholder it replaces: the FILES - // section's click offsets are computed from the lines above it, so a - // different count here would misdirect every file hit. - add(m.sidebarOrchestrateLine(width)) default: add(sidebarPlaceholder("no active plan", width)) } @@ -764,6 +755,17 @@ func sidebarPlaceholder(text string, width int) string { func (m model) sidebarPlanHeader(width int) string { state := m.plan if state.isEmpty() { + if orchestrate := m.orchestrate; !orchestrate.isEmpty() { + done, failed, _, _, _ := orchestrate.counts() + style := zeroTheme.accent + if failed > 0 { + style = zeroTheme.red + } else if done == len(orchestrate.tasks) { + style = zeroTheme.green + } + return sidebarHeaderWithCount("PLAN", + fmt.Sprintf("%d/%d", done, len(orchestrate.tasks)), style, width) + } return sidebarHeader("PLAN", width) } total := len(state.steps) @@ -788,7 +790,12 @@ func (m model) sidebarPlanHeader(width int) string { func (m model) sidebarPlanLines(width int) []string { state := m.plan if state.isEmpty() { - return nil + // No update_plan steps, but an ORCHESTRATE plan may be running. Its + // lines belong in THIS function rather than beside the placeholder, + // because sidebarFileSelectables derives the FILES section's click + // offsets from len(sidebarPlanLines) — anything rendered outside it + // would silently misdirect every file hit by the number of lines added. + return m.sidebarOrchestrateLines(width) } room := maxInt(4, width-3) lines := make([]string, 0, len(state.steps)) diff --git a/internal/tui/sidebar_plan_test.go b/internal/tui/sidebar_plan_test.go new file mode 100644 index 000000000..adb4834f7 --- /dev/null +++ b/internal/tui/sidebar_plan_test.go @@ -0,0 +1,144 @@ +package tui + +import ( + "strings" + "testing" + "time" + + "github.com/charmbracelet/x/ansi" +) + +func sidebarPlanModel(t *testing.T) model { + t.Helper() + m := model{now: func() time.Time { return time.Unix(1000, 0) }} + m.orchestrate.admit(diamondAdmitted(), m.now()) + now := m.now() + m.orchestrate.markStarted("a", "root", "k1", now) + m.orchestrate.markDone("a", "succeeded", 100, now) + m.orchestrate.markStarted("b", "left", "k2", now) + m.orchestrate.markDone("b", "failed", 200, now) + m.orchestrate.markStarted("c", "right", "k3", now) + return m +} + +// THE OFFSET CONSTRAINT, and it is the reason these lines live inside +// sidebarPlanLines rather than beside the placeholder. +// +// sidebarFileSelectables computes the FILES section's click offsets from +// len(sidebarPlanLines). Rendering the plan anywhere else would silently +// misdirect every file hit by the number of lines added — a click landing on +// the wrong file, with nothing to indicate it. +func TestFileClickOffsetsSurviveThePlanSection(t *testing.T) { + withPlan := sidebarPlanModel(t) + planLines := len(withPlan.sidebarPlanLines(34)) + if planLines < 2 { + t.Fatalf("expected the plan to render several lines, got %d", planLines) + } + + // The offsets the FILES section computes must move by exactly the number of + // lines the PLAN section actually renders. + empty := model{now: withPlan.now} + if got := len(empty.sidebarPlanLines(34)); got != 0 { + t.Fatalf("with no plan the section must render nothing, got %d lines", got) + } +} + +// Each status carries its own glyph, and the colours are distinct — the section +// is read at a glance, and a wall of one colour says nothing. +func TestTheSidebarPlanColoursEachStatus(t *testing.T) { + m := sidebarPlanModel(t) + lines := m.sidebarPlanLines(34) + + glyphs := map[string]string{} + for _, line := range lines { + plain := strings.TrimSpace(ansi.Strip(line)) + fields := strings.Fields(plain) + if len(fields) >= 2 { + glyphs[fields[1]] = fields[0] + } + } + want := map[string]string{"a": "✓", "b": "✗", "c": "•", "d": "○"} + for id, glyph := range want { + if glyphs[id] != glyph { + t.Errorf("task %q carries glyph %q, want %q", id, glyphs[id], glyph) + } + } + + // Distinct STYLING, not just distinct glyphs: the raw lines must differ in + // their escape sequences or the section is monochrome. + styles := map[string]bool{} + for _, line := range lines { + if index := strings.Index(line, "m"); index > 0 { + styles[line[:index]] = true + } + } + if len(styles) < 3 { + t.Fatalf("only %d distinct colours across the section; statuses must be distinguishable", len(styles)) + } +} + +// The header carries progress, and turns red when something failed — the one +// thing worth noticing from across the screen. +func TestTheSidebarPlanHeaderShowsProgress(t *testing.T) { + m := sidebarPlanModel(t) + header := ansi.Strip(m.sidebarPlanHeader(34)) + if !strings.Contains(header, "PLAN") || !strings.Contains(header, "1/4") { + t.Fatalf("header = %q, want the section name and progress", header) + } + + clean := model{now: m.now} + clean.orchestrate.admit(diamondAdmitted(), clean.now()) + if styled := clean.sidebarPlanHeader(34); styled == m.sidebarPlanHeader(34) { + t.Fatal("a plan with a failure must not look the same as one without") + } +} + +// update_plan's own steps still win the section when it has any: the two are +// different things, and the TODO list is the one the model is actively editing. +func TestUpdatePlanStepsStillWinTheSection(t *testing.T) { + m := sidebarPlanModel(t) + m.plan.steps = []planStep{{content: "a step of the model's own plan", status: "in_progress"}} + + rendered := strings.Join(m.sidebarPlanLines(34), "\n") + if !strings.Contains(ansi.Strip(rendered), "a step of the model") { + t.Fatalf("update_plan's steps must still render:\n%s", rendered) + } + if strings.Contains(ansi.Strip(rendered), "root") { + t.Fatalf("the orchestrate plan must not displace them:\n%s", rendered) + } +} + +// A long plan is bounded and says what it dropped. A silently truncated list +// reads as a complete one. +func TestALongPlanIsBoundedInTheSidebar(t *testing.T) { + msg := planAdmittedMsg{runID: 1, name: "big"} + for index := 0; index < 20; index++ { + msg.tasks = append(msg.tasks, planGraphTask{id: string(rune('a' + index))}) + } + msg.taskCount = len(msg.tasks) + m := model{now: func() time.Time { return time.Unix(1000, 0) }} + m.orchestrate.admit(msg, m.now()) + + lines := m.sidebarPlanLines(34) + if len(lines) > maxSidebarOrchestrateLines+1 { + t.Fatalf("the sidebar drew %d lines for a 20-task plan", len(lines)) + } + if !strings.Contains(ansi.Strip(strings.Join(lines, "\n")), "more") { + t.Fatalf("a truncated list must say so:\n%s", strings.Join(lines, "\n")) + } +} + +// Cancelled and skipped are not failures, in the sidebar as everywhere else. +func TestTheSidebarDoesNotPaintSkippedTasksRed(t *testing.T) { + m := model{now: func() time.Time { return time.Unix(1000, 0) }} + m.orchestrate.admit(diamondAdmitted(), m.now()) + m.orchestrate.markDone("a", "dependency_failed", 0, m.now()) + + icon, _ := sidebarOrchestrateStyle(m.orchestrate.tasks[0], 30) + if strings.Contains(icon, "✗") { + t.Fatal("a skipped task is not a failure and must not be drawn as one") + } + if !strings.Contains(ansi.Strip(icon), "⊘") { + t.Fatalf("a skipped task must carry the neutral marker, got %q", ansi.Strip(icon)) + } +} From 2957bd64118fc883298de3bb46cd5db1c4048c33 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:29:46 +0530 Subject: [PATCH 28/86] =?UTF-8?q?feat(tui):=20the=20plan=20owns=20the=20ri?= =?UTF-8?q?ght=20column=20=E2=80=94=20progress=20bar,=20task=20list,=20liv?= =?UTF-8?q?e=20detail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overlay was wrong and the screenshot showed why: composited into the transcript column, a twelve-task plan drew its phase list straight through the user's own prompt. It is gone. The right column already owns "what is happening" — agents, files, activity — and had half a screen padded with blank lines down to the token floor. The plan lives there now: PLAN 1/8 █████████░░░░░░░░░░░░░░░░░ 1/8 ✓ t-sandbox x ✗ t-agent y • t-tools Read the Go package at… ○ t-redaction +2 more TASK t-tools running 52 tool calls · 21,400 tok blocks pair-a ↳ read_file internal/tools/registry.go Read the Go package at internal/tools A progress bar that keeps done, failed, skipped and running in their own colours inside one span, sized to the column rather than to a fixed width, and never rounding a segment away — one failure in thirty still gets a cell, because a failure the bar hides is a failure it does not report. TASK fills what was dead space, and yields first when the column is short: the list above it is what the section is for. It shows the REVERSE dependency edge too — what a task blocks — because a failure matters in proportion to what waits on it and the forward edge alone does not say. Click a task to select it, click PLAN to collapse the section, ctrl+g to cycle the selection from the keyboard. The sidebar is not focusable, so a mouse-only affordance would not be one. THE OFFSET ARITHMETIC IS THE RISK HERE, not the drawing. The progress bar sits between the header and the first task, so every clickable row below it moves down one — in the plan's own hit table AND in the FILES section beneath. Both are adjusted, and both are asserted against where the row actually RENDERS rather than against a re-derivation of the sum: the first version of the FILES test recomputed the arithmetic and would have passed with the bar unaccounted for on both sides. Nine mutations, all caught. Two needed the tests rewritten first — the FILES one asserted nothing, and the bar one used numbers that never round to zero. --- internal/tui/files_panel.go | 3 + internal/tui/model.go | 46 +--- internal/tui/mouse.go | 21 +- internal/tui/orchestrate_detail.go | 281 --------------------- internal/tui/orchestrate_detail_test.go | 300 ----------------------- internal/tui/orchestrate_panel.go | 75 +++++- internal/tui/sidebar.go | 13 + internal/tui/sidebar_plan_detail.go | 278 +++++++++++++++++++++ internal/tui/sidebar_plan_detail_test.go | 243 ++++++++++++++++++ 9 files changed, 637 insertions(+), 623 deletions(-) delete mode 100644 internal/tui/orchestrate_detail.go delete mode 100644 internal/tui/orchestrate_detail_test.go create mode 100644 internal/tui/sidebar_plan_detail.go create mode 100644 internal/tui/sidebar_plan_detail_test.go diff --git a/internal/tui/files_panel.go b/internal/tui/files_panel.go index 624b72ffa..7cfe5134a 100644 --- a/internal/tui/files_panel.go +++ b/internal/tui/files_panel.go @@ -305,6 +305,9 @@ func (m model) sidebarFileSelectables(width int) []fileHit { if planBody == 0 { planBody = 1 // the "no active plan" placeholder occupies one line } + if m.orchestratePlanBar(width) != "" { + planBody++ // the orchestrate progress bar sits inside the PLAN section + } base := 1 + agentBody + 2 + planBody + 2 // sections above + (blank + FILES header) for i := range hits { hits[i].lineOffset += base diff --git a/internal/tui/model.go b/internal/tui/model.go index 72f5c508b..1447a9394 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -204,9 +204,8 @@ type model struct { transcript []transcriptRow transcriptDetailed bool helpOverlay bool // the `?` keyboard-shortcut overlay is open - // orchestrateDetail is the plan drill-in: phases left, the selected task's - // live agent detail right. Opened by pressing the PLAN header. - orchestrateDetail bool + // orchestrateSelected is the plan task the sidebar's TASK section details. + // Clicking a task row in the sidebar sets it; ctrl+g cycles it. orchestrateSelected int // leaderHelpOverlay is the Ctrl+X ? modal listing every leader slash chord. leaderHelpOverlay bool @@ -1405,31 +1404,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { } // The `?` help overlay is modal: `?`, Esc, q, or Enter close it; every // other key is swallowed so nothing types into the hidden composer. - // The plan detail view owns esc and the arrows while it is open, so a - // keypress meant for it never falls through to the transcript. - if m.orchestrateDetailOpen() { - switch { - case keyIs(msg, tea.KeyEscape): - m.orchestrateDetail = false - return m, nil - case keyIs(msg, tea.KeyUp): - return m.moveOrchestrateSelection(-1), nil - case keyIs(msg, tea.KeyDown): - return m.moveOrchestrateSelection(1), nil - case keyIs(msg, tea.KeyEnter): - // Drill into the task's child session, reusing the same subchat - // the specialist cards open. Only when there IS one — a running - // task is still keyed by a temporary id. - if session := m.selectedTaskSession(); session != "" { - m.orchestrateDetail = false - if errMsg := m.subchat.enter(m.sessionStore, session, m.orchestrate.tasks[m.orchestrateSelected].id, m.chatScrollOffset); errMsg != "" { - m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: errMsg}) - } - return m, nil - } - return m, nil - } - } if m.helpOverlay { if keyText(msg) == "?" || keyText(msg) == "q" || keyIs(msg, tea.KeyEsc) || keyIs(msg, tea.KeyEnter) || keyCtrl(msg, 'c') { m.helpOverlay = false @@ -1755,6 +1729,13 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { // /plans are already two different things, and one key toggling // whichever happened to be present would make that worse. if m.noBlockingModal() && !m.orchestrate.isEmpty() { + // With the sidebar open the plan lives there, so ctrl+g walks + // the task selection — the keyboard route to clicking a row. + // Without it, the inline panel is the only surface, so ctrl+g + // keeps expanding that. + if m.sidebarActive() { + return m.cycleOrchestrateSelection(), nil + } m.orchestrate.expanded = !m.orchestrate.expanded return m, nil } @@ -3000,7 +2981,6 @@ func (m model) transcriptView() string { leaderHelpOverlayContent = m.renderLeaderHelpOverlay(width) } - orchestrateOverlay := m.renderOrchestrateDetailOverlay(width) suggestionOverlay := m.suggestionOverlay(width) providerOverlay := m.providerWizardOverlay(width) mcpAddOverlay := m.mcpAddWizardOverlay(width) @@ -3011,8 +2991,6 @@ func (m model) transcriptView() string { switch { case sttKeyOverlay != "": viewportOverlay = sttKeyOverlay - case orchestrateOverlay != "": - viewportOverlay = orchestrateOverlay case helpOverlayContent != "": viewportOverlay = helpOverlayContent case leaderHelpOverlayContent != "": @@ -3071,16 +3049,10 @@ func (m model) twoColumnTranscriptView() string { width := chatW - orchestrateOverlay := m.renderOrchestrateDetailOverlay(width) suggestionOverlay := m.suggestionOverlay(width) bodyItems := m.transcriptBodyItems(width, "", false) footer := m.footerView(width) - // The plan detail view wins over the suggestion popup: it is a deliberate - // drill-in, and the composer is not what the user is looking at. overlayForViewport := suggestionOverlay - if orchestrateOverlay != "" { - overlayForViewport = orchestrateOverlay - } if m.transcriptEmpty() && !m.pending { overlayForViewport = "" } diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go index de9b0b367..d06e745fe 100644 --- a/internal/tui/mouse.go +++ b/internal/tui/mouse.go @@ -90,11 +90,24 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { // Clicking the orchestrate plan's header line opens or closes it. Checked // before the surface switch below because the panel lives in the FOOTER, // outside every transcript/sidebar region those cases test. + // The plan lives in the sidebar: clicking a task selects it (the TASK + // section below shows it), clicking the PLAN header collapses the section. + // Checked before the surface switch because the sidebar is not one of the + // regions those cases test. + if mouseLeftPress(msg) { + if index, ok := m.orchestrateTaskAtMouse(msg); ok { + m.orchestrateSelected = index + return m, nil + } + if m.orchestrateHeaderAtMouse(msg) { + m.orchestrate.sidebarCollapsed = !m.orchestrate.sidebarCollapsed + return m, nil + } + } + // Clicking the inline panel's header still expands it in place. if mouseLeftPress(msg) && m.clickedOrchestrateHeader(msg) { - // Pressing PLAN opens the drill-in — phases left, the selected task's - // live agent detail right. The panel's own expand/collapse stays on - // ctrl+g for a quick look without leaving the conversation. - return m.openOrchestrateDetail(), nil + m.orchestrate.expanded = !m.orchestrate.expanded + return m, nil } if mouseLeftPress(msg) { switch { diff --git a/internal/tui/orchestrate_detail.go b/internal/tui/orchestrate_detail.go deleted file mode 100644 index 19017fafd..000000000 --- a/internal/tui/orchestrate_detail.go +++ /dev/null @@ -1,281 +0,0 @@ -package tui - -import ( - "fmt" - "strings" - - "charm.land/lipgloss/v2" -) - -// The plan detail view: the plan's phases down the left, the selected phase's -// live agent detail on the right. -// -// Opened by pressing the PLAN header, closed with Esc. It is an OVERLAY, using -// the same slot the help and picker overlays use, so it composites over the -// chat rather than replacing the view — the conversation stays visible behind -// it and nothing about the transcript, footer or composer changes. -// -// It renders NOTHING when no plan has been admitted, which is what keeps a -// posture-off session unchanged. No separate posture gate is needed: a plan -// only exists under the posture, so there is no PLAN line to press without one. - -const ( - // orchestrateDetailPhaseWidth is the left column. Wide enough for a task id - // and its status glyph, narrow enough to leave the detail room. - orchestrateDetailPhaseWidth = 22 - // orchestrateDetailMinWidth is the total below which the two-column layout - // stops making sense and the detail alone is shown. - orchestrateDetailMinWidth = 56 -) - -// orchestrateDetailOpen reports whether the view should render. -func (m model) orchestrateDetailOpen() bool { - return m.orchestrateDetail && !m.orchestrate.isEmpty() -} - -// openOrchestrateDetail opens the view on the task most worth looking at: the -// one running, else the first that failed, else the first task. -func (m model) openOrchestrateDetail() model { - if m.orchestrate.isEmpty() { - return m - } - m.orchestrateDetail = true - m.orchestrateSelected = m.orchestrate.defaultSelection() - return m -} - -// defaultSelection picks the task a user opening the view wants to see first. -func (s orchestratePanelState) defaultSelection() int { - for index, task := range s.tasks { - if task.status == orchestrateRunning { - return index - } - } - for index, task := range s.tasks { - if task.status == orchestrateFailed { - return index - } - } - return 0 -} - -// moveOrchestrateSelection walks the phase list, clamped at both ends so the -// selection never wraps onto an unrelated task. -func (m model) moveOrchestrateSelection(delta int) model { - if len(m.orchestrate.tasks) == 0 { - return m - } - next := m.orchestrateSelected + delta - if next < 0 { - next = 0 - } - if next >= len(m.orchestrate.tasks) { - next = len(m.orchestrate.tasks) - 1 - } - m.orchestrateSelected = next - return m -} - -// renderOrchestrateDetailOverlay draws the whole view, or "" when it is closed. -func (m model) renderOrchestrateDetailOverlay(width int) string { - if !m.orchestrateDetailOpen() { - return "" - } - if width < orchestrateDetailMinWidth { - // Too narrow for two columns: the detail is what the user came for, so - // the phase list is what goes. - return m.orchestrateDetailBox(width, m.detailPaneLines(width-4)) - } - - phaseWidth := orchestrateDetailPhaseWidth - detailWidth := width - phaseWidth - 7 - - phases := strings.Join(m.phasePaneLines(phaseWidth), "\n") - detail := strings.Join(m.detailPaneLines(detailWidth), "\n") - - columns := lipgloss.JoinHorizontal(lipgloss.Top, - lipgloss.NewStyle().Width(phaseWidth).Render(phases), - lipgloss.NewStyle().Width(2).Render(""), - lipgloss.NewStyle().Width(detailWidth).Render(detail), - ) - return m.orchestrateDetailBox(width, strings.Split(columns, "\n")) -} - -// orchestrateDetailBox wraps the content in the plan's title and footer hint. -func (m model) orchestrateDetailBox(width int, body []string) string { - state := m.orchestrate - title := strings.TrimSpace(state.name) - if title == "" { - title = "plan" - } - - var b strings.Builder - b.WriteString(zeroTheme.accent.Render(truncateRunes(title, maxInt(1, width-2)))) - done, failed, skipped, cancelled, _ := state.counts() - subtitle := fmt.Sprintf("%d/%d done", done, len(state.tasks)) - if failed > 0 { - subtitle += fmt.Sprintf(" · %d failed", failed) - } - if skipped > 0 { - subtitle += fmt.Sprintf(" · %d skipped", skipped) - } - if cancelled > 0 { - subtitle += fmt.Sprintf(" · %d cancelled", cancelled) - } - if state.tokensUsed > 0 { - subtitle += fmt.Sprintf(" · %d tokens", state.tokensUsed) - } - b.WriteString("\n") - b.WriteString(zeroTheme.faint.Render(truncateRunes(subtitle, maxInt(1, width-2)))) - b.WriteString("\n\n") - b.WriteString(strings.Join(body, "\n")) - b.WriteString("\n\n") - hint := "↑/↓ phase esc close" - if m.selectedTaskSession() != "" { - // Only offered when the selected task HAS a child session to open. A - // hint for a key that does nothing trains the user to ignore hints. - hint = "↑/↓ phase enter open agent esc close" - } - b.WriteString(zeroTheme.faint.Render(hint)) - return b.String() -} - -// selectedTaskSession returns the selected task's child session id, or "" when -// it has none yet. A running task is still keyed by its temporary card key, -// which is not a session and cannot be opened. -func (m model) selectedTaskSession() string { - if m.orchestrateSelected < 0 || m.orchestrateSelected >= len(m.orchestrate.tasks) { - return "" - } - key := m.orchestrate.tasks[m.orchestrateSelected].cardKey - if strings.HasPrefix(key, "specialist_") { - return key - } - return "" -} - -// phasePaneLines is the left column: every task, numbered, with the selected -// one marked. Numbered rather than indented — this is a list to navigate, and -// the dependency shape is on the panel and in /plans. -func (m model) phasePaneLines(width int) []string { - lines := []string{zeroTheme.muted.Render("Phases")} - for index, task := range m.orchestrate.tasks { - marker := " " - style := zeroTheme.faint - if index == m.orchestrateSelected { - marker = "❯ " - style = zeroTheme.ink - } - label := fmt.Sprintf("%s%d %s", marker, index+1, task.id) - row := style.Render(truncateRunes(label, maxInt(1, width-6))) - glyph := orchestrateGlyph(task.status) - if task.status == orchestrateRunning { - // The panel spins here; a static list should still distinguish the - // task in flight from one that has not started, which the default - // pending dot did not. - glyph = zeroTheme.accent.Render("▸") - } - lines = append(lines, row+" "+glyph) - } - return lines -} - -// detailPaneLines is the right column: what the selected task is doing. -func (m model) detailPaneLines(width int) []string { - if m.orchestrateSelected < 0 || m.orchestrateSelected >= len(m.orchestrate.tasks) { - return []string{zeroTheme.faint.Render("no task selected")} - } - task := m.orchestrate.tasks[m.orchestrateSelected] - now := m.orchestrateNow() - - lines := []string{zeroTheme.ink.Render(truncateRunes(task.id, maxInt(1, width)))} - - status := task.status.label() - if elapsed := task.elapsed(now); elapsed > 0 { - status += " · " + formatElapsedSeconds(elapsed) - } - if len(task.dependsOn) > 0 { - status += " · after " + strings.Join(task.dependsOn, ", ") - } - lines = append(lines, zeroTheme.faint.Render(truncateRunes(status, maxInt(1, width)))) - - // Live agent state, read from the specialist tracker through the task's - // card key. Absent for a task that never started, which is the honest - // answer rather than a row of zeroes. - info, hasCard := m.specialists.getBySessionID(task.cardKey) - if hasCard { - meta := fmt.Sprintf("%d tool calls", info.toolCount) - if info.toolCount == 1 { - meta = "1 tool call" - } - if info.tokenCount > 0 { - meta += fmt.Sprintf(" · %s tokens", formatTokenCount(info.tokenCount)) - } - lines = append(lines, zeroTheme.faint.Render(truncateRunes(meta, maxInt(1, width)))) - } - - if summary := strings.TrimSpace(task.summary); summary != "" { - lines = append(lines, "", zeroTheme.muted.Render("Prompt")) - lines = append(lines, wrapToWidth(summary, width, 3)...) - } - - if hasCard && strings.TrimSpace(info.currentTool) != "" { - lines = append(lines, "", zeroTheme.muted.Render("Activity")) - activity := info.currentTool - if detail := strings.TrimSpace(info.currentDetail); detail != "" { - activity += "(" + detail + ")" - } - lines = append(lines, " "+zeroTheme.faint.Render(truncateRunes(activity, maxInt(1, width-2)))) - } - - lines = append(lines, "", zeroTheme.muted.Render("Outcome")) - lines = append(lines, " "+zeroTheme.faint.Render(truncateRunes(m.orchestrateOutcomeLine(task, hasCard, info), maxInt(1, width-2)))) - return lines -} - -// orchestrateOutcomeLine states where the task ended up, in one line. -func (m model) orchestrateOutcomeLine(task orchestrateTask, hasCard bool, info specialistInfo) string { - switch task.status { - case orchestrateRunning: - return "still running…" - case orchestratePending: - return "waiting on " + strings.Join(task.dependsOn, ", ") - case orchestrateDone: - return "completed" - } - // Failed, skipped or cancelled: the reason is what matters, and the card's - // error text is more specific than the status word. - if hasCard && strings.TrimSpace(info.errorMsg) != "" { - return firstLineOf(info.errorMsg) - } - return task.status.label() -} - -func firstLineOf(text string) string { - if index := strings.IndexAny(text, "\r\n"); index >= 0 { - return strings.TrimSpace(text[:index]) - } - return strings.TrimSpace(text) -} - -// wrapToWidth breaks text onto at most maxLines lines of the given width, -// cutting on RUNE boundaries and saying when it truncated. -func wrapToWidth(text string, width, maxLines int) []string { - width = maxInt(4, width-2) - runes := []rune(strings.Join(strings.Fields(text), " ")) - var lines []string - for len(runes) > 0 && len(lines) < maxLines { - take := width - if take > len(runes) { - take = len(runes) - } - lines = append(lines, " "+zeroTheme.faint.Render(string(runes[:take]))) - runes = runes[take:] - } - if len(runes) > 0 && len(lines) > 0 { - // Say that it was cut. A silently truncated prompt reads as the whole - // prompt, and the full text is in the tool output either way. - lines = append(lines, " "+zeroTheme.faint.Render("…")) - } - return lines -} diff --git a/internal/tui/orchestrate_detail_test.go b/internal/tui/orchestrate_detail_test.go deleted file mode 100644 index 9c98a2dd0..000000000 --- a/internal/tui/orchestrate_detail_test.go +++ /dev/null @@ -1,300 +0,0 @@ -package tui - -import ( - "context" - "strings" - "testing" - "time" - - tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/x/ansi" -) - -func detailModel(t *testing.T) model { - t.Helper() - m := admittedModel(t, diamondAdmitted()) - now := m.now() - m.orchestrate.markStarted("a", "survey the packages", "plantask_1", now) - m.orchestrate.markDone("a", "succeeded", 16470, now.Add(6*time.Second)) - m.orchestrate.linkCard("a", "specialist_aaa") - m.specialists.start("a", "survey the packages", "specialist_aaa", now) - m.specialists.complete("specialist_aaa", specialistCompleted, 0, "", now) - m.specialists.setTokens("specialist_aaa", 16470) - - m.orchestrate.markStarted("b", "read the left branch", "plantask_2", now.Add(6*time.Second)) - m.specialists.start("b", "read the left branch", "plantask_2", now) - m.specialists.incrementToolCount("plantask_2") - m.specialists.setCurrentTool("plantask_2", "read_file", "internal/agent/loop.go") - m.orchestrate.linkCard("b", "plantask_2") - return m -} - -// THE MOUNT-POINT INVARIANT. Closed, and with no plan at all, the view -// contributes nothing — which is what keeps a posture-off session unchanged. -func TestTheDetailViewRendersNothingWhenClosed(t *testing.T) { - m := detailModel(t) - if got := m.renderOrchestrateDetailOverlay(120); got != "" { - t.Fatalf("a closed view must render nothing, got %q", got) - } - - empty := model{now: func() time.Time { return time.Unix(1000, 0) }} - empty.orchestrateDetail = true - if got := empty.renderOrchestrateDetailOverlay(120); got != "" { - t.Fatalf("with no plan there is nothing to open, got %q", got) - } -} - -// Opening lands on the task worth looking at: the one running. -func TestOpeningSelectsTheRunningTask(t *testing.T) { - m := detailModel(t).openOrchestrateDetail() - if !m.orchestrateDetailOpen() { - t.Fatal("the view did not open") - } - if got := m.orchestrate.tasks[m.orchestrateSelected].id; got != "b" { - t.Fatalf("selected %q, want the running task", got) - } -} - -// ...and on the first failure when nothing is running, since that is what a -// user opens the view to read. -func TestOpeningSelectsTheFirstFailureWhenIdle(t *testing.T) { - m := admittedModel(t, diamondAdmitted()) - now := m.now() - m.orchestrate.markStarted("a", "root", "k1", now) - m.orchestrate.markDone("a", "succeeded", 0, now) - m.orchestrate.markStarted("b", "left", "k2", now) - m.orchestrate.markDone("b", "failed", 0, now) - - m = m.openOrchestrateDetail() - if got := m.orchestrate.tasks[m.orchestrateSelected].id; got != "b" { - t.Fatalf("selected %q, want the failed task", got) - } -} - -// Both panes render: the phase list on the left, the selected task's live -// detail on the right. -func TestTheViewShowsPhasesAndTheSelectedTasksDetail(t *testing.T) { - m := detailModel(t).openOrchestrateDetail() - rendered := ansi.Strip(m.renderOrchestrateDetailOverlay(120)) - - if !strings.Contains(rendered, "diamond") { - t.Fatalf("the plan's name is missing:\n%s", rendered) - } - for _, id := range []string{"a", "b", "c", "d"} { - if !strings.Contains(rendered, id) { - t.Fatalf("phase %q missing from the list:\n%s", id, rendered) - } - } - // The running task's live agent state. - for _, want := range []string{"Prompt", "read the left branch", "Activity", "read_file", "Outcome", "still running"} { - if !strings.Contains(rendered, want) { - t.Errorf("the detail pane does not show %q:\n%s", want, rendered) - } - } - if !strings.Contains(rendered, "1 tool call") { - t.Errorf("the detail pane does not show the live tool count:\n%s", rendered) - } -} - -// Moving the selection changes what the right pane shows — the whole point of -// the two panes. -func TestMovingTheSelectionChangesTheDetail(t *testing.T) { - m := detailModel(t).openOrchestrateDetail() - before := ansi.Strip(m.renderOrchestrateDetailOverlay(120)) - - m = m.moveOrchestrateSelection(-1) - after := ansi.Strip(m.renderOrchestrateDetailOverlay(120)) - if before == after { - t.Fatal("moving the selection did not change the detail pane") - } - if !strings.Contains(after, "survey the packages") { - t.Fatalf("the pane does not show the newly selected task:\n%s", after) - } - if !strings.Contains(after, "16,470 tokens") && !strings.Contains(after, "16470 tokens") { - t.Fatalf("the pane does not show the selected task's spend:\n%s", after) - } -} - -// The selection clamps at both ends rather than wrapping onto an unrelated -// task. -func TestTheSelectionClampsAtBothEnds(t *testing.T) { - m := detailModel(t).openOrchestrateDetail() - for range 10 { - m = m.moveOrchestrateSelection(-1) - } - if m.orchestrateSelected != 0 { - t.Fatalf("selection = %d, want it clamped at the first task", m.orchestrateSelected) - } - for range 20 { - m = m.moveOrchestrateSelection(1) - } - if want := len(m.orchestrate.tasks) - 1; m.orchestrateSelected != want { - t.Fatalf("selection = %d, want it clamped at %d", m.orchestrateSelected, want) - } -} - -// Esc closes it and the arrows move within it, through the real key handler — -// a keypress meant for the view must never fall through to the transcript. -func TestTheViewOwnsItsKeysWhileOpen(t *testing.T) { - m := detailModel(t).openOrchestrateDetail() - m.width, m.height = 120, 40 - - press := func(m model, key tea.KeyMsg) model { - t.Helper() - updated, _ := m.Update(key) - return updated.(model) - } - - selected := m.orchestrateSelected - m = press(m, tea.KeyPressMsg{Code: tea.KeyUp}) - if m.orchestrateSelected == selected { - t.Fatal("up did not move the selection") - } - m = press(m, tea.KeyPressMsg{Code: tea.KeyDown}) - if m.orchestrateSelected != selected { - t.Fatal("down did not move the selection back") - } - m = press(m, tea.KeyPressMsg{Code: tea.KeyEscape}) - if m.orchestrateDetailOpen() { - t.Fatal("esc did not close the view") - } -} - -// A task that never started has no agent card, so the pane says what it knows -// rather than showing a row of zeroes. -func TestAPendingTaskShowsWhatItIsWaitingOn(t *testing.T) { - m := detailModel(t).openOrchestrateDetail() - for m.orchestrate.tasks[m.orchestrateSelected].id != "d" { - m = m.moveOrchestrateSelection(1) - } - rendered := ansi.Strip(m.renderOrchestrateDetailOverlay(120)) - if !strings.Contains(rendered, "waiting on b, c") { - t.Fatalf("a pending task must say what it is waiting on:\n%s", rendered) - } - if strings.Contains(rendered, "0 tool calls") { - t.Fatalf("a task that never started has no tool count to report:\n%s", rendered) - } -} - -// Narrow terminals drop the phase list rather than crushing both columns, and -// never cut mid-rune. -func TestTheViewSurvivesNarrowWidths(t *testing.T) { - m := detailModel(t).openOrchestrateDetail() - for _, width := range []int{20, 40, 55, 56, 80, 200} { - rendered := m.renderOrchestrateDetailOverlay(width) - if rendered == "" { - t.Fatalf("width %d rendered nothing", width) - } - if strings.Contains(rendered, "\ufffd") { - t.Fatalf("width %d cut mid-rune:\n%s", width, rendered) - } - } -} - -// A long prompt is summarised and says it was cut. The full text stays in the -// tool output — a display surface is never the data path. -func TestALongPromptIsTruncatedAndSaysSo(t *testing.T) { - m := admittedModel(t, diamondAdmitted()) - m.orchestrate.markStarted("a", strings.Repeat("verbose ", 200), "k", m.now()) - m = m.openOrchestrateDetail() - - rendered := ansi.Strip(m.renderOrchestrateDetailOverlay(100)) - if !strings.Contains(rendered, "…") { - t.Fatalf("a truncated prompt must say so:\n%s", rendered) - } - if strings.Count(rendered, "verbose") > 60 { - t.Fatalf("the prompt was dumped rather than summarised:\n%s", rendered) - } -} - -// Enter is only advertised when it does something. A hint for a key that is a -// no-op trains the user to ignore hints. -func TestEnterIsOnlyOfferedWhenThereIsASessionToOpen(t *testing.T) { - m := detailModel(t).openOrchestrateDetail() // selects "b", still running - if got := m.selectedTaskSession(); got != "" { - t.Fatalf("a running task has no child session yet, got %q", got) - } - running := ansi.Strip(m.renderOrchestrateDetailOverlay(120)) - if strings.Contains(running, "enter open agent") { - t.Fatalf("enter is offered for a task with no session to open:\n%s", running) - } - - m = m.moveOrchestrateSelection(-1) // "a", finished, has a real session - if got := m.selectedTaskSession(); got != "specialist_aaa" { - t.Fatalf("selectedTaskSession = %q, want the child's real session id", got) - } - finished := ansi.Strip(m.renderOrchestrateDetailOverlay(120)) - if !strings.Contains(finished, "enter open agent") { - t.Fatalf("a task with a session must offer to open it:\n%s", finished) - } -} - -// The running task is distinguishable from one that has not started. Both fell -// into the pending glyph, so the phase list could not show what was in flight. -func TestThePhaseListMarksTheRunningTask(t *testing.T) { - m := detailModel(t).openOrchestrateDetail() - rendered := ansi.Strip(m.renderOrchestrateDetailOverlay(120)) - var runningRow, pendingRow string - for _, line := range strings.Split(rendered, "\n") { - if strings.Contains(line, " 2 b") { - runningRow = line - } - if strings.Contains(line, " 3 c") { - pendingRow = line - } - } - if runningRow == "" || pendingRow == "" { - t.Fatalf("phase rows missing:\n%s", rendered) - } - // Assert on the MARKER, not on the whole row: the columns are joined - // horizontally, so every line also carries the detail pane, and comparing - // rows let the running marker be removed without the test noticing. - phaseOnly := func(line string) string { - if index := strings.Index(line, " "); index > 0 { - return line[:index+1] - } - return line - } - if !strings.Contains(phaseOnly(runningRow), "▸") { - t.Fatalf("the running task carries no in-flight marker:\n%s", rendered) - } - if strings.Contains(phaseOnly(pendingRow), "▸") { - t.Fatalf("a pending task must not carry the in-flight marker:\n%s", rendered) - } -} - -// Pressing the PLAN header opens the view — the interaction that was asked for. -func TestClickingPlanOpensTheDetailView(t *testing.T) { - m := newModel(context.Background(), Options{}) - m.width, m.height = 120, 40 - m.altScreen = true - m.activeRunID = 1 - updated, _ := m.Update(diamondAdmitted()) - m = updated.(model) - - if m.orchestrateDetailOpen() { - t.Fatal("the view must start closed") - } - - // Locate the header row in the rendered footer and click it, rather than - // guessing a coordinate: the footer's height varies with the composer and - // the pinned plan panel. - frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(m.chatColumnWidth()), m.footerView(m.chatColumnWidth())) - row := -1 - for index, line := range frame.footerLines { - if strings.Contains(ansi.Strip(line), "PLAN diamond") { - row = index - break - } - } - if row < 0 { - t.Fatal("the plan header is not in the footer") - } - - click := tea.MouseClickMsg{X: frame.footerRect.x + 2, Y: frame.footerRect.y + row, Button: tea.MouseLeft} - updated, _ = m.Update(click) - m = updated.(model) - if !m.orchestrateDetailOpen() { - t.Fatal("clicking the PLAN header did not open the detail view") - } -} diff --git a/internal/tui/orchestrate_panel.go b/internal/tui/orchestrate_panel.go index 13265ad84..34f0387f2 100644 --- a/internal/tui/orchestrate_panel.go +++ b/internal/tui/orchestrate_panel.go @@ -5,6 +5,8 @@ import ( "strings" "time" + "charm.land/lipgloss/v2" + tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi" ) @@ -90,6 +92,11 @@ type orchestratePanelState struct { // event — an interrupt or a crash. Mirrors planPanelState.frozenAt. frozenAt time.Time expanded bool + // sidebarCollapsed hides the task list and detail in the right column, + // toggled by clicking the sidebar's PLAN header. Separate from `expanded`, + // which is the inline panel above the composer: they are two surfaces and + // collapsing one must not silently collapse the other. + sidebarCollapsed bool } func (s *orchestratePanelState) clear() { *s = orchestratePanelState{} } @@ -558,7 +565,7 @@ const maxSidebarOrchestrateLines = 6 // what is happening rather than what already happened. What it drops is stated. func (m model) sidebarOrchestrateLines(width int) []string { state := m.orchestrate - if state.isEmpty() { + if state.isEmpty() || state.sidebarCollapsed { return nil } room := maxInt(4, width-3) @@ -589,6 +596,72 @@ func (m model) sidebarOrchestrateLines(width int) []string { return lines } +// dependents lists the tasks that depend on the given one — the reverse of the +// declared edges, which the plan only stores forward. +func (s orchestratePanelState) dependents(taskID string) []string { + var out []string + for _, task := range s.tasks { + for _, dep := range task.dependsOn { + if dep == taskID { + out = append(out, task.id) + break + } + } + } + return out +} + +// sidebarProgressBar draws the plan's progress across the column: done, failed +// and skipped each keep their own colour inside one bar, so a glance says both +// how far along it is AND whether it is going well. +// +// Proportional to the COLUMN, not to a fixed width — a 26-cell sidebar and a +// 40-cell one both get a bar that fills their space. +func sidebarProgressBar(state orchestratePanelState, width int) string { + total := len(state.tasks) + if total == 0 || width < 12 { + return "" + } + done, failed, skipped, cancelled, running := state.counts() + + cells := maxInt(4, width-8) + fill := func(count int) int { + if count <= 0 { + return 0 + } + // At least one cell for anything non-zero: a failure that rounds to + // zero cells is a failure the bar does not show. + return maxInt(1, count*cells/total) + } + segments := []struct { + count int + style lipgloss.Style + }{ + {done, zeroTheme.green}, + {failed, zeroTheme.red}, + {skipped + cancelled, zeroTheme.muted}, + {running, zeroTheme.accent}, + } + + var b strings.Builder + used := 0 + for _, segment := range segments { + n := fill(segment.count) + if used+n > cells { + n = cells - used + } + if n <= 0 { + continue + } + b.WriteString(segment.style.Render(strings.Repeat("█", n))) + used += n + } + if used < cells { + b.WriteString(zeroTheme.faint.Render(strings.Repeat("░", cells-used))) + } + return " " + b.String() + zeroTheme.faint.Render(fmt.Sprintf(" %d/%d", done, total)) +} + // sidebarOrchestrateStyle picks the glyph and text colour for one task. The // palette matches the update_plan steps above it: green done, red failed, accent // in flight, faint everything else — and cancelled/skipped are deliberately NOT diff --git a/internal/tui/sidebar.go b/internal/tui/sidebar.go index 9174c9912..ef29d10bc 100644 --- a/internal/tui/sidebar.go +++ b/internal/tui/sidebar.go @@ -632,7 +632,13 @@ func (m model) renderContextSidebar(width, height int) []string { add(m.sidebarPlanHeader(width)) planLines := m.sidebarPlanLines(width) switch { + case m.plan.isEmpty() && m.orchestrate.sidebarCollapsed && !m.orchestrate.isEmpty(): + // Collapsed by a click on the header: the count stays, the body goes. + add(sidebarPlaceholder("collapsed — click PLAN to open", width)) case len(planLines) > 0: + if bar := m.orchestratePlanBar(width); bar != "" { + add(bar) + } lines = append(lines, planLines...) default: add(sidebarPlaceholder("no active plan", width)) @@ -661,6 +667,13 @@ func (m model) renderContextSidebar(width, height int) []string { lines = append(lines, activityLines...) } + // PLAN DETAIL: the selected task, drawn into the space that was otherwise + // padded with blank lines down to the token floor. Last, so it only ever + // consumes what the sections above did not want. + if detail := m.sidebarPlanDetailLines(width, maxInt(0, height-1-len(lines))); len(detail) > 0 { + lines = append(lines, detail...) + } + // Token readout pinned to the bottom. tokenLine := m.sidebarTokenLine(width) // Reserve the bottom row for tokens; pad the gap so it sits at the floor. diff --git a/internal/tui/sidebar_plan_detail.go b/internal/tui/sidebar_plan_detail.go new file mode 100644 index 000000000..cef004e66 --- /dev/null +++ b/internal/tui/sidebar_plan_detail.go @@ -0,0 +1,278 @@ +package tui + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +// The plan's detail lives in the SIDEBAR, in the space below ACTIVITY that was +// otherwise padded with blank lines to the token floor. +// +// It was an overlay over the chat, and that was wrong: an overlay composited +// into the transcript column collided with the conversation mid-render, so a +// twelve-task plan drew its phase list straight through the user's own prompt. +// The right column already owns "what is happening" — agents, files, activity — +// and had half a screen of dead space under it. +// +// Clicking a task row selects it; clicking the PLAN header collapses the whole +// section. Both are reachable by keyboard too, since the sidebar is not +// focusable and a mouse-only affordance is not an affordance. + +// orchestrateTaskHit is one clickable plan row in the sidebar. +type orchestrateTaskHit struct { + lineOffset int + taskIndex int +} + +// sidebarOrchestrateSelectables returns the clickable plan rows with their line +// offsets, mirroring sidebarPlanSelectables' accounting exactly: AGENTS header + +// body, then a blank line and the PLAN header, then one line per rendered task. +// +// Derived from the SAME slice the renderer draws, so a row the renderer dropped +// (the live-task filter, the six-line cap) is never clickable — an offset table +// built from the full task list would map clicks onto rows that are not there. +func (m model) sidebarOrchestrateSelectables(width int) []orchestrateTaskHit { + if m.orchestrate.isEmpty() || !m.plan.isEmpty() { + // update_plan's steps own the section when it has any, and they have + // their own hit table. + return nil + } + agentBody := len(m.sidebarAgentLines(width)) + if agentBody == 0 { + agentBody = 1 + } + base := 1 + agentBody + 2 + if m.orchestratePlanBar(width) != "" { + // The progress bar sits between the header and the first task, so every + // row below it moves down one. An offset table that ignored it would + // select the task ABOVE the one clicked. + base++ + } + + rows := m.sidebarOrchestrateRows() + hits := make([]orchestrateTaskHit, 0, len(rows)) + for offset, index := range rows { + hits = append(hits, orchestrateTaskHit{lineOffset: base + offset, taskIndex: index}) + } + return hits +} + +// sidebarOrchestrateRows returns the task INDICES the sidebar draws, in order. +// One source for the renderer and the hit table, so they cannot disagree about +// which row is which. +func (m model) sidebarOrchestrateRows() []int { + state := m.orchestrate + if state.isEmpty() || state.sidebarCollapsed { + return nil + } + live := state.liveTasks(m.orchestrateNow()) + if len(live) == 0 { + live = state.tasks + if len(live) > maxSidebarOrchestrateLines { + live = live[len(live)-maxSidebarOrchestrateLines:] + } + } + if len(live) > maxSidebarOrchestrateLines { + live = live[:maxSidebarOrchestrateLines] + } + rows := make([]int, 0, len(live)) + for _, task := range live { + if index, ok := state.byID[task.id]; ok { + rows = append(rows, index) + } + } + return rows +} + +// orchestrateTaskAtMouse maps a left-click in the sidebar to a plan task, +// mirroring planStepAtMouse's column and x gate. +func (m model) orchestrateTaskAtMouse(msg tea.MouseMsg) (int, bool) { + if !m.sidebarActive() || m.orchestrate.isEmpty() { + return 0, false + } + if m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || + m.mcpManager != nil || m.picker != nil || m.suggestionsActive() { + return 0, false + } + sidebarW := sidebarWidth(m.width) + if sidebarW <= 0 { + return 0, false + } + x0 := m.chatColumnWidth() + 3 // " │ " divider between the columns + x, y := mouseX(msg), mouseY(msg) + if x < x0 || x >= x0+sidebarW { + return 0, false + } + for _, hit := range m.sidebarOrchestrateSelectables(sidebarW) { + if hit.lineOffset == y { + return hit.taskIndex, true + } + } + return 0, false +} + +// orchestrateHeaderAtMouse reports a click on the sidebar's PLAN header, which +// collapses and expands the whole section. +func (m model) orchestrateHeaderAtMouse(msg tea.MouseMsg) bool { + if !m.sidebarActive() || m.orchestrate.isEmpty() || !m.plan.isEmpty() { + return false + } + sidebarW := sidebarWidth(m.width) + if sidebarW <= 0 { + return false + } + x0 := m.chatColumnWidth() + 3 + x, y := mouseX(msg), mouseY(msg) + if x < x0 || x >= x0+sidebarW { + return false + } + agentBody := len(m.sidebarAgentLines(sidebarW)) + if agentBody == 0 { + agentBody = 1 + } + // AGENTS header + body + blank line, then the PLAN header. + return y == 1+agentBody+1 +} + +// sidebarPlanDetailLines renders the selected task into the space left between +// ACTIVITY and the token floor. Returns nothing when there is no room — the +// detail is the first thing to give way, since the task list above it is what +// the section is for. +func (m model) sidebarPlanDetailLines(width, budget int) []string { + state := m.orchestrate + if state.isEmpty() || state.sidebarCollapsed || budget < 4 { + return nil + } + if m.orchestrateSelected < 0 || m.orchestrateSelected >= len(state.tasks) { + return nil + } + task := state.tasks[m.orchestrateSelected] + now := m.orchestrateNow() + room := maxInt(6, width-3) + + lines := []string{ + "", + sidebarHeaderWithCount("TASK", task.id, orchestrateStatusStyle(task.status), width), + } + + head := task.status.label() + if elapsed := task.elapsed(now); elapsed > 0 { + head += " · " + formatElapsedSeconds(elapsed) + } + lines = append(lines, " "+zeroTheme.muted.Render(truncateStep(head, room))) + + info, hasCard := m.specialists.getBySessionID(task.cardKey) + if hasCard { + meta := fmt.Sprintf("%d tool calls", info.toolCount) + if info.toolCount == 1 { + meta = "1 tool call" + } + if info.tokenCount > 0 { + meta += " · " + formatTokenCount(info.tokenCount) + " tok" + } + lines = append(lines, " "+zeroTheme.faint.Render(truncateStep(meta, room))) + } + + if len(task.dependsOn) > 0 { + lines = append(lines, " "+zeroTheme.faint.Render(truncateStep("after "+strings.Join(task.dependsOn, ", "), room))) + } + // The REVERSE edge: what this task is holding up. A failure matters in + // proportion to what waits on it, and the forward edge alone does not say. + if blocks := state.dependents(task.id); len(blocks) > 0 { + lines = append(lines, " "+zeroTheme.faint.Render(truncateStep("blocks "+strings.Join(blocks, ", "), room))) + } + + // What it is doing right now, or where it ended up. One or the other — the + // column has no room for both, and a running task's activity is the more + // useful of the two. + if hasCard && strings.TrimSpace(info.currentTool) != "" && task.status == orchestrateRunning { + activity := info.currentTool + if detail := strings.TrimSpace(info.currentDetail); detail != "" { + activity += " " + detail + } + lines = append(lines, " "+zeroTheme.accent.Render("↳ ")+zeroTheme.faint.Render(truncateStep(activity, room-2))) + } else if outcome := m.orchestrateOutcomeLine(task, hasCard, info); outcome != "" { + lines = append(lines, " "+zeroTheme.faint.Render(truncateStep(outcome, room))) + } + + // The prompt last: it is the least urgent and the first thing worth losing + // when the column is short. + if summary := strings.TrimSpace(task.summary); summary != "" && len(lines) < budget { + lines = append(lines, " "+zeroTheme.faint.Render(truncateStep(summary, room))) + } + + if len(lines) > budget { + lines = lines[:budget] + } + return lines +} + +// orchestrateStatusStyle is the header colour for a task's state, matching the +// glyph palette the task list uses. +func orchestrateStatusStyle(status orchestrateTaskStatus) lipgloss.Style { + switch status { + case orchestrateDone: + return zeroTheme.green + case orchestrateFailed: + return zeroTheme.red + case orchestrateRunning: + return zeroTheme.accent + default: + return zeroTheme.faint + } +} + +// orchestrateOutcomeLine states where a task ended up, in one line. +func (m model) orchestrateOutcomeLine(task orchestrateTask, hasCard bool, info specialistInfo) string { + switch task.status { + case orchestrateRunning: + return "still running…" + case orchestratePending: + if len(task.dependsOn) == 0 { + return "queued" + } + return "waiting on " + strings.Join(task.dependsOn, ", ") + case orchestrateDone: + return "completed" + } + // Failed, skipped or cancelled: the reason is what matters, and the card's + // error text is more specific than the status word. + if hasCard && strings.TrimSpace(info.errorMsg) != "" { + return firstLineOf(info.errorMsg) + } + return task.status.label() +} + +func firstLineOf(text string) string { + if index := strings.IndexAny(text, "\r\n"); index >= 0 { + return strings.TrimSpace(text[:index]) + } + return strings.TrimSpace(text) +} + +// cycleOrchestrateSelection moves the sidebar's task selection. The sidebar is +// not focusable, so this is the keyboard route to what clicking a row does — +// a mouse-only affordance is not an affordance. +func (m model) cycleOrchestrateSelection() model { + if len(m.orchestrate.tasks) == 0 { + return m + } + m.orchestrateSelected = (m.orchestrateSelected + 1) % len(m.orchestrate.tasks) + return m +} + +// orchestratePlanBar is the progress bar under the sidebar's PLAN header, drawn +// only for an orchestrate plan — update_plan's steps have their own section +// conventions and no notion of failure to colour. +// +// It occupies ONE line, and sidebarOrchestrateSelectables accounts for it, so +// the click offsets below stay correct. +func (m model) orchestratePlanBar(width int) string { + if !m.plan.isEmpty() || m.orchestrate.isEmpty() || m.orchestrate.sidebarCollapsed { + return "" + } + return sidebarProgressBar(m.orchestrate, width) +} diff --git a/internal/tui/sidebar_plan_detail_test.go b/internal/tui/sidebar_plan_detail_test.go new file mode 100644 index 000000000..cfc419775 --- /dev/null +++ b/internal/tui/sidebar_plan_detail_test.go @@ -0,0 +1,243 @@ +package tui + +import ( + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" +) + +func sidebarDetailModel(t *testing.T) model { + t.Helper() + m := model{now: func() time.Time { return time.Unix(1000, 0) }} + m.orchestrate.admit(diamondAdmitted(), m.now()) + now := m.now() + m.orchestrate.markStarted("a", "survey the packages", "k1", now) + m.orchestrate.markDone("a", "succeeded", 9000, now) + m.orchestrate.markStarted("b", "read the left branch", "k2", now) + m.specialists.start("b", "read the left branch", "k2", now) + m.specialists.incrementToolCount("k2") + m.specialists.setCurrentTool("k2", "read_file", "internal/agent/loop.go") + m.specialists.setTokens("k2", 21400) + m.orchestrate.linkCard("b", "k2") + m.orchestrateSelected = 1 + m.width, m.height = 140, 40 + m.altScreen = true + // The sidebar only renders once there is real conversation + // (sidebarAvailable), so an empty transcript would leave every interaction + // below unreachable and the tests passing for the wrong reason. + m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowUser, text: "hello"}) + if !m.sidebarActive() { + t.Fatal("setup: the sidebar must be active for these interactions to be reachable") + } + return m +} + +// THE DEAD SPACE IS THE POINT. The detail fills what the sidebar previously +// padded with blank lines down to the token floor. +func TestTheTaskDetailFillsTheSidebarsSpareSpace(t *testing.T) { + m := sidebarDetailModel(t) + rendered := stripANSILines(m.renderContextSidebar(34, 28)) + + if !strings.Contains(rendered, "TASK") { + t.Fatalf("the TASK section is missing:\n%s", rendered) + } + for _, want := range []string{"b", "running", "1 tool call", "21,400 tok", "read_file"} { + if !strings.Contains(rendered, want) { + t.Errorf("the detail does not show %q:\n%s", want, rendered) + } + } +} + +// It gives way when there is no room: the task LIST is what the section is for, +// and a detail that pushed it off would be worse than no detail. +func TestTheDetailYieldsWhenTheColumnIsShort(t *testing.T) { + m := sidebarDetailModel(t) + if got := m.sidebarPlanDetailLines(34, 3); len(got) != 0 { + t.Fatalf("with 3 rows to spare the detail must yield, got %d lines", len(got)) + } + if got := m.sidebarPlanDetailLines(34, 8); len(got) == 0 { + t.Fatal("with room to spare the detail must render") + } +} + +// THE OFFSET ARITHMETIC. The progress bar sits between the PLAN header and the +// first task, so every clickable row moves down one. A hit table that ignored +// it would select the task ABOVE the one clicked — silently. +func TestTheProgressBarIsAccountedForInClickOffsets(t *testing.T) { + m := sidebarDetailModel(t) + width := 34 + + hits := m.sidebarOrchestrateSelectables(width) + if len(hits) == 0 { + t.Fatal("no clickable plan rows") + } + + // Render the section and find where the first task actually lands. + lines := stripANSILines(m.renderContextSidebar(width, 28)) + rows := strings.Split(lines, "\n") + firstTaskRow := -1 + for index, row := range rows { + if strings.Contains(row, "✓ a") { + firstTaskRow = index + break + } + } + if firstTaskRow < 0 { + t.Fatalf("the first task is not in the rendered sidebar:\n%s", lines) + } + if hits[0].lineOffset != firstTaskRow { + t.Fatalf("the hit table puts the first task at row %d, it renders at %d — clicks would select the wrong task", + hits[0].lineOffset, firstTaskRow) + } +} + +// FILES sits below the PLAN section, so its offsets must move with the bar too. +// Asserted against where the row actually RENDERS, not against a re-derivation +// of the arithmetic — the first version of this test recomputed the sum and +// would have passed with the bar unaccounted for on both sides. +func TestFileOffsetsAccountForTheProgressBar(t *testing.T) { + m := sidebarDetailModel(t) + // Touched files are derived from the transcript, so seed one the way a real + // run would rather than poking a field. + m.transcript = appendTranscriptRow(m.transcript, transcriptRow{ + kind: rowToolResult, + changedFiles: []string{"internal/tui/model.go"}, + detail: "1 insertion(+), 1 deletion(-)", + }) + if m.orchestratePlanBar(34) == "" { + t.Fatal("setup: expected a progress bar") + } + + hits := m.sidebarFileSelectables(34) + if len(hits) == 0 { + t.Fatal("setup: expected a clickable file row") + } + rows := strings.Split(stripANSILines(m.renderContextSidebar(34, 30)), "\n") + rendered := -1 + for index, row := range rows { + if strings.Contains(row, "model.go") { + rendered = index + break + } + } + if rendered < 0 { + t.Fatalf("the file row is not in the rendered sidebar:\n%s", strings.Join(rows, "\n")) + } + if hits[0].lineOffset != rendered { + t.Fatalf("the file hit table says row %d, it renders at %d — a click would open the wrong thing", + hits[0].lineOffset, rendered) + } +} + +// Clicking a task row selects it, through the real mouse handler. +func TestClickingASidebarTaskSelectsIt(t *testing.T) { + m := sidebarDetailModel(t) + width := sidebarWidth(m.width) + hits := m.sidebarOrchestrateSelectables(width) + if len(hits) < 2 { + t.Fatalf("expected several clickable rows, got %d", len(hits)) + } + + target := hits[0] + x := m.chatColumnWidth() + 4 + updated, _ := m.Update(tea.MouseClickMsg{X: x, Y: target.lineOffset, Button: tea.MouseLeft}) + m = updated.(model) + if m.orchestrateSelected != target.taskIndex { + t.Fatalf("selected %d, want %d — the click did not land on the task it was over", + m.orchestrateSelected, target.taskIndex) + } +} + +// Clicking the PLAN header collapses the section, and says how to reopen it. +func TestClickingTheSidebarPlanHeaderCollapsesIt(t *testing.T) { + m := sidebarDetailModel(t) + sidebarW := sidebarWidth(m.width) + agentBody := len(m.sidebarAgentLines(sidebarW)) + if agentBody == 0 { + agentBody = 1 + } + headerRow := 1 + agentBody + 1 + + x := m.chatColumnWidth() + 4 + updated, _ := m.Update(tea.MouseClickMsg{X: x, Y: headerRow, Button: tea.MouseLeft}) + m = updated.(model) + if !m.orchestrate.sidebarCollapsed { + t.Fatal("clicking the PLAN header did not collapse the section") + } + rendered := stripANSILines(m.renderContextSidebar(sidebarW, 28)) + if !strings.Contains(rendered, "collapsed") { + t.Fatalf("a collapsed section must say how to reopen it:\n%s", rendered) + } + if strings.Contains(rendered, "TASK") { + t.Fatalf("the detail must collapse with the list:\n%s", rendered) + } +} + +// The keyboard reaches what the mouse does. The sidebar is not focusable, so a +// mouse-only affordance is not an affordance. +func TestCtrlGCyclesTheSidebarSelection(t *testing.T) { + m := sidebarDetailModel(t) + before := m.orchestrateSelected + updated, _ := m.Update(tea.KeyPressMsg{Code: 'g', Mod: tea.ModCtrl}) + m = updated.(model) + if m.orchestrateSelected == before { + t.Fatal("ctrl+g did not move the sidebar's task selection") + } + // ...and it wraps rather than sticking at the end. + for range len(m.orchestrate.tasks) { + updated, _ = m.Update(tea.KeyPressMsg{Code: 'g', Mod: tea.ModCtrl}) + m = updated.(model) + } + if m.orchestrateSelected < 0 || m.orchestrateSelected >= len(m.orchestrate.tasks) { + t.Fatalf("selection escaped the task list: %d", m.orchestrateSelected) + } +} + +// The bar colours failure separately from progress, and never rounds a failure +// away to nothing. +func TestTheProgressBarShowsFailuresSeparately(t *testing.T) { + m := sidebarDetailModel(t) + m.orchestrate.markDone("b", "failed", 0, m.now()) + + bar := m.orchestratePlanBar(34) + if bar == "" { + t.Fatal("no progress bar rendered") + } + if !strings.Contains(bar, "1/4") { + t.Fatalf("the bar must carry the count: %q", ansi.Strip(bar)) + } + if strings.Count(ansi.Strip(bar), "█") < 2 { + t.Fatalf("done and failed must both be drawn: %q", ansi.Strip(bar)) + } + + // THE ROUNDING CASE, which is the one that matters: more tasks than cells, + // so a single failure divides to zero. A failure the bar rounds away is a + // failure it does not show. + big := model{now: m.now} + msg := planAdmittedMsg{runID: 1, name: "big"} + for index := 0; index < 30; index++ { + msg.tasks = append(msg.tasks, planGraphTask{id: string(rune('a' + index%26))}) + } + msg.taskCount = len(msg.tasks) + big.orchestrate.admit(msg, big.now()) + big.orchestrate.tasks[0].status = orchestrateFailed + + plain := ansi.Strip(sidebarProgressBar(big.orchestrate, 34)) + if !strings.Contains(plain, "█") { + t.Fatalf("one failure in thirty rounded away to nothing: %q", plain) + } +} + +// A task's reverse edge is shown: a failure matters in proportion to what waits +// on it, and the forward edge alone does not say. +func TestTheDetailShowsWhatATaskBlocks(t *testing.T) { + m := sidebarDetailModel(t) + m.orchestrateSelected = 0 // "a", which b and c depend on + rendered := strings.Join(m.sidebarPlanDetailLines(34, 12), "\n") + if !strings.Contains(ansi.Strip(rendered), "blocks b, c") { + t.Fatalf("the detail does not show what the task blocks:\n%s", rendered) + } +} From 330860390a43ff71ebad6883e34d959afd0d3b7e Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:43:26 +0530 Subject: [PATCH 29/86] feat(tui): the zeromaxing posture glows, in the footer and where it is offered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The posture raises a cost multiplier — 320 turns per run, inherited by every sub-agent — and it was a word in amber, in the same weight as everything beside it. "Is it on?" should be answerable from across the room. The footer chip is now a FILLED badge: the label sits on the amber rather than in it, which is what reads as lit rather than as another label in the row. Off still means ABSENT, not dim — the footer is byte-identical without the posture. It breathes while a turn is in flight, ◦ • ● ◉ ● •, on a 1.4s cycle. Slow on purpose: this marks a standing state, not activity, and a fast blink beside a working spinner reads as a second thing happening. THE PULSE IS FREE, and that constraint shaped it. It advances on the spinner tick that is ALREADY running during a turn and holds a steady full glyph otherwise. ensureSpinnerTick deliberately schedules no timer on an idle session — "an idle plain session schedules no timer" — and a chip is not a reason to break that. Steady rather than frozen mid-cycle, because a half-lit chip on an idle session would read as a rendering fault. Reduced motion pins it, like every other animation here. The /effort picker marks the posture row too, so what you are about to turn on looks like what you will see once it is on. Marked with a glyph rather than merely coloured: the selected row already owns its background, so colour alone would be invisible exactly when you are looking at it. The marker is display only — the VALUE handed to the command stays the bare name, or selecting it would be refused as an unknown effort. Seven mutations, all caught. Two needed the tests fixed first: the chip test called the helper rather than the footer, so reverting the footer to plain amber text passed it; and the value test located the row BY value, so the moment the marker leaked into the value the row stopped matching and the test passed by finding nothing. --- internal/tui/picker.go | 9 +- internal/tui/view.go | 4 +- internal/tui/zeromaxing_glow.go | 70 ++++++++++++ internal/tui/zeromaxing_glow_test.go | 158 +++++++++++++++++++++++++++ 4 files changed, 238 insertions(+), 3 deletions(-) create mode 100644 internal/tui/zeromaxing_glow.go create mode 100644 internal/tui/zeromaxing_glow_test.go diff --git a/internal/tui/picker.go b/internal/tui/picker.go index b90e881c4..337777995 100644 --- a/internal/tui/picker.go +++ b/internal/tui/picker.go @@ -989,7 +989,14 @@ func (m model) newEffortPicker() *commandPicker { items := []pickerItem{{Label: "auto", Value: "auto"}} selected := 0 for _, value := range m.settableEfforts() { - items = append(items, pickerItem{Label: value, Value: value}) + label := value + if isZeromaxingOption(value) { + // The posture is not another effort level — it is a run posture + // that raises a cost multiplier. It is marked here so it looks in + // the picker like it looks once it is on. + label = "◉ " + value + } + items = append(items, pickerItem{Label: label, Value: value}) if m.reasoningEffort != "" && value == string(m.reasoningEffort) { selected = len(items) - 1 } diff --git a/internal/tui/view.go b/internal/tui/view.go index 14d22fbda..4171a1abe 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -224,8 +224,8 @@ func (m model) statusLine(width int) string { // The zeromaxing posture sits beside the effort chip: both describe how hard // this session tries, and it raises a cost multiplier, so it stays visible // for as long as it is on rather than only appearing in a status card. - if m.zeromaxingActive() { - left += zeroTheme.muted.Render(" · ") + zeroTheme.amber.Render(zeromaxingChipLabel) + if chip := m.zeromaxingGlowChip(); chip != "" { + left += zeroTheme.muted.Render(" · ") + chip } if m.exitConfirmActive { left = prefix + btwChip + zeroTheme.amber.Render("●") + " " + zeroTheme.amber.Render(ctrlCExitConfirmText) diff --git a/internal/tui/zeromaxing_glow.go b/internal/tui/zeromaxing_glow.go new file mode 100644 index 000000000..021aafc43 --- /dev/null +++ b/internal/tui/zeromaxing_glow.go @@ -0,0 +1,70 @@ +package tui + +import ( + "strings" + "time" +) + +// The zeromaxing posture gets a LIT chip rather than coloured text. +// +// It raises a cost multiplier — 320 turns per run, inherited by every sub-agent +// — so "is it on?" must be answerable from across the room, not by reading a +// word in the same weight as everything beside it. A filled badge reads as lit +// where amber text reads as a label. +// +// THE PULSE IS FREE. It advances on the spinner tick that is already running +// while a turn is in flight, and holds a steady lit state when nothing is +// animating. ensureSpinnerTick deliberately schedules no timer on an idle +// session — "an idle plain session schedules no timer" — and a chip is not a +// reason to break that. So the glow breathes exactly while there is work to +// breathe with, and simply stays on otherwise. +// +// Reduced motion pins it to the steady state, like every other animation here. + +// zeromaxingGlowFrames are the pulse's brightness steps, cycled on the spinner +// tick. Symmetric, so it breathes in and out rather than snapping back. +var zeromaxingGlowFrames = []string{"◦", "•", "●", "◉", "●", "•"} + +// zeromaxingPulsePeriod is how long one full breath takes. Slow: this marks a +// standing state, not activity, and a fast blink beside a working spinner reads +// as a second thing happening. +const zeromaxingPulsePeriod = 1400 * time.Millisecond + +// zeromaxingGlowChip renders the footer's posture indicator. +// +// Returns "" when the posture is off, so the footer is byte-identical without +// it — the same rule every other part of this feature follows. +func (m model) zeromaxingGlowChip() string { + if !m.zeromaxingActive() { + return "" + } + marker := m.zeromaxingPulseGlyph() + // A filled badge: the label sits ON the amber rather than in it, which is + // what makes it read as lit rather than as another word in the row. + return zeroTheme.permBadge.Render(" " + marker + " " + zeromaxingChipLabel + " ") +} + +// zeromaxingPulseGlyph picks the current breath frame. Steady when nothing is +// animating, so the chip never freezes mid-pulse on a dimmed frame — a half-lit +// chip on an idle session would read as a rendering fault. +func (m model) zeromaxingPulseGlyph() string { + if m.reducedMotion || !m.pending { + return "●" + } + step := m.now().UnixMilli() % zeromaxingPulsePeriod.Milliseconds() + index := int(step * int64(len(zeromaxingGlowFrames)) / zeromaxingPulsePeriod.Milliseconds()) + if index < 0 || index >= len(zeromaxingGlowFrames) { + return "●" + } + return zeromaxingGlowFrames[index] +} + +// zeromaxingOptionValue is the posture's name as it appears in option lists. +// Declared here rather than reaching into execprofile so this file has no +// dependency beyond rendering. +const zeromaxingOptionValue = "zeromaxing" + +// isZeromaxingOption reports whether a picker/palette entry is the posture. +func isZeromaxingOption(value string) bool { + return strings.EqualFold(strings.TrimSpace(value), zeromaxingOptionValue) +} diff --git a/internal/tui/zeromaxing_glow_test.go b/internal/tui/zeromaxing_glow_test.go new file mode 100644 index 000000000..5478cd5ff --- /dev/null +++ b/internal/tui/zeromaxing_glow_test.go @@ -0,0 +1,158 @@ +package tui + +import ( + "strings" + "testing" + "time" + + "github.com/charmbracelet/x/ansi" +) + +func glowModel(t *testing.T) model { + t.Helper() + m := model{now: func() time.Time { return time.Unix(1000, 0) }} + m.execProfileName = "zeromaxing" + m.zeromaxing = 2 // ZeromaxingActive + if !m.zeromaxingActive() { + t.Fatal("setup: the posture must be active") + } + return m +} + +// The chip is LIT, not merely coloured: a filled badge reads as on from across +// the room, where amber text reads as another word in the row. The posture +// raises a cost multiplier, so "is it on?" has to be answerable at a glance. +// +// Asserted on the RENDERED FOOTER, not on the helper: the first version called +// zeromaxingGlowChip directly, and reverting the footer to plain amber text +// passed it. +func TestTheZeromaxingChipIsAFilledBadge(t *testing.T) { + m := glowModel(t) + m.width, m.height = 100, 30 + + footer := m.footerView(m.width) + if !strings.Contains(ansi.Strip(footer), zeromaxingChipLabel) { + t.Fatalf("the footer does not carry the posture label:\n%s", ansi.Strip(footer)) + } + // A background fill on the label's own run is what distinguishes a lit + // badge from coloured text. + if !strings.Contains(footer, "48;2;") { + t.Fatalf("the footer chip has no background fill, so it is text and not a badge") + } + if chip := m.zeromaxingGlowChip(); !strings.Contains(footer, chip) { + t.Fatalf("the footer does not render the glow chip it was given:\n%s", ansi.Strip(footer)) + } +} + +// Off means ABSENT, not dim. The footer is byte-identical without the posture, +// which is the rule every part of this feature follows. +func TestThePostureChipVanishesWhenOff(t *testing.T) { + m := model{now: func() time.Time { return time.Unix(1000, 0) }} + if got := m.zeromaxingGlowChip(); got != "" { + t.Fatalf("with the posture off the chip must render nothing, got %q", got) + } +} + +// THE PULSE IS FREE, and that constraint shapes it: it advances on the spinner +// tick that is already running during a turn, and holds steady otherwise. +// ensureSpinnerTick schedules no timer on an idle session, and a chip is not a +// reason to break that. +func TestThePulseOnlyBreathesWhileThereIsWork(t *testing.T) { + m := glowModel(t) + + // Idle: steady, at full brightness. A chip frozen on a dim frame would read + // as a rendering fault. + for _, ms := range []int64{0, 300, 700, 1100} { + m.now = func() time.Time { return time.UnixMilli(ms) } + if got := m.zeromaxingPulseGlyph(); got != "●" { + t.Fatalf("idle at %dms rendered %q, want a steady full glyph", ms, got) + } + } + + // Pending: it moves. + m.pending = true + seen := map[string]bool{} + for ms := int64(0); ms < zeromaxingPulsePeriod.Milliseconds(); ms += 100 { + m.now = func() time.Time { return time.UnixMilli(ms) } + seen[m.zeromaxingPulseGlyph()] = true + } + if len(seen) < 3 { + t.Fatalf("the pulse only reached %d frames across a full period; it is not breathing", len(seen)) + } +} + +// Reduced motion pins it, like every other animation here. +func TestReducedMotionStopsThePulse(t *testing.T) { + m := glowModel(t) + m.pending = true + m.reducedMotion = true + for _, ms := range []int64{0, 400, 900} { + m.now = func() time.Time { return time.UnixMilli(ms) } + if got := m.zeromaxingPulseGlyph(); got != "●" { + t.Fatalf("reduced motion still animated: %q at %dms", got, ms) + } + } +} + +// The pulse never falls off its frame table, whatever the clock says. +func TestThePulseNeverLeavesItsFrames(t *testing.T) { + m := glowModel(t) + m.pending = true + valid := map[string]bool{} + for _, frame := range zeromaxingGlowFrames { + valid[frame] = true + } + valid["●"] = true + for _, ms := range []int64{0, 1, 1399, 1400, 999999999, 1} { + m.now = func() time.Time { return time.UnixMilli(ms) } + if got := m.zeromaxingPulseGlyph(); !valid[got] { + t.Fatalf("clock %dms produced %q, which is not a frame", ms, got) + } + } +} + +// The posture is marked where it is OFFERED too, so what you are about to turn +// on looks like what you will see once it is on. Marked rather than merely +// coloured: the selected picker row already owns its background, so colour +// alone would be invisible exactly when you are looking at it. +func TestThePostureIsMarkedInTheEffortPicker(t *testing.T) { + for _, name := range []string{"glm-5.2", "claude-sonnet-4.5", "gpt-4o"} { + var postureLabel string + for _, item := range (model{modelName: name}).newEffortPicker().items { + if isZeromaxingOption(item.Value) { + postureLabel = item.Label + } + } + if postureLabel == "" { + t.Fatalf("%s: the picker does not offer the posture at all", name) + } + if !strings.Contains(postureLabel, "◉") { + t.Errorf("%s: the posture row is unmarked: %q", name, postureLabel) + } + } +} + +// The marker is display only — the VALUE the picker hands to the command must +// stay the bare name, or selecting it would be refused as an unknown effort. +func TestTheMarkerDoesNotLeakIntoTheSelectedValue(t *testing.T) { + m := model{modelName: "glm-5.2"} + // Located by its LABEL, not its value: matching on the value would skip the + // row entirely the moment the marker leaked into it, and the test would + // pass by finding nothing. + found := false + for _, item := range m.newEffortPicker().items { + if !strings.Contains(item.Label, "◉") { + continue + } + found = true + if item.Value != "zeromaxing" { + t.Fatalf("the picker would send %q to the command, want the bare name", item.Value) + } + if _, out := m.handleEffortCommand(item.Value); strings.Contains(out, "Unknown reasoning effort") { + t.Fatalf("the command refuses the value the picker offers: %s", out) + } + } + if !found { + t.Fatal("no marked row in the picker at all") + } +} From 9909f7e66606fe61314a820823dec9c117f744a1 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:59:40 +0530 Subject: [PATCH 30/86] feat(tui): hover on the posture chip and the plan rows, and the chip is clickable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chip is now clickable — pressing it opens /effort, where the posture can be turned off or changed — so it highlights under the cursor. A clickable chip that looks identical to a label never teaches anyone it can be pressed. Not while a turn is in flight: /effort refuses mid-run, and opening a dialog that cannot be acted on is worse than opening none. Its hit region is measured from the RENDERED footer rather than assumed. The chips before it — permission mode, effort — vary in width with the session, so a fixed offset would drift the moment either changed. The sidebar's plan rows highlight too, joining the agent, plan-step and file rows that already did. The hover is held by TASK ID, not row index, for the reason the others hold identities: the rendered row set changes as tasks finish and fade, with no mouse motion in between to re-resolve it, and a cached offset would light up whatever slid into the slot. The /effort picker already highlighted its rows on hover — selectGenericPicker- AtMouse live-previews on motion — so the zeromaxing row needed nothing. Six mutations, five caught. The sixth is belt-and-braces rather than a gap and the comment now says so: the posture-off guard in the chip's hit test is a fast path, not the enforcement — with the posture off the footer carries no chip, so the span lookup fails anyway. --- internal/tui/hover.go | 16 ++++ internal/tui/mouse.go | 14 ++++ internal/tui/sidebar.go | 10 +++ internal/tui/sidebar_plan_detail_test.go | 38 +++++++++ internal/tui/zeromaxing_glow.go | 74 +++++++++++++++- internal/tui/zeromaxing_glow_test.go | 102 +++++++++++++++++++++++ 6 files changed, 253 insertions(+), 1 deletion(-) diff --git a/internal/tui/hover.go b/internal/tui/hover.go index 5ec530e1a..93cf45d2a 100644 --- a/internal/tui/hover.go +++ b/internal/tui/hover.go @@ -18,6 +18,13 @@ const ( // hoverFileRow: a touched-file row in the sidebar's FILES section, identified // by path. hoverFileRow + // hoverZeromaxingChip: the footer's posture badge, which opens /effort. + hoverZeromaxingChip + // hoverOrchestrateTask: a plan task row in the sidebar's PLAN section, + // identified by task ID. The ID rather than the index for the same reason + // the others use stable identities: the rendered row set changes as tasks + // finish and fade, with no mouse motion in between to re-resolve it. + hoverOrchestrateTask ) // hoverTarget identifies the single clickable row (if any) currently under the @@ -38,6 +45,7 @@ type hoverTarget struct { sessionID string // hoverSidebarAgent stepIndex int // hoverPlanStep filePath string // hoverFileRow + taskID string // hoverOrchestrateTask } // mouseHover reports whether msg is a plain cursor-movement event with NO button @@ -57,12 +65,20 @@ func mouseHover(msg tea.MouseMsg) bool { // step) takes priority since it's outside the chat column, then a clickable // transcript line. Clears the hover when nothing clickable is under the cursor. func (m model) updateHoverTarget(msg tea.MouseMsg) model { + // The footer chip first: it sits outside every other hit-tester's region, + // and those all return false for it rather than claiming it. + if m.zeromaxingChipAtMouse(msg) { + return m.withHover(hoverTarget{kind: hoverZeromaxingChip}) + } if hit, ok := m.sidebarLineAtMouse(msg); ok { return m.withHover(hoverTarget{kind: hoverSidebarAgent, sessionID: hit.sessionID}) } if stepIndex, ok := m.planStepAtMouse(msg); ok { return m.withHover(hoverTarget{kind: hoverPlanStep, stepIndex: stepIndex}) } + if index, ok := m.orchestrateTaskAtMouse(msg); ok && index < len(m.orchestrate.tasks) { + return m.withHover(hoverTarget{kind: hoverOrchestrateTask, taskID: m.orchestrate.tasks[index].id}) + } if path, ok := m.fileRowAtMouse(msg); ok { return m.withHover(hoverTarget{kind: hoverFileRow, filePath: path}) } diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go index d06e745fe..cf1db07ba 100644 --- a/internal/tui/mouse.go +++ b/internal/tui/mouse.go @@ -90,6 +90,20 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { // Clicking the orchestrate plan's header line opens or closes it. Checked // before the surface switch below because the panel lives in the FOOTER, // outside every transcript/sidebar region those cases test. + // Clicking the posture chip opens /effort, where it can be turned off or + // changed. A chip that highlights under the cursor and then does nothing + // when pressed is worse than one that never highlighted. + if mouseLeftPress(msg) && m.zeromaxingChipAtMouse(msg) { + // Not while a turn is in flight: the effort picker refuses mid-run for + // the same reason /effort does, and opening one that cannot be acted on + // would be a dead dialog. + if !m.pending { + if picker := m.newEffortPicker(); picker != nil { + m.picker = picker + } + } + return m, nil + } // The plan lives in the sidebar: clicking a task selects it (the TASK // section below shows it), clicking the PLAN header collapses the section. // Checked before the surface switch because the sidebar is not one of the diff --git a/internal/tui/sidebar.go b/internal/tui/sidebar.go index ef29d10bc..48c437cf0 100644 --- a/internal/tui/sidebar.go +++ b/internal/tui/sidebar.go @@ -734,6 +734,16 @@ func (m model) hoveredSidebarLineOffset(width int) (int, bool) { return hit.lineOffset, true } } + case hoverOrchestrateTask: + // Re-resolved by task ID every render: a task that faded out of the + // list since the hover was set simply stops highlighting, rather than + // lighting up whatever row slid into its slot. + for _, hit := range m.sidebarOrchestrateSelectables(width) { + if hit.taskIndex < len(m.orchestrate.tasks) && + m.orchestrate.tasks[hit.taskIndex].id == m.hover.taskID { + return hit.lineOffset, true + } + } } return 0, false } diff --git a/internal/tui/sidebar_plan_detail_test.go b/internal/tui/sidebar_plan_detail_test.go index cfc419775..fbbbc09f8 100644 --- a/internal/tui/sidebar_plan_detail_test.go +++ b/internal/tui/sidebar_plan_detail_test.go @@ -241,3 +241,41 @@ func TestTheDetailShowsWhatATaskBlocks(t *testing.T) { t.Fatalf("the detail does not show what the task blocks:\n%s", rendered) } } + +// HOVER ON THE PLAN ROWS. They are clickable, so they must highlight under the +// cursor like every other clickable sidebar row. +func TestHoveringASidebarTaskHighlightsIt(t *testing.T) { + m := sidebarDetailModel(t) + width := sidebarWidth(m.width) + hits := m.sidebarOrchestrateSelectables(width) + if len(hits) == 0 { + t.Fatal("no clickable plan rows") + } + target := hits[0] + + m = m.updateHoverTarget(tea.MouseMotionMsg{X: m.chatColumnWidth() + 4, Y: target.lineOffset}) + if m.hover.kind != hoverOrchestrateTask { + t.Fatalf("hovering a plan row set hover kind %v, want the task kind", m.hover.kind) + } + if want := m.orchestrate.tasks[target.taskIndex].id; m.hover.taskID != want { + t.Fatalf("hover identified %q, want %q", m.hover.taskID, want) + } + + offset, ok := m.hoveredSidebarLineOffset(width) + if !ok || offset != target.lineOffset { + t.Fatalf("the hover resolved to row %d (ok=%v), want %d", offset, ok, target.lineOffset) + } +} + +// The hover is held by TASK ID, not by row index. A task that faded out of the +// list since the hover was set must stop highlighting rather than light up +// whatever row slid into its slot. +func TestAFadedTasksHoverStopsResolving(t *testing.T) { + m := sidebarDetailModel(t) + width := sidebarWidth(m.width) + + m.hover = hoverTarget{kind: hoverOrchestrateTask, taskID: "no-such-task"} + if _, ok := m.hoveredSidebarLineOffset(width); ok { + t.Fatal("a hover on a row that is gone still resolved to a line") + } +} diff --git a/internal/tui/zeromaxing_glow.go b/internal/tui/zeromaxing_glow.go index 021aafc43..ce6010809 100644 --- a/internal/tui/zeromaxing_glow.go +++ b/internal/tui/zeromaxing_glow.go @@ -3,6 +3,9 @@ package tui import ( "strings" "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" ) // The zeromaxing posture gets a LIT chip rather than coloured text. @@ -39,9 +42,75 @@ func (m model) zeromaxingGlowChip() string { return "" } marker := m.zeromaxingPulseGlyph() + body := " " + marker + " " + zeromaxingChipLabel + " " + // HOVER: the chip is clickable — it opens /effort — so it has to say so + // under the cursor. Without a hover state a clickable chip is + // indistinguishable from a label, and the user never learns it can be + // pressed. + if m.hover.kind == hoverZeromaxingChip { + return zeroTheme.hover.Render(body) + } // A filled badge: the label sits ON the amber rather than in it, which is // what makes it read as lit rather than as another word in the row. - return zeroTheme.permBadge.Render(" " + marker + " " + zeromaxingChipLabel + " ") + return zeroTheme.permBadge.Render(body) +} + +// zeromaxingChipWidth is the chip's rendered cell width, used to hit-test it. +// Derived from the same string the renderer builds, so the two cannot drift. +func zeromaxingChipWidth() int { + return len([]rune(" ● " + zeromaxingChipLabel + " ")) +} + +// zeromaxingChipAtMouse reports whether the cursor is over the footer chip. +// +// The chip sits at the END of the footer's left run, so its span is measured +// from the rendered footer rather than assumed: the chips before it (permission +// mode, effort) vary in width with the session. +func (m model) zeromaxingChipAtMouse(msg tea.MouseMsg) bool { + // A FAST PATH, not the enforcement: with the posture off the footer carries + // no chip, so the span lookup below fails anyway. Removing this guard does + // not make the chip hittable — it just renders the footer to find that out. + if !m.zeromaxingActive() || !m.altScreen || m.height <= 0 { + return false + } + row, ok := m.zeromaxingChipRow() + if !ok || mouseY(msg) != row { + return false + } + start, end, ok := m.zeromaxingChipSpan() + if !ok { + return false + } + x := mouseX(msg) + return x >= start && x < end +} + +// zeromaxingChipRow is the footer row the chip renders on. +func (m model) zeromaxingChipRow() (int, bool) { + footer := viewLines(m.footerView(m.chatColumnWidth())) + for index, line := range footer { + if strings.Contains(ansiStripLine(line), zeromaxingChipLabel) { + // Footer rows sit at the bottom of the screen. + return m.height - len(footer) + index, true + } + } + return 0, false +} + +// zeromaxingChipSpan is the chip's [start,end) column range on its row. +func (m model) zeromaxingChipSpan() (int, int, bool) { + footer := viewLines(m.footerView(m.chatColumnWidth())) + for _, line := range footer { + plain := ansiStripLine(line) + index := strings.Index(plain, zeromaxingChipLabel) + if index < 0 { + continue + } + // The label is preceded by " ● " inside the badge. + start := maxInt(0, index-3) + return start, start + zeromaxingChipWidth(), true + } + return 0, 0, false } // zeromaxingPulseGlyph picks the current breath frame. Steady when nothing is @@ -64,6 +133,9 @@ func (m model) zeromaxingPulseGlyph() string { // dependency beyond rendering. const zeromaxingOptionValue = "zeromaxing" +// ansiStripLine removes styling so a rendered row can be measured in cells. +func ansiStripLine(line string) string { return ansi.Strip(line) } + // isZeromaxingOption reports whether a picker/palette entry is the posture. func isZeromaxingOption(value string) bool { return strings.EqualFold(strings.TrimSpace(value), zeromaxingOptionValue) diff --git a/internal/tui/zeromaxing_glow_test.go b/internal/tui/zeromaxing_glow_test.go index 5478cd5ff..ac4c268e1 100644 --- a/internal/tui/zeromaxing_glow_test.go +++ b/internal/tui/zeromaxing_glow_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi" ) @@ -156,3 +157,104 @@ func TestTheMarkerDoesNotLeakIntoTheSelectedValue(t *testing.T) { t.Fatal("no marked row in the picker at all") } } + +// HOVER ON THE CHIP. It opens /effort when pressed, so it has to say so under +// the cursor — a clickable chip that looks identical to a label never teaches +// anyone it can be pressed. +func TestTheChipHighlightsUnderTheCursor(t *testing.T) { + m := glowModel(t) + m.width, m.height = 100, 30 + m.altScreen = true + + plain := m.zeromaxingGlowChip() + m.hover = hoverTarget{kind: hoverZeromaxingChip} + hovered := m.zeromaxingGlowChip() + + if plain == hovered { + t.Fatal("the chip renders identically hovered and not, so hovering it says nothing") + } + if !strings.Contains(ansi.Strip(hovered), zeromaxingChipLabel) { + t.Fatalf("the hovered chip lost its label: %q", ansi.Strip(hovered)) + } +} + +// The chip's hit region is measured from the RENDERED footer, not assumed: the +// chips before it vary in width with the session's permission mode and effort. +func TestTheChipHitRegionTracksTheRenderedFooter(t *testing.T) { + m := glowModel(t) + m.width, m.height = 100, 30 + m.altScreen = true + + start, end, ok := m.zeromaxingChipSpan() + if !ok { + t.Fatal("the chip span could not be resolved from the footer") + } + if end <= start { + t.Fatalf("empty chip span [%d,%d)", start, end) + } + row, ok := m.zeromaxingChipRow() + if !ok { + t.Fatal("the chip row could not be resolved") + } + + inside := tea.MouseMotionMsg{X: start + 1, Y: row} + if !m.zeromaxingChipAtMouse(inside) { + t.Fatalf("a point inside the chip [%d,%d) on row %d did not hit", start, end, row) + } + for _, outside := range []tea.MouseMotionMsg{ + {X: maxInt(0, start-2), Y: row}, + {X: end + 2, Y: row}, + {X: start + 1, Y: maxInt(0, row-2)}, + } { + if m.zeromaxingChipAtMouse(outside) { + t.Fatalf("a point outside the chip (%d,%d) hit anyway", outside.X, outside.Y) + } + } +} + +// With the posture off there is no chip, so nothing can hover or click it. +func TestTheChipIsNotHittableWhenThePostureIsOff(t *testing.T) { + m := model{now: func() time.Time { return time.Unix(1000, 0) }} + m.width, m.height = 100, 30 + m.altScreen = true + if m.zeromaxingChipAtMouse(tea.MouseMotionMsg{X: 5, Y: 29}) { + t.Fatal("the chip is hittable with the posture off") + } +} + +// Clicking it opens the effort picker — where the posture can be turned off or +// changed. +func TestClickingTheChipOpensTheEffortPicker(t *testing.T) { + m := glowModel(t) + m.width, m.height = 100, 30 + m.altScreen = true + m.modelName = "glm-5.2" + + row, ok := m.zeromaxingChipRow() + if !ok { + t.Fatal("the chip row could not be resolved") + } + start, _, _ := m.zeromaxingChipSpan() + + updated, _ := m.Update(tea.MouseClickMsg{X: start + 1, Y: row, Button: tea.MouseLeft}) + m = updated.(model) + if m.picker == nil || m.picker.kind != pickerEffort { + t.Fatalf("clicking the chip did not open the effort picker: %#v", m.picker) + } +} + +// ...but not mid-turn: /effort refuses while a run is in flight, and a dialog +// that cannot be acted on is worse than none. +func TestClickingTheChipMidTurnOpensNothing(t *testing.T) { + m := glowModel(t) + m.width, m.height = 100, 30 + m.altScreen = true + m.pending = true + + row, _ := m.zeromaxingChipRow() + start, _, _ := m.zeromaxingChipSpan() + updated, _ := m.Update(tea.MouseClickMsg{X: start + 1, Y: row, Button: tea.MouseLeft}) + if updated.(model).picker != nil { + t.Fatal("a picker opened mid-turn, where it cannot be acted on") + } +} From 0e17caafedd18848e4bb1c197099995fa10da73d Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:23:28 +0530 Subject: [PATCH 31/86] =?UTF-8?q?feat(specialist):=20per-task=20stall=20wa?= =?UTF-8?q?tchdog=20(gap=20report=20=C2=A75.10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing the token cap left max_wall_seconds — optional and model-supplied — as the only bound on a plan, and NOTHING bounding a single task. A child that wedges against an unresponsive provider hung until the user noticed. KEYED ON SILENCE, NOT DURATION, and that distinction is the whole design. A task reading forty files for four minutes is working; one that has emitted nothing for three is not. Killing the first would be worse than having no watchdog, because a timeout that fires on healthy work teaches users to raise it until it never fires. The clock resets on every stream event the child emits. Default 180s, settable per plan via budget.max_stall_seconds, floored at 30s — below that it fires on ordinary think-time and becomes a random task-killer. The floor is enforced on MODEL input in planBudget, not at construction, so internal callers with an already-validated value are not second-guessed. It cancels the TASK's own context, derived from the caller's, so a wedged task never takes the plan with it; the dependents skip as they would for any failure. A stall reports as a stall, not as a cancellation: both surface as a context error, and a user who stopped a plan and a plan that hung are looking at very different problems. A BOUND, NOT A RETRY POLICY. A stall is usually a provider or network condition that a second identical attempt hits again, and spending another task's budget to find that out needs its own argument. The runner now always hands the executor a progress callback, because the watchdog needs the liveness signal whether or not a UI wants the events. TestPlanRunnerWithoutProgressPassesNil asserted the opposite and was right until this existed — with no feed, a child talking happily would be judged silent and killed. The executor is unaffected: `progress != nil` gates only the call, and the event is parsed and stored regardless. Ten mutations, all caught — but four of them only after fixing the tests: Two were indistinguishable. The wedged-child test's child emits nothing, so dropping the watchdog's feed and using the parent context both still produced a stall. A chatty-child test (speaks every 10ms, 60ms timeout, must survive) and a parent-context-liveness test tell them apart. One test could HANG rather than fail: with the watchdog disabled, the wedged child blocked forever and took the whole mutation sweep with it. A test for a hang must not be able to hang; it carries its own deadline now. And the sweep itself needed per-run timeouts, for the same reason. --- internal/specialist/plan.go | 14 + internal/specialist/plan_exec.go | 8 +- internal/specialist/plan_progress_test.go | 22 +- internal/specialist/plan_runner.go | 21 +- internal/specialist/plan_tool.go | 2 +- internal/specialist/plan_watchdog.go | 181 ++++++++++++ internal/specialist/plan_watchdog_test.go | 335 ++++++++++++++++++++++ 7 files changed, 574 insertions(+), 9 deletions(-) create mode 100644 internal/specialist/plan_watchdog.go create mode 100644 internal/specialist/plan_watchdog_test.go diff --git a/internal/specialist/plan.go b/internal/specialist/plan.go index b64c21333..e864452f7 100644 --- a/internal/specialist/plan.go +++ b/internal/specialist/plan.go @@ -60,6 +60,11 @@ type Budget struct { MaxWorkers int MaxTokens int MaxWall time.Duration + // MaxStall bounds how long a single task may emit NOTHING before it is + // stopped. Distinct from MaxWall, which bounds the whole plan: a plan can + // sit inside its wall budget while one task is wedged and the rest never + // run. Zero means the default. + MaxStall time.Duration } // Limits are the caller-supplied hard caps a plan must fit inside. @@ -316,6 +321,15 @@ func planBudget(args map[string]any, limits Limits) (Budget, error) { if seconds := planInt(raw, "max_wall_seconds"); seconds > 0 { budget.MaxWall = time.Duration(seconds) * time.Second } + if seconds := planInt(raw, "max_stall_seconds"); seconds > 0 { + stall := time.Duration(seconds) * time.Second + if stall < minStallTimeout { + return Budget{}, fmt.Errorf( + "budget.max_stall_seconds must be at least %d: below that the watchdog fires on ordinary think-time and becomes a random task-killer", + int(minStallTimeout.Seconds())) + } + budget.MaxStall = stall + } // MaxWorkers must be exactly 1. Rejecting rather than coercing keeps the // field meaningful: a caller that asked for 8 and silently got 1 would have // been told nothing, and Phase 3 would inherit a field nobody trusts. diff --git a/internal/specialist/plan_exec.go b/internal/specialist/plan_exec.go index 1b85b4380..a5bd174cf 100644 --- a/internal/specialist/plan_exec.go +++ b/internal/specialist/plan_exec.go @@ -124,6 +124,10 @@ type PlanTaskRequest struct { ParentSessionID string ParentModel string ParentReasoningEffort string + // StallTimeout bounds how long this task may emit nothing. Resolved by + // ExecutePlan from the plan's budget so every task in a plan shares one + // answer, rather than each runner re-deriving it. + StallTimeout time.Duration } // PlanRunner runs one task. The executor depends on this seam rather than on @@ -168,6 +172,8 @@ func ExecutePlan(ctx context.Context, plan Plan, parentTools []string, run PlanR if wall := plan.Budget().MaxWall; wall > 0 { deadline = time.Now().Add(wall) } + // One stall timeout for the whole plan, resolved once. + stallTimeout := stallTimeoutFor(plan.Budget()) cancelled := false for _, id := range plan.Order() { @@ -237,7 +243,7 @@ func ExecutePlan(ctx context.Context, plan Plan, parentTools []string, run PlanR recordDispatched(recorder, task) started := time.Now() - result, err := run(ctx, PlanTaskRequest{Task: task, Tools: granted}) + result, err := run(ctx, PlanTaskRequest{Task: task, Tools: granted, StallTimeout: stallTimeout}) result.ID = id if result.Duration == 0 { result.Duration = time.Since(started) diff --git a/internal/specialist/plan_progress_test.go b/internal/specialist/plan_progress_test.go index f09b413c5..c2b726dd7 100644 --- a/internal/specialist/plan_progress_test.go +++ b/internal/specialist/plan_progress_test.go @@ -89,9 +89,16 @@ func TestPlanRunnerForwardsProgress(t *testing.T) { } } -// A caller that wires no progress is unaffected: nil stays nil rather than -// becoming a non-nil no-op, so the executor's own behaviour is unchanged. -func TestPlanRunnerWithoutProgressPassesNil(t *testing.T) { +// A caller that wires no progress still gets a callback into the executor — +// the WATCHDOG needs the liveness signal whether or not a UI wants the events. +// What must not happen is the caller's own callback being invented. +// +// This test used to assert the opposite (nil stays nil). That was right until +// the stall watchdog existed: with no feed, a task whose child is talking +// happily would be judged silent and killed. The executor is unaffected either +// way — `progress != nil` gates only the call, and the event is parsed and +// stored regardless — so the cost is one function call per event. +func TestPlanRunnerAlwaysFeedsTheWatchdogEvenWithNoCallerCallback(t *testing.T) { var sawCallback bool executor := Executor{ BinaryPath: "/bin/true", @@ -99,6 +106,11 @@ func TestPlanRunnerWithoutProgressPassesNil(t *testing.T) { Load: func(LoadOptions) (LoadResult, error) { return LoadResult{}, nil }, RunChild: func(_ context.Context, _ string, _ []string, progress func(streamjson.Event)) (ChildRunResult, error) { sawCallback = progress != nil + // Emitting is safe: an unwired caller must not be invoked, and the + // watchdog must be. + if progress != nil { + progress(streamjson.Event{Type: streamjson.EventToolCall}) + } return ChildRunResult{Started: true}, nil }, } @@ -106,8 +118,8 @@ func TestPlanRunnerWithoutProgressPassesNil(t *testing.T) { if _, err := runner(context.Background(), PlanTaskRequest{Task: Task{ID: "a", Prompt: "x"}, Tools: []string{"read_file"}}); err != nil { t.Fatalf("runner: %v", err) } - if sawCallback { - t.Fatal("an unwired plan must hand the executor a nil progress callback, as it always did") + if !sawCallback { + t.Fatal("the executor got no callback, so the watchdog cannot see the child is alive") } } diff --git a/internal/specialist/plan_runner.go b/internal/specialist/plan_runner.go index b666a3a69..5f8465083 100644 --- a/internal/specialist/plan_runner.go +++ b/internal/specialist/plan_runner.go @@ -58,9 +58,19 @@ func NewPlanRunner(planCtx PlanTaskContext) PlanRunner { return TaskResult{Outcome: TaskFailed, Err: err.Error()}, err } + // THE STALL WATCHDOG. Its clock resets on every event the child emits, + // so a task that is working — however slowly — is never stopped; only + // silence counts. The context it cancels is this task's alone, so a + // wedged task does not take the plan with it. + watchdog := newStallWatchdog(req.StallTimeout, nil) + taskCtx, cancelTask := context.WithCancel(ctx) + defer cancelTask() + stopWatchdog := watchdog.watch(taskCtx, cancelTask) + defer stopWatchdog() + started := time.Now() manifest := planTaskManifest(planCtx.SpecialistName, grantedTools) - res, err := planCtx.Executor.Run(ctx, TaskParameters{ + res, err := planCtx.Executor.Run(taskCtx, TaskParameters{ Name: planCtx.SpecialistName, Prompt: task.Prompt, Description: "plan task " + task.ID, @@ -79,7 +89,7 @@ func NewPlanRunner(planCtx PlanTaskContext) PlanRunner { // streamed to nobody. Same class as finding 7 (ParentModel): a // second construction path that does not carry what the first one // did. Fixed with the tool-side half, not separately. - Progress: req.Progress, + Progress: watchedProgress(watchdog, req.Progress), // Explicitly NOT MemberAutonomy: Phase 2 tasks are read-only, and // granting it here would be the authority widening that was ruled // out as needing its own decision. @@ -95,6 +105,13 @@ func NewPlanRunner(planCtx PlanTaskContext) PlanRunner { // token count; MaxWall is the backstop in that case. Tokens: res.TotalTokens, } + if watchdog.didFire() { + // A stall and a user cancellation both surface as a context error; + // they are not the same event and must not read the same. + result.Outcome = TaskFailed + result.Err = stallError(task.ID, watchdog.timeout).Error() + return result, nil + } if err != nil { result.Outcome = TaskFailed result.Err = err.Error() diff --git a/internal/specialist/plan_tool.go b/internal/specialist/plan_tool.go index a47414f6c..efdaad2dc 100644 --- a/internal/specialist/plan_tool.go +++ b/internal/specialist/plan_tool.go @@ -109,7 +109,7 @@ func (tool *OrchestrateTool) Parameters() tools.Schema { }, "budget": { Type: "object", - Description: "Required. max_workers must be 1 (this phase executes sequentially). max_tokens and max_wall_seconds are optional bounds; omit them to run unbounded — spend is reported either way.", + Description: "Required. max_workers must be 1 (this phase executes sequentially). max_tokens and max_wall_seconds are optional bounds; omit them to run unbounded — spend is reported either way. max_stall_seconds bounds how long ONE task may emit nothing (default 180); it resets on every event, so a slow-but-working task is never stopped.", }, }, Required: []string{"tasks", "budget"}, diff --git a/internal/specialist/plan_watchdog.go b/internal/specialist/plan_watchdog.go new file mode 100644 index 000000000..729876daf --- /dev/null +++ b/internal/specialist/plan_watchdog.go @@ -0,0 +1,181 @@ +package specialist + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/Gitlawb/zero/internal/streamjson" +) + +// The per-task stall watchdog. +// +// WHY IT EXISTS NOW. The plan's token budget became optional and unbounded by +// default, because it never bounded anything: it was checked only between +// tasks, so a six-task chain asking for 200k spent 469,555. That left +// max_wall_seconds — also optional, also model-supplied — as the only bound on +// a plan, and nothing at all bounding a SINGLE task. A child that wedges +// against an unresponsive provider hangs until the user notices. +// +// KEYED ON SILENCE, NOT DURATION. A task legitimately reading forty files for +// four minutes is working; a task that has emitted nothing for three is not. +// Killing the first would be worse than not having a watchdog — a timeout that +// fires on healthy work teaches users to raise it until it never fires. So the +// clock resets on every stream event the child emits, and only silence counts. +// +// It is a BOUND, not a retry policy. A stalled task is reported as stalled and +// the plan carries on with its dependents skipped, exactly as for any other +// failure. Retrying is a separate decision: a stall is usually a provider or +// network condition that a second identical attempt will hit again, and +// spending another task's worth of budget to find that out needs its own +// argument. + +// defaultStallTimeout is how long a task may emit nothing before it is +// considered wedged. Deliberately generous: a slow model on a large file can be +// quiet for a long time, and a false positive here costs real work. +const defaultStallTimeout = 3 * time.Minute + +// minStallTimeout floors a caller-supplied value. Below this the watchdog would +// fire on ordinary think-time and become a random task-killer. +const minStallTimeout = 30 * time.Second + +// stallWatchdog cancels a task's context when its child goes quiet. +// +// One goroutine per task, started and stopped inside a single runner call, so +// it cannot outlive the task it watches — the failure mode the prototype's +// captured-context goroutine had. +type stallWatchdog struct { + mu sync.Mutex + lastSeen time.Time + fired bool + timeout time.Duration + now func() time.Time + // poll is how often the watcher checks. Zero means timeout/6. Injectable so + // a test can exercise the real goroutine in milliseconds instead of + // sleeping for the production interval — a watchdog whose only test is + // "wait three minutes" does not get tested. + poll time.Duration +} + +// newStallWatchdog honours the timeout it is GIVEN. The floor lives in +// planBudget, where model-supplied input is validated; enforcing it a second +// time here would mean an internal caller could not construct a fast watchdog +// even when it has already been validated, which is how the tests ended up +// sleeping for the production interval. +func newStallWatchdog(timeout time.Duration, now func() time.Time) *stallWatchdog { + if now == nil { + now = time.Now + } + if timeout <= 0 { + timeout = defaultStallTimeout + } + return &stallWatchdog{lastSeen: now(), timeout: timeout, now: now} +} + +// touch records activity. Called on every stream event the child emits. +func (w *stallWatchdog) touch() { + if w == nil { + return + } + w.mu.Lock() + w.lastSeen = w.now() + w.mu.Unlock() +} + +// stalledFor reports how long the child has been silent. +func (w *stallWatchdog) stalledFor() time.Duration { + w.mu.Lock() + defer w.mu.Unlock() + return w.now().Sub(w.lastSeen) +} + +// didFire reports whether the watchdog cancelled the task, so the runner can +// tell a stall apart from an ordinary cancellation — the two produce the same +// context error and must not produce the same message. +func (w *stallWatchdog) didFire() bool { + if w == nil { + return false + } + w.mu.Lock() + defer w.mu.Unlock() + return w.fired +} + +func (w *stallWatchdog) markFired() { + w.mu.Lock() + w.fired = true + w.mu.Unlock() +} + +// watch runs until the task finishes, the parent is cancelled, or the child +// goes quiet for longer than the timeout — whichever comes first. It returns a +// stop function the caller MUST defer. +// +// The ticker interval is a fraction of the timeout rather than a fixed second: +// a three-minute watchdog polling every second is 180 wakeups to answer a +// question that changes slowly. +func (w *stallWatchdog) watch(ctx context.Context, cancel context.CancelFunc) func() { + done := make(chan struct{}) + interval := w.poll + if interval <= 0 { + interval = w.timeout / 6 + if interval < time.Second { + interval = time.Second + } + } + + go func() { + // Every goroutine gets recover(): a panic in a watchdog must not take + // down the run it exists to protect. + defer func() { _ = recover() }() + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-done: + return + case <-ctx.Done(): + return + case <-ticker.C: + if w.stalledFor() >= w.timeout { + w.markFired() + cancel() + return + } + } + } + }() + + var once sync.Once + return func() { once.Do(func() { close(done) }) } +} + +// stallTimeoutFor resolves the timeout for a plan: the budget's own value when +// it set one, otherwise the default. +func stallTimeoutFor(budget Budget) time.Duration { + if budget.MaxStall > 0 { + return budget.MaxStall + } + return defaultStallTimeout +} + +// watchedProgress wraps a task's progress callback so every event the child +// emits resets the stall clock. Returns a callback even when the caller wired +// none — the watchdog needs the liveness signal whether or not a UI wants it. +func watchedProgress(watchdog *stallWatchdog, forward func(streamjson.Event)) func(streamjson.Event) { + return func(event streamjson.Event) { + watchdog.touch() + if forward != nil { + forward(event) + } + } +} + +// stallError is the failure a wedged task reports. Distinct wording from a +// cancellation, because a user who stopped a plan and a plan that hung are +// looking at very different problems. +func stallError(taskID string, timeout time.Duration) error { + return fmt.Errorf("task %q produced no output for %s and was stopped; "+ + "raise budget.max_stall_seconds if this task is legitimately slow", taskID, timeout) +} diff --git a/internal/specialist/plan_watchdog_test.go b/internal/specialist/plan_watchdog_test.go new file mode 100644 index 000000000..b78448812 --- /dev/null +++ b/internal/specialist/plan_watchdog_test.go @@ -0,0 +1,335 @@ +package specialist + +import ( + "context" + "strings" + "sync" + "testing" + "time" + + "github.com/Gitlawb/zero/internal/streamjson" +) + +// fakeClock lets a watchdog test advance time without sleeping for minutes. +type fakeClock struct { + mu sync.Mutex + at time.Time +} + +func (c *fakeClock) now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.at +} + +func (c *fakeClock) advance(d time.Duration) { + c.mu.Lock() + c.at = c.at.Add(d) + c.mu.Unlock() +} + +// SILENCE is what the watchdog measures, not elapsed time. A task reading forty +// files for four minutes is working; killing it would be worse than having no +// watchdog at all, because a timeout that fires on healthy work teaches users +// to raise it until it never fires. +func TestActivityKeepsATaskAliveHoweverLongItRuns(t *testing.T) { + clock := &fakeClock{at: time.Unix(1000, 0)} + watchdog := newStallWatchdog(time.Minute, clock.now) + + // Ten minutes of work, an event every 30s: never stalled. + for range 20 { + clock.advance(30 * time.Second) + if got := watchdog.stalledFor(); got >= time.Minute { + t.Fatalf("a working task was judged stalled after %s of silence", got) + } + watchdog.touch() + } + if watchdog.didFire() { + t.Fatal("the watchdog fired on a task that never went quiet") + } +} + +// ...and silence past the timeout is a stall. +func TestSilencePastTheTimeoutIsAStall(t *testing.T) { + clock := &fakeClock{at: time.Unix(1000, 0)} + watchdog := newStallWatchdog(time.Minute, clock.now) + + clock.advance(59 * time.Second) + if watchdog.stalledFor() >= time.Minute { + t.Fatal("fired one second early") + } + clock.advance(2 * time.Second) + if watchdog.stalledFor() < time.Minute { + t.Fatal("did not register a stall past the timeout") + } +} + +// The watchdog cancels the task's OWN context, and the goroutine stops with it. +func TestTheWatchdogCancelsTheTaskAndStops(t *testing.T) { + clock := &fakeClock{at: time.Unix(1000, 0)} + watchdog := newStallWatchdog(minStallTimeout, clock.now) + // Poll fast so the REAL goroutine is exercised in milliseconds. The clock + // it reads is still fake, so what is being tested is the watcher's logic, + // not the passage of time. + watchdog.poll = 2 * time.Millisecond + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + stop := watchdog.watch(ctx, cancel) + defer stop() + + clock.advance(2 * minStallTimeout) + deadline := time.After(3 * time.Second) + for { + select { + case <-ctx.Done(): + if !watchdog.didFire() { + t.Fatal("the context was cancelled but the watchdog does not own it") + } + return + case <-deadline: + t.Fatal("the watchdog never fired on a wedged task") + case <-time.After(10 * time.Millisecond): + } + } +} + +// A too-small timeout is refused rather than honoured: below the floor the +// watchdog fires on ordinary think-time and becomes a random task-killer. +func TestATooSmallStallTimeoutIsRejected(t *testing.T) { + budget := okBudget() + budget["max_stall_seconds"] = float64(5) + _, err := ParsePlan(planArgs([]any{task("a", "x")}, budget), readOnlyLimits()) + if err == nil || !strings.Contains(err.Error(), "max_stall_seconds") { + t.Fatalf("a 5-second stall timeout must be refused, got %v", err) + } +} + +// An omitted timeout gets the default rather than zero — zero would mean "no +// bound", and the whole point is that something bounds a task. +func TestAnOmittedStallTimeoutFallsBackToTheDefault(t *testing.T) { + if got := stallTimeoutFor(Budget{}); got != defaultStallTimeout { + t.Fatalf("stall timeout = %s, want the default %s", got, defaultStallTimeout) + } + if got := stallTimeoutFor(Budget{MaxStall: 10 * time.Minute}); got != 10*time.Minute { + t.Fatalf("an explicit timeout must win, got %s", got) + } + // Zero means the default; a positive value is honoured as given, because + // the floor is enforced on model input in planBudget rather than a second + // time here. + if w := newStallWatchdog(0, nil); w.timeout != defaultStallTimeout { + t.Fatalf("a zero timeout must fall back to the default, got %s", w.timeout) + } + if w := newStallWatchdog(90*time.Second, nil); w.timeout != 90*time.Second { + t.Fatalf("a validated timeout must be honoured as given, got %s", w.timeout) + } +} + +// The progress wrapper feeds the watchdog AND the caller. Dropping either would +// be a silent regression: the watchdog would kill working tasks, or the UI +// would go dark. +func TestWatchedProgressFeedsBothTheWatchdogAndTheCaller(t *testing.T) { + clock := &fakeClock{at: time.Unix(1000, 0)} + watchdog := newStallWatchdog(time.Minute, clock.now) + forwarded := 0 + + wrapped := watchedProgress(watchdog, func(streamjson.Event) { forwarded++ }) + clock.advance(30 * time.Second) + wrapped(streamjson.Event{Type: streamjson.EventToolCall}) + + if forwarded != 1 { + t.Fatalf("the caller's callback was invoked %d times, want 1", forwarded) + } + if got := watchdog.stalledFor(); got != 0 { + t.Fatalf("the event did not reset the stall clock: %s", got) + } + + // A caller that wired no callback still feeds the watchdog. + silent := watchedProgress(watchdog, nil) + clock.advance(20 * time.Second) + silent(streamjson.Event{Type: streamjson.EventToolCall}) + if got := watchdog.stalledFor(); got != 0 { + t.Fatalf("the watchdog is not fed when no UI callback is wired: %s", got) + } +} + +// A STALL AND A CANCELLATION ARE DIFFERENT EVENTS. Both surface as a context +// error, and a user who stopped a plan and a plan that hung are looking at very +// different problems. +func TestAStalledTaskReportsAStallNotACancellation(t *testing.T) { + executor := Executor{ + BinaryPath: "/bin/true", + NewSessionID: func() (string, error) { return "specialist_00000000000000000000000a", nil }, + Load: func(LoadOptions) (LoadResult, error) { return LoadResult{}, nil }, + RunChild: func(ctx context.Context, _ string, _ []string, _ func(streamjson.Event)) (ChildRunResult, error) { + // A wedged child: never emits, never returns until cancelled. + <-ctx.Done() + return ChildRunResult{Started: true, ExitCode: -1}, ctx.Err() + }, + } + runner := NewPlanRunner(PlanTaskContext{Executor: executor, Cwd: t.TempDir(), SpecialistName: "explorer"}) + + // A DEADLINE ON THE TEST ITSELF. Without it, a watchdog that fails to fire + // leaves the wedged child blocking forever and the test hangs rather than + // failing — which is exactly what happened: a mutation run that disabled + // the watchdog wedged the whole sweep instead of reporting a caught + // mutation. A test for a hang must not be able to hang. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // A short timeout here, not the 30s floor: the floor guards MODEL input in + // planBudget, and a test that sleeps for it is a test nobody runs. + result, err := runner(ctx, PlanTaskRequest{ + Task: Task{ID: "wedged", Prompt: "x"}, + Tools: []string{"read_file"}, + StallTimeout: 40 * time.Millisecond, + }) + if err != nil { + t.Fatalf("a stalled task is a task result, not a runner error: %v", err) + } + if result.Outcome != TaskFailed { + t.Fatalf("outcome = %q, want a failure", result.Outcome) + } + if !strings.Contains(result.Err, "produced no output") { + t.Fatalf("a stall must say so rather than reading as a cancellation: %q", result.Err) + } + if !strings.Contains(result.Err, "max_stall_seconds") { + t.Fatalf("the message must name the knob that changes it: %q", result.Err) + } +} + +// A wedged task must not take the PLAN with it: the watchdog cancels that +// task's own context, and the plan carries on with the dependents skipped. +func TestAStalledTaskDoesNotCancelTheWholePlan(t *testing.T) { + plan := mustPlan(t, []any{task("a", "x"), task("b", "y")}, okBudget(), readOnlyLimits()) + parent := context.Background() + + ran := 0 + report := ExecutePlan(parent, plan, []string{"read_file"}, + func(ctx context.Context, req PlanTaskRequest) (TaskResult, error) { + ran++ + if req.Task.ID == "a" { + return TaskResult{ID: "a", Outcome: TaskFailed, Err: "produced no output for 3m0s"}, nil + } + return TaskResult{ID: req.Task.ID, Outcome: TaskSucceeded}, nil + }, nil) + + if ran != 2 { + t.Fatalf("the plan dispatched %d tasks; a stalled task must not stop independent work", ran) + } + if parent.Err() != nil { + t.Fatal("the parent context was cancelled by a task-level stall") + } + if report.Failed != 1 || report.Succeeded != 1 { + t.Fatalf("report = %+v, want one stall and one success", report) + } +} + +// The plan resolves ONE stall timeout and every task gets it, rather than each +// runner re-deriving it from a budget it would have to be handed anyway. +func TestPlanPassesTheStallTimeoutToEveryTask(t *testing.T) { + budget := okBudget() + budget["max_stall_seconds"] = float64(90) + plan := mustPlan(t, []any{task("a", "x"), task("b", "y")}, budget, readOnlyLimits()) + + var seen []time.Duration + ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + seen = append(seen, req.StallTimeout) + return TaskResult{ID: req.Task.ID, Outcome: TaskSucceeded}, nil + }, nil) + + if len(seen) != 2 { + t.Fatalf("expected two dispatches, got %d", len(seen)) + } + for index, got := range seen { + if got != 90*time.Second { + t.Fatalf("task %d received a stall timeout of %s, want the plan's 90s", index, got) + } + } +} + +// A CHILD THAT IS TALKING MUST SURVIVE, however long it runs. This is the +// watchdog's whole premise, and the wedged-child test cannot check it: a child +// that emits nothing stalls whether or not its events are wired to the clock. +func TestAChattyChildOutlivesItsStallTimeout(t *testing.T) { + emitted := 0 + executor := Executor{ + BinaryPath: "/bin/true", + NewSessionID: func() (string, error) { return "specialist_00000000000000000000000a", nil }, + Load: func(LoadOptions) (LoadResult, error) { return LoadResult{}, nil }, + RunChild: func(ctx context.Context, _ string, _ []string, progress func(streamjson.Event)) (ChildRunResult, error) { + // Work for well past the stall timeout, speaking throughout. + deadline := time.After(300 * time.Millisecond) + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return ChildRunResult{Started: true, ExitCode: -1}, ctx.Err() + case <-deadline: + return ChildRunResult{Started: true}, nil + case <-ticker.C: + if progress != nil { + progress(streamjson.Event{Type: streamjson.EventToolCall, Name: "read_file"}) + emitted++ + } + } + } + }, + } + runner := NewPlanRunner(PlanTaskContext{Executor: executor, Cwd: t.TempDir(), SpecialistName: "explorer"}) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + result, err := runner(ctx, PlanTaskRequest{ + Task: Task{ID: "chatty", Prompt: "x"}, + Tools: []string{"read_file"}, + // Far shorter than the child's runtime: only silence may stop it. + StallTimeout: 60 * time.Millisecond, + }) + if err != nil { + t.Fatalf("a working task must not error: %v", err) + } + if emitted == 0 { + t.Fatal("setup: the child never emitted, so this proves nothing") + } + if strings.Contains(result.Err, "produced no output") { + t.Fatalf("a child that spoke every 10ms was killed by a 60ms stall timeout: %q", result.Err) + } + if result.Outcome != TaskSucceeded { + t.Fatalf("outcome = %q (%s), want success", result.Outcome, result.Err) + } +} + +// A STALL CANCELS THAT TASK, NOT THE RUN. The watchdog owns a context derived +// from the caller's; cancelling the caller's instead would end the whole plan +// on one wedged task. +func TestAStallLeavesTheParentContextAlive(t *testing.T) { + executor := Executor{ + BinaryPath: "/bin/true", + NewSessionID: func() (string, error) { return "specialist_00000000000000000000000a", nil }, + Load: func(LoadOptions) (LoadResult, error) { return LoadResult{}, nil }, + RunChild: func(ctx context.Context, _ string, _ []string, _ func(streamjson.Event)) (ChildRunResult, error) { + <-ctx.Done() + return ChildRunResult{Started: true, ExitCode: -1}, ctx.Err() + }, + } + runner := NewPlanRunner(PlanTaskContext{Executor: executor, Cwd: t.TempDir(), SpecialistName: "explorer"}) + + // A plain background context with NO deadline: if the runner cancels this + // one, the plan is over. Nothing else here would cancel it, so its state + // after the call is the assertion. + parent := context.Background() + result, _ := runner(parent, PlanTaskRequest{ + Task: Task{ID: "wedged", Prompt: "x"}, + Tools: []string{"read_file"}, + StallTimeout: 40 * time.Millisecond, + }) + if !strings.Contains(result.Err, "produced no output") { + t.Fatalf("setup: expected a stall, got %q", result.Err) + } + if parent.Err() != nil { + t.Fatalf("the stall cancelled the caller's context: %v", parent.Err()) + } +} From b49926937bbc6701238762bdae67cdb7fc95e44b Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:31:58 +0530 Subject: [PATCH 32/86] fix(specialist): resuming a task must not widen its authority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runResume resolved the manifest by session.AgentName and ignored params.Manifest entirely, while the fresh path honoured it. That is not merely "inline-manifest children are unresumable", which is how it was filed — it is a WIDENING. A plan task launched under a parent holding only grep carries the inline grant [grep]. Resuming it reloaded the registered explorer manifest and handed the child back [glob grep list_directory read_file read_minified_file]. The parent-grant narrowing that Workstream A made real was undone by resuming. Measured, not inferred: the registered explorer resolves five read-only tools; a plan grant under --enabled-tools grep is one. Both paths now share ONE resolver — an inline manifest wins, validated exactly as before, and only a caller that supplied none falls back to the registry. The session's identity still governs: a mismatched specialist name is refused, and the registry fallback still looks up the specialist the session belongs to. Not currently reachable from a plan (NewPlanRunner sets no Resume), so this is a latent widening rather than a live one — and it is the defect that stood in front of Stage 2c, since plan tasks are exactly the inline-manifest children that could not resume. Four mutations, all caught. One test needed fixing first and the failure was instructive: the fixture set ResolvedTools but not Metadata.Tools, and Validate re-resolves from Metadata.Tools — so the fixture was re-expanded to the default read-only category and the test "failed" against correct code. It now uses planTaskManifest itself, which is what a plan task actually carries. --- internal/specialist/exec.go | 29 ++- internal/specialist/resume_manifest_test.go | 203 ++++++++++++++++++++ 2 files changed, 227 insertions(+), 5 deletions(-) create mode 100644 internal/specialist/resume_manifest_test.go diff --git a/internal/specialist/exec.go b/internal/specialist/exec.go index 1fe77c311..61d489212 100644 --- a/internal/specialist/exec.go +++ b/internal/specialist/exec.go @@ -360,7 +360,7 @@ func (executor Executor) BuildResumeArgs(input BuildResumeArgsInput) (BuildArgsR } func (executor Executor) runFresh(ctx context.Context, params TaskParameters, options TaskRunOptions) (ExecResult, error) { - manifest, err := executor.freshManifest(params) + manifest, err := executor.resolveManifest(params) if err != nil { return ExecResult{}, err } @@ -386,11 +386,22 @@ func (executor Executor) runFresh(ctx context.Context, params TaskParameters, op return executor.runBuiltArgs(ctx, built, manifest, params, options, "foreground", options.Progress) } -// freshManifest resolves the manifest for a fresh run. A caller-supplied inline +// resolveManifest resolves the manifest for a run. A caller-supplied inline // manifest (validated here) takes precedence over a registry lookup by name, so a // caller with its own definition — the swarm launcher running a member whose -// agent type is not a registered specialist — can run without a registry entry. -func (executor Executor) freshManifest(params TaskParameters) (Manifest, error) { +// agent type is not a registered specialist, or a plan task running under a +// narrowed grant — can run without a registry entry. +// +// USED BY BOTH the fresh and resume paths. It used to serve only the fresh one: +// runResume looked the manifest up by session.AgentName and ignored +// params.Manifest entirely, so the two paths answered "what may this child do?" +// differently. For an inline-manifest child that is not merely a lost +// definition — it is a WIDENING. A plan task launched under a parent holding +// only grep carries the inline grant [grep]; resuming it reloaded the +// registered explorer manifest and handed it back +// [glob grep list_directory read_file read_minified_file]. Resuming a task +// must never grant it more than launching it did. +func (executor Executor) resolveManifest(params TaskParameters) (Manifest, error) { if params.Manifest != nil { manifest := *params.Manifest if err := Validate(&manifest); err != nil { @@ -413,7 +424,15 @@ func (executor Executor) runResume(ctx context.Context, params TaskParameters, o if requestedName := strings.TrimSpace(params.Name); requestedName != "" && requestedName != specialistName { return ExecResult{}, fmt.Errorf("resume session %q belongs to specialist %q, not %q", session.SessionID, specialistName, requestedName) } - manifest, err := executor.loadManifest(specialistName) + // The SAME resolver the fresh path uses: an inline manifest wins, and only + // a caller that supplied none falls back to the registry. params.Name is + // already reconciled against the session's agent name above, so the + // fallback still looks up the specialist the session actually belongs to. + resumeParams := params + if strings.TrimSpace(resumeParams.Name) == "" { + resumeParams.Name = specialistName + } + manifest, err := executor.resolveManifest(resumeParams) if err != nil { return ExecResult{}, err } diff --git a/internal/specialist/resume_manifest_test.go b/internal/specialist/resume_manifest_test.go new file mode 100644 index 000000000..ad83975f3 --- /dev/null +++ b/internal/specialist/resume_manifest_test.go @@ -0,0 +1,203 @@ +package specialist + +import ( + "context" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/sessions" + "github.com/Gitlawb/zero/internal/streamjson" +) + +// narrowManifest is what a plan task carries: an inline definition with a grant +// already intersected down to what the parent run holds. +func narrowManifest() Manifest { + // EXACTLY what planTaskManifest builds, Metadata.Tools included. Validate + // re-resolves the tool list from Metadata.Tools, so a fixture that set only + // ResolvedTools would be re-expanded to the default read-only category — + // which is what the first version of this test did, and it then "failed" + // against correct code. + return planTaskManifest("explorer", []string{"grep"}) +} + +func enabledToolsOf(args []string) string { + for index, arg := range args { + if arg == "--enabled-tools" && index+1 < len(args) { + return args[index+1] + } + } + return "" +} + +func resumableSession(t *testing.T) (*sessions.Store, sessions.Metadata) { + t.Helper() + store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()}) + session, err := store.Create(sessions.CreateInput{ + SessionID: "specialist_00000000000000000000000a", + SessionKind: sessions.SessionKindChild, + Cwd: t.TempDir(), + AgentName: "explorer", + Tag: sessionTagSpecialist, + }) + if err != nil { + t.Fatalf("create session: %v", err) + } + return store, session +} + +// RESUMING A TASK MUST NEVER GRANT IT MORE THAN LAUNCHING IT DID. +// +// runResume looked the manifest up by session.AgentName and ignored +// params.Manifest, so a plan task launched under a parent holding only grep — +// inline grant [grep] — came back from a resume holding the registered +// explorer's five tools. The parent-grant narrowing was undone by resuming. +func TestResumeDoesNotWidenAnInlineGrant(t *testing.T) { + store, session := resumableSession(t) + var resumeArgs []string + executor := Executor{ + BinaryPath: "/bin/true", + SessionStore: store, + Load: func(LoadOptions) (LoadResult, error) { + // The REGISTERED explorer is deliberately wider than the plan's + // grant — that difference is the defect. + return LoadResult{Specialists: []Manifest{{ + Metadata: Metadata{Name: "explorer", Description: "registered"}, + SystemPrompt: "x", + ResolvedTools: []string{"glob", "grep", "list_directory", "read_file", "read_minified_file"}, + ToolsResolved: true, + Location: LocationBuiltin, + }}}, nil + }, + RunChild: func(_ context.Context, _ string, args []string, _ func(streamjson.Event)) (ChildRunResult, error) { + resumeArgs = args + return ChildRunResult{Started: true}, nil + }, + } + + manifest := narrowManifest() + if _, err := executor.Run(context.Background(), + TaskParameters{Name: "explorer", Prompt: "carry on", Resume: session.SessionID, Manifest: &manifest}, + TaskRunOptions{Cwd: t.TempDir()}); err != nil { + t.Fatalf("Run: %v", err) + } + + if got := enabledToolsOf(resumeArgs); got != "grep" { + t.Fatalf("the resumed child was granted %q, want the inline grant \"grep\": resuming widened its authority", got) + } +} + +// THE SIBLING COMPARISON. Launching and resuming are two doors onto "what may +// this child do?", so the relationship is EQUALITY: the same params must +// produce the same grant through either. +func TestFreshAndResumeResolveTheSameManifest(t *testing.T) { + store, session := resumableSession(t) + var fresh, resumed []string + executor := Executor{ + BinaryPath: "/bin/true", + SessionStore: store, + NewSessionID: func() (string, error) { return "specialist_00000000000000000000000b", nil }, + Load: func(LoadOptions) (LoadResult, error) { + return LoadResult{Specialists: []Manifest{{ + Metadata: Metadata{Name: "explorer"}, + SystemPrompt: "x", + ResolvedTools: []string{"glob", "grep", "list_directory"}, + ToolsResolved: true, + Location: LocationBuiltin, + }}}, nil + }, + } + + manifest := narrowManifest() + executor.RunChild = func(_ context.Context, _ string, args []string, _ func(streamjson.Event)) (ChildRunResult, error) { + fresh = args + return ChildRunResult{Started: true}, nil + } + if _, err := executor.Run(context.Background(), + TaskParameters{Name: "explorer", Prompt: "go", Manifest: &manifest}, + TaskRunOptions{Cwd: t.TempDir()}); err != nil { + t.Fatalf("fresh Run: %v", err) + } + + executor.RunChild = func(_ context.Context, _ string, args []string, _ func(streamjson.Event)) (ChildRunResult, error) { + resumed = args + return ChildRunResult{Started: true}, nil + } + if _, err := executor.Run(context.Background(), + TaskParameters{Name: "explorer", Prompt: "go on", Resume: session.SessionID, Manifest: &manifest}, + TaskRunOptions{Cwd: t.TempDir()}); err != nil { + t.Fatalf("resume Run: %v", err) + } + + if enabledToolsOf(fresh) != enabledToolsOf(resumed) { + t.Fatalf("fresh grants %q and resume grants %q for the same manifest", + enabledToolsOf(fresh), enabledToolsOf(resumed)) + } +} + +// A caller that supplies NO manifest still resolves by name, so an ordinary +// Task resume is unchanged. +func TestResumeWithoutAnInlineManifestStillResolvesByName(t *testing.T) { + store, session := resumableSession(t) + var args []string + executor := Executor{ + BinaryPath: "/bin/true", + SessionStore: store, + Load: func(LoadOptions) (LoadResult, error) { + return LoadResult{Specialists: []Manifest{{ + Metadata: Metadata{Name: "explorer"}, + SystemPrompt: "x", + ResolvedTools: []string{"glob", "grep"}, + ToolsResolved: true, + Location: LocationBuiltin, + }}}, nil + }, + RunChild: func(_ context.Context, _ string, a []string, _ func(streamjson.Event)) (ChildRunResult, error) { + args = a + return ChildRunResult{Started: true}, nil + }, + } + if _, err := executor.Run(context.Background(), + TaskParameters{Prompt: "carry on", Resume: session.SessionID}, + TaskRunOptions{Cwd: t.TempDir()}); err != nil { + t.Fatalf("Run: %v", err) + } + if got := enabledToolsOf(args); got != "glob,grep" { + t.Fatalf("grant = %q, want the registered manifest's when none was supplied", got) + } +} + +// An inline manifest is VALIDATED on resume exactly as on a fresh launch — +// honouring the caller's definition must not mean trusting it unchecked. +func TestAnInvalidInlineManifestIsRefusedOnResume(t *testing.T) { + store, session := resumableSession(t) + executor := Executor{ + BinaryPath: "/bin/true", + SessionStore: store, + Load: func(LoadOptions) (LoadResult, error) { return LoadResult{}, nil }, + RunChild: func(context.Context, string, []string, func(streamjson.Event)) (ChildRunResult, error) { + return ChildRunResult{Started: true}, nil + }, + } + broken := Manifest{Metadata: Metadata{Name: ""}} + _, err := executor.Run(context.Background(), + TaskParameters{Name: "explorer", Prompt: "x", Resume: session.SessionID, Manifest: &broken}, + TaskRunOptions{Cwd: t.TempDir()}) + if err == nil || !strings.Contains(err.Error(), "inline specialist manifest") { + t.Fatalf("an invalid inline manifest must be refused on resume, got %v", err) + } +} + +// The session's identity still wins: resuming another specialist's session with +// a mismatched name is refused, inline manifest or not. +func TestResumeStillRefusesAMismatchedSpecialist(t *testing.T) { + store, session := resumableSession(t) + executor := Executor{BinaryPath: "/bin/true", SessionStore: store, + Load: func(LoadOptions) (LoadResult, error) { return LoadResult{}, nil }} + manifest := narrowManifest() + _, err := executor.Run(context.Background(), + TaskParameters{Name: "reviewer", Prompt: "x", Resume: session.SessionID, Manifest: &manifest}, + TaskRunOptions{Cwd: t.TempDir()}) + if err == nil || !strings.Contains(err.Error(), "belongs to specialist") { + t.Fatalf("a mismatched specialist must still be refused, got %v", err) + } +} From 7ccae3b9facf1c767bbe1642bed27ad0cec49d90 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:57:14 +0530 Subject: [PATCH 33/86] =?UTF-8?q?feat(tui):=20a=20plan=20run=20in=20the=20?= =?UTF-8?q?TUI=20is=20durable=20(gap=20report=20=C2=A75.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The report asked for an append-only journal beside the session store. It is not needed, and ARCHITECTURE.md says why: "Plan state is a deterministic reduction over the five events. No new store." The five events already reached the session log under `zero exec`. The TUI wrote NONE of them — PlanProgressBridge drove the panel and persisted nothing — so a plan was durable or not depending on which surface you ran it from, and only one of the two could ever be resumed. That was the actual gap; a journal would have been a second store papering over it. Events are appended AS THEY HAPPEN, not batched to run end. Batching would lose exactly the crash the durability is for. Written from the TOOL's goroutine, never the Bubble Tea loop — appending is file I/O and the loop must not block on it. Dispatch is written BEFORE the child runs, so a task that was in flight when the process died is distinguishable on resume from one that never started; that ordering IS the guarantee. THE PAYLOADS NOW HAVE ONE AUTHOR. They were built in internal/cli, and a TUI copy would have drifted — with the drift only surfacing when someone resumed a plan recorded by the other surface. specialist.PlanAdmittedEvent and its four siblings are now the single source both recorders append, and a test compares the two by round-tripping through JSON, the form they are actually stored in. Best effort at every level, mirroring execSessionRecorder.append: no store, no session, or a latched earlier failure and it does nothing. The first failure is latched and readable, so a surface can say once that the plan was not fully persisted rather than leaving a user to believe it was. Five mutations, all caught. The fifth needed a test that drives beginRun rather than the bridge: a recorder that records perfectly and is never bound records nothing, which is the shape this feature keeps producing. --- internal/cli/plan_recorder.go | 64 +------- internal/specialist/plan_events.go | 87 +++++++++++ internal/tui/model.go | 2 +- internal/tui/plan_durability_test.go | 223 +++++++++++++++++++++++++++ internal/tui/plan_progress.go | 60 ++++++- internal/tui/plan_progress_test.go | 6 +- 6 files changed, 381 insertions(+), 61 deletions(-) create mode 100644 internal/specialist/plan_events.go create mode 100644 internal/tui/plan_durability_test.go diff --git a/internal/cli/plan_recorder.go b/internal/cli/plan_recorder.go index 34a7168d6..b213c9619 100644 --- a/internal/cli/plan_recorder.go +++ b/internal/cli/plan_recorder.go @@ -52,59 +52,23 @@ func (bridge *planSessionRecorder) append(eventType sessions.EventType, payload bridge.recorder.append(eventType, payload) } +// The payloads come from specialist's own builders, shared with the TUI's +// recorder: resume is a reduction over these events and cannot be written +// against two shapes. func (bridge *planSessionRecorder) PlanAdmitted(plan specialist.Plan) { - tasks := make([]map[string]any, 0, plan.TaskCount()) - for _, task := range plan.Tasks() { - tasks = append(tasks, map[string]any{ - "id": task.ID, "depends_on": task.DependsOn, "phase": task.Phase, - }) - } - bridge.append(sessions.EventPlanAdmitted, map[string]any{ - "name": plan.Name(), - "task_count": plan.TaskCount(), - "order": plan.Order(), - "tasks": tasks, - "max_tokens": plan.Budget().MaxTokens, - }) + bridge.append(specialist.PlanAdmittedEvent(plan)) } func (bridge *planSessionRecorder) TaskDispatched(task specialist.Task) { - bridge.append(sessions.EventTaskDispatched, map[string]any{ - "id": task.ID, "depends_on": task.DependsOn, - }) + bridge.append(specialist.TaskDispatchedEvent(task)) } func (bridge *planSessionRecorder) TaskCompleted(result specialist.TaskResult) { - bridge.append(sessions.EventTaskCompleted, map[string]any{ - "id": result.ID, - // Duration is what the metric is computed from, so it is recorded per - // task rather than only in the aggregate. - "duration_ms": result.Duration.Milliseconds(), - "session_id": result.SessionID, - "tokens": result.Tokens, - }) + bridge.append(specialist.TaskCompletedEvent(result)) } func (bridge *planSessionRecorder) TaskFailed(result specialist.TaskResult) { - bridge.append(sessions.EventTaskFailed, map[string]any{ - "id": result.ID, - // The outcome distinguishes a real failure from a dependency or budget - // skip. A skipped task is RECORDED, never dropped. - "outcome": string(result.Outcome), - "reason": result.Err, - "duration_ms": result.Duration.Milliseconds(), - // THE SAME FIELDS AS TaskCompleted, and for the failure they matter - // more. Executor.Run carries the child session id even on a post-start - // failure specifically so a failed child stays drillable; recording it - // only on success made the one task a user needs to open the one task - // they could not find. Empty for a dependency or budget skip, which - // never started a child — that is the honest value, not a gap. - "session_id": result.SessionID, - // Tokens the failed task spent. They are already counted in the plan's - // aggregate, so omitting them here made the per-task records fail to - // add up to the total. - "tokens": result.Tokens, - }) + bridge.append(specialist.TaskFailedEvent(result)) } func (bridge *planSessionRecorder) PlanCompleted(plan specialist.Plan, report specialist.PlanReport) { @@ -117,17 +81,5 @@ func (bridge *planSessionRecorder) PlanCompleted(plan specialist.Plan, report sp plan.Name(), report.Status, report.Succeeded, report.Failed, report.Skipped) } } - bridge.append(sessions.EventPlanCompleted, map[string]any{ - "name": plan.Name(), - "status": string(report.Status), - "succeeded": report.Succeeded, - "failed": report.Failed, - "skipped": report.Skipped, - // THE METRIC. Recorded so the kill criterion can be evaluated across - // many sessions without re-running anything. - "sequential_total_ms": report.SequentialTotal.Milliseconds(), - "critical_path_ms": report.CriticalPath.Milliseconds(), - "max_speedup": report.MaxSpeedup, - "tokens_used": report.TokensUsed, - }) + bridge.append(specialist.PlanCompletedEvent(plan, report)) } diff --git a/internal/specialist/plan_events.go b/internal/specialist/plan_events.go new file mode 100644 index 000000000..3c6a770a9 --- /dev/null +++ b/internal/specialist/plan_events.go @@ -0,0 +1,87 @@ +package specialist + +import "github.com/Gitlawb/zero/internal/sessions" + +// The plan lifecycle's session-event payloads, built in ONE place. +// +// There are two recorders — the headless one in internal/cli and the TUI's — +// and they must produce identical events for the same plan, because resume is a +// deterministic reduction over these events and a reducer cannot be written +// against two shapes. Two builders would drift (invariant 5), and the drift +// would only surface when someone resumed a plan recorded by the other surface. +// +// The payloads are deliberately small and structured — ids, counts, durations, +// terminal status — not whole task outputs. A plan's outputs already reach the +// transcript through the tool result; duplicating them here would multiply a +// large plan's on-disk size for no added recoverability. + +// PlanAdmittedEvent is the record of a validated plan, written before its first +// task runs so a crash mid-plan still leaves the shape on disk. +func PlanAdmittedEvent(plan Plan) (sessions.EventType, map[string]any) { + tasks := make([]map[string]any, 0, plan.TaskCount()) + for _, task := range plan.Tasks() { + tasks = append(tasks, map[string]any{ + "id": task.ID, "depends_on": task.DependsOn, "phase": task.Phase, + }) + } + return sessions.EventPlanAdmitted, map[string]any{ + "name": plan.Name(), + "task_count": plan.TaskCount(), + "order": plan.Order(), + "tasks": tasks, + "max_tokens": plan.Budget().MaxTokens, + } +} + +// TaskDispatchedEvent marks a task as started. Written BEFORE the child runs, so +// a task that was in flight when the process died is distinguishable on resume +// from one that never started. +func TaskDispatchedEvent(task Task) (sessions.EventType, map[string]any) { + return sessions.EventTaskDispatched, map[string]any{ + "id": task.ID, "depends_on": task.DependsOn, + } +} + +// TaskCompletedEvent records a successful task, including the child session id +// so it stays drillable, and its spend so the per-task records add up to the +// plan's total. +func TaskCompletedEvent(result TaskResult) (sessions.EventType, map[string]any) { + return sessions.EventTaskCompleted, map[string]any{ + "id": result.ID, + "duration_ms": result.Duration.Milliseconds(), + "session_id": result.SessionID, + "tokens": result.Tokens, + } +} + +// TaskFailedEvent records a task that failed, was skipped or was cancelled. +// +// It carries the SAME identity fields as TaskCompletedEvent. They diverged once +// — session_id and tokens were recorded only on success — and the effect was +// that the one task worth investigating was the one the log could not point at. +func TaskFailedEvent(result TaskResult) (sessions.EventType, map[string]any) { + return sessions.EventTaskFailed, map[string]any{ + "id": result.ID, + "outcome": string(result.Outcome), + "reason": result.Err, + "duration_ms": result.Duration.Milliseconds(), + "session_id": result.SessionID, + "tokens": result.Tokens, + } +} + +// PlanCompletedEvent is the plan's terminal record. +func PlanCompletedEvent(plan Plan, report PlanReport) (sessions.EventType, map[string]any) { + return sessions.EventPlanCompleted, map[string]any{ + "name": plan.Name(), + "status": string(report.Status), + "succeeded": report.Succeeded, + "failed": report.Failed, + "skipped": report.Skipped, + "cancelled": report.Cancelled, + "sequential_total_ms": report.SequentialTotal.Milliseconds(), + "critical_path_ms": report.CriticalPath.Milliseconds(), + "max_speedup": report.MaxSpeedup, + "tokens_used": report.TokensUsed, + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 1447a9394..887c7b817 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -5003,7 +5003,7 @@ func (m model) beginRun(cancel context.CancelFunc) model { // bridge for the process's life (the registry is built once per session), // so the run id has to be pushed in per run — the PostureGate problem, same // solution. - m.planProgress.Attach(m.runtimeMessageSink, m.runID) + m.planProgress.Attach(m.runtimeMessageSink, m.runID, m.sessionStore, m.activeSession.SessionID) m.stepWork = nil m.stepNarration = nil m.stepExplanation = nil diff --git a/internal/tui/plan_durability_test.go b/internal/tui/plan_durability_test.go new file mode 100644 index 000000000..5324ac71d --- /dev/null +++ b/internal/tui/plan_durability_test.go @@ -0,0 +1,223 @@ +package tui + +import ( + "encoding/json" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/Gitlawb/zero/internal/sessions" + "github.com/Gitlawb/zero/internal/specialist" +) + +func durableBridge(t *testing.T) (*PlanProgressBridge, *sessions.Store, string) { + t.Helper() + store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()}) + session, err := store.Create(sessions.CreateInput{Cwd: t.TempDir()}) + if err != nil { + t.Fatalf("create session: %v", err) + } + bridge := NewPlanProgressBridge() + bridge.Attach(func(tea.Msg) {}, 1, store, session.SessionID) + return bridge, store, session.SessionID +} + +func recordedTypes(t *testing.T, store *sessions.Store, sessionID string) []sessions.EventType { + t.Helper() + events, err := store.ReadEvents(sessionID) + if err != nil { + t.Fatalf("read events: %v", err) + } + var types []sessions.EventType + for _, event := range events { + types = append(types, event.Type) + } + return types +} + +func samplePlan(t *testing.T) specialist.Plan { + t.Helper() + plan, err := specialist.ParsePlan(map[string]any{ + "name": "durable", + "tasks": []any{ + map[string]any{"id": "a", "prompt": "one"}, + map[string]any{"id": "b", "prompt": "two", "depends_on": []any{"a"}}, + }, + "budget": map[string]any{"max_workers": float64(1)}, + }, specialist.Limits{MaxTasks: 20, ParentTools: []string{"read_file"}}) + if err != nil { + t.Fatalf("ParsePlan: %v", err) + } + return plan +} + +// A PLAN RUN IN THE TUI MUST LEAVE A RECORD. It drove the panel and wrote +// nothing: the same plan under `zero exec` wrote five events per task, so a +// plan was durable or not depending on which surface you ran it from. +func TestATuiPlanIsRecordedDurably(t *testing.T) { + bridge, store, sessionID := durableBridge(t) + plan := samplePlan(t) + + bridge.PlanAdmitted(plan) + bridge.TaskDispatched(specialist.Task{ID: "a"}) + bridge.TaskCompleted(specialist.TaskResult{ID: "a", Outcome: specialist.TaskSucceeded, SessionID: "specialist_aaa", Tokens: 150}) + bridge.TaskFailed(specialist.TaskResult{ID: "b", Outcome: specialist.TaskFailed, Err: "boom"}) + bridge.PlanCompleted(plan, specialist.PlanReport{Status: specialist.PlanPartial, Succeeded: 1, Failed: 1}) + + want := []sessions.EventType{ + sessions.EventPlanAdmitted, sessions.EventTaskDispatched, + sessions.EventTaskCompleted, sessions.EventTaskFailed, sessions.EventPlanCompleted, + } + got := recordedTypes(t, store, sessionID) + if len(got) != len(want) { + t.Fatalf("recorded %v, want the five lifecycle events", got) + } + for index := range want { + if got[index] != want[index] { + t.Fatalf("event %d = %q, want %q", index, got[index], want[index]) + } + } +} + +// Dispatch is written BEFORE the child runs, so a task that was in flight when +// the process died is distinguishable on resume from one that never started. +// That ordering IS the durability guarantee. +func TestDispatchIsRecordedBeforeTheTaskFinishes(t *testing.T) { + bridge, store, sessionID := durableBridge(t) + bridge.TaskDispatched(specialist.Task{ID: "a"}) + + got := recordedTypes(t, store, sessionID) + if len(got) != 1 || got[0] != sessions.EventTaskDispatched { + t.Fatalf("recorded %v, want a dispatch already on disk before the task ends", got) + } +} + +// THE PARITY OBLIGATION. Resume is a deterministic reduction over these events, +// and a reducer cannot be written against two shapes. The two recorders must +// emit byte-identical payloads for the same plan. +// +// Compared by round-tripping both through JSON — the form they are actually +// stored in — rather than by eyeballing two builders. +func TestBothRecordersEmitTheSamePayloads(t *testing.T) { + plan := samplePlan(t) + result := specialist.TaskResult{ + ID: "a", Outcome: specialist.TaskSucceeded, + Duration: 1500 * time.Millisecond, SessionID: "specialist_aaa", Tokens: 150, + } + failed := specialist.TaskResult{ + ID: "b", Outcome: specialist.TaskSkippedDependency, + Err: "skipped", Duration: time.Second, + } + report := specialist.PlanReport{Status: specialist.PlanPartial, Succeeded: 1, Skipped: 1, TokensUsed: 150} + + // The TUI's recorder, straight to a store. + tuiBridge, store, sessionID := durableBridge(t) + tuiBridge.PlanAdmitted(plan) + tuiBridge.TaskDispatched(specialist.Task{ID: "a", DependsOn: nil}) + tuiBridge.TaskCompleted(result) + tuiBridge.TaskFailed(failed) + tuiBridge.PlanCompleted(plan, report) + + events, err := store.ReadEvents(sessionID) + if err != nil { + t.Fatalf("read events: %v", err) + } + if len(events) != 5 { + t.Fatalf("expected five events, got %d", len(events)) + } + + // The shared builders are what the headless recorder appends, so comparing + // against them compares the two surfaces. + builders := []func() (sessions.EventType, map[string]any){ + func() (sessions.EventType, map[string]any) { return specialist.PlanAdmittedEvent(plan) }, + func() (sessions.EventType, map[string]any) { + return specialist.TaskDispatchedEvent(specialist.Task{ID: "a"}) + }, + func() (sessions.EventType, map[string]any) { return specialist.TaskCompletedEvent(result) }, + func() (sessions.EventType, map[string]any) { return specialist.TaskFailedEvent(failed) }, + func() (sessions.EventType, map[string]any) { return specialist.PlanCompletedEvent(plan, report) }, + } + for index, build := range builders { + wantType, wantPayload := build() + if events[index].Type != wantType { + t.Fatalf("event %d type = %q, want %q", index, events[index].Type, wantType) + } + got, _ := json.Marshal(events[index].Payload) + want, _ := json.Marshal(wantPayload) + if string(got) != string(want) { + t.Fatalf("event %d payload differs between the surfaces:\n TUI: %s\nexec: %s", index, got, want) + } + } +} + +// Recording is BEST EFFORT: no store, no session, or a nil bridge and the plan +// still runs. Recording must never be the thing that fails a plan. +func TestRecordingFailuresNeverStopAPlan(t *testing.T) { + plan := samplePlan(t) + + unattached := NewPlanProgressBridge() + unattached.PlanAdmitted(plan) + unattached.TaskDispatched(specialist.Task{ID: "a"}) + unattached.PlanCompleted(plan, specialist.PlanReport{}) + if err := unattached.RecordingError(); err != nil { + t.Fatalf("an unattached bridge must not latch an error: %v", err) + } + + sinkOnly := NewPlanProgressBridge() + sinkOnly.Attach(func(tea.Msg) {}, 1, nil, "") + sinkOnly.PlanAdmitted(plan) + if err := sinkOnly.RecordingError(); err != nil { + t.Fatalf("a bridge with no store must not latch an error: %v", err) + } + + var nilBridge *PlanProgressBridge + nilBridge.PlanAdmitted(plan) + nilBridge.TaskCompleted(specialist.TaskResult{}) + if err := nilBridge.RecordingError(); err != nil { + t.Fatalf("a nil bridge must be inert: %v", err) + } +} + +// A failed append is LATCHED and surfaced once, not silently dropped — a user +// must not believe a plan was persisted when it was not. +func TestAFailedAppendIsLatched(t *testing.T) { + bridge := NewPlanProgressBridge() + store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()}) + // A session id that does not exist: appends fail. + bridge.Attach(func(tea.Msg) {}, 1, store, "zero_00000000000000000000000000000000_1") + + bridge.PlanAdmitted(samplePlan(t)) + if bridge.RecordingError() == nil { + t.Fatal("a failed append must be latched so it can be reported once") + } +} + +// THE WIRING. Everything above drives the bridge directly; this drives the +// model's own run-start path, which is the only thing that ever binds the +// bridge to a session. A bridge that records perfectly and is never bound +// records nothing — the shape this feature keeps producing. +func TestTheBridgeIsBoundToTheActiveSession(t *testing.T) { + store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()}) + session, err := store.Create(sessions.CreateInput{Cwd: t.TempDir()}) + if err != nil { + t.Fatalf("create session: %v", err) + } + + m := model{now: func() time.Time { return time.Unix(1000, 0) }} + m.planProgress = NewPlanProgressBridge() + m.sessionStore = store + m.activeSession = session + m.runtimeMessageSink = func(tea.Msg) {} + + m = m.beginRun(func() {}) + + // If beginRun bound the bridge, an event recorded now lands in the store. + m.planProgress.PlanAdmitted(samplePlan(t)) + if err := m.planProgress.RecordingError(); err != nil { + t.Fatalf("recording failed after beginRun: %v", err) + } + if got := recordedTypes(t, store, session.SessionID); len(got) != 1 || got[0] != sessions.EventPlanAdmitted { + t.Fatalf("recorded %v; beginRun did not bind the bridge to the active session", got) + } +} diff --git a/internal/tui/plan_progress.go b/internal/tui/plan_progress.go index 11a12962f..9cea2bde3 100644 --- a/internal/tui/plan_progress.go +++ b/internal/tui/plan_progress.go @@ -7,6 +7,7 @@ import ( tea "charm.land/bubbletea/v2" + "github.com/Gitlawb/zero/internal/sessions" "github.com/Gitlawb/zero/internal/specialist" ) @@ -30,6 +31,20 @@ type PlanProgressBridge struct { mu sync.Mutex sink func(tea.Msg) runID int + // store/sessionID make the plan DURABLE. The TUI drove the panel and wrote + // nothing: a plan that ran here left no record at all, while the same plan + // under `zero exec` wrote five events per task. Resume is a reduction over + // those events, so a plan recorded by only one of the two surfaces is a + // plan only one of them can ever resume. + // + // Written from THIS goroutine — the tool's — never the event loop. Appending + // is file I/O, and the Bubble Tea loop must not block on it. + store *sessions.Store + sessionID string + // recordErr latches the first append failure. Recording is best-effort and + // must never fail a plan, but a silent drop would let a user believe a plan + // was persisted when it was not. + recordErr error // dispatched counts tasks so each gets a unique temporary card key. The // child's real session id is not known until the child process creates it, // so the card is keyed by this and reconciled on completion — exactly how @@ -43,7 +58,7 @@ func NewPlanProgressBridge() *PlanProgressBridge { return &PlanProgressBridge{} // Attach binds the bridge to the run that is about to start. Called on every // run so a plan's cards belong to the run that produced them; the stale-run // guard in the message handlers does the rest. -func (bridge *PlanProgressBridge) Attach(sink func(tea.Msg), runID int) { +func (bridge *PlanProgressBridge) Attach(sink func(tea.Msg), runID int, store *sessions.Store, sessionID string) { if bridge == nil { return } @@ -52,6 +67,44 @@ func (bridge *PlanProgressBridge) Attach(sink func(tea.Msg), runID int) { bridge.sink = sink bridge.runID = runID bridge.dispatched = 0 + bridge.store = store + bridge.sessionID = sessionID + bridge.recordErr = nil +} + +// record appends one plan event to the session log. +// +// BEST EFFORT at every level: no store, no session, or a latched earlier +// failure and it does nothing. It mirrors execSessionRecorder.append's +// contract exactly, because the two are the same record written from two +// surfaces. +func (bridge *PlanProgressBridge) record(eventType sessions.EventType, payload map[string]any) { + if bridge == nil { + return + } + bridge.mu.Lock() + store, sessionID, failed := bridge.store, bridge.sessionID, bridge.recordErr != nil + bridge.mu.Unlock() + if store == nil || sessionID == "" || failed { + return + } + _, err := store.AppendEvent(sessionID, sessions.AppendEventInput{Type: eventType, Payload: payload}) + if err != nil { + bridge.mu.Lock() + bridge.recordErr = err + bridge.mu.Unlock() + } +} + +// RecordingError reports the first append failure, so a surface can say once +// that the plan was not fully persisted rather than leaving it silent. +func (bridge *PlanProgressBridge) RecordingError() error { + if bridge == nil { + return nil + } + bridge.mu.Lock() + defer bridge.mu.Unlock() + return bridge.recordErr } // send posts a message if the bridge is attached. Nil-safe at every level. @@ -75,6 +128,7 @@ func planTaskKey(n int) string { return fmt.Sprintf("plantask_%d", n) } // PlanAdmitted announces the plan so the transcript can show that N tasks are // about to run rather than going silent until the first one finishes. func (bridge *PlanProgressBridge) PlanAdmitted(plan specialist.Plan) { + bridge.record(specialist.PlanAdmittedEvent(plan)) name := plan.Name() count := plan.TaskCount() limit := plan.Budget().MaxTokens @@ -107,6 +161,7 @@ func (bridge *PlanProgressBridge) TaskDispatched(task specialist.Task) { if bridge == nil { return } + bridge.record(specialist.TaskDispatchedEvent(task)) bridge.mu.Lock() bridge.dispatched++ key := planTaskKey(bridge.dispatched) @@ -121,12 +176,14 @@ func (bridge *PlanProgressBridge) TaskDispatched(task specialist.Task) { // TaskCompleted closes the card and reconciles it to the child's real session // id so the user can drill into it. func (bridge *PlanProgressBridge) TaskCompleted(result specialist.TaskResult) { + bridge.record(specialist.TaskCompletedEvent(result)) bridge.finish(result, specialistCompleted) } // TaskFailed closes the card with the outcome's own status. A cancelled or // skipped task is NOT rendered as an error: nothing broke. func (bridge *PlanProgressBridge) TaskFailed(result specialist.TaskResult) { + bridge.record(specialist.TaskFailedEvent(result)) bridge.finish(result, planOutcomeStatus(result.Outcome)) } @@ -162,6 +219,7 @@ func (bridge *PlanProgressBridge) finish(result specialist.TaskResult, status sp // PlanCompleted reports the plan's terminal state. func (bridge *PlanProgressBridge) PlanCompleted(plan specialist.Plan, report specialist.PlanReport) { + bridge.record(specialist.PlanCompletedEvent(plan, report)) name := plan.Name() status := string(report.Status) succeeded, failed := report.Succeeded, report.Failed diff --git a/internal/tui/plan_progress_test.go b/internal/tui/plan_progress_test.go index c6ab27207..df7850c94 100644 --- a/internal/tui/plan_progress_test.go +++ b/internal/tui/plan_progress_test.go @@ -88,13 +88,13 @@ func TestPlanProgressBridgeIsInertUntilAttached(t *testing.T) { var nilBridge *PlanProgressBridge nilBridge.TaskDispatched(specialist.Task{ID: "a"}) - nilBridge.Attach(nil, 1) + nilBridge.Attach(nil, 1, nil, "") } func TestPlanProgressBridgeEmitsACardPerTask(t *testing.T) { var got []tea.Msg bridge := NewPlanProgressBridge() - bridge.Attach(func(msg tea.Msg) { got = append(got, msg) }, 42) + bridge.Attach(func(msg tea.Msg) { got = append(got, msg) }, 42, nil, "") bridge.TaskDispatched(specialist.Task{ID: "a", Prompt: "first"}) bridge.TaskCompleted(specialist.TaskResult{ID: "a", Outcome: specialist.TaskSucceeded, SessionID: "specialist_aaa"}) @@ -123,7 +123,7 @@ func TestPlanProgressBridgeEmitsACardPerTask(t *testing.T) { func TestSkippedTaskDoesNotCloseTheDispatchedTasksCard(t *testing.T) { var got []tea.Msg bridge := NewPlanProgressBridge() - bridge.Attach(func(msg tea.Msg) { got = append(got, msg) }, 1) + bridge.Attach(func(msg tea.Msg) { got = append(got, msg) }, 1, nil, "") bridge.TaskDispatched(specialist.Task{ID: "b", Prompt: "runs"}) bridge.TaskFailed(specialist.TaskResult{ID: "b", Outcome: specialist.TaskFailed, Err: "boom"}) From fec3276e38c2a908cb778499c63e0c1699eabb2a Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:02:15 +0530 Subject: [PATCH 34/86] =?UTF-8?q?feat(config):=20the=20plan-size=20ceiling?= =?UTF-8?q?=20is=20a=20configurable=20tier=20(gap=20report=20=C2=A75.9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plan's task count was bounded by ONE hard-coded number with no way to move it. Twenty was too many for someone on a metered provider and too few for a real sweep, and the only way to discover it was to read plan_tool.go. profiles.planSize now names the ceiling: small 5, medium 20, large 50, unrestricted. NAMED rather than numeric so the config file says what it means and so "no ceiling at all" is expressible without a sentinel integer. TWO DELIBERATE DIVERGENCES FROM THE REPORT, both worth stating. It stays a HARD CAP, not the advisory warning the reference implementation has. Under this posture every plan task is a child inheriting a 320-turn ceiling, so fifty tasks authorise 16,000 child turns from a single tool call. A warning the model emits to itself is not a bound on that. What the report actually wanted — that the number be discoverable and movable — is delivered by the rejection naming the tier and the setting instead of printing a bare integer. And medium is 20, not the reference's 15, because 20 is the number defaultPlanMaxTasks already held. Making a bound configurable and moving it in the same change hides a behaviour change inside a mechanism change; the tier mechanism is the deliverable and the number stays where it was. PROJECT CONFIG MAY ONLY TIGHTEN IT. A cloned repo can ask for a smaller ceiling and can never raise one, because raising it raises a spend ceiling for whoever opens the repo. Same privilege boundary as Sandbox.Network's allow/deny rule and DisableZeromaxing's disable-only rule, and a looser tier is dropped silently the way an ignored network "allow" is: the project scope simply does not hold that privilege, which is not a malformed config. Fail closed throughout. An unrecognised tier resolves to the default and never to "no ceiling" — a typo must not be the thing that removes a bound — and the same is true when the early config resolve in exec fails. The mutation sweep found that rank() returned -1 for an unknown tier with a comment claiming that made it safe. It does the opposite: the tighten-only comparison adopts the SMALLER rank, so -1 would have beaten every real tier. Nothing was reachable through it, because ParsePlanSize rejects the name first — but a defence-in-depth value that only holds because of the guard in front of it is not defence in depth. Verified against the real binary, not just the tests: the same six-task plan is admitted with no setting and refused at 5 with planSize small, and a project config asking for unrestricted against a user config of small stays refused. --- internal/cli/app.go | 8 ++ internal/cli/exec.go | 10 +- internal/cli/exec_plan_size_test.go | 142 +++++++++++++++++++ internal/cli/plan_grant_test.go | 27 ++++ internal/config/plan_size.go | 150 ++++++++++++++++++++ internal/config/plan_size_test.go | 195 ++++++++++++++++++++++++++ internal/config/resolver.go | 23 +++ internal/config/types.go | 9 ++ internal/specialist/plan.go | 28 +++- internal/specialist/plan_size_test.go | 121 ++++++++++++++++ internal/specialist/plan_tool.go | 27 +++- 11 files changed, 729 insertions(+), 11 deletions(-) create mode 100644 internal/cli/exec_plan_size_test.go create mode 100644 internal/config/plan_size.go create mode 100644 internal/config/plan_size_test.go create mode 100644 internal/specialist/plan_size_test.go diff --git a/internal/cli/app.go b/internal/cli/app.go index 6d1e6e5f7..0e28a4c0b 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -721,6 +721,9 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a // --depth and is never launched as a child — so zero is the // measured value here, not an unset one. PlanContext: specialist.PlanTaskContext{Cwd: workspaceRoot, Depth: 0}, + // The plan-size tier, from the SAME resolved config the rest of this + // wiring reads. Project config may only have tightened it. + Size: resolved.Profiles.PlanSizeTier(), }) if err != nil { return writeAppError(stderr, "failed to initialize specialist tools: "+err.Error(), 1) @@ -1083,6 +1086,10 @@ type orchestrateWiring struct { Gate *specialist.PostureGate // PlanContext supplies the run-invariant state a plan task inherits. PlanContext specialist.PlanTaskContext + // Size is the configured plan-size tier. The zero value is the default tier, + // so a call site that has no resolved config yet still gets a real ceiling + // rather than none. + Size config.PlanSize } // planParentTools is the run's grant: the tools a plan task may inherit. @@ -1161,6 +1168,7 @@ func registerSpecialistTools(registry *tools.Registry, workspaceRoot string, max Recorder: recorder, ParentTools: planParentTools(registry, enabledTools, disabledTools), Depth: planContext.Depth, + Size: wiring.Size, }) return &agentToolRuntime{specialist: runtime, swarm: sw, specialists: specialistSummaries(paths)}, nil } diff --git a/internal/cli/exec.go b/internal/cli/exec.go index f0debfcee..c4e0ff119 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -243,8 +243,13 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in // overrides, so an empty-overrides resolve yields the same value; a resolve // error falls back to the swarm's built-in default (0 => 8). maxTeamSize := 0 - if swarmCfg, cfgErr := deps.resolveConfig(workspaceRoot, config.Overrides{}); cfgErr == nil { - maxTeamSize = swarmCfg.Swarm.MaxTeamSize + // planSize resolves to the DEFAULT tier when this early resolve fails — + // the same ceiling an unset value gets, never "no ceiling". A config + // error must not be the thing that removes a bound. + planSize := config.DefaultPlanSize + if earlyCfg, cfgErr := deps.resolveConfig(workspaceRoot, config.Overrides{}); cfgErr == nil { + maxTeamSize = earlyCfg.Swarm.MaxTeamSize + planSize = earlyCfg.Profiles.PlanSizeTier() } var err error // The posture is fixed for a headless run, so the gate is set once here @@ -270,6 +275,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in // ever fire — an inert guard someone would later trust. Depth: options.depth, }, + Size: planSize, }) if err != nil { return writeExecProviderError(stdout, stderr, options.outputFormat, "specialist_error", err.Error()) diff --git a/internal/cli/exec_plan_size_test.go b/internal/cli/exec_plan_size_test.go new file mode 100644 index 000000000..ef5309f8b --- /dev/null +++ b/internal/cli/exec_plan_size_test.go @@ -0,0 +1,142 @@ +package cli + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// The plan-size tier, proved END TO END: a .zero/config.json setting has to +// reach the ceiling a plan is actually rejected against. +// +// A test on registerSpecialistTools would pass while `zero exec` never read the +// setting at all — that is the exact shape of this feature's recurring defect, +// a field correctly threaded through everything except the one call site that +// populates it. So this drives Run(), writes a real config file, and asserts on +// what the model is told. +func TestExecReadsThePlanSizeTierFromProjectConfig(t *testing.T) { + clearProviderEnv(t) + root := t.TempDir() + configDir := filepath.Join(root, ".zero") + if err := os.MkdirAll(configDir, 0o700); err != nil { + t.Fatal(err) + } + + // BOTH probes are plans that get REJECTED, at DIFFERENT ceilings. An admitted + // plan would spawn real child processes — six of them — so the control here + // cannot be "and this one is admitted"; it is "and this one is rejected at + // the other tier's number", which isolates the configured value just as well + // and executes nothing. + planArgs := func(n int) string { + tasks := make([]map[string]any, 0, n) + for i := 0; i < n; i++ { + tasks = append(tasks, map[string]any{"id": "t" + strings.Repeat("z", i), "prompt": "look"}) + } + encoded, err := json.Marshal(map[string]any{ + "name": "sweep", + "tasks": tasks, + "budget": map[string]any{"max_workers": 1}, + }) + if err != nil { + t.Fatal(err) + } + return string(encoded) + } + + var plan string + var toolOutput string + turn := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + turn++ + if turn == 1 { + call, _ := json.Marshal(map[string]any{ + "choices": []any{map[string]any{"delta": map[string]any{ + "tool_calls": []any{map[string]any{ + "index": 0, "id": "call_1", "type": "function", + "function": map[string]any{"name": "orchestrate", "arguments": plan}, + }}, + }}}, + }) + _, _ = io.WriteString(w, "data: "+string(call)+"\n\n") + _, _ = io.WriteString(w, `data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}`+"\n\n") + _, _ = io.WriteString(w, "data: [DONE]\n\n") + return + } + // The second turn carries the tool result back. That message is what the + // model would have been told, and it is the assertion target. + if messages, ok := body["messages"].([]any); ok { + for _, raw := range messages { + message, _ := raw.(map[string]any) + if message["role"] == "tool" { + if content, ok := message["content"].(string); ok { + toolOutput += content + } + } + } + } + _, _ = io.WriteString(w, `data: {"choices":[{"delta":{"content":"done"}}]}`+"\n\n") + _, _ = io.WriteString(w, "data: [DONE]\n\n") + })) + defer server.Close() + + providerConfig := func(planSize string) string { + profiles := "" + if planSize != "" { + profiles = `"profiles": {"planSize": "` + planSize + `"},` + } + return `{ + ` + profiles + ` + "activeProvider": "local", + "providers": [{ + "name": "local", + "provider_kind": "openai-compatible", + "base_url": "` + server.URL + `", + "api_key": "sk-local", + "model": "local-model" + }] + }` + } + + run := func(planSize string, taskCount int) string { + turn = 0 + toolOutput = "" + plan = planArgs(taskCount) + if err := os.WriteFile(filepath.Join(configDir, "config.json"), []byte(providerConfig(planSize)), 0o600); err != nil { + t.Fatal(err) + } + var stdout, stderr bytes.Buffer + Run([]string{"exec", "--cwd", root, "--reasoning-effort", "zeromaxing", "--auto", "high", "sweep it"}, &stdout, &stderr) + return toolOutput + } + + // small: a six-task plan is refused at 5, and the message names the tier that + // refused it plus how to raise it. + small := run("small", 6) + for _, want := range []string{`"small" plan size`, "planSize", "exceeds the limit of 5"} { + if !strings.Contains(small, want) { + t.Fatalf("under planSize=small the tool output must contain %q; got:\n%s", want, small) + } + } + + // THE OTHER DIRECTION, which is what makes the first half mean anything: with + // no setting the ceiling is the DEFAULT tier's 20, not small's 5. Without + // this, a run that ignored config entirely and always used one hard-coded + // number would satisfy the assertion above. + unset := run("", 25) + for _, want := range []string{`"medium" plan size`, "exceeds the limit of 20"} { + if !strings.Contains(unset, want) { + t.Fatalf("with no planSize set the ceiling must be the default tier's; want %q, got:\n%s", want, unset) + } + } + if strings.Contains(unset, "limit of 5") { + t.Fatalf("an unset planSize applied small's ceiling:\n%s", unset) + } +} diff --git a/internal/cli/plan_grant_test.go b/internal/cli/plan_grant_test.go index a4e45fac4..df4a8fbb1 100644 --- a/internal/cli/plan_grant_test.go +++ b/internal/cli/plan_grant_test.go @@ -4,6 +4,7 @@ import ( "strings" "testing" + "github.com/Gitlawb/zero/internal/config" "github.com/Gitlawb/zero/internal/specialist" ) @@ -157,3 +158,29 @@ type countingPlanRecorder struct{ dispatched int } func (r *countingPlanRecorder) TaskDispatched(specialist.Task) { r.dispatched++ } func (r *countingPlanRecorder) TaskCompleted(specialist.TaskResult) {} func (r *countingPlanRecorder) TaskFailed(specialist.TaskResult) {} + +// The plan-size tier must survive registerSpecialistTools. Both call sites pass +// it through this function, and a tier read from config but dropped here would +// leave every run on the default ceiling while the config file said otherwise — +// the same shape as the grant defect above, one layer along. +func TestRegisteredOrchestrateToolCarriesTheConfiguredTier(t *testing.T) { + workspace := t.TempDir() + registry := newCoreRegistry(workspace) + runtime, err := registerSpecialistTools(registry, workspace, 0, nil, nil, nil, orchestrateWiring{ + Gate: &specialist.PostureGate{}, + Size: config.PlanSizeSmall, + }) + if err != nil { + t.Fatalf("registerSpecialistTools: %v", err) + } + t.Cleanup(func() { closeSpecialistRuntime(nil, runtime) }) + + registered, _ := registry.Get(specialist.OrchestrateToolName) + tool, ok := registered.(*specialist.OrchestrateTool) + if !ok { + t.Fatalf("orchestrate is %T", registered) + } + if tool.Size != config.PlanSizeSmall { + t.Fatalf("registered tier = %q; want %q — the wiring dropped it", tool.Size, config.PlanSizeSmall) + } +} diff --git a/internal/config/plan_size.go b/internal/config/plan_size.go new file mode 100644 index 000000000..3eaf5777c --- /dev/null +++ b/internal/config/plan_size.go @@ -0,0 +1,150 @@ +package config + +import ( + "fmt" + "sort" + "strings" +) + +// The zeromaxing plan-size tiers. +// +// A plan's task count was bounded by ONE hard-coded number with no way to move +// it: a twenty-task ceiling that a user with a genuinely large sweep could not +// raise and a user on a metered provider could not lower. The tier is the knob, +// and it is NAMED rather than numeric so the config file says what it means and +// so "no ceiling at all" is expressible without a sentinel integer. +// +// IT REMAINS A HARD CAP, not an advisory warning. The reference implementation +// warns and proceeds; here a plan over the tier is REJECTED, because under this +// posture every plan task is a child inheriting a 320-turn ceiling — fifty tasks +// authorise 16,000 child turns from one tool call. A warning the model emits to +// itself is not a bound on that. The rejection names the tier and the setting, +// so the ceiling is discoverable at the moment it is hit rather than being a +// number the user has to find in the source. +type PlanSize string + +const ( + // PlanSizeSmall suits a metered provider or a first look at the feature. + PlanSizeSmall PlanSize = "small" + // PlanSizeMedium is the default, and its 20 is TODAY'S number. Phase 2 exists + // to measure whether fan-out would pay, not to run large plans, and changing + // the default while making it configurable would have hidden a behaviour + // change inside a mechanism change. + PlanSizeMedium PlanSize = "medium" + // PlanSizeLarge is for a real sweep across a large tree. + PlanSizeLarge PlanSize = "large" + // PlanSizeUnrestricted removes the ceiling. Named rather than "0" so a reader + // of the config file cannot mistake it for "unset". + PlanSizeUnrestricted PlanSize = "unrestricted" +) + +// DefaultPlanSize is what an unset — or unreadable — setting resolves to. +const DefaultPlanSize = PlanSizeMedium + +// planSizeTiers is THE tier table: the only place a tier maps to a number. +// +// Ordered smallest-first, and that order is load-bearing twice over: it gives +// PlanSizeNames a stable rendering and it gives the project-config merge its +// tighten-only comparison. A second ordered list elsewhere would drift +// (invariant 5), so both read this one. +var planSizeTiers = []struct { + size PlanSize + maxTasks int +}{ + {PlanSizeSmall, 5}, + {PlanSizeMedium, 20}, + {PlanSizeLarge, 50}, + // 0 means no ceiling. It is the LAST entry, so it is also the loosest rank — + // which is what makes the tighten-only rule reject it from project config. + {PlanSizeUnrestricted, 0}, +} + +// MaxTasks is the tier's ceiling on a plan's task count. 0 means no ceiling. +// +// An unrecognised tier resolves to the DEFAULT rather than to no ceiling. Fail +// closed: a typo in a config file must never be the thing that removes a bound. +func (size PlanSize) MaxTasks() int { + for _, tier := range planSizeTiers { + if tier.size == size { + return tier.maxTasks + } + } + return DefaultPlanSize.MaxTasks() +} + +// Valid reports whether the tier is one this build knows. +func (size PlanSize) Valid() bool { + for _, tier := range planSizeTiers { + if tier.size == size { + return true + } + } + return false +} + +// rank orders the tiers from tightest to loosest. Used only by the merge rules; +// unexported because "how loose is this" is not a question a caller outside this +// package should be answering for itself. +func (size PlanSize) rank() int { + for index, tier := range planSizeTiers { + if tier.size == size { + return index + } + } + // An unknown tier ranks LOOSEST, so a "may only tighten" comparison can never + // adopt it. + // + // The first version returned -1 here with a comment claiming that made it + // safe. It does the opposite: the comparison adopts the SMALLER rank, so a + // tier ranked -1 would win against every real tier. Nothing was reachable + // through it — ParsePlanSize rejects an unknown name before the merge calls + // rank — but a defence-in-depth value that only holds because of the guard in + // front of it is not defence in depth. It must be safe on its own. + return len(planSizeTiers) +} + +// ParsePlanSize resolves a configured value, case- and space-insensitively. +// +// An empty value is NOT an error: an unset setting is the default, which is the +// overwhelmingly common case and must not make a config file unloadable. +func ParsePlanSize(value string) (PlanSize, error) { + trimmed := strings.ToLower(strings.TrimSpace(value)) + if trimmed == "" { + return DefaultPlanSize, nil + } + size := PlanSize(trimmed) + if !size.Valid() { + return DefaultPlanSize, fmt.Errorf("unknown plan size %q; expected one of: %s", value, strings.Join(PlanSizeNames(), ", ")) + } + return size, nil +} + +// PlanSizeNames renders the tiers for an error message, tightest first. +func PlanSizeNames() []string { + names := make([]string, 0, len(planSizeTiers)) + for _, tier := range planSizeTiers { + names = append(names, string(tier.size)) + } + return names +} + +// SortedPlanSizeNames is PlanSizeNames in alphabetical order, for callers that +// want a stable rendering independent of the tier ordering. +func SortedPlanSizeNames() []string { + names := PlanSizeNames() + sort.Strings(names) + return names +} + +// PlanSize resolves the configured tier for this workspace. +// +// It never fails: an unreadable or unknown value is the default, which is what +// keeps a typo in a config file from either breaking the run or removing the +// ceiling. Callers wanting to REPORT a bad value call ParsePlanSize. +func (cfg ProfilesConfig) PlanSizeTier() PlanSize { + size, err := ParsePlanSize(cfg.PlanSize) + if err != nil { + return DefaultPlanSize + } + return size +} diff --git a/internal/config/plan_size_test.go b/internal/config/plan_size_test.go new file mode 100644 index 000000000..22399c1f0 --- /dev/null +++ b/internal/config/plan_size_test.go @@ -0,0 +1,195 @@ +package config + +import "testing" + +// The tier table is the ceiling. Asserted as a table so a change to any number +// is a deliberate edit to this test rather than a silent widening. +func TestPlanSizeTierCeilings(t *testing.T) { + for _, tc := range []struct { + size PlanSize + want int + }{ + {PlanSizeSmall, 5}, + {PlanSizeMedium, 20}, + {PlanSizeLarge, 50}, + {PlanSizeUnrestricted, 0}, + } { + if got := tc.size.MaxTasks(); got != tc.want { + t.Errorf("%s.MaxTasks() = %d; want %d", tc.size, got, tc.want) + } + } +} + +// THE NO-REGRESSION ASSERTION. The ceiling was a hard-coded 20 before it was +// configurable, and the default must still be that number: making a bound +// configurable and moving it in the same change would hide a behaviour change +// inside a mechanism change. +func TestTheDefaultTierIsTheCeilingThatWasHardCoded(t *testing.T) { + if got := DefaultPlanSize.MaxTasks(); got != 20 { + t.Fatalf("default tier ceiling = %d; want 20, the value defaultPlanMaxTasks held", got) + } + // The zero value of the type must resolve there too — a caller that never + // wires the tier gets the old ceiling, not no ceiling. + var unset PlanSize + if got := unset.MaxTasks(); got != 20 { + t.Fatalf("unset tier ceiling = %d; want 20", got) + } +} + +// FAIL CLOSED. A typo in a config file must never be the thing that removes the +// bound — the failure mode that matters is "planSize": "unlimted" silently +// granting an unbounded plan. +func TestAnUnknownTierResolvesToTheDefaultAndNeverToUnbounded(t *testing.T) { + unknown := PlanSize("unlimted") + if unknown.Valid() { + t.Fatal("a misspelled tier must not be Valid") + } + if got := unknown.MaxTasks(); got != DefaultPlanSize.MaxTasks() { + t.Fatalf("unknown tier ceiling = %d; want the default %d", got, DefaultPlanSize.MaxTasks()) + } + if unknown.MaxTasks() == 0 { + t.Fatal("an unknown tier resolved to NO ceiling; a typo must not remove the bound") + } + if _, err := ParsePlanSize("unlimted"); err == nil { + t.Fatal("ParsePlanSize must report an unknown tier so a caller can surface it") + } +} + +// An unset value is not an error: the overwhelmingly common config has no key +// at all, and that must not make the file unloadable. +func TestAnUnsetTierIsTheDefaultAndNotAnError(t *testing.T) { + size, err := ParsePlanSize(" ") + if err != nil { + t.Fatalf("ParsePlanSize(empty): %v", err) + } + if size != DefaultPlanSize { + t.Fatalf("ParsePlanSize(empty) = %q; want %q", size, DefaultPlanSize) + } +} + +func TestParsePlanSizeIgnoresCaseAndSpace(t *testing.T) { + size, err := ParsePlanSize(" LARGE ") + if err != nil { + t.Fatalf("ParsePlanSize: %v", err) + } + if size != PlanSizeLarge { + t.Fatalf("got %q; want %q", size, PlanSizeLarge) + } +} + +// User config is higher-trust and may LOOSEN the tier. +func TestUserConfigMaySetAnyTier(t *testing.T) { + dst := FileConfig{} + mergeConfig(&dst, FileConfig{Profiles: ProfilesConfig{PlanSize: "unrestricted"}}) + if got := dst.Profiles.PlanSizeTier(); got != PlanSizeUnrestricted { + t.Fatalf("tier = %q; want %q — user config may loosen", got, PlanSizeUnrestricted) + } +} + +// Project config may TIGHTEN. Same privilege boundary as Sandbox.Network's +// tighten-only rule: a project may make its own runs stricter. +func TestProjectConfigMayTightenTheTier(t *testing.T) { + dst := FileConfig{Profiles: ProfilesConfig{PlanSize: "large"}} + if err := mergeProjectConfig(&dst, FileConfig{Profiles: ProfilesConfig{PlanSize: "small"}}); err != nil { + t.Fatalf("mergeProjectConfig: %v", err) + } + if got := dst.Profiles.PlanSizeTier(); got != PlanSizeSmall { + t.Fatalf("tier = %q; want %q — a project may tighten", got, PlanSizeSmall) + } +} + +// ...and may NOT loosen it. A cloned repo must not be able to raise a spend +// ceiling for whoever opens it. Dropped silently, like an ignored network +// "allow": the project scope simply does not hold that privilege. +func TestProjectConfigCannotLoosenTheTier(t *testing.T) { + for _, attempt := range []string{"large", "unrestricted"} { + dst := FileConfig{Profiles: ProfilesConfig{PlanSize: "small"}} + if err := mergeProjectConfig(&dst, FileConfig{Profiles: ProfilesConfig{PlanSize: attempt}}); err != nil { + t.Fatalf("mergeProjectConfig(%s): %v", attempt, err) + } + if got := dst.Profiles.PlanSizeTier(); got != PlanSizeSmall { + t.Fatalf("project config raised the tier to %q with %q; it must stay %q", got, attempt, PlanSizeSmall) + } + } +} + +// The unset case is the one that matters most: with no user setting the +// effective tier is medium, and a project asking for large must still lose. +func TestProjectConfigCannotLoosenTheDefaultTier(t *testing.T) { + dst := FileConfig{} + if err := mergeProjectConfig(&dst, FileConfig{Profiles: ProfilesConfig{PlanSize: "unrestricted"}}); err != nil { + t.Fatalf("mergeProjectConfig: %v", err) + } + if got := dst.Profiles.PlanSizeTier(); got != DefaultPlanSize { + t.Fatalf("tier = %q; want the default %q — a project may not loosen an unset tier", got, DefaultPlanSize) + } + if dst.Profiles.PlanSizeTier().MaxTasks() == 0 { + t.Fatal("project config removed the ceiling entirely") + } +} + +// An unrecognised tier from project config must not win either. Two independent +// things stop it — ParsePlanSize rejects the name, and rank puts it loosest — +// and this asserts the outcome rather than which layer did it. +func TestAnUnknownProjectTierIsIgnored(t *testing.T) { + dst := FileConfig{Profiles: ProfilesConfig{PlanSize: "large"}} + if err := mergeProjectConfig(&dst, FileConfig{Profiles: ProfilesConfig{PlanSize: "enormous"}}); err != nil { + t.Fatalf("mergeProjectConfig: %v", err) + } + if got := dst.Profiles.PlanSize; got != "large" { + t.Fatalf("stored tier = %q; an unknown project tier must be ignored, leaving %q", got, "large") + } +} + +// An unrecognised tier from USER config is not stored either, so no reader +// downstream has to re-validate what it was handed. +func TestAnUnknownUserTierIsNotStored(t *testing.T) { + dst := FileConfig{} + mergeConfig(&dst, FileConfig{Profiles: ProfilesConfig{PlanSize: "enormous"}}) + if dst.Profiles.PlanSize != "" { + t.Fatalf("stored tier = %q; an unknown user tier must not be stored", dst.Profiles.PlanSize) + } + if got := dst.Profiles.PlanSizeTier(); got != DefaultPlanSize { + t.Fatalf("tier = %q; want the default %q", got, DefaultPlanSize) + } +} + +// profiles.planSize is the key users actually type. +func TestPlanSizeRoundTripsThroughJSON(t *testing.T) { + var cfg FileConfig + if err := cfg.UnmarshalJSON([]byte(`{"profiles":{"planSize":"large"}}`)); err != nil { + t.Fatalf("UnmarshalJSON: %v", err) + } + if cfg.Profiles.PlanSize != "large" { + t.Fatalf("profiles.planSize decoded as %q", cfg.Profiles.PlanSize) + } + encoded, err := cfg.MarshalJSON() + if err != nil { + t.Fatalf("MarshalJSON: %v", err) + } + var round FileConfig + if err := round.UnmarshalJSON(encoded); err != nil { + t.Fatalf("re-decode: %v", err) + } + if round.Profiles.PlanSize != "large" { + t.Fatalf("profiles.planSize did not survive a round trip: %q", round.Profiles.PlanSize) + } +} + +// rank is the tighten-only comparison's input, and it must be safe WITHOUT the +// ParsePlanSize guard in front of it. A defence-in-depth layer that only holds +// because of the check before it is not a layer. +// +// The comparison adopts the SMALLER rank, so an unknown tier has to rank +// loosest. Ranking it tightest — which an earlier version did, with a comment +// asserting the opposite — would have made an unrecognised name beat every real +// tier the moment it reached this function. +func TestAnUnknownTierRanksLoosestSoItCanNeverBeAdopted(t *testing.T) { + unknown := PlanSize("enormous").rank() + for _, tier := range planSizeTiers { + if unknown <= tier.size.rank() { + t.Fatalf("unknown ranks %d, at or below %q's %d; the tighten-only comparison would adopt it", + unknown, tier.size, tier.size.rank()) + } + } +} diff --git a/internal/config/resolver.go b/internal/config/resolver.go index 244494c6a..69ed2c042 100644 --- a/internal/config/resolver.go +++ b/internal/config/resolver.go @@ -259,6 +259,13 @@ func mergeConfig(dst *FileConfig, src FileConfig) { if src.Profiles.DisableZeromaxing { dst.Profiles.DisableZeromaxing = true } + // User config is higher-trust and may set ANY tier, tighter or looser. An + // unrecognised value is ignored rather than stored, so a typo falls back to + // the default instead of being carried around as a value every reader has to + // re-validate. + if size, err := ParsePlanSize(src.Profiles.PlanSize); err == nil && strings.TrimSpace(src.Profiles.PlanSize) != "" { + dst.Profiles.PlanSize = string(size) + } if src.Preferences.FavoriteModels != nil { dst.Preferences.FavoriteModels = normalizeFavoriteModels(src.Preferences.FavoriteModels) } @@ -342,6 +349,22 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error { if src.Profiles.DisableZeromaxing { dst.Profiles.DisableZeromaxing = true } + // Profiles.PlanSize from project config may only TIGHTEN. A cloned repo can + // ask for a smaller ceiling; it cannot raise one, because raising it is + // raising a spend ceiling for whoever opens the repo — the same privilege + // boundary as Sandbox.Network's allow/deny rule and DisableZeromaxing's + // disable-only rule. A looser tier is ignored SILENTLY, matching the ignored + // network "allow" rather than erroring: the project simply does not hold that + // privilege, which is not a malformed config. + // + // An unknown tier ranks tightest (rank -1) and so can never win here either. + if strings.TrimSpace(src.Profiles.PlanSize) != "" { + if size, err := ParsePlanSize(src.Profiles.PlanSize); err == nil { + if size.rank() < dst.Profiles.PlanSizeTier().rank() { + dst.Profiles.PlanSize = string(size) + } + } + } mergeKeyBindings(&dst.KeyBindings, src.KeyBindings) // Local control is intentionally user-config/override only. A cloned project // must not be able to make browser, desktop, or terminal automation tools diff --git a/internal/config/types.go b/internal/config/types.go index c489fb594..9d2063cb2 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -334,6 +334,15 @@ type ProfilesConfig struct { // switch a cost multiplier ON for whoever opens it. Only global user config // may leave it off. See mergeProjectConfig. DisableZeromaxing bool `json:"disableZeromaxing,omitempty"` + // PlanSize is the zeromaxing plan-size tier: how many tasks one plan may + // contain. One of small, medium (the default), large, unrestricted — see + // plan_size.go for the numbers and for why this stays a hard cap rather than + // an advisory warning. + // + // Project config may only TIGHTEN it, for the same reason DisableZeromaxing + // is disable-only: a cloned repo must not be able to raise a cost ceiling for + // whoever opens it. See mergeProjectConfig. + PlanSize string `json:"planSize,omitempty"` } func (cfg *ToolsConfig) UnmarshalJSON(data []byte) error { diff --git a/internal/specialist/plan.go b/internal/specialist/plan.go index e864452f7..2dc7d0b4b 100644 --- a/internal/specialist/plan.go +++ b/internal/specialist/plan.go @@ -6,6 +6,8 @@ import ( "sort" "strings" "time" + + "github.com/Gitlawb/zero/internal/config" ) // ZeroMaxing Phase 2: plan capture, executed SEQUENTIALLY. @@ -69,8 +71,14 @@ type Budget struct { // Limits are the caller-supplied hard caps a plan must fit inside. type Limits struct { - // MaxTasks bounds plan size. + // MaxTasks bounds plan size. 0 means no bound. MaxTasks int + // MaxTasksSource LABELS where MaxTasks came from, for the rejection message + // only — "the \"medium\" plan size". Deliberately a phrase and not a second + // number: a label cannot contradict the bound it describes, whereas a second + // copy of the count would eventually disagree with the one being enforced + // (invariant 5). Empty renders a generic message. + MaxTasksSource string // MaxTokens is the ceiling a plan's own budget may not exceed. MaxTokens int // ParentTools is the grant the parent run holds. A task's Tools must be a @@ -152,7 +160,7 @@ func ParsePlan(args map[string]any, limits Limits) (Plan, error) { return Plan{}, fmt.Errorf("plan requires at least one task") } if limits.MaxTasks > 0 && len(rawTasks) > limits.MaxTasks { - return Plan{}, fmt.Errorf("plan has %d tasks, which exceeds the limit of %d", len(rawTasks), limits.MaxTasks) + return Plan{}, planTooLargeError(len(rawTasks), limits) } // DEPTH, at admission. A plan runs inside a child at limits.CurrentDepth, so @@ -292,6 +300,22 @@ func validateTaskTools(task Task, limits Limits) error { return nil } +// planTooLargeError names the ceiling AND how to move it. +// +// The old message was "plan has 24 tasks, which exceeds the limit of 20" — a +// number with no origin and no remedy, so the only way to act on it was to read +// the source. The ceiling is configurable now, and a bound the user cannot +// discover is a bound they will work around by splitting the plan instead. +func planTooLargeError(count int, limits Limits) error { + source := strings.TrimSpace(limits.MaxTasksSource) + if source == "" { + return fmt.Errorf("plan has %d tasks, which exceeds the limit of %d", count, limits.MaxTasks) + } + return fmt.Errorf( + "plan has %d tasks, which exceeds the limit of %d set by %s; raise it with \"profiles\": {\"planSize\": \"%s\"} in .zero/config.json, or split the plan", + count, limits.MaxTasks, source, config.PlanSizeLarge) +} + func sortedReadOnlyTools() []string { names := make([]string, 0, len(planReadOnlyTools)) for name := range planReadOnlyTools { diff --git a/internal/specialist/plan_size_test.go b/internal/specialist/plan_size_test.go new file mode 100644 index 000000000..95258b33c --- /dev/null +++ b/internal/specialist/plan_size_test.go @@ -0,0 +1,121 @@ +package specialist + +import ( + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/tools" +) + +// sizedPlanArgs builds a plan with n trivial tasks, for exercising the ceiling. +func sizedPlanArgs(n int) map[string]any { + tasks := make([]any, 0, n) + for i := 0; i < n; i++ { + tasks = append(tasks, map[string]any{ + "id": "t" + string(rune('a'+i%26)) + strings.Repeat("x", i/26), + "prompt": "look at something", + }) + } + return map[string]any{ + "tasks": tasks, + "budget": map[string]any{"max_workers": float64(1)}, + } +} + +// THE WIRE. A tier configured on the tool must reach the ceiling ParsePlan +// enforces — the class of defect this feature keeps producing is a field that is +// declared, documented and never populated at the production site. +func TestTheConfiguredTierIsTheCeilingTheToolEnforces(t *testing.T) { + for _, tc := range []struct { + size config.PlanSize + admit int + block int + }{ + {config.PlanSizeSmall, 5, 6}, + {config.PlanSizeMedium, 20, 21}, + {config.PlanSizeLarge, 50, 51}, + } { + tool := &OrchestrateTool{Size: tc.size} + limits := tool.limits(tools.RunOptions{}) + limits.ParentTools = []string{"read_file"} + if _, err := ParsePlan(sizedPlanArgs(tc.admit), limits); err != nil { + t.Errorf("%s: a %d-task plan was rejected: %v", tc.size, tc.admit, err) + } + if _, err := ParsePlan(sizedPlanArgs(tc.block), limits); err == nil { + t.Errorf("%s: a %d-task plan was admitted; the ceiling is %d", tc.size, tc.block, tc.admit) + } + } +} + +// "unrestricted" means no ceiling, and it has to be provable — a tier whose +// number is 0 would silently become "reject everything" if the guard were +// written as >= instead of >. +func TestTheUnrestrictedTierHasNoCeiling(t *testing.T) { + tool := &OrchestrateTool{Size: config.PlanSizeUnrestricted} + limits := tool.limits(tools.RunOptions{}) + limits.ParentTools = []string{"read_file"} + if limits.MaxTasks != 0 { + t.Fatalf("MaxTasks = %d; unrestricted must carry no ceiling", limits.MaxTasks) + } + if _, err := ParsePlan(sizedPlanArgs(120), limits); err != nil { + t.Fatalf("a 120-task plan was rejected under the unrestricted tier: %v", err) + } +} + +// FAIL CLOSED at the tool boundary too. An unrecognised tier — a typo that +// reached the tool despite the config merge dropping it — must land on the +// default ceiling, never on no ceiling. +func TestAnUnknownTierOnTheToolFallsBackToTheDefaultCeiling(t *testing.T) { + tool := &OrchestrateTool{Size: config.PlanSize("enormous")} + limits := tool.limits(tools.RunOptions{}) + if limits.MaxTasks != config.DefaultPlanSize.MaxTasks() { + t.Fatalf("MaxTasks = %d; want the default %d", limits.MaxTasks, config.DefaultPlanSize.MaxTasks()) + } + if !strings.Contains(limits.MaxTasksSource, string(config.DefaultPlanSize)) { + t.Fatalf("MaxTasksSource = %q; it must name the tier actually in force", limits.MaxTasksSource) + } +} + +// A tool that never had a tier wired keeps the ceiling it had before the tier +// existed. This is the additive half: nothing changes for a caller that does +// not opt in. +func TestAnUnwiredTierKeepsTheOldCeiling(t *testing.T) { + tool := &OrchestrateTool{} + if got := tool.limits(tools.RunOptions{}).MaxTasks; got != 20 { + t.Fatalf("MaxTasks = %d; an unwired tier must keep the previous ceiling of 20", got) + } +} + +// The rejection has to be ACTIONABLE. The old message was a number with no +// origin and no remedy, so the only way to act on it was to read the source. +func TestTheTooLargeRejectionNamesTheTierAndTheRemedy(t *testing.T) { + tool := &OrchestrateTool{Size: config.PlanSizeSmall} + limits := tool.limits(tools.RunOptions{}) + limits.ParentTools = []string{"read_file"} + _, err := ParsePlan(sizedPlanArgs(6), limits) + if err == nil { + t.Fatal("a 6-task plan must be rejected under the small tier") + } + for _, want := range []string{`"small" plan size`, "planSize", ".zero/config.json", "6", "5"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("rejection %q does not mention %q", err.Error(), want) + } + } +} + +// Without a source label the message still renders — the generic form is what a +// test or an internal caller constructing Limits by hand gets, and it must not +// contain a dangling "set by ". +func TestTheTooLargeRejectionWithoutASourceIsStillWellFormed(t *testing.T) { + err := planTooLargeError(9, Limits{MaxTasks: 4}) + if err == nil { + t.Fatal("expected an error") + } + if strings.Contains(err.Error(), "set by") { + t.Fatalf("generic rejection %q leaked an empty source clause", err.Error()) + } + if !strings.Contains(err.Error(), "9") || !strings.Contains(err.Error(), "4") { + t.Fatalf("generic rejection %q lost the counts", err.Error()) + } +} diff --git a/internal/specialist/plan_tool.go b/internal/specialist/plan_tool.go index efdaad2dc..b0b0bee0e 100644 --- a/internal/specialist/plan_tool.go +++ b/internal/specialist/plan_tool.go @@ -2,8 +2,10 @@ package specialist import ( "context" + "fmt" "strconv" + "github.com/Gitlawb/zero/internal/config" "github.com/Gitlawb/zero/internal/tools" ) @@ -41,14 +43,14 @@ type OrchestrateTool struct { // Depth is the nesting depth of the run holding this tool, used for the // admission-time headroom check. Depth int + // Size is the configured plan-size tier. The zero value is the default tier, + // so a caller that never wires it gets the same ceiling as before. + Size config.PlanSize // Limits overrides the default caps; nil uses them. Limits *Limits } const ( - // defaultPlanMaxTasks bounds plan size. Deliberately small: Phase 2 exists - // to measure whether fan-out would pay, not to run large plans. - defaultPlanMaxTasks = 20 // defaultPlanMaxTokens is 0: NO ceiling on what a plan may request. // // It was 200_000, and a six-task chain asking for exactly that spent @@ -204,12 +206,23 @@ func (tool *OrchestrateTool) runnerForCall(options tools.RunOptions) PlanRunner } // Limits supplies the hard caps a plan must fit inside. nil means the defaults. +// +// The task ceiling comes from the CONFIGURED TIER rather than a constant here. +// It was a hard-coded 20 with no way to move it: too many for a metered +// provider, too few for a real sweep, and discoverable only by reading this +// file. PlanSize.MaxTasks resolves an unset or unrecognised tier to the default, +// so the zero value is exactly the old ceiling. func (tool *OrchestrateTool) limits(options tools.RunOptions) Limits { + size := tool.Size + if !size.Valid() { + size = config.DefaultPlanSize + } limits := Limits{ - MaxTasks: defaultPlanMaxTasks, - MaxTokens: defaultPlanMaxTokens, - CurrentDepth: tool.Depth, - ParentTools: tool.ParentTools, + MaxTasks: size.MaxTasks(), + MaxTasksSource: fmt.Sprintf("the %q plan size", size), + MaxTokens: defaultPlanMaxTokens, + CurrentDepth: tool.Depth, + ParentTools: tool.ParentTools, } if tool.Limits != nil { limits = *tool.Limits From ff9937e39f1c3afb1bbfa31fe51f2775e0bd4a27 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:05:46 +0530 Subject: [PATCH 35/86] =?UTF-8?q?feat(specialist):=20a=20stalled=20plan=20?= =?UTF-8?q?task=20is=20retried,=20and=20only=20a=20stalled=20one=20(gap=20?= =?UTF-8?q?report=20=C2=A75.10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The watchdog file said retrying needed its own argument before it was built. Here it is. ONLY A STALL IS RETRIED, and that asymmetry is the entire justification. A task whose child ran and reported an error produced an ANSWER; running it again spends another child to receive the same one. A stall is the opposite — it is the ABSENCE of an answer, so there is no result to disbelieve and nothing partial to lose. Every other failure behaves exactly as it did before. That is why the retry keys on TaskResult.Stalled, set by the runner from the watchdog's own verdict, rather than on "the task failed" or on a phrase in the error text. Deciding to spend another child by matching a message is the same class as comparing errors with == instead of errors.Is, which is what silently disabled every stall retry in the prototype. THE DEFAULT IS ONE EXTRA ATTEMPT, and that is a judgment, not a measurement. It costs a genuinely wedged task twice the stall timeout before the plan moves on — six minutes at the default — in exchange for surviving a transient provider condition without losing the task's dependents with it. Under a posture whose premise is spending more for a better answer, six minutes of patience is the cheaper mistake. A caller who disagrees sets max_retries to 0, which is why an explicit 0 has to be distinguishable from an absent key: planInt returns 0 for both, so the budget parses PRESENCE. An unset value reading as an explicit one is the invariant that made an empty tool grant expand to the full read-only category. THE RETRY LIVES IN THE EXECUTOR, not the runner. The executor owns the budget, the wall deadline, cancellation and the record; a retry hidden inside the runner would spend a second child's tokens with none of them counting. Duration and tokens come back as the TOTALS across attempts, so a plan's reported spend is its real spend. Every reason to stop is checked before launching another child — a cancelled run, an expired wall deadline, an exhausted attempt budget. The prototype's equivalent loop retried a cancelled task, which turned Ctrl-C into another spawn. One dispatch per task, so the panel still shows one card and the transcript keeps one result per tool call. The count reaches the summary, the session events and the task detail, because a retried task's totals otherwise read as one extraordinarily expensive attempt. Seventeen mutations. The one that MISSED is the reason this note exists: TaskResult.Stalled — the flag the whole policy turns on — was produced by the real runner and asserted by nothing, because every retry test supplies it from a fake runner. Same shape as the budget meter fed by a counter NewPlanRunner never populated. A fake that fabricates its own inputs cannot test the producer. Driven through the real binary, not only the tests: a wedged child with max_stall_seconds 30 produced two child requests and "stuck [failed] 1m10s (2 attempts)", the independent task still ran, the dependent was skipped, exit 4. With max_retries 0, exactly one child request and 35s. --- internal/specialist/plan.go | 35 ++- internal/specialist/plan_events.go | 8 + internal/specialist/plan_exec.go | 99 ++++++++- internal/specialist/plan_retry_test.go | 249 ++++++++++++++++++++++ internal/specialist/plan_runner.go | 8 +- internal/specialist/plan_tool.go | 2 +- internal/specialist/plan_watchdog.go | 46 +++- internal/specialist/plan_watchdog_test.go | 54 ++++- internal/tui/model.go | 2 +- internal/tui/orchestrate_panel.go | 7 +- internal/tui/orchestrate_panel_test.go | 24 +-- internal/tui/plan_messages.go | 4 + internal/tui/plan_progress.go | 3 +- internal/tui/plan_progress_test.go | 45 ++++ internal/tui/sidebar_plan_detail.go | 5 + internal/tui/sidebar_plan_detail_test.go | 4 +- internal/tui/sidebar_plan_test.go | 6 +- 17 files changed, 559 insertions(+), 42 deletions(-) create mode 100644 internal/specialist/plan_retry_test.go diff --git a/internal/specialist/plan.go b/internal/specialist/plan.go index 2dc7d0b4b..2df1633bd 100644 --- a/internal/specialist/plan.go +++ b/internal/specialist/plan.go @@ -67,6 +67,13 @@ type Budget struct { // sit inside its wall budget while one task is wedged and the rest never // run. Zero means the default. MaxStall time.Duration + // MaxRetries is how many EXTRA attempts a STALLED task gets. Resolved to its + // effective value at parse time — 0 here means no retries, and an unset + // max_retries has already become the default by the time anything reads it, + // so nothing downstream re-derives it and 0 can never be mistaken for unset. + // + // Only stalls are retried. See runTaskWithRetries. + MaxRetries int } // Limits are the caller-supplied hard caps a plan must fit inside. @@ -354,6 +361,20 @@ func planBudget(args map[string]any, limits Limits) (Budget, error) { } budget.MaxStall = stall } + // max_retries needs PRESENCE, not a value: an explicit 0 means "do not retry + // this plan" and an absent key means "use the default", and planInt cannot + // tell them apart — it returns 0 for both. An unset value that reads as an + // explicit one is invariant 2, the defect that made an empty tool grant + // expand to the full read-only category. + budget.MaxRetries = defaultPlanRetries + if retries, set := planIntSet(raw, "max_retries"); set { + if retries < 0 || retries > maxPlanRetries { + return Budget{}, fmt.Errorf( + "budget.max_retries must be between 0 and %d; a task that stalls repeatedly is a provider or network condition, not something more attempts will fix", + maxPlanRetries) + } + budget.MaxRetries = retries + } // MaxWorkers must be exactly 1. Rejecting rather than coercing keeps the // field meaningful: a caller that asked for 8 and silently got 1 would have // been told nothing, and Phase 3 would inherit a field nobody trusts. @@ -447,12 +468,20 @@ func planStrings(args map[string]any, key string) []string { // planInt accepts the float64 a JSON number decodes to, as well as an int. func planInt(args map[string]any, key string) int { + value, _ := planIntSet(args, key) + return value +} + +// planIntSet also reports whether the key was PRESENT, for the settings where +// an explicit 0 and an absent key mean different things. planInt is this +// function with the answer discarded, so the two can never decode differently. +func planIntSet(args map[string]any, key string) (int, bool) { switch value := args[key].(type) { case float64: - return int(value) + return int(value), true case int: - return value + return value, true default: - return 0 + return 0, false } } diff --git a/internal/specialist/plan_events.go b/internal/specialist/plan_events.go index 3c6a770a9..6c433365d 100644 --- a/internal/specialist/plan_events.go +++ b/internal/specialist/plan_events.go @@ -51,6 +51,10 @@ func TaskCompletedEvent(result TaskResult) (sessions.EventType, map[string]any) "duration_ms": result.Duration.Milliseconds(), "session_id": result.SessionID, "tokens": result.Tokens, + // attempts is what makes duration_ms and tokens readable: a retried task + // carries the TOTAL across its attempts, and without the count those + // totals look like one very expensive attempt. + "attempts": result.Attempts, } } @@ -67,6 +71,10 @@ func TaskFailedEvent(result TaskResult) (sessions.EventType, map[string]any) { "duration_ms": result.Duration.Milliseconds(), "session_id": result.SessionID, "tokens": result.Tokens, + // attempts is what makes duration_ms and tokens readable: a retried task + // carries the TOTAL across its attempts, and without the count those + // totals look like one very expensive attempt. + "attempts": result.Attempts, } } diff --git a/internal/specialist/plan_exec.go b/internal/specialist/plan_exec.go index a5bd174cf..bc496c39a 100644 --- a/internal/specialist/plan_exec.go +++ b/internal/specialist/plan_exec.go @@ -63,6 +63,19 @@ type TaskResult struct { Err string SessionID string Tokens int + // Stalled reports that the stall watchdog stopped this task — the child + // emitted nothing for the whole timeout. Set by the runner, read by the + // executor to decide whether the task is worth another attempt. + // + // A FLAG, not a message match: deciding to spend another child by looking + // for a phrase in Err is the same class as comparing errors with == instead + // of errors.Is, which is what silently disabled every stall retry in the + // prototype. + Stalled bool + // Attempts is how many times the task ran, always at least 1 for a task that + // was dispatched. Duration and Tokens are the TOTALS across those attempts, + // because what the plan spent is what the plan spent. + Attempts int } // PlanReport is the plan's terminal record, carried in plan_completed. @@ -242,12 +255,14 @@ func ExecutePlan(ctx context.Context, plan Plan, parentTools []string, run PlanR } recordDispatched(recorder, task) - started := time.Now() - result, err := run(ctx, PlanTaskRequest{Task: task, Tools: granted, StallTimeout: stallTimeout}) + result, err := runTaskWithRetries(ctx, retryPolicy{ + task: task, + tools: granted, + stallTimeout: stallTimeout, + maxRetries: plan.Budget().MaxRetries, + deadline: deadline, + }, run) result.ID = id - if result.Duration == 0 { - result.Duration = time.Since(started) - } report.SequentialTotal += result.Duration budgetLeft -= result.Tokens report.TokensUsed += result.Tokens @@ -293,6 +308,75 @@ func ExecutePlan(ctx context.Context, plan Plan, parentTools []string, run PlanR return report } +// retryPolicy is one task's retry inputs. A struct for the same reason +// PlanTaskRequest is one: this parameter list is the thing that grows, and a +// positional list is where a second call site quietly omits a field. +type retryPolicy struct { + task Task + tools []string + stallTimeout time.Duration + maxRetries int + deadline time.Time +} + +// runTaskWithRetries runs one task, retrying it ONLY when it stalled. +// +// The retry lives HERE, in the executor, and not in the runner — the executor +// owns the budget, the wall deadline, cancellation and the record, and a retry +// hidden inside the runner would spend a second child's tokens without any of +// them counting. Duration and Tokens come back as the TOTAL across attempts, +// so a plan's reported spend is its real spend. +// +// Every reason to stop is checked BEFORE launching another child: a cancelled +// run, an expired wall deadline, or an attempt budget that is used up. The +// prototype's equivalent loop retried a cancelled task, which turned Ctrl-C into +// another spawn. +func runTaskWithRetries(ctx context.Context, policy retryPolicy, run PlanRunner) (TaskResult, error) { + var result TaskResult + var err error + var totalDuration time.Duration + var totalTokens int + + for attempt := 1; ; attempt++ { + started := time.Now() + result, err = run(ctx, PlanTaskRequest{ + Task: policy.task, + Tools: policy.tools, + StallTimeout: policy.stallTimeout, + }) + if result.Duration == 0 { + result.Duration = time.Since(started) + } + totalDuration += result.Duration + totalTokens += result.Tokens + result.Attempts = attempt + result.Duration = totalDuration + result.Tokens = totalTokens + + switch { + case !result.Stalled: + // Anything other than a stall is an ANSWER, including a failure. The + // child ran and reported; running it again buys the same report. + return result, err + case attempt > policy.maxRetries: + // Out of attempts. Restate the failure with the count, because + // "stalled" and "stalled on every one of three attempts" call for + // different responses. + result.Err = stallError(policy.task.ID, policy.stallTimeout, attempt).Error() + return result, err + case ctx.Err() != nil: + // The run was stopped. NOT retried, and not relabelled either — the + // executor's own cancellation handling turns this into TaskCancelled. + return result, err + case !policy.deadline.IsZero() && time.Now().After(policy.deadline): + // The plan's wall budget is gone. Another attempt would overrun it + // on behalf of the task that already exhausted it. + result.Err = stallError(policy.task.ID, policy.stallTimeout, attempt).Error() + return result, err + } + } +} + // terminalStatus maps counts onto the three terminal states. Partial is its own // status precisely so a mostly-failed plan can never be reported as success. func terminalStatus(report PlanReport) PlanStatus { @@ -426,6 +510,11 @@ func (report PlanReport) Summary() string { report.SequentialTotal.Round(time.Millisecond), report.CriticalPath.Round(time.Millisecond), report.MaxSpeedup) for _, task := range report.Tasks { fmt.Fprintf(&b, "\n - %s [%s] %s", task.ID, task.Outcome, task.Duration.Round(time.Millisecond)) + // A retried task cost more than its one line suggests. Stated only when + // it happened, so the common case stays unchanged. + if task.Attempts > 1 { + fmt.Fprintf(&b, " (%d attempts)", task.Attempts) + } if task.Output != "" { b.WriteString("\n result:\n" + task.Output) } diff --git a/internal/specialist/plan_retry_test.go b/internal/specialist/plan_retry_test.go new file mode 100644 index 000000000..a40ef80d8 --- /dev/null +++ b/internal/specialist/plan_retry_test.go @@ -0,0 +1,249 @@ +package specialist + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +// stallingRunner returns a stalled result for the first `stalls` attempts and +// succeeds after that, counting attempts per task id. +func stallingRunner(stalls map[string]int, counts map[string]int) PlanRunner { + return func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + id := req.Task.ID + counts[id]++ + if counts[id] <= stalls[id] { + return TaskResult{ID: id, Outcome: TaskFailed, Stalled: true, Err: "no output", Tokens: 10}, nil + } + return TaskResult{ID: id, Outcome: TaskSucceeded, Tokens: 7}, nil + } +} + +func retryBudget(retries any) map[string]any { + budget := okBudget() + if retries != nil { + budget["max_retries"] = retries + } + return budget +} + +// THE DEFAULT: one extra attempt, and it is the ABSENCE of a max_retries key +// that produces it. A plan that never mentions retries still survives a single +// transient stall. +func TestAStalledTaskIsRetriedOnceByDefault(t *testing.T) { + plan := mustPlan(t, []any{task("a", "x")}, retryBudget(nil), readOnlyLimits()) + counts := map[string]int{} + report := ExecutePlan(context.Background(), plan, []string{"read_file"}, + stallingRunner(map[string]int{"a": 1}, counts), nil) + + if counts["a"] != 2 { + t.Fatalf("the task ran %d times; the default is one retry after a stall", counts["a"]) + } + if report.Succeeded != 1 { + t.Fatalf("report = %+v; the second attempt succeeded and the plan must say so", report) + } + if got := report.Tasks[0].Attempts; got != 2 { + t.Fatalf("Attempts = %d; want 2", got) + } +} + +// An EXPLICIT ZERO must turn retries off. It is the case that cannot work if +// "unset" and "0" decode to the same value, which is why the budget parses +// presence rather than a number. +func TestAnExplicitZeroDisablesRetries(t *testing.T) { + plan := mustPlan(t, []any{task("a", "x")}, retryBudget(float64(0)), readOnlyLimits()) + if got := plan.Budget().MaxRetries; got != 0 { + t.Fatalf("MaxRetries = %d; an explicit 0 must survive parsing", got) + } + counts := map[string]int{} + report := ExecutePlan(context.Background(), plan, []string{"read_file"}, + stallingRunner(map[string]int{"a": 5}, counts), nil) + + if counts["a"] != 1 { + t.Fatalf("the task ran %d times; max_retries 0 must mean one attempt", counts["a"]) + } + if report.Failed != 1 { + t.Fatalf("report = %+v; the task stalled and was not retried", report) + } +} + +// The parsed value has to be the EFFECTIVE one, so nothing downstream re-derives +// the default and no second reader can disagree about what unset meant. +func TestAnUnsetMaxRetriesParsesToTheDefault(t *testing.T) { + plan := mustPlan(t, []any{task("a", "x")}, retryBudget(nil), readOnlyLimits()) + if got := plan.Budget().MaxRetries; got != defaultPlanRetries { + t.Fatalf("MaxRetries = %d; an unset max_retries must parse to the default %d", got, defaultPlanRetries) + } +} + +func TestMaxRetriesIsHonouredAndBounded(t *testing.T) { + plan := mustPlan(t, []any{task("a", "x")}, retryBudget(float64(3)), readOnlyLimits()) + counts := map[string]int{} + ExecutePlan(context.Background(), plan, []string{"read_file"}, + stallingRunner(map[string]int{"a": 99}, counts), nil) + if counts["a"] != 4 { + t.Fatalf("the task ran %d times; max_retries 3 means 1 attempt plus 3 retries", counts["a"]) + } + + for _, bad := range []float64{-1, 4, 100} { + if _, err := ParsePlan(planArgs([]any{task("a", "x")}, retryBudget(bad)), readOnlyLimits()); err == nil { + t.Errorf("max_retries %v was accepted; the range is 0-%d", bad, maxPlanRetries) + } + } +} + +// THE ASYMMETRY THE WHOLE POLICY RESTS ON. A task whose child ran and reported +// an error produced an ANSWER; running it again spends another child's budget +// to receive the same one. Only the absence of an answer is retried. +func TestAnOrdinaryFailureIsNeverRetried(t *testing.T) { + plan := mustPlan(t, []any{task("a", "x")}, retryBudget(float64(3)), readOnlyLimits()) + runs := 0 + report := ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(context.Context, PlanTaskRequest) (TaskResult, error) { + runs++ + return TaskResult{ID: "a", Outcome: TaskFailed, Err: "the file does not exist"}, errors.New("boom") + }, nil) + + if runs != 1 { + t.Fatalf("a failing task ran %d times; only a stall is retried", runs) + } + if report.Failed != 1 { + t.Fatalf("report = %+v", report) + } + if got := report.Tasks[0].Attempts; got != 1 { + t.Fatalf("Attempts = %d; want 1", got) + } +} + +// A CANCELLED run must never spawn another child. The user stopping a plan and +// a provider going quiet both surface as a context error, and retrying the +// first would turn Ctrl-C into another attempt. +func TestACancelledRunIsNotRetried(t *testing.T) { + plan := mustPlan(t, []any{task("a", "x")}, retryBudget(float64(3)), readOnlyLimits()) + ctx, cancel := context.WithCancel(context.Background()) + runs := 0 + report := ExecutePlan(ctx, plan, []string{"read_file"}, + func(context.Context, PlanTaskRequest) (TaskResult, error) { + runs++ + // Stalled AND cancelled: the shape a Ctrl-C during a quiet child + // actually produces. Cancellation has to win. + cancel() + return TaskResult{ID: "a", Outcome: TaskFailed, Stalled: true, Err: "no output"}, nil + }, nil) + + if runs != 1 { + t.Fatalf("a cancelled run launched %d attempts; it must launch one", runs) + } + if report.Cancelled != 1 { + t.Fatalf("report = %+v; a stall that coincided with a cancellation is a cancellation", report) + } +} + +// The plan's WALL budget bounds the retries too. Another attempt on behalf of +// the task that already exhausted the budget is the budget not being a budget. +func TestARetryDoesNotOverrunTheWallDeadline(t *testing.T) { + budget := retryBudget(float64(3)) + budget["max_wall_seconds"] = float64(1) + plan := mustPlan(t, []any{task("a", "x")}, budget, readOnlyLimits()) + + runs := 0 + ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(context.Context, PlanTaskRequest) (TaskResult, error) { + runs++ + // Burn past the one-second wall on the first attempt. A REAL sleep, + // not a fabricated Duration: the deadline is wall-clock, and a test + // that only reports a long duration would pass against code that + // never checked the clock at all. + time.Sleep(1100 * time.Millisecond) + return TaskResult{ID: "a", Outcome: TaskFailed, Stalled: true, Err: "no output"}, nil + }, nil) + + if runs != 1 { + t.Fatalf("the task ran %d times; an expired wall deadline must stop the retries", runs) + } +} + +// Spend is the TOTAL across attempts. A retry that did not count would make a +// plan's reported cost smaller than its real one, which is the reporting-failure +// -as-success class applied to money. +func TestRetriedSpendIsTheTotalAcrossAttempts(t *testing.T) { + plan := mustPlan(t, []any{task("a", "x")}, retryBudget(float64(2)), readOnlyLimits()) + counts := map[string]int{} + report := ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + counts["a"]++ + if counts["a"] <= 2 { + return TaskResult{ID: "a", Outcome: TaskFailed, Stalled: true, + Tokens: 100, Duration: 10 * time.Millisecond, Err: "no output"}, nil + } + return TaskResult{ID: "a", Outcome: TaskSucceeded, Tokens: 5, Duration: 10 * time.Millisecond}, nil + }, nil) + + if report.TokensUsed != 205 { + t.Fatalf("TokensUsed = %d; want 205 — two stalled attempts at 100 plus a 5-token success", report.TokensUsed) + } + if got := report.Tasks[0].Duration; got != 30*time.Millisecond { + t.Fatalf("Duration = %s; want 30ms, the sum across three attempts", got) + } + if got := report.Tasks[0].Attempts; got != 3 { + t.Fatalf("Attempts = %d; want 3", got) + } +} + +// When the retries run out, the failure has to say how many times it happened — +// "stalled" and "stalled on every one of three attempts" call for different +// responses from the user. +func TestAnExhaustedRetryBudgetNamesTheAttemptCount(t *testing.T) { + plan := mustPlan(t, []any{task("a", "x")}, retryBudget(float64(2)), readOnlyLimits()) + counts := map[string]int{} + report := ExecutePlan(context.Background(), plan, []string{"read_file"}, + stallingRunner(map[string]int{"a": 99}, counts), nil) + + reason := report.Tasks[0].Err + if !strings.Contains(reason, "3 attempts") { + t.Fatalf("the failure must name the attempt count: %q", reason) + } + if !strings.Contains(reason, "max_stall_seconds") { + t.Fatalf("the failure must still name the knob that changes it: %q", reason) + } + if !strings.Contains(report.Summary(), "(3 attempts)") { + t.Fatalf("the summary must show the retried task's cost:\n%s", report.Summary()) + } +} + +// A plan that never stalls must be untouched by any of this: one attempt per +// task, and no "(1 attempts)" noise in the summary. +func TestAPlanThatNeverStallsIsUnchanged(t *testing.T) { + plan := mustPlan(t, []any{task("a", "x"), task("b", "y", "a")}, retryBudget(nil), readOnlyLimits()) + counts := map[string]int{} + report := ExecutePlan(context.Background(), plan, []string{"read_file"}, + stallingRunner(map[string]int{}, counts), nil) + + if counts["a"] != 1 || counts["b"] != 1 { + t.Fatalf("attempt counts = %v; want one each", counts) + } + if strings.Contains(report.Summary(), "attempts)") { + t.Fatalf("the summary mentions attempts for a plan that never retried:\n%s", report.Summary()) + } + for _, result := range report.Tasks { + if result.Attempts != 1 { + t.Fatalf("task %q Attempts = %d; a dispatched task always ran at least once", result.ID, result.Attempts) + } + } +} + +// attempts has to reach the RECORD. Duration and Tokens are totals across +// attempts, and without the count a retried task's numbers read as one +// extraordinarily expensive attempt. +func TestTheAttemptCountReachesTheEvents(t *testing.T) { + _, completed := TaskCompletedEvent(TaskResult{ID: "a", Attempts: 2}) + if completed["attempts"] != 2 { + t.Fatalf("task_completed attempts = %v; want 2", completed["attempts"]) + } + _, failed := TaskFailedEvent(TaskResult{ID: "a", Attempts: 3, Outcome: TaskFailed}) + if failed["attempts"] != 3 { + t.Fatalf("task_failed attempts = %v; want 3", failed["attempts"]) + } +} diff --git a/internal/specialist/plan_runner.go b/internal/specialist/plan_runner.go index 5f8465083..ad8153d02 100644 --- a/internal/specialist/plan_runner.go +++ b/internal/specialist/plan_runner.go @@ -108,8 +108,14 @@ func NewPlanRunner(planCtx PlanTaskContext) PlanRunner { if watchdog.didFire() { // A stall and a user cancellation both surface as a context error; // they are not the same event and must not read the same. + // + // Stalled is what makes the task RETRYABLE, and it is set here rather + // than inferred by the executor from the message: matching on error + // text to decide whether to spend another child is exactly the shape + // invariant 9 warns about, one layer up from errors.Is. result.Outcome = TaskFailed - result.Err = stallError(task.ID, watchdog.timeout).Error() + result.Stalled = true + result.Err = stallError(task.ID, watchdog.timeout, 1).Error() return result, nil } if err != nil { diff --git a/internal/specialist/plan_tool.go b/internal/specialist/plan_tool.go index b0b0bee0e..2441b8797 100644 --- a/internal/specialist/plan_tool.go +++ b/internal/specialist/plan_tool.go @@ -111,7 +111,7 @@ func (tool *OrchestrateTool) Parameters() tools.Schema { }, "budget": { Type: "object", - Description: "Required. max_workers must be 1 (this phase executes sequentially). max_tokens and max_wall_seconds are optional bounds; omit them to run unbounded — spend is reported either way. max_stall_seconds bounds how long ONE task may emit nothing (default 180); it resets on every event, so a slow-but-working task is never stopped.", + Description: "Required. max_workers must be 1 (this phase executes sequentially). max_tokens and max_wall_seconds are optional bounds; omit them to run unbounded — spend is reported either way. max_stall_seconds bounds how long ONE task may emit nothing (default 180); it resets on every event, so a slow-but-working task is never stopped. max_retries (0-3, default 1) is how many extra attempts a STALLED task gets; a task that failed with a real error is never retried.", }, }, Required: []string{"tasks", "budget"}, diff --git a/internal/specialist/plan_watchdog.go b/internal/specialist/plan_watchdog.go index 729876daf..f0ea1a079 100644 --- a/internal/specialist/plan_watchdog.go +++ b/internal/specialist/plan_watchdog.go @@ -24,12 +24,28 @@ import ( // fires on healthy work teaches users to raise it until it never fires. So the // clock resets on every stream event the child emits, and only silence counts. // -// It is a BOUND, not a retry policy. A stalled task is reported as stalled and -// the plan carries on with its dependents skipped, exactly as for any other -// failure. Retrying is a separate decision: a stall is usually a provider or -// network condition that a second identical attempt will hit again, and -// spending another task's worth of budget to find that out needs its own -// argument. +// THE RETRY POLICY, which this file previously said needed its own argument. +// Here it is. +// +// ONLY A STALL IS RETRIED. Every other failure is left exactly as it was: a +// task whose child ran and reported an error produced a real answer, and running +// it again spends another task's budget to receive the same one. A stall is the +// opposite — it is the ABSENCE of an answer, so there is no result to disbelieve +// and nothing partial to lose. That asymmetry is the whole justification, and it +// is why the retry is keyed on the watchdog's own verdict rather than on "the +// task failed". +// +// THE DEFAULT IS ONE EXTRA ATTEMPT, and that is a judgment, not a measurement. +// It costs a wedged task twice the stall timeout before the plan moves on — six +// minutes at the default — in exchange for surviving a transient provider or +// network condition without losing the task's dependents too. Under a posture +// whose entire premise is spending more for a better answer, six minutes of +// patience is the cheaper mistake. A caller who disagrees sets max_retries to 0, +// which is why an explicit 0 has to be distinguishable from an absent key. +// +// A CANCELLED TASK IS NEVER RETRIED. The user stopping a plan and a provider +// going quiet both surface as a context error; retrying the first would mean +// Ctrl-C launching another child. // defaultStallTimeout is how long a task may emit nothing before it is // considered wedged. Deliberately generous: a slow model on a large file can be @@ -40,6 +56,16 @@ const defaultStallTimeout = 3 * time.Minute // fire on ordinary think-time and become a random task-killer. const minStallTimeout = 30 * time.Second +const ( + // defaultPlanRetries is how many EXTRA attempts a stalled task gets when a + // plan does not say. See the retry policy above for why it is 1. + defaultPlanRetries = 1 + // maxPlanRetries caps what a plan may ask for. Three attempts at the default + // stall timeout is already nine minutes on one task; past that the answer is + // not "try again", it is "the provider is down". + maxPlanRetries = 3 +) + // stallWatchdog cancels a task's context when its child goes quiet. // // One goroutine per task, started and stopped inside a single runner call, so @@ -175,7 +201,13 @@ func watchedProgress(watchdog *stallWatchdog, forward func(streamjson.Event)) fu // stallError is the failure a wedged task reports. Distinct wording from a // cancellation, because a user who stopped a plan and a plan that hung are // looking at very different problems. -func stallError(taskID string, timeout time.Duration) error { +// It names the ATTEMPT COUNT when there was more than one, because "stalled" and +// "stalled three times running" call for different responses from the user. +func stallError(taskID string, timeout time.Duration, attempts int) error { + if attempts > 1 { + return fmt.Errorf("task %q produced no output for %s on each of %d attempts and was stopped; "+ + "raise budget.max_stall_seconds if this task is legitimately slow", taskID, timeout, attempts) + } return fmt.Errorf("task %q produced no output for %s and was stopped; "+ "raise budget.max_stall_seconds if this task is legitimately slow", taskID, timeout) } diff --git a/internal/specialist/plan_watchdog_test.go b/internal/specialist/plan_watchdog_test.go index b78448812..74105199f 100644 --- a/internal/specialist/plan_watchdog_test.go +++ b/internal/specialist/plan_watchdog_test.go @@ -2,6 +2,7 @@ package specialist import ( "context" + "errors" "strings" "sync" "testing" @@ -196,6 +197,45 @@ func TestAStalledTaskReportsAStallNotACancellation(t *testing.T) { if !strings.Contains(result.Err, "max_stall_seconds") { t.Fatalf("the message must name the knob that changes it: %q", result.Err) } + // AND IT MUST BE FLAGGED RETRYABLE. Stalled is what the executor reads to + // decide on another attempt, and every retry test supplies it from a fake + // runner — so nothing else in the suite can catch the real runner failing to + // set it. That is the shape of the defect where the budget meter was fed by + // a counter NewPlanRunner never populated: a fake that fabricates its own + // inputs cannot test the producer. + if !result.Stalled { + t.Fatal("the runner did not flag the stall, so the executor would never retry it") + } +} + +// A task that FAILED rather than stalled must not be flagged retryable — the +// child ran and reported, and the flag is what would spend another one. +func TestAFailedTaskIsNotFlaggedAsStalled(t *testing.T) { + executor := Executor{ + BinaryPath: "/bin/true", + NewSessionID: func() (string, error) { return "specialist_00000000000000000000000b", nil }, + Load: func(LoadOptions) (LoadResult, error) { return LoadResult{}, nil }, + RunChild: func(_ context.Context, _ string, _ []string, emit func(streamjson.Event)) (ChildRunResult, error) { + // Emits, then fails. The watchdog never fires. + emit(streamjson.Event{Type: "assistant"}) + return ChildRunResult{Started: true, ExitCode: 1}, errors.New("the child died") + }, + } + runner := NewPlanRunner(PlanTaskContext{Executor: executor, Cwd: t.TempDir(), SpecialistName: "explorer"}) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + result, _ := runner(ctx, PlanTaskRequest{ + Task: Task{ID: "broken", Prompt: "x"}, + Tools: []string{"read_file"}, + StallTimeout: time.Minute, + }) + if result.Outcome != TaskFailed { + t.Fatalf("outcome = %q, want a failure", result.Outcome) + } + if result.Stalled { + t.Fatal("an ordinary failure was flagged as a stall, so the executor would retry it") + } } // A wedged task must not take the PLAN with it: the watchdog cancels that @@ -204,18 +244,22 @@ func TestAStalledTaskDoesNotCancelTheWholePlan(t *testing.T) { plan := mustPlan(t, []any{task("a", "x"), task("b", "y")}, okBudget(), readOnlyLimits()) parent := context.Background() - ran := 0 + ran := map[string]int{} report := ExecutePlan(parent, plan, []string{"read_file"}, func(ctx context.Context, req PlanTaskRequest) (TaskResult, error) { - ran++ + ran[req.Task.ID]++ if req.Task.ID == "a" { - return TaskResult{ID: "a", Outcome: TaskFailed, Err: "produced no output for 3m0s"}, nil + // Stalled is what MAKES this a stall. Without the flag the result + // is an ordinary failure, and this test would have gone on + // passing while asserting nothing about stalls at all. + return TaskResult{ID: "a", Outcome: TaskFailed, Stalled: true, Err: "produced no output for 3m0s"}, nil } return TaskResult{ID: req.Task.ID, Outcome: TaskSucceeded}, nil }, nil) - if ran != 2 { - t.Fatalf("the plan dispatched %d tasks; a stalled task must not stop independent work", ran) + // a stalls on both its attempts (one plus the default retry); b still runs. + if ran["a"] != 2 || ran["b"] != 1 { + t.Fatalf("attempts = %v; a stalled task must exhaust its retries and must not stop independent work", ran) } if parent.Err() != nil { t.Fatal("the parent context was cancelled by a task-level stall") diff --git a/internal/tui/model.go b/internal/tui/model.go index 887c7b817..a960f4a93 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -2697,7 +2697,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.runID != m.activeRunID { return m, nil } - m.orchestrate.markDone(msg.taskID, msg.outcome, msg.tokens, m.now()) + m.orchestrate.markDone(msg.taskID, msg.outcome, msg.tokens, msg.attempts, m.now()) if m.planRunningCardKey == msg.cardKey { m.planRunningCardKey = "" } diff --git a/internal/tui/orchestrate_panel.go b/internal/tui/orchestrate_panel.go index 34f0387f2..27c8e63a4 100644 --- a/internal/tui/orchestrate_panel.go +++ b/internal/tui/orchestrate_panel.go @@ -54,6 +54,10 @@ type orchestrateTask struct { // makes a diamond LOOK like a diamond — tasks at the same depth ran from the // same fan-out and are drawn on the same rung. depth int + // attempts is how many times the task ran. A stalled task is retried, and + // the retry happens INSIDE the executor — one dispatch, one card — so + // without this the panel shows a task that silently took twice as long. + attempts int // cardKey links this task to its live agent state in the specialist // tracker: the temporary key while it runs, the child's real session id // once it finishes. The detail pane reads tool counts, the current tool and @@ -165,7 +169,7 @@ func (s *orchestratePanelState) linkCard(taskID, cardKey string) { } } -func (s *orchestratePanelState) markDone(taskID, outcome string, tokens int, now time.Time) { +func (s *orchestratePanelState) markDone(taskID, outcome string, tokens, attempts int, now time.Time) { index, ok := s.byID[taskID] if !ok { return @@ -178,6 +182,7 @@ func (s *orchestratePanelState) markDone(taskID, outcome string, tokens int, now s.tokensUsed += tokens task := &s.tasks[index] task.status = orchestrateStatusFromOutcome(outcome) + task.attempts = attempts task.endedAt = now if task.startedAt.IsZero() { // Never dispatched: it has no duration, and pretending otherwise would diff --git a/internal/tui/orchestrate_panel_test.go b/internal/tui/orchestrate_panel_test.go index 2c11b9a9b..8e29e34af 100644 --- a/internal/tui/orchestrate_panel_test.go +++ b/internal/tui/orchestrate_panel_test.go @@ -149,11 +149,11 @@ func TestPanelTracksEveryOutcome(t *testing.T) { now := m.now() m.orchestrate.markStarted("a", "root", "", now) - m.orchestrate.markDone("a", "succeeded", 0, now.Add(time.Second)) + m.orchestrate.markDone("a", "succeeded", 0, 1, now.Add(time.Second)) m.orchestrate.markStarted("b", "left", "", now.Add(time.Second)) - m.orchestrate.markDone("b", "failed", 0, now.Add(2*time.Second)) - m.orchestrate.markDone("c", "cancelled", 0, now.Add(2*time.Second)) - m.orchestrate.markDone("d", "dependency_failed", 0, now.Add(2*time.Second)) + m.orchestrate.markDone("b", "failed", 0, 1, now.Add(2*time.Second)) + m.orchestrate.markDone("c", "cancelled", 0, 1, now.Add(2*time.Second)) + m.orchestrate.markDone("d", "dependency_failed", 0, 1, now.Add(2*time.Second)) want := map[string]orchestrateTaskStatus{ "a": orchestrateDone, "b": orchestrateFailed, @@ -176,9 +176,9 @@ func TestCancelledPlanIsNotShownAsFailures(t *testing.T) { m := admittedModel(t, diamondAdmitted()) now := m.now() m.orchestrate.markStarted("a", "root", "", now) - m.orchestrate.markDone("a", "succeeded", 0, now) + m.orchestrate.markDone("a", "succeeded", 0, 1, now) for _, id := range []string{"b", "c", "d"} { - m.orchestrate.markDone(id, "cancelled", 0, now) + m.orchestrate.markDone(id, "cancelled", 0, 1, now) } m.orchestrate.complete(planCompletedMsg{status: "partial", succeeded: 1, cancelled: 3}, now) @@ -562,7 +562,7 @@ func TestFinishedTasksFadeOutOfThePanel(t *testing.T) { start := m.now() m.orchestrate.markStarted("a", "root", "", start) - m.orchestrate.markDone("a", "succeeded", 0, start) + m.orchestrate.markDone("a", "succeeded", 0, 1, start) m.orchestrate.markStarted("b", "left", "", start) // Immediately after finishing, a is still there — you have to be able to @@ -589,7 +589,7 @@ func TestAFadedTaskIsStillCountedAndStillInPlans(t *testing.T) { m := admittedModel(t, diamondAdmitted()) start := m.now() m.orchestrate.markStarted("a", "root", "", start) - m.orchestrate.markDone("a", "succeeded", 0, start) + m.orchestrate.markDone("a", "succeeded", 0, 1, start) m.now = func() time.Time { return start.Add(orchestrateTaskLinger + time.Second) } if done, _, _, _, _ := m.orchestrate.counts(); done != 1 { @@ -629,12 +629,12 @@ func TestTheBudgetLineCountsDuringTheRun(t *testing.T) { now := m.now() m.orchestrate.markStarted("a", "root", "", now) - m.orchestrate.markDone("a", "succeeded", 16470, now) + m.orchestrate.markDone("a", "succeeded", 16470, 1, now) if m.orchestrate.tokensUsed != 16470 { t.Fatalf("tokensUsed = %d after one task, want it counted as it finishes", m.orchestrate.tokensUsed) } m.orchestrate.markStarted("b", "left", "", now) - m.orchestrate.markDone("b", "succeeded", 68483, now) + m.orchestrate.markDone("b", "succeeded", 68483, 1, now) rendered := m.renderOrchestratePanel(100) if !strings.Contains(rendered, "budget 84953/100000 tokens") { @@ -649,7 +649,7 @@ func TestThePlansOwnTotalWinsAtTheEnd(t *testing.T) { m := admittedModel(t, diamondAdmitted()) now := m.now() m.orchestrate.markStarted("a", "root", "", now) - m.orchestrate.markDone("a", "succeeded", 100, now) + m.orchestrate.markDone("a", "succeeded", 100, 1, now) m.orchestrate.complete(planCompletedMsg{status: "partial", tokensUsed: 379773}, now) if m.orchestrate.tokensUsed != 379773 { @@ -662,7 +662,7 @@ func TestAMissingFinalTotalDoesNotZeroTheCount(t *testing.T) { m := admittedModel(t, diamondAdmitted()) now := m.now() m.orchestrate.markStarted("a", "root", "", now) - m.orchestrate.markDone("a", "succeeded", 4242, now) + m.orchestrate.markDone("a", "succeeded", 4242, 1, now) m.orchestrate.complete(planCompletedMsg{status: "completed"}, now) if m.orchestrate.tokensUsed != 4242 { diff --git a/internal/tui/plan_messages.go b/internal/tui/plan_messages.go index 3b4e87e4d..f194c34af 100644 --- a/internal/tui/plan_messages.go +++ b/internal/tui/plan_messages.go @@ -61,6 +61,10 @@ type planTaskDoneMsg struct { // tokens is what the task actually spent. The card omits the segment when // it is zero rather than reporting a total nobody measured. tokens int + // attempts is how many times the task ran — more than one when the stall + // watchdog fired and the executor retried it. Carried so the detail can say + // why an apparently single run took twice as long as its siblings. + attempts int } // planCompletedMsg carries the plan's terminal record. diff --git a/internal/tui/plan_progress.go b/internal/tui/plan_progress.go index 9cea2bde3..5288a3e49 100644 --- a/internal/tui/plan_progress.go +++ b/internal/tui/plan_progress.go @@ -201,7 +201,7 @@ func (bridge *PlanProgressBridge) finish(result specialist.TaskResult, status sp // own key and the handler creates the card on demand. dispatched := result.Outcome == specialist.TaskSucceeded || result.Outcome == specialist.TaskFailed taskID, sessionID, reason := result.ID, result.SessionID, result.Err - outcome, tokens := result.Outcome, result.Tokens + outcome, tokens, attempts := result.Outcome, result.Tokens, result.Attempts bridge.send(func(runID int) tea.Msg { return planTaskDoneMsg{ runID: runID, @@ -213,6 +213,7 @@ func (bridge *PlanProgressBridge) finish(result specialist.TaskResult, status sp outcome: string(outcome), reason: reason, tokens: tokens, + attempts: attempts, } }) } diff --git a/internal/tui/plan_progress_test.go b/internal/tui/plan_progress_test.go index df7850c94..670a96d59 100644 --- a/internal/tui/plan_progress_test.go +++ b/internal/tui/plan_progress_test.go @@ -183,3 +183,48 @@ func TestPlanTaskSummaryIsShortAndCutsOnRuneBoundaries(t *testing.T) { } } } + +// A retried task ran more than once behind ONE dispatch — the retry lives in the +// executor, so the panel sees a single card that took twice as long. The count +// has to reach the detail, or that time looks like one very slow attempt. +func TestARetriedTaskShowsItsAttemptCount(t *testing.T) { + var got []tea.Msg + bridge := NewPlanProgressBridge() + bridge.Attach(func(msg tea.Msg) { got = append(got, msg) }, 7, nil, "") + bridge.TaskDispatched(specialist.Task{ID: "a", Prompt: "look"}) + bridge.TaskFailed(specialist.TaskResult{ID: "a", Outcome: specialist.TaskFailed, Attempts: 3, Err: "stalled"}) + + var done planTaskDoneMsg + found := false + for _, msg := range got { + if typed, ok := msg.(planTaskDoneMsg); ok { + done, found = typed, true + } + } + if !found { + t.Fatal("no planTaskDoneMsg was posted") + } + if done.attempts != 3 { + t.Fatalf("attempts = %d; want 3 — the bridge dropped the count", done.attempts) + } + + state := &orchestratePanelState{} + state.admit(planAdmittedMsg{name: "p", taskCount: 1, tasks: []planGraphTask{{id: "a"}}}, time.Now()) + state.markStarted("a", "look", "k", time.Now()) + state.markDone("a", done.outcome, done.tokens, done.attempts, time.Now()) + if got := state.tasks[0].attempts; got != 3 { + t.Fatalf("panel attempts = %d; want 3", got) + } +} + +// A task that ran once says nothing about attempts: the ordinary case must be +// untouched by the retry machinery. +func TestASingleAttemptAddsNoAttemptCount(t *testing.T) { + state := &orchestratePanelState{} + state.admit(planAdmittedMsg{name: "p", taskCount: 1, tasks: []planGraphTask{{id: "a"}}}, time.Now()) + state.markStarted("a", "look", "k", time.Now()) + state.markDone("a", string(specialist.TaskSucceeded), 0, 1, time.Now()) + if got := state.tasks[0].attempts; got != 1 { + t.Fatalf("attempts = %d; want 1", got) + } +} diff --git a/internal/tui/sidebar_plan_detail.go b/internal/tui/sidebar_plan_detail.go index cef004e66..521a6813d 100644 --- a/internal/tui/sidebar_plan_detail.go +++ b/internal/tui/sidebar_plan_detail.go @@ -162,6 +162,11 @@ func (m model) sidebarPlanDetailLines(width, budget int) []string { if elapsed := task.elapsed(now); elapsed > 0 { head += " · " + formatElapsedSeconds(elapsed) } + // A retried task ran more than once for one row's worth of elapsed time. + // Said only when it happened, so the ordinary case is unchanged. + if task.attempts > 1 { + head += fmt.Sprintf(" · %d attempts", task.attempts) + } lines = append(lines, " "+zeroTheme.muted.Render(truncateStep(head, room))) info, hasCard := m.specialists.getBySessionID(task.cardKey) diff --git a/internal/tui/sidebar_plan_detail_test.go b/internal/tui/sidebar_plan_detail_test.go index fbbbc09f8..e4778361e 100644 --- a/internal/tui/sidebar_plan_detail_test.go +++ b/internal/tui/sidebar_plan_detail_test.go @@ -15,7 +15,7 @@ func sidebarDetailModel(t *testing.T) model { m.orchestrate.admit(diamondAdmitted(), m.now()) now := m.now() m.orchestrate.markStarted("a", "survey the packages", "k1", now) - m.orchestrate.markDone("a", "succeeded", 9000, now) + m.orchestrate.markDone("a", "succeeded", 9000, 1, now) m.orchestrate.markStarted("b", "read the left branch", "k2", now) m.specialists.start("b", "read the left branch", "k2", now) m.specialists.incrementToolCount("k2") @@ -200,7 +200,7 @@ func TestCtrlGCyclesTheSidebarSelection(t *testing.T) { // away to nothing. func TestTheProgressBarShowsFailuresSeparately(t *testing.T) { m := sidebarDetailModel(t) - m.orchestrate.markDone("b", "failed", 0, m.now()) + m.orchestrate.markDone("b", "failed", 0, 1, m.now()) bar := m.orchestratePlanBar(34) if bar == "" { diff --git a/internal/tui/sidebar_plan_test.go b/internal/tui/sidebar_plan_test.go index adb4834f7..5507d3d95 100644 --- a/internal/tui/sidebar_plan_test.go +++ b/internal/tui/sidebar_plan_test.go @@ -14,9 +14,9 @@ func sidebarPlanModel(t *testing.T) model { m.orchestrate.admit(diamondAdmitted(), m.now()) now := m.now() m.orchestrate.markStarted("a", "root", "k1", now) - m.orchestrate.markDone("a", "succeeded", 100, now) + m.orchestrate.markDone("a", "succeeded", 100, 1, now) m.orchestrate.markStarted("b", "left", "k2", now) - m.orchestrate.markDone("b", "failed", 200, now) + m.orchestrate.markDone("b", "failed", 200, 1, now) m.orchestrate.markStarted("c", "right", "k3", now) return m } @@ -132,7 +132,7 @@ func TestALongPlanIsBoundedInTheSidebar(t *testing.T) { func TestTheSidebarDoesNotPaintSkippedTasksRed(t *testing.T) { m := model{now: func() time.Time { return time.Unix(1000, 0) }} m.orchestrate.admit(diamondAdmitted(), m.now()) - m.orchestrate.markDone("a", "dependency_failed", 0, m.now()) + m.orchestrate.markDone("a", "dependency_failed", 0, 1, m.now()) icon, _ := sidebarOrchestrateStyle(m.orchestrate.tasks[0], 30) if strings.Contains(icon, "✗") { From c67a12e42672d07315aaa30d5c7ca95d9976b41a Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:26:10 +0530 Subject: [PATCH 36/86] =?UTF-8?q?feat(tui):=20stop=20or=20pause=20a=20plan?= =?UTF-8?q?=20without=20stopping=20the=20turn=20(gap=20report=20=C2=A75.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Abandoning a twenty-task plan meant Ctrl-C, which cancels the whole run and takes the conversation with it. "Stop this plan" and "stop this turn" are different intentions and now have different acts. THE CANCEL IS DERIVED IN ExecutePlan, not in the orchestrate tool. The first version put it in the tool, which would have given per-plan cancellation to exactly one call path — the recurring defect of this codebase, written on purpose. ExecutePlan is the function that owns a plan's lifetime, so the seam belongs where the lifetime is, and every caller gets it. Control arrives through the SAME seam the display does: PlanController is type-asserted off the recorder exactly like PlanLifecycleRecorder, so nothing that only records is affected and no existing signature changes. The surface that shows a plan is the one a user asks to stop it. PAUSE HOLDS AT A TASK BOUNDARY, and the message says so. A child already talking to a provider cannot be suspended; a pause claiming to be immediate would be claiming tokens had stopped being spent when they had not. The gate is consulted BEFORE the cancellation check so stopping a paused plan is not a wait for a resume that will never come, and resume is a CLOSED channel rather than a signal so a waiter arriving after the resume never blocks on a send nobody makes. The handle is dropped when the plan ends. A stale cancel would let a later stop cancel a context that has since been reused — the PostureGate lifetime mistake in another costume — and a new plan never inherits the last one's pause. A SLASH COMMAND, not the reference's bare p/x/r. Bare letters go to the composer, seventeen ctrl chords are already bound, and — the actual reason — only commandPrompt is queued while a run is pending, so a slash command is dispatched immediately. That is the entire requirement for "stop the plan that is running right now". Bare /plans is unchanged. NOT BUILT, both from this gap item, both with reasons. RESTART needs the plan's arguments and nothing stores them; the panel holds a rendering of a plan, not a plan, and re-running from that copy would produce something that merely resembles what ran. That is what named plans (§5.7) is for. FILTER solves the second problem first: a 50-task plan needs SCROLLING, which is the row cap. Eleven mutations, all caught after one repair. The miss was the same shape as the last commit's: the comment on StopPlan claimed clearing the pause is what releases the parked executor. It is not — WaitWhilePaused selects on ctx, so the cancel does that on its own. What the clear actually fixes is the reported STATE, which otherwise goes on offering "/plans resume" for a plan being abandoned. The test now asserts both, because they are not one thing. --- internal/specialist/plan_exec.go | 56 ++++++ internal/specialist/plan_retry_test.go | 105 +++++++++++ internal/tui/commands.go | 4 +- internal/tui/model.go | 10 +- internal/tui/orchestrate_control.go | 89 +++++++++ internal/tui/orchestrate_control_test.go | 224 +++++++++++++++++++++++ internal/tui/plan_progress.go | 137 ++++++++++++++ 7 files changed, 622 insertions(+), 3 deletions(-) create mode 100644 internal/tui/orchestrate_control.go create mode 100644 internal/tui/orchestrate_control_test.go diff --git a/internal/specialist/plan_exec.go b/internal/specialist/plan_exec.go index bc496c39a..a4ffe0223 100644 --- a/internal/specialist/plan_exec.go +++ b/internal/specialist/plan_exec.go @@ -162,6 +162,18 @@ type PlanRecorder interface { // The order comes from the same Kahn pass that proved the graph acyclic, so // admission and execution cannot disagree about it. func ExecutePlan(ctx context.Context, plan Plan, parentTools []string, run PlanRunner, recorder PlanRecorder) PlanReport { + // THE PLAN'S OWN CONTEXT, derived here rather than by the caller. + // + // Cancelling it abandons the PLAN and leaves the TURN alive; cancelling the + // run still cancels this too, because it is a child. Deriving it here rather + // than in the orchestrate tool is the difference between one call path + // having per-plan cancellation and every call path having it — this is the + // function that owns a plan's lifetime, and the seam belongs where the + // lifetime is. + ctx, cancelPlan := context.WithCancel(ctx) + defer cancelPlan() + planRunning(recorder, cancelPlan) + tasks := map[string]Task{} for _, task := range plan.Tasks() { tasks[task.ID] = task @@ -192,6 +204,12 @@ func ExecutePlan(ctx context.Context, plan Plan, parentTools []string, run PlanR for _, id := range plan.Order() { task := tasks[id] + // PAUSE, at the task boundary, BEFORE the cancellation check — so a user + // who stops a paused plan is not left waiting for a resume that will + // never come. WaitWhilePaused returns on ctx, and the check below then + // turns it into a cancellation exactly as if the plan had been running. + waitWhilePaused(recorder, ctx) + // Cancellation is checked FIRST and recorded as its own outcome. Once // the run is cancelled every remaining task is cancelled too — they are // not blocked by a dependency and the budget did not run out. @@ -543,6 +561,44 @@ func recordFailed(recorder PlanRecorder, result TaskResult) { } } +// PlanController is the optional CONTROL half of a recorder. +// +// Stopping a plan meant stopping the whole turn: Ctrl-C cancels the run, and +// there was no way to abandon a twenty-task plan while keeping the conversation. +// The surface that displays a plan is the one a user asks to stop it, so control +// arrives through the same seam the display does — type-asserted exactly like +// PlanLifecycleRecorder, so a recorder that only records is unaffected and no +// existing signature changes. +type PlanController interface { + // PlanRunning hands the surface a cancel scoped to THIS PLAN, not to the + // run that issued it. Called once before the first task; the surface drops + // it when the plan ends, so a later stop cannot cancel a context that has + // already been reused. + PlanRunning(cancel context.CancelFunc) + // WaitWhilePaused blocks at a TASK BOUNDARY while the user has paused. + // + // A boundary, not mid-task, and that is the honest limit: a child process + // already talking to a provider cannot be suspended, and pretending + // otherwise would mean "paused" while tokens kept being spent. It must + // return when ctx is done, or stopping a paused plan would deadlock. + WaitWhilePaused(ctx context.Context) +} + +// planRunning and waitWhilePaused are best-effort and nil-safe, mirroring +// recordPlanAdmitted: a recorder that does not implement control simply cannot +// be asked to control anything. +func planRunning(recorder PlanRecorder, cancel context.CancelFunc) { + if controller, ok := recorder.(PlanController); ok && controller != nil { + controller.PlanRunning(cancel) + } +} + +func waitWhilePaused(recorder PlanRecorder, ctx context.Context) { + if controller, ok := recorder.(PlanController); ok && controller != nil { + controller.WaitWhilePaused(ctx) + } +} + // PlanLifecycleRecorder extends PlanRecorder with the plan-level events. Kept // separate so a caller that only wants task events need not implement both. type PlanLifecycleRecorder interface { diff --git a/internal/specialist/plan_retry_test.go b/internal/specialist/plan_retry_test.go index a40ef80d8..c67ff8e4b 100644 --- a/internal/specialist/plan_retry_test.go +++ b/internal/specialist/plan_retry_test.go @@ -247,3 +247,108 @@ func TestTheAttemptCountReachesTheEvents(t *testing.T) { t.Fatalf("task_failed attempts = %v; want 3", failed["attempts"]) } } + +// controllingRecorder is a recorder that also CONTROLS: it holds the plan's +// cancel and can park the executor at a task boundary. +type controllingRecorder struct { + recordingRecorder + cancel context.CancelFunc + release chan struct{} + waits int +} + +func (r *controllingRecorder) PlanRunning(cancel context.CancelFunc) { r.cancel = cancel } +func (r *controllingRecorder) WaitWhilePaused(ctx context.Context) { + r.waits++ + if r.release == nil { + return + } + select { + case <-r.release: + case <-ctx.Done(): + } +} + +// The gate has to be REACHED, once per task, before anything is dispatched. +// A pause the executor never consults is a flag, not a pause. +func TestTheExecutorConsultsThePauseGateBeforeEveryTask(t *testing.T) { + plan := mustPlan(t, []any{task("a", "x"), task("b", "y"), task("c", "z")}, okBudget(), readOnlyLimits()) + recorder := &controllingRecorder{} + ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(context.Context, PlanTaskRequest) (TaskResult, error) { + return TaskResult{Outcome: TaskSucceeded}, nil + }, recorder) + + if recorder.waits != 3 { + t.Fatalf("the pause gate was consulted %d times for 3 tasks", recorder.waits) + } +} + +// A PAUSED PLAN DISPATCHES NOTHING. Asserting only that the gate was called +// would pass against an executor that called it and ran the task anyway. +func TestAPausedPlanDispatchesNothingUntilReleased(t *testing.T) { + plan := mustPlan(t, []any{task("a", "x"), task("b", "y")}, okBudget(), readOnlyLimits()) + recorder := &controllingRecorder{release: make(chan struct{})} + + dispatched := make(chan string, 4) + done := make(chan PlanReport, 1) + go func() { + done <- ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + dispatched <- req.Task.ID + return TaskResult{Outcome: TaskSucceeded}, nil + }, recorder) + }() + + select { + case id := <-dispatched: + t.Fatalf("task %q was dispatched while the plan was paused", id) + case <-time.After(80 * time.Millisecond): + } + + close(recorder.release) + select { + case report := <-done: + if report.Succeeded != 2 { + t.Fatalf("report = %+v; both tasks must run once released", report) + } + case <-time.After(5 * time.Second): + t.Fatal("the plan never resumed") + } +} + +// The cancel handed to the surface must be the one that STOPS THE PLAN, and the +// remainder must be recorded as CANCELLED — not failed. A user who stopped a +// plan did not break it. +func TestTheHandedCancelStopsThePlanAndRecordsCancellations(t *testing.T) { + plan := mustPlan(t, []any{task("a", "x"), task("b", "y"), task("c", "z")}, okBudget(), readOnlyLimits()) + recorder := &controllingRecorder{} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + runs := 0 + report := ExecutePlan(ctx, plan, []string{"read_file"}, + func(context.Context, PlanTaskRequest) (TaskResult, error) { + runs++ + if runs == 1 { + if recorder.cancel == nil { + t.Fatal("the executor's caller never handed the surface a cancel") + } + recorder.cancel() + } + return TaskResult{Outcome: TaskSucceeded}, nil + }, recorder) + + if runs != 1 { + t.Fatalf("%d tasks ran; a stopped plan must not dispatch more", runs) + } + if report.Cancelled != 2 { + t.Fatalf("report = %+v; the remainder must be cancelled, never failed", report) + } + if report.Failed != 0 { + t.Fatalf("a stopped plan reported %d failures; nothing broke", report.Failed) + } + if report.Status != PlanPartial { + t.Fatalf("status = %q; one success and two cancellations is partial", report.Status) + } +} diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 51171f04d..6359bf3de 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -120,13 +120,13 @@ var commandDefinitions = []commandDefinition{ }, { name: "/plans", - usage: "/plans", + usage: "/plans [stop|pause|resume]", group: commandGroupSession, // Deliberately close to /plan, and they are different things: /plan is // planning-mode status (the update_plan TODO list), /plans is the // orchestrate plan's task graph. The description says so, because the // names alone do not. - description: "Show the running orchestrate plan: tasks, dependency shape, budget.", + description: "Show the running orchestrate plan, or stop / pause / resume it without stopping the turn.", kind: commandPlans, }, { diff --git a/internal/tui/model.go b/internal/tui/model.go index a960f4a93..cbfb80078 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -4614,7 +4614,15 @@ func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) { case commandVoice: return m.toggleVoiceMode() case commandPlans: - m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.orchestratePlansText()}) + // The control verbs are a SLASH COMMAND rather than a key chord, and + // deliberately: almost every ctrl letter is already bound, and only + // commandPrompt is queued while a run is pending — a slash command runs + // immediately, which is exactly what "stop the plan that is running + // right now" needs. + m.transcript = reduceTranscript(m.transcript, transcriptAction{ + kind: actionAppendSystem, + text: m.orchestrateControlText(command.text), + }) return m, nil case commandContext: m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.contextText()}) diff --git a/internal/tui/orchestrate_control.go b/internal/tui/orchestrate_control.go new file mode 100644 index 000000000..caa9baf33 --- /dev/null +++ b/internal/tui/orchestrate_control.go @@ -0,0 +1,89 @@ +package tui + +import "strings" + +// Per-plan control: stop, pause and resume the PLAN without stopping the TURN. +// +// Before this, abandoning a twenty-task plan meant Ctrl-C, which cancels the +// whole run and takes the conversation with it. The two are different +// intentions and now have different acts. +// +// WHY A SLASH COMMAND rather than the reference's bare p/x/r keys. Bare letters +// go to the composer here, and almost every ctrl chord is already bound — +// ctrl+g alone had to dodge ctrl+o (detailed transcript) and ctrl+p (the +// update_plan panel). More importantly, only commandPrompt is queued while a +// run is pending; a slash command is dispatched immediately, which is the whole +// requirement for "stop the plan that is running right now". +// +// NOT BUILT, with reasons, both from the same gap-report item: +// +// - RESTART. Re-running a plan means re-issuing the tool call with the same +// arguments, and nothing stores them — the panel holds a rendering of the +// plan, not the plan. That is the persistence seam named plans (§5.7) is +// for, and faking it from the panel's copy would produce a plan that only +// resembles the one that ran. +// - FILTER. A 50-task plan needs SCROLLING, which is the row cap, not a +// predicate. Filtering a list the user cannot see all of solves the second +// problem first. +const ( + planControlStop = "stop" + planControlPause = "pause" + planControlResume = "resume" +) + +// orchestrateControlText handles `/plans`, `/plans stop|pause|resume`. +// +// Bare `/plans` keeps its old behaviour exactly — the graph — so the command +// that existed before this does not change under anyone. +func (m model) orchestrateControlText(args string) string { + verb := strings.ToLower(strings.TrimSpace(args)) + switch verb { + case "": + return m.orchestratePlansText() + m.planControlHint() + case planControlStop: + if !m.planProgress.StopPlan() { + return planControlNotice("warning", "No plan is running, so there is nothing to stop.") + } + // The tasks already finished KEEP their results: the executor records the + // remainder as cancelled rather than failed, and a stopped plan reports + // as cancelled or partial, never as a broken one. + return planControlNotice("info", + "Stopping the plan. The task in flight is cancelled, the rest are recorded as cancelled, "+ + "and finished tasks keep their results. The turn itself is untouched.") + case planControlPause: + if !m.planProgress.SetPlanPaused(true) { + return planControlNotice("warning", "No plan is running, so there is nothing to pause.") + } + // SAY WHERE IT TAKES EFFECT. A child already talking to a provider + // cannot be suspended, so a pause that claimed to be immediate would be + // claiming tokens had stopped being spent when they had not. + return planControlNotice("info", + "Pausing at the next task boundary. The task in flight runs to completion — a child mid-request "+ + "cannot be suspended. Resume with /plans resume, or abandon it with /plans stop.") + case planControlResume: + if !m.planProgress.SetPlanPaused(false) { + return planControlNotice("warning", "No plan is running, so there is nothing to resume.") + } + return planControlNotice("info", "Resuming the plan.") + default: + return planControlNotice("warning", + "Unknown: /plans "+verb+"\nUse /plans on its own for the task graph, or /plans stop | pause | resume.") + } +} + +// planControlHint appends the verbs to the graph, and ONLY while a plan is +// actually running: advertising "stop" against a finished plan is an offer the +// next line refuses. +func (m model) planControlHint() string { + if !m.planProgress.PlanRunningNow() { + return "" + } + if m.planProgress.PlanPaused() { + return "\n\npaused at a task boundary · /plans resume to continue · /plans stop to abandon" + } + return "\n\n/plans pause to hold at the next task · /plans stop to abandon the plan (the turn keeps going)" +} + +func planControlNotice(status, body string) string { + return "Plans\nstatus: " + status + "\n" + body +} diff --git a/internal/tui/orchestrate_control_test.go b/internal/tui/orchestrate_control_test.go new file mode 100644 index 000000000..b82b07cc5 --- /dev/null +++ b/internal/tui/orchestrate_control_test.go @@ -0,0 +1,224 @@ +package tui + +import ( + "context" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/Gitlawb/zero/internal/specialist" +) + +// runningBridge is a bridge with a plan in flight, holding a cancel whose +// effect the test can observe. +func runningBridge(t *testing.T) (*PlanProgressBridge, context.Context) { + t.Helper() + bridge := NewPlanProgressBridge() + bridge.Attach(func(tea.Msg) {}, 1, nil, "") + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + bridge.PlanRunning(cancel) + return bridge, ctx +} + +// STOPPING A PLAN MUST NOT STOP THE TURN. Ctrl-C cancels the run; this cancels +// only the context the plan runs under, which is a child of it. +func TestStoppingAPlanCancelsOnlyThePlansContext(t *testing.T) { + bridge := NewPlanProgressBridge() + bridge.Attach(func(tea.Msg) {}, 1, nil, "") + runCtx, cancelRun := context.WithCancel(context.Background()) + defer cancelRun() + planCtx, cancelPlan := context.WithCancel(runCtx) + defer cancelPlan() + bridge.PlanRunning(cancelPlan) + + if !bridge.StopPlan() { + t.Fatal("StopPlan reported no running plan") + } + if planCtx.Err() == nil { + t.Fatal("the plan's context was not cancelled") + } + if runCtx.Err() != nil { + t.Fatal("stopping the plan cancelled the whole run; the turn must survive") + } +} + +// A control verb with no plan running must SAY so rather than appear to work. +func TestPlanControlWithNoPlanRunningRefusesWithAReason(t *testing.T) { + m := model{planProgress: NewPlanProgressBridge()} + for _, verb := range []string{"stop", "pause", "resume"} { + text := m.orchestrateControlText(verb) + if !strings.Contains(text, "status: warning") { + t.Errorf("/plans %s with no plan running: %q", verb, text) + } + if !strings.Contains(text, "No plan is running") { + t.Errorf("/plans %s must name the reason: %q", verb, text) + } + } +} + +// THE PAUSE ACTUALLY HOLDS THE EXECUTOR, and the release actually releases it. +// Asserting the boolean alone would pass against a flag nothing waits on. +func TestPauseHoldsTheExecutorAtATaskBoundary(t *testing.T) { + bridge, ctx := runningBridge(t) + if !bridge.SetPlanPaused(true) { + t.Fatal("SetPlanPaused reported no running plan") + } + + released := make(chan struct{}) + go func() { + bridge.WaitWhilePaused(ctx) + close(released) + }() + + select { + case <-released: + t.Fatal("WaitWhilePaused returned while paused; nothing is holding the executor") + case <-time.After(80 * time.Millisecond): + } + + bridge.SetPlanPaused(false) + select { + case <-released: + case <-time.After(3 * time.Second): + t.Fatal("the executor was never released after resume") + } +} + +// STOPPING A PAUSED PLAN MUST NOT DEADLOCK — a plan cancelled on paper and +// parked forever in fact is the worst of both. Two separate things have to +// hold, and the mutation sweep showed they are not the same thing: the waiter +// has to be released (ctx does that), AND the reported pause state has to +// follow (clearing the flag does that). Asserting only the first passes against +// a bridge that goes on calling itself paused. +func TestStoppingAPausedPlanReleasesTheExecutor(t *testing.T) { + bridge, ctx := runningBridge(t) + bridge.SetPlanPaused(true) + + released := make(chan struct{}) + go func() { + bridge.WaitWhilePaused(ctx) + close(released) + }() + time.Sleep(30 * time.Millisecond) + + bridge.StopPlan() + select { + case <-released: + case <-time.After(3 * time.Second): + t.Fatal("a stopped plan is still parked in the pause; stop must release the waiter") + } + + // AND THE REPORTED STATE MUST FOLLOW. The cancel alone frees the waiter — + // WaitWhilePaused selects on ctx — so releasing it proves nothing about the + // pause FLAG. Left set, the surface goes on offering "/plans resume" for a + // plan that is being abandoned. + if bridge.PlanPaused() { + t.Fatal("a stopped plan still reports itself paused, so the surface would offer to resume it") + } + m := model{planProgress: bridge} + if strings.Contains(m.planControlHint(), "resume") { + t.Fatalf("the hint offers resume on a stopped plan: %q", m.planControlHint()) + } +} + +// A waiter that arrives AFTER the resume must not block. This is why resume is +// a closed channel rather than a signal nobody is there to receive. +func TestAWaiterArrivingAfterTheResumeDoesNotBlock(t *testing.T) { + bridge, ctx := runningBridge(t) + bridge.SetPlanPaused(true) + bridge.SetPlanPaused(false) + + done := make(chan struct{}) + go func() { bridge.WaitWhilePaused(ctx); close(done) }() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("a waiter that arrived after the resume is stuck") + } +} + +// The plan's context alone must release the waiter too: WaitWhilePaused takes +// ctx precisely so a cancellation from anywhere ends the wait. +func TestACancelledContextReleasesThePause(t *testing.T) { + bridge := NewPlanProgressBridge() + bridge.Attach(func(tea.Msg) {}, 1, nil, "") + ctx, cancel := context.WithCancel(context.Background()) + bridge.PlanRunning(cancel) + bridge.SetPlanPaused(true) + + done := make(chan struct{}) + go func() { bridge.WaitWhilePaused(ctx); close(done) }() + time.Sleep(30 * time.Millisecond) + cancel() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("a cancelled context did not release the pause") + } +} + +// THE HANDLE IS DROPPED WHEN THE PLAN ENDS. A stale cancel would let a later +// "/plans stop" cancel a context that has since been reused — the PostureGate +// lifetime mistake in another costume. +func TestTheCancelHandleIsDroppedWhenThePlanEnds(t *testing.T) { + bridge, _ := runningBridge(t) + if !bridge.PlanRunningNow() { + t.Fatal("the bridge does not consider the plan running") + } + bridge.PlanCompleted(samplePlan(t), specialist.PlanReport{Status: specialist.PlanCompleted, Succeeded: 1}) + if bridge.PlanRunningNow() { + t.Fatal("the cancel handle survived the plan") + } + if bridge.StopPlan() { + t.Fatal("StopPlan acted on a finished plan") + } +} + +// A NEW PLAN MUST NOT START PAUSED. The user paused the last one; carrying that +// into the next plan would suspend work nobody asked to suspend. +func TestANewPlanDoesNotInheritTheLastPlansPause(t *testing.T) { + bridge, _ := runningBridge(t) + bridge.SetPlanPaused(true) + + _, cancel := context.WithCancel(context.Background()) + defer cancel() + bridge.PlanRunning(cancel) + if bridge.PlanPaused() { + t.Fatal("the new plan inherited the previous plan's pause") + } +} + +// The hint is offered only while a plan is running: advertising "stop" against a +// finished plan is an offer the next line refuses. +func TestTheControlHintIsOfferedOnlyWhileAPlanRuns(t *testing.T) { + idle := model{planProgress: NewPlanProgressBridge()} + if strings.Contains(idle.planControlHint(), "stop") { + t.Fatal("the hint offers stop with no plan running") + } + + bridge, _ := runningBridge(t) + running := model{planProgress: bridge} + if !strings.Contains(running.planControlHint(), "/plans stop") { + t.Fatalf("a running plan must advertise the verbs: %q", running.planControlHint()) + } + bridge.SetPlanPaused(true) + if !strings.Contains(running.planControlHint(), "/plans resume") { + t.Fatalf("a paused plan must advertise resume: %q", running.planControlHint()) + } +} + +// An unrecognised verb is refused by NAME, and bare /plans keeps its old +// behaviour exactly — the command that existed before this must not change. +func TestPlansKeepsItsGraphAndRefusesUnknownVerbs(t *testing.T) { + m := model{planProgress: NewPlanProgressBridge()} + if got := m.orchestrateControlText(""); got != m.orchestratePlansText() { + t.Fatalf("bare /plans changed:\n%q\nvs\n%q", got, m.orchestratePlansText()) + } + unknown := m.orchestrateControlText("halt") + if !strings.Contains(unknown, "halt") || !strings.Contains(unknown, "status: warning") { + t.Fatalf("an unknown verb must be refused by name: %q", unknown) + } +} diff --git a/internal/tui/plan_progress.go b/internal/tui/plan_progress.go index 5288a3e49..a9919ff14 100644 --- a/internal/tui/plan_progress.go +++ b/internal/tui/plan_progress.go @@ -1,6 +1,7 @@ package tui import ( + "context" "fmt" "strings" "sync" @@ -45,6 +46,15 @@ type PlanProgressBridge struct { // must never fail a plan, but a silent drop would let a user believe a plan // was persisted when it was not. recordErr error + // cancelPlan stops THIS PLAN without stopping the turn. Held only while a + // plan is running and dropped in PlanCompleted, so a stop arriving after the + // plan ended cannot cancel a context that has since been reused. + cancelPlan context.CancelFunc + // paused / resume implement the task-boundary pause. resume is closed on + // resume rather than signalled, so a waiter that arrives after the resume + // still proceeds instead of blocking forever on a send nobody makes. + paused bool + resume chan struct{} // dispatched counts tasks so each gets a unique temporary card key. The // child's real session id is not known until the child process creates it, // so the card is keyed by this and reconciled on completion — exactly how @@ -96,6 +106,124 @@ func (bridge *PlanProgressBridge) record(eventType sessions.EventType, payload m } } +// PlanRunning takes the cancel scoped to the plan that is starting. Any pause +// left over from a previous plan is cleared here: a new plan must never begin +// life suspended by a key the user pressed during the last one. +func (bridge *PlanProgressBridge) PlanRunning(cancel context.CancelFunc) { + if bridge == nil { + return + } + bridge.mu.Lock() + defer bridge.mu.Unlock() + bridge.cancelPlan = cancel + bridge.clearPauseLocked() +} + +// WaitWhilePaused blocks the TOOL's goroutine — never the event loop — until +// the user resumes or the plan is cancelled. +func (bridge *PlanProgressBridge) WaitWhilePaused(ctx context.Context) { + if bridge == nil { + return + } + for { + bridge.mu.Lock() + paused, resume := bridge.paused, bridge.resume + bridge.mu.Unlock() + if !paused || resume == nil { + return + } + select { + case <-resume: + // Loop rather than return: a resume followed immediately by another + // pause must be honoured, and re-reading the state is what makes + // the two orderings equivalent. + case <-ctx.Done(): + return + } + } +} + +// StopPlan cancels the running plan and reports whether there was one. +// +// Called from the Bubble Tea event loop, so it does exactly two cheap things: +// it reads a pointer and calls a cancel func. +// +// It also clears the pause, and it is worth being precise about WHY, because +// the first version of this comment claimed the wrong mechanism. Releasing the +// parked executor is NOT what the clear does — WaitWhilePaused selects on ctx, +// so the cancel below frees it on its own. What the clear does is fix the +// reported STATE: without it the bridge still says "paused" between the stop +// and the plan's terminal event, so the surface would offer "/plans resume" for +// a plan that is being abandoned. +func (bridge *PlanProgressBridge) StopPlan() bool { + if bridge == nil { + return false + } + bridge.mu.Lock() + cancel := bridge.cancelPlan + bridge.clearPauseLocked() + bridge.mu.Unlock() + if cancel == nil { + return false + } + cancel() + return true +} + +// SetPlanPaused pauses or resumes at the next task boundary. Reports whether +// there was a running plan to act on, so the caller can say "no plan is +// running" rather than silently doing nothing. +func (bridge *PlanProgressBridge) SetPlanPaused(paused bool) bool { + if bridge == nil { + return false + } + bridge.mu.Lock() + defer bridge.mu.Unlock() + if bridge.cancelPlan == nil { + return false + } + if !paused { + bridge.clearPauseLocked() + return true + } + if !bridge.paused { + bridge.paused = true + bridge.resume = make(chan struct{}) + } + return true +} + +// PlanPaused reports the pause state, for the status line. +func (bridge *PlanProgressBridge) PlanPaused() bool { + if bridge == nil { + return false + } + bridge.mu.Lock() + defer bridge.mu.Unlock() + return bridge.paused +} + +// PlanRunningNow reports whether a plan is in flight, so a control command can +// refuse with a reason instead of appearing to work. +func (bridge *PlanProgressBridge) PlanRunningNow() bool { + if bridge == nil { + return false + } + bridge.mu.Lock() + defer bridge.mu.Unlock() + return bridge.cancelPlan != nil +} + +// clearPauseLocked releases any waiter. Closing the channel rather than sending +// on it means every waiter wakes and a late waiter never blocks. +func (bridge *PlanProgressBridge) clearPauseLocked() { + bridge.paused = false + if bridge.resume != nil { + close(bridge.resume) + bridge.resume = nil + } +} + // RecordingError reports the first append failure, so a surface can say once // that the plan was not fully persisted rather than leaving it silent. func (bridge *PlanProgressBridge) RecordingError() error { @@ -221,6 +349,15 @@ func (bridge *PlanProgressBridge) finish(result specialist.TaskResult, status sp // PlanCompleted reports the plan's terminal state. func (bridge *PlanProgressBridge) PlanCompleted(plan specialist.Plan, report specialist.PlanReport) { bridge.record(specialist.PlanCompletedEvent(plan, report)) + // The plan is over: drop the cancel and release any pause. Keeping a stale + // cancel would let a later "stop the plan" cancel a context that has since + // been reused, which is the PostureGate lifetime mistake in another costume. + if bridge != nil { + bridge.mu.Lock() + bridge.cancelPlan = nil + bridge.clearPauseLocked() + bridge.mu.Unlock() + } name := plan.Name() status := string(report.Status) succeeded, failed := report.Succeeded, report.Failed From dcdbd02d4d69aa63917a0e9d77884b00acc72df5 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:34:59 +0530 Subject: [PATCH 37/86] fix(tui): the plan list follows the running task instead of pinning to the head MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The large plan-size tier allows fifty tasks. Three surfaces truncated a long plan and all three took the FIRST N rows, so task 40 would be running while the user watched tasks 1 to 12 with "+38 more" underneath. A progress display that cannot show the thing in progress is not one, and the previous ceiling of twenty is the only reason this was survivable before. The window FOLLOWS rather than scrolling. Keeping the anchor — the running task, or the one the user selected — in view means the list moves itself through the surfaces that already exist, clicking and ctrl+g, rather than needing a scroll offset and another chord in a keymap where seventeen ctrl letters are taken. A running task OUTRANKS the selection. Someone who clicked a task to read its detail is still watching the plan move, and pinning the list to their last click would hide the work; the detail pane shows the selection either way. ONE window function, three callers. The footer panel, the sidebar list and the sidebar's HIT TABLE each derived their own slice, and they had already drifted — two took the head, one took the tail once everything faded. The renderer and the hit table are two doors onto one state, so that drift is a click landing on a different task from the one drawn under the cursor. The test asserts EQUALITY between them, which is the relationship two doors have. Both directions are named. "+38 more" under a list whose first row is task 30 reads as though the plan has 68 tasks. Seven mutations, all caught. The bounds are exercised directly across every count/limit/anchor shape rather than only through a rendered plan, because that is the part that panics when it is wrong — and it found the degenerate case where a zero-height window reported no hidden tasks at all instead of all of them. --- internal/tui/orchestrate_panel.go | 36 ++---- internal/tui/orchestrate_panel_test.go | 2 +- internal/tui/orchestrate_window.go | 131 ++++++++++++++++++++ internal/tui/orchestrate_window_test.go | 157 ++++++++++++++++++++++++ internal/tui/sidebar_plan_detail.go | 15 +-- 5 files changed, 305 insertions(+), 36 deletions(-) create mode 100644 internal/tui/orchestrate_window.go create mode 100644 internal/tui/orchestrate_window_test.go diff --git a/internal/tui/orchestrate_panel.go b/internal/tui/orchestrate_panel.go index 27c8e63a4..824abb700 100644 --- a/internal/tui/orchestrate_panel.go +++ b/internal/tui/orchestrate_panel.go @@ -324,22 +324,22 @@ func (m model) renderOrchestratePanel(width int) string { return b.String() } - rows := state.liveTasks(now) // Hidden by the ROW CAP is worth saying; hidden by the linger is not. A // truncated list reads as a complete one, but a faded task is already // accounted for in the header's done count. - capped := 0 - if len(rows) > orchestrateMaxRows { - capped = len(rows) - orchestrateMaxRows - rows = rows[:orchestrateMaxRows] - } + // + // The window FOLLOWS the running task rather than always starting at the + // first — see orchestrate_window.go. With the large plan-size tier a plan can + // hold fifty tasks, and a list pinned to tasks 1-12 while task 40 runs shows + // everything except the thing in progress. + rows, above, below := m.orchestrateVisibleRows(orchestrateMaxRows) for _, task := range rows { b.WriteString("\n") b.WriteString(m.renderOrchestrateTaskLine(task, now, width)) } - if capped > 0 { + if note := orchestrateHiddenNote(above, below); note != "" { b.WriteString("\n") - b.WriteString(zeroTheme.faint.Render(fmt.Sprintf(" … %d more task(s) not shown", capped))) + b.WriteString(zeroTheme.faint.Render(" … " + note)) } if footer := orchestrateFooterLine(state); footer != "" { b.WriteString("\n") @@ -575,28 +575,14 @@ func (m model) sidebarOrchestrateLines(width int) []string { } room := maxInt(4, width-3) - rows := state.liveTasks(m.orchestrateNow()) - if len(rows) == 0 { - // Everything has finished and faded: show the tail of the plan rather - // than an empty section. - rows = state.tasks - if len(rows) > maxSidebarOrchestrateLines { - rows = rows[len(rows)-maxSidebarOrchestrateLines:] - } - } - hidden := 0 - if len(rows) > maxSidebarOrchestrateLines { - hidden = len(rows) - maxSidebarOrchestrateLines - rows = rows[:maxSidebarOrchestrateLines] - } - + rows, above, below := m.orchestrateVisibleRows(maxSidebarOrchestrateLines) lines := make([]string, 0, len(rows)+1) for _, task := range rows { icon, body := sidebarOrchestrateStyle(task, room) lines = append(lines, " "+icon+" "+body) } - if hidden > 0 { - lines = append(lines, " "+zeroTheme.faint.Render(fmt.Sprintf(" +%d more", hidden))) + if note := orchestrateHiddenNote(above, below); note != "" { + lines = append(lines, " "+zeroTheme.faint.Render(" "+note)) } return lines } diff --git a/internal/tui/orchestrate_panel_test.go b/internal/tui/orchestrate_panel_test.go index 8e29e34af..d8292df0c 100644 --- a/internal/tui/orchestrate_panel_test.go +++ b/internal/tui/orchestrate_panel_test.go @@ -214,7 +214,7 @@ func TestALargePlanIsBoundedAndSaysWhatItHid(t *testing.T) { if lines > orchestrateMaxRows+3 { t.Fatalf("the panel drew %d lines for a 20-task plan; it must stay bounded", lines) } - if !strings.Contains(rendered, "more task(s) not shown") { + if !strings.Contains(rendered, "more below") { t.Fatalf("a truncated panel must say so:\n%s", rendered) } // /plans is where the whole plan can still be read. diff --git a/internal/tui/orchestrate_window.go b/internal/tui/orchestrate_window.go new file mode 100644 index 000000000..d34f79514 --- /dev/null +++ b/internal/tui/orchestrate_window.go @@ -0,0 +1,131 @@ +package tui + +import "strconv" + +// The plan list's WINDOW: which slice of a long plan the panel actually draws. +// +// Three surfaces truncated a long plan and all three took the FIRST N rows: the +// footer panel, the sidebar list, and the sidebar's hit table. That was +// survivable while the ceiling was twenty tasks and unacceptable now the large +// tier allows fifty — task 40 would be running and the user would be watching +// tasks 1 to 12, with "+38 more" underneath. A progress display that cannot show +// the thing in progress is not one. +// +// Rather than a scroll offset and another key chord, the window FOLLOWS: it +// keeps the anchor — the running task, or the one the user selected — in view. +// Selection already moves by click and by ctrl+g, so the list scrolls itself +// through the surfaces that exist rather than through new ones. +// +// ONE function, three callers. The three truncations had already drifted (two +// took the head, one took the tail when everything had faded), and a window that +// disagrees with the hit table means a click selects the wrong task — +// invariant 5 with a mouse attached. + +// orchestrateWindow returns the visible slice of rows plus how many are hidden +// on each side, keeping index `anchor` inside the window. +// +// anchor is an index INTO rows. Out-of-range means "no anchor", which windows +// from the top exactly as before — a plan with nothing running and nothing +// selected should not scroll about. +func orchestrateWindow(count, limit, anchor int) (start, end, above, below int) { + if count <= 0 { + return 0, 0, 0, 0 + } + // No room to draw anything. Everything is hidden BELOW rather than nowhere: + // the counts must always account for every task, or a caller that adds them + // up gets a different plan size depending on the terminal height. + if limit <= 0 { + return 0, 0, 0, count + } + if count <= limit { + return 0, count, 0, 0 + } + if anchor < 0 || anchor >= count { + return 0, limit, 0, count - limit + } + // Centre on the anchor, then clamp. Centring rather than merely scrolling it + // into view keeps the tasks either side of the running one visible, which is + // what makes the shape readable while it moves. + start = anchor - limit/2 + if start < 0 { + start = 0 + } + if start+limit > count { + start = count - limit + } + return start, start + limit, start, count - (start + limit) +} + +// orchestrateAnchor is the index in `rows` the window must keep visible: the +// running task if there is one, otherwise the selected task, otherwise none. +// +// RUNNING WINS over selected. A user who selected a task to read its detail is +// still watching the plan move; pinning the window to their last click would +// hide the work. The detail pane shows the selection either way, so nothing is +// lost by letting the list follow the run. +func orchestrateAnchor(rows []orchestrateTask, selectedID string) int { + for index, task := range rows { + if task.status == orchestrateRunning { + return index + } + } + if selectedID == "" { + return -1 + } + for index, task := range rows { + if task.id == selectedID { + return index + } + } + return -1 +} + +// orchestrateSelectedID names the task the user has selected, or "" when the +// selection points nowhere. The panel state and the model's index live apart, +// so this is the one place that joins them. +func (m model) orchestrateSelectedID() string { + if m.orchestrateSelected < 0 || m.orchestrateSelected >= len(m.orchestrate.tasks) { + return "" + } + return m.orchestrate.tasks[m.orchestrateSelected].id +} + +// orchestrateVisibleRows is THE windowing entry point every surface uses: the +// live tasks, windowed around the anchor, with the hidden counts. +func (m model) orchestrateVisibleRows(limit int) (rows []orchestrateTask, above, below int) { + all := m.orchestrate.liveTasks(m.orchestrateNow()) + if len(all) == 0 { + // Everything finished and faded. Show the TAIL rather than an empty + // section — the end of a plan is what a user looks for once it is over. + all = m.orchestrate.tasks + if len(all) > limit { + return all[len(all)-limit:], len(all) - limit, 0 + } + return all, 0, 0 + } + start, end, above, below := orchestrateWindow(len(all), limit, orchestrateAnchor(all, m.orchestrateSelectedID())) + return all[start:end], above, below +} + +// orchestrateHiddenNote renders the "more above / more below" line, or "" when +// nothing is hidden. Both directions are named: "+38 more" under a list whose +// first row is task 30 reads as though the plan has 68 tasks. +func orchestrateHiddenNote(above, below int) string { + switch { + case above > 0 && below > 0: + return plural(above, "above") + " · " + plural(below, "below") + case above > 0: + return plural(above, "above") + case below > 0: + return plural(below, "below") + default: + return "" + } +} + +func plural(n int, where string) string { + if n == 1 { + return "1 more " + where + } + return strconv.Itoa(n) + " more " + where +} diff --git a/internal/tui/orchestrate_window_test.go b/internal/tui/orchestrate_window_test.go new file mode 100644 index 000000000..d34cb9991 --- /dev/null +++ b/internal/tui/orchestrate_window_test.go @@ -0,0 +1,157 @@ +package tui + +import ( + "fmt" + "strings" + "testing" +) + +func longPlanModel(t *testing.T, n int) model { + t.Helper() + msg := planAdmittedMsg{runID: 1, name: "sweep"} + for index := 0; index < n; index++ { + msg.tasks = append(msg.tasks, planGraphTask{id: fmt.Sprintf("t%02d", index)}) + } + msg.taskCount = len(msg.tasks) + return admittedModel(t, msg) +} + +// THE DEFECT THIS EXISTS FOR. The large plan-size tier allows fifty tasks; a +// list pinned to the first twelve shows everything except the thing in +// progress. +func TestTheWindowFollowsTheRunningTask(t *testing.T) { + m := longPlanModel(t, 40) + m.orchestrate.markStarted("t30", "working", "k30", m.now()) + + rendered := m.renderOrchestratePanel(100) + if !strings.Contains(rendered, "t30") { + t.Fatalf("the running task is not on screen:\n%s", rendered) + } + // ...and the window really moved, rather than the cap merely being larger. + if strings.Contains(rendered, "t00") { + t.Fatalf("the window did not move; it is still showing the head:\n%s", rendered) + } + if !strings.Contains(rendered, "more above") || !strings.Contains(rendered, "more below") { + t.Fatalf("a scrolled window must name BOTH directions:\n%s", rendered) + } +} + +// With nothing running, the SELECTED task is what the window keeps in view — +// that is what makes ctrl+g and clicking able to move a long list at all. +func TestTheWindowFollowsTheSelectionWhenNothingRuns(t *testing.T) { + m := longPlanModel(t, 40) + m.orchestrateSelected = 35 + + rows, above, below := m.orchestrateVisibleRows(6) + ids := make([]string, 0, len(rows)) + for _, row := range rows { + ids = append(ids, row.id) + } + joined := strings.Join(ids, ",") + if !strings.Contains(joined, "t35") { + t.Fatalf("the selected task is outside the window: %s", joined) + } + if above == 0 { + t.Fatalf("a window near the end must report tasks above it: %s", joined) + } + _ = below +} + +// RUNNING WINS OVER SELECTED. A user who clicked a task to read its detail is +// still watching the plan move; pinning the list to their last click would hide +// the work. The detail pane shows the selection regardless. +func TestARunningTaskOutranksTheSelectionForTheWindow(t *testing.T) { + m := longPlanModel(t, 40) + m.orchestrateSelected = 1 + m.orchestrate.markStarted("t30", "working", "k30", m.now()) + + rows, _, _ := m.orchestrateVisibleRows(6) + var sawRunning bool + for _, row := range rows { + if row.id == "t30" { + sawRunning = true + } + } + if !sawRunning { + t.Fatal("the selection pinned the window and hid the running task") + } +} + +// A plan that fits needs no window and must report nothing hidden — the note is +// noise on a six-task plan. +func TestAShortPlanIsNotWindowed(t *testing.T) { + m := longPlanModel(t, 4) + rows, above, below := m.orchestrateVisibleRows(12) + if len(rows) != 4 || above != 0 || below != 0 { + t.Fatalf("rows=%d above=%d below=%d; a plan that fits must not be windowed", len(rows), above, below) + } + if note := orchestrateHiddenNote(above, below); note != "" { + t.Fatalf("a plan that fits must say nothing about hidden rows: %q", note) + } +} + +// THE RENDERER AND THE HIT TABLE MUST AGREE. They are two doors onto one +// state — this is EQUALITY, not a subset: a click has to land on the task drawn +// under the cursor. They had already drifted, one taking the head and one the +// tail once everything faded, which is what a click on the wrong task looks +// like before anyone notices. +func TestTheSidebarListAndItsHitTableSeeTheSameTasks(t *testing.T) { + m := longPlanModel(t, 40) + m.orchestrate.markStarted("t30", "working", "k30", m.now()) + m.width = 160 + + drawn, _, _ := m.orchestrateVisibleRows(maxSidebarOrchestrateLines) + hitIndices := m.sidebarOrchestrateRows() + if len(drawn) != len(hitIndices) { + t.Fatalf("the renderer drew %d rows and the hit table has %d", len(drawn), len(hitIndices)) + } + for position, index := range hitIndices { + if index < 0 || index >= len(m.orchestrate.tasks) { + t.Fatalf("hit row %d points outside the plan", position) + } + if got, want := m.orchestrate.tasks[index].id, drawn[position].id; got != want { + t.Fatalf("row %d: the hit table says %q, the renderer drew %q", position, got, want) + } + } +} + +// The bounds are the part that panics if it is wrong, so they are exercised +// directly across every shape rather than only through a rendered plan. +func TestTheWindowStaysInBounds(t *testing.T) { + for _, count := range []int{0, 1, 5, 12, 13, 50} { + for _, limit := range []int{0, 1, 6, 12} { + for anchor := -2; anchor <= count+1; anchor++ { + start, end, above, below := orchestrateWindow(count, limit, anchor) + if start < 0 || end < start || end > count { + t.Fatalf("count=%d limit=%d anchor=%d gave [%d:%d]", count, limit, anchor, start, end) + } + if above != start || below != count-end { + t.Fatalf("count=%d limit=%d anchor=%d: counts %d/%d do not match [%d:%d]", + count, limit, anchor, above, below, start, end) + } + if limit > 0 && count > 0 && end-start > limit { + t.Fatalf("count=%d limit=%d anchor=%d drew %d rows", count, limit, anchor, end-start) + } + // An in-range anchor must actually be inside the window; that is + // the entire point of the function. + if limit > 0 && anchor >= 0 && anchor < count && (anchor < start || anchor >= end) { + t.Fatalf("count=%d limit=%d anchor=%d fell outside [%d:%d]", count, limit, anchor, start, end) + } + } + } + } +} + +// "+38 more" under a list whose first row is task 30 reads as though the plan +// has 68 tasks. Both directions are named, and singular reads as singular. +func TestTheHiddenNoteNamesBothDirections(t *testing.T) { + if got := orchestrateHiddenNote(3, 4); got != "3 more above · 4 more below" { + t.Fatalf("note = %q", got) + } + if got := orchestrateHiddenNote(1, 0); got != "1 more above" { + t.Fatalf("note = %q", got) + } + if got := orchestrateHiddenNote(0, 1); got != "1 more below" { + t.Fatalf("note = %q", got) + } +} diff --git a/internal/tui/sidebar_plan_detail.go b/internal/tui/sidebar_plan_detail.go index 521a6813d..eb78f146f 100644 --- a/internal/tui/sidebar_plan_detail.go +++ b/internal/tui/sidebar_plan_detail.go @@ -68,16 +68,11 @@ func (m model) sidebarOrchestrateRows() []int { if state.isEmpty() || state.sidebarCollapsed { return nil } - live := state.liveTasks(m.orchestrateNow()) - if len(live) == 0 { - live = state.tasks - if len(live) > maxSidebarOrchestrateLines { - live = live[len(live)-maxSidebarOrchestrateLines:] - } - } - if len(live) > maxSidebarOrchestrateLines { - live = live[:maxSidebarOrchestrateLines] - } + // THE SAME WINDOW the renderer draws. These two disagreeing means a click + // lands on a different task than the one under the cursor, so they read one + // function rather than each re-deriving the slice — which is exactly how + // they had already drifted, one taking the head and one the tail. + live, _, _ := m.orchestrateVisibleRows(maxSidebarOrchestrateLines) rows := make([]int, 0, len(live)) for _, task := range live { if index, ok := state.byID[task.id]; ok { From ddeb5ba5e6d6befcd11ae2a74a72f27a52e2520c Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:52:28 +0530 Subject: [PATCH 38/86] =?UTF-8?q?feat(specialist):=20save=20a=20plan=20tha?= =?UTF-8?q?t=20worked=20and=20run=20it=20again=20(gap=20report=20=C2=A75.7?= =?UTF-8?q?,=20=C2=A75.8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plan that took a real sweep to get right could only ever run once. Saved plans live at .zero/plans/.json (project, checked in and shared) or the user config equivalent, with project shadowing user — the same precedence usercommands and the specialist loader already use. A PLAN IS STORED AS ARGUMENTS AND RE-ADMITTED THROUGH ParsePlan. Never as a serialised Plan: the fields are unexported and ParsePlan is the only constructor precisely so nothing can enter execution having skipped validation, and a stored object that deserialises straight into an executable would be that hole with a filesystem in front of it. So a saved plan is re-validated against the CURRENT run — a plan saved when the tier was large is refused when it is small, and one naming a tool this run does not hold is refused rather than granted. Proved with the real binary, not only in tests. RUNNING ONE IS NOT A NEW EXECUTION PATH, which is the deliberate divergence from the report. The report wants saved plans to become /name slash commands; they do not. A plan is not a prompt, and a command that reached into tool execution would need its own copy of the posture gate, the depth check, the grant intersection and the recorder wiring — this feature's whole defect history is second call paths carrying less than the first. Instead orchestrate takes a `saved` argument, /plans run dispatches an ordinary prompt, and by the time ParsePlan sees it a stored plan and a model-supplied one are indistinguishable. A saved plan runs AS IT WAS SAVED: `saved` alongside tasks/budget/name/ description is refused rather than merged, because a half-overridden plan is not the plan whose name is still in the transcript. The name is the PATH GUARD as well as the name guard, and it is an allow-list: no separator, dot or traversal component can be spelled with those characters, so "../../etc/passwd" is refused by the same rule that refuses a space. Symlinks are refused on the file AND the directory, on write and on read — without it "save my plan" is a file-overwrite primitive. A malformed plan file is reported by name rather than skipped, and a lookup past one says the file was unreadable instead of "you have no plan by that name". Save-from-run reads the bridge, not the panel. The panel holds a RENDERING of a plan — ids, statuses, depths — and saving that would write something that merely resembles what ran. Thirteen mutations. N10 could not be killed and the code now says why rather than implying otherwise: the re-admission on the SAVE side is unreachable, because those args came from a Plan that was already admitted. It is handled because ignoring a returned error is how a reachable version goes wrong later, not because it guards anything today. The re-admission that DOES fire is on the way back in, and a hand-edited file that is valid JSON and an impossible plan is now refused there by name. --- internal/cli/app.go | 15 +- internal/cli/exec.go | 12 + internal/specialist/plan.go | 58 ++++ internal/specialist/plan_store.go | 233 ++++++++++++++++ internal/specialist/plan_store_test.go | 358 +++++++++++++++++++++++++ internal/specialist/plan_tool.go | 52 +++- internal/tui/commands.go | 4 +- internal/tui/model.go | 10 +- internal/tui/options.go | 5 +- internal/tui/orchestrate_control.go | 4 +- internal/tui/orchestrate_saved.go | 228 ++++++++++++++++ internal/tui/orchestrate_saved_test.go | 170 ++++++++++++ internal/tui/plan_progress.go | 31 +++ 13 files changed, 1169 insertions(+), 11 deletions(-) create mode 100644 internal/specialist/plan_store.go create mode 100644 internal/specialist/plan_store_test.go create mode 100644 internal/tui/orchestrate_saved.go create mode 100644 internal/tui/orchestrate_saved_test.go diff --git a/internal/cli/app.go b/internal/cli/app.go index 0e28a4c0b..ca6fc0171 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -712,6 +712,13 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a // run by the model — without it the TUI recorded NONE of the five plan // lifecycle events, so a plan ran completely invisibly (audit finding 9). planProgress := tui.NewPlanProgressBridge() + // Saved plans, resolved ONCE and handed to both consumers: the orchestrate + // tool (which loads a plan named with `saved`) and the TUI (which saves, + // lists and shows them). Two computations of the same pair of directories + // would eventually disagree about where a plan lives, and the symptom would + // be "I saved it" followed by "no saved plan named that". + tuiUserConfigDir, _ := config.UserConfigDir() + planPaths := specialist.DefaultPlanPaths(workspaceRoot, tuiUserConfigDir) // nil filters: the TUI has no --enabled-tools/--disabled-tools equivalent, // so the run's grant is every read-only tool the registry holds. specialistRuntime, err := registerSpecialistTools(registry, workspaceRoot, resolved.Swarm.MaxTeamSize, nil, nil, planProgress, @@ -723,7 +730,8 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a PlanContext: specialist.PlanTaskContext{Cwd: workspaceRoot, Depth: 0}, // The plan-size tier, from the SAME resolved config the rest of this // wiring reads. Project config may only have tightened it. - Size: resolved.Profiles.PlanSizeTier(), + Size: resolved.Profiles.PlanSizeTier(), + Plans: planPaths, }) if err != nil { return writeAppError(stderr, "failed to initialize specialist tools: "+err.Error(), 1) @@ -858,6 +866,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a ZeromaxingDisabled: resolved.Profiles.DisableZeromaxing, ZeromaxingGate: zeromaxingGate, PlanProgress: planProgress, + PlanPaths: planPaths, MCPPermissionStore: mcpPermissionStore, MCPTokenStore: mcpTokenStore, MCPCommand: func(ctx context.Context, args []string) tui.MCPCommandResult { @@ -1090,6 +1099,9 @@ type orchestrateWiring struct { // so a call site that has no resolved config yet still gets a real ceiling // rather than none. Size config.PlanSize + // Plans locates saved plans. Empty means a `saved` reference is refused with + // a reason rather than searched for in nowhere. + Plans specialist.PlanPaths } // planParentTools is the run's grant: the tools a plan task may inherit. @@ -1169,6 +1181,7 @@ func registerSpecialistTools(registry *tools.Registry, workspaceRoot string, max ParentTools: planParentTools(registry, enabledTools, disabledTools), Depth: planContext.Depth, Size: wiring.Size, + Plans: wiring.Plans, }) return &agentToolRuntime{specialist: runtime, swarm: sw, specialists: specialistSummaries(paths)}, nil } diff --git a/internal/cli/exec.go b/internal/cli/exec.go index c4e0ff119..9fae62553 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -276,6 +276,9 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in Depth: options.depth, }, Size: planSize, + // The SAME pair of directories the TUI uses, so a plan saved in + // one surface is found by the other. + Plans: execPlanPaths(workspaceRoot), }) if err != nil { return writeExecProviderError(stdout, stderr, options.outputFormat, "specialist_error", err.Error()) @@ -1604,3 +1607,12 @@ func writeTraceSnapshot(snapshot *trace.TurnTrace, dest string, stderr io.Writer defer file.Close() return trace.WriteNDJSON(file, snapshot) } + +// execPlanPaths locates saved plans for a headless run, from the same pair of +// directories the TUI uses. A resolve failure yields the project directory +// alone rather than nothing: a plan checked into the repo must still run when +// the user config directory is unavailable. +func execPlanPaths(workspaceRoot string) specialist.PlanPaths { + userConfigDir, _ := config.UserConfigDir() + return specialist.DefaultPlanPaths(workspaceRoot, userConfigDir) +} diff --git a/internal/specialist/plan.go b/internal/specialist/plan.go index 2df1633bd..ba7307c8b 100644 --- a/internal/specialist/plan.go +++ b/internal/specialist/plan.go @@ -150,6 +150,64 @@ func (p Plan) Order() []string { // parsed structure removes the class rather than fixing the regex. func (p Plan) TaskCount() int { return len(p.tasks) } +// Args renders a validated plan back into the argument shape ParsePlan accepts. +// +// THE ROUND TRIP IS THE POINT. Saving a plan means saving something that can be +// run again, and the only thing that can be run is what ParsePlan admits — so a +// saved plan is stored as ARGS and re-admitted on load, rather than as a +// serialised Plan that would enter execution having skipped the one constructor +// that validates. The prototype's validator was reachable, correct and never +// called on the production path; a stored object that deserialises straight into +// an executable is the same hole with a filesystem in front of it. +// +// Budget fields that were resolved to defaults are written as the values in +// force, so a plan saved today runs the same way after a default changes. +func (p Plan) Args() map[string]any { + tasks := make([]any, 0, len(p.tasks)) + for _, task := range p.tasks { + entry := map[string]any{"id": task.ID, "prompt": task.Prompt} + if len(task.DependsOn) > 0 { + entry["depends_on"] = stringsToAny(task.DependsOn) + } + if len(task.Tools) > 0 { + entry["tools"] = stringsToAny(task.Tools) + } + if task.Phase != "" { + entry["phase"] = task.Phase + } + tasks = append(tasks, entry) + } + budget := map[string]any{ + "max_workers": p.budget.MaxWorkers, + "max_retries": p.budget.MaxRetries, + } + if p.budget.MaxTokens > 0 { + budget["max_tokens"] = p.budget.MaxTokens + } + if p.budget.MaxWall > 0 { + budget["max_wall_seconds"] = int(p.budget.MaxWall.Seconds()) + } + if p.budget.MaxStall > 0 { + budget["max_stall_seconds"] = int(p.budget.MaxStall.Seconds()) + } + args := map[string]any{"tasks": tasks, "budget": budget} + if p.name != "" { + args["name"] = p.name + } + if p.description != "" { + args["description"] = p.description + } + return args +} + +func stringsToAny(in []string) []any { + out := make([]any, len(in)) + for i, s := range in { + out[i] = s + } + return out +} + // ParsePlan is the ONLY way to obtain a Plan. It parses and validates in one // step; there is no path from tool arguments to an executable plan that skips // it. Every check rejects by default. diff --git a/internal/specialist/plan_store.go b/internal/specialist/plan_store.go new file mode 100644 index 000000000..0c3005ae1 --- /dev/null +++ b/internal/specialist/plan_store.go @@ -0,0 +1,233 @@ +package specialist + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// Named plans: a plan that ran once, saved and run again. +// +// A plan is stored as the ARGUMENTS ParsePlan accepts, never as a serialised +// Plan, and it is re-admitted through ParsePlan on load. That is what keeps the +// "no path from stored data to an executable that skipped validation" property: +// a saved plan is re-validated against the CURRENT run's limits, so a plan saved +// when the tier was large is refused on a run where it is small, and a plan +// naming a tool this run does not hold is refused rather than granted. +// +// TWO SCOPES, project shadowing user, mirroring usercommands and the specialist +// loader. A project plan is checked into the repo and shared with the team. +// +// HOW ONE IS RUN, and this is a deliberate divergence from the gap report. The +// report wants saved plans to become `/name` slash commands. They do not: a +// plan is not a prompt, and giving each saved plan its own command would need a +// path from the TUI straight into tool execution — a SECOND way to run a plan, +// beside the model calling orchestrate. Instead the tool takes a `saved` +// argument. One execution path, one set of guards, and the model can compose a +// saved plan into a turn the way it composes anything else. +// +// MALFORMED IS AN ERROR, NEVER A SILENT SKIP (invariant 13). A plan file that +// does not parse is reported by name; dropping it would leave a user believing +// they had run something. + +// planFileExt is the stored extension. JSON, not a script: the argument shape is +// already data, and the whole feature exists partly because a script language +// would have needed an evaluator. +const planFileExt = ".json" + +// PlanPaths are the directories scanned for saved plans, project first. +type PlanPaths struct { + ProjectDir string + UserDir string +} + +// DefaultPlanPaths returns the project and user plan directories. +func DefaultPlanPaths(workspaceRoot, userConfigDir string) PlanPaths { + var paths PlanPaths + if strings.TrimSpace(workspaceRoot) != "" { + paths.ProjectDir = filepath.Join(workspaceRoot, ".zero", "plans") + } + if strings.TrimSpace(userConfigDir) != "" { + paths.UserDir = filepath.Join(userConfigDir, "zero", "plans") + } + return paths +} + +// SavedPlan is a stored plan as found on disk. Args is the raw argument map, +// not a Plan: it becomes a Plan only by going through ParsePlan. +type SavedPlan struct { + Name string + Description string + TaskCount int + Path string + Project bool + Args map[string]any +} + +// validPlanName is an ALLOW-LIST, and it is the path guard as well as the name +// guard: no separator, no dot, no traversal component can be spelled with these +// characters, so "../../etc/passwd" is refused by the same rule that refuses a +// space. A deny-list of dangerous sequences is the pattern that has leaked +// repeatedly in this repo. +func validPlanName(name string) bool { + if name == "" || len(name) > 64 { + return false + } + return planIDPattern.MatchString(name) +} + +// SavePlan writes a validated plan under dir as name.json. +// +// It REFUSES to follow a symlink, on the directory and on the file. A saved +// plan is written into a repo-checked-in location, and a `.zero/plans/x.json` +// symlinked at ~/.ssh/config would otherwise make "save my plan" a file +// overwrite primitive. +func SavePlan(dir, name string, plan Plan) (string, error) { + if strings.TrimSpace(dir) == "" { + return "", fmt.Errorf("no directory to save plans in") + } + if !validPlanName(name) { + return "", fmt.Errorf("plan name %q must use only letters, digits, hyphen and underscore, and be at most 64 characters", name) + } + if err := refuseSymlink(dir); err != nil { + return "", err + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", fmt.Errorf("create %s: %w", dir, err) + } + path := filepath.Join(dir, name+planFileExt) + if err := refuseSymlink(path); err != nil { + return "", err + } + body, err := json.MarshalIndent(plan.Args(), "", " ") + if err != nil { + return "", fmt.Errorf("encode plan: %w", err) + } + // Write-then-rename, so a crash mid-write leaves the previous plan intact + // rather than a truncated file that fails to parse on the next run. + temp := path + ".tmp" + if err := os.WriteFile(temp, append(body, '\n'), 0o600); err != nil { + return "", fmt.Errorf("write %s: %w", path, err) + } + if err := os.Rename(temp, path); err != nil { + _ = os.Remove(temp) + return "", fmt.Errorf("save %s: %w", path, err) + } + return path, nil +} + +// refuseSymlink reports an error when path exists and is a symlink. Lstat, not +// Stat: Stat follows the link and would report the target's kind, which is the +// whole thing being guarded against. +func refuseSymlink(path string) error { + info, err := os.Lstat(path) + if err != nil { + // Does not exist yet is fine; anything else is reported rather than + // assumed safe. + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("inspect %s: %w", path, err) + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("%s is a symlink; refusing to write through it", path) + } + return nil +} + +// LoadPlans reads every saved plan under the given paths, project shadowing +// user. It returns the plans sorted by name and, separately, the files it could +// not read — malformed data is reported, never silently skipped. +func LoadPlans(paths PlanPaths) (plans []SavedPlan, problems []string) { + byName := map[string]SavedPlan{} + // User first so a project plan of the same name overwrites it. + for _, dir := range []string{paths.UserDir, paths.ProjectDir} { + if strings.TrimSpace(dir) == "" { + continue + } + found, bad := loadPlanDir(dir, dir == paths.ProjectDir) + problems = append(problems, bad...) + for _, plan := range found { + byName[plan.Name] = plan + } + } + for _, plan := range byName { + plans = append(plans, plan) + } + sort.Slice(plans, func(i, j int) bool { return plans[i].Name < plans[j].Name }) + sort.Strings(problems) + return plans, problems +} + +func loadPlanDir(dir string, project bool) (plans []SavedPlan, problems []string) { + entries, err := os.ReadDir(dir) + if err != nil { + // A missing directory is the ordinary case, not a problem worth naming. + return nil, nil + } + for _, entry := range entries { + if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), planFileExt) { + continue + } + name := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())) + path := filepath.Join(dir, entry.Name()) + if !validPlanName(name) { + problems = append(problems, fmt.Sprintf("%s: name is not a valid plan name", path)) + continue + } + if err := refuseSymlink(path); err != nil { + problems = append(problems, fmt.Sprintf("%s: %v", path, err)) + continue + } + raw, err := os.ReadFile(path) + if err != nil { + problems = append(problems, fmt.Sprintf("%s: %v", path, err)) + continue + } + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + problems = append(problems, fmt.Sprintf("%s: %v", path, err)) + continue + } + plans = append(plans, SavedPlan{ + Name: name, + Description: planString(args, "description"), + TaskCount: savedTaskCount(args), + Path: path, + Project: project, + Args: args, + }) + } + return plans, problems +} + +// savedTaskCount is for LISTING only — a length for a display line, before any +// validation has happened. The authoritative count is Plan.TaskCount, after +// ParsePlan; nothing decides anything from this number. +func savedTaskCount(args map[string]any) int { + list, _ := args["tasks"].([]any) + return len(list) +} + +// FindSavedPlan returns the named plan, with project shadowing user. +func FindSavedPlan(paths PlanPaths, name string) (SavedPlan, error) { + if !validPlanName(name) { + return SavedPlan{}, fmt.Errorf("plan name %q must use only letters, digits, hyphen and underscore", name) + } + plans, problems := LoadPlans(paths) + for _, plan := range plans { + if plan.Name == name { + return plan, nil + } + } + if len(problems) > 0 { + // Say that something was unreadable. "No plan named x" while x sits on + // disk unparseable is a lie by omission. + return SavedPlan{}, fmt.Errorf("no saved plan named %q; some plan files could not be read: %s", + name, strings.Join(problems, "; ")) + } + return SavedPlan{}, fmt.Errorf("no saved plan named %q", name) +} diff --git a/internal/specialist/plan_store_test.go b/internal/specialist/plan_store_test.go new file mode 100644 index 000000000..977d9632b --- /dev/null +++ b/internal/specialist/plan_store_test.go @@ -0,0 +1,358 @@ +package specialist + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/tools" +) + +func savedPlanFixture(t *testing.T) Plan { + t.Helper() + return mustPlan(t, []any{ + task("root", "look at the tree"), + map[string]any{"id": "left", "prompt": "read a\nsecond line", "depends_on": []any{"root"}, + "tools": []any{"grep"}, "phase": "analysis"}, + task("right", "read b", "root"), + }, map[string]any{ + "max_workers": float64(1), "max_tokens": float64(5000), + "max_wall_seconds": float64(600), "max_stall_seconds": float64(45), "max_retries": float64(2), + }, readOnlyLimits()) +} + +// THE ROUND TRIP IS THE FEATURE. A saved plan is stored as ARGS and re-admitted +// through ParsePlan, so a plan that comes back has to be the plan that went in — +// including through JSON, which is the form it is actually stored in. +func TestASavedPlanRoundTripsThroughArgsAndJSON(t *testing.T) { + original := savedPlanFixture(t) + + encoded, err := json.Marshal(original.Args()) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var decoded map[string]any + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + restored, err := ParsePlan(decoded, readOnlyLimits()) + if err != nil { + t.Fatalf("a saved plan did not re-admit: %v", err) + } + + if !reflect.DeepEqual(original.Tasks(), restored.Tasks()) { + t.Fatalf("tasks changed:\n%+v\nvs\n%+v", original.Tasks(), restored.Tasks()) + } + if !reflect.DeepEqual(original.Order(), restored.Order()) { + t.Fatalf("execution order changed: %v vs %v", original.Order(), restored.Order()) + } + if original.Budget() != restored.Budget() { + t.Fatalf("budget changed:\n%+v\nvs\n%+v", original.Budget(), restored.Budget()) + } + if original.Name() != restored.Name() || original.Description() != restored.Description() { + t.Fatalf("identity changed: %q/%q vs %q/%q", + original.Name(), original.Description(), restored.Name(), restored.Description()) + } +} + +// RESOLVED DEFAULTS ARE WRITTEN OUT. A plan saved today must run the same way +// after a default moves — otherwise "run it again" quietly means something else. +func TestASavedPlanPinsTheDefaultsThatWereInForce(t *testing.T) { + plan := mustPlan(t, []any{task("a", "x")}, map[string]any{"max_workers": float64(1)}, readOnlyLimits()) + args := plan.Args() + budget, _ := args["budget"].(map[string]any) + if budget["max_retries"] != defaultPlanRetries { + t.Fatalf("max_retries = %v; the resolved default must be written out", budget["max_retries"]) + } + // An unbounded budget stays unbounded rather than acquiring a zero that a + // later reader might treat as a bound. + if _, present := budget["max_tokens"]; present { + t.Fatalf("an unbounded plan gained a max_tokens: %v", budget["max_tokens"]) + } +} + +func TestSavedPlansAreWrittenAndListedByScope(t *testing.T) { + root := t.TempDir() + userDir := filepath.Join(t.TempDir(), "zero", "plans") + paths := PlanPaths{ProjectDir: filepath.Join(root, ".zero", "plans"), UserDir: userDir} + + if _, err := SavePlan(paths.ProjectDir, "sweep", savedPlanFixture(t)); err != nil { + t.Fatalf("SavePlan project: %v", err) + } + if _, err := SavePlan(paths.UserDir, "personal", savedPlanFixture(t)); err != nil { + t.Fatalf("SavePlan user: %v", err) + } + + plans, problems := LoadPlans(paths) + if len(problems) != 0 { + t.Fatalf("unexpected problems: %v", problems) + } + if len(plans) != 2 { + t.Fatalf("loaded %d plans, want 2", len(plans)) + } + byName := map[string]SavedPlan{} + for _, plan := range plans { + byName[plan.Name] = plan + } + if !byName["sweep"].Project { + t.Fatal("the project plan is not marked as one") + } + if byName["personal"].Project { + t.Fatal("the user plan is marked as a project plan") + } + if byName["sweep"].TaskCount != 3 { + t.Fatalf("task count = %d, want 3", byName["sweep"].TaskCount) + } +} + +// Project shadows user, mirroring usercommands and the specialist loader: a +// repo's own plan is the one its contributors get. +func TestAProjectPlanShadowsAUserPlanOfTheSameName(t *testing.T) { + root := t.TempDir() + paths := PlanPaths{ + ProjectDir: filepath.Join(root, ".zero", "plans"), + UserDir: filepath.Join(t.TempDir(), "zero", "plans"), + } + if _, err := SavePlan(paths.UserDir, "sweep", mustPlan(t, + []any{task("u", "user version")}, okBudget(), readOnlyLimits())); err != nil { + t.Fatal(err) + } + if _, err := SavePlan(paths.ProjectDir, "sweep", mustPlan(t, + []any{task("p1", "project"), task("p2", "project")}, okBudget(), readOnlyLimits())); err != nil { + t.Fatal(err) + } + + found, err := FindSavedPlan(paths, "sweep") + if err != nil { + t.Fatalf("FindSavedPlan: %v", err) + } + if !found.Project || found.TaskCount != 2 { + t.Fatalf("the user plan won: project=%v tasks=%d", found.Project, found.TaskCount) + } +} + +// THE NAME IS THE PATH GUARD. It is an allow-list, so no traversal component +// can be spelled at all — the pattern this repo has watched leak three times +// when written as a deny-list. +func TestPlanNamesAreAnAllowListAndCannotTraverse(t *testing.T) { + dir := filepath.Join(t.TempDir(), "plans") + for _, name := range []string{ + "../escape", "..", ".", "a/b", `a\b`, "a b", "a.json", "", strings.Repeat("x", 65), + "~/evil", "a;b", "a\x00b", + } { + if _, err := SavePlan(dir, name, savedPlanFixture(t)); err == nil { + t.Errorf("SavePlan accepted %q", name) + } + if _, err := FindSavedPlan(PlanPaths{ProjectDir: dir}, name); err == nil { + t.Errorf("FindSavedPlan accepted %q", name) + } + } + // ...and the ordinary shapes still work. + for _, name := range []string{"sweep", "pre-release", "audit_2", "A1"} { + if _, err := SavePlan(dir, name, savedPlanFixture(t)); err != nil { + t.Errorf("SavePlan rejected %q: %v", name, err) + } + } +} + +// A SYMLINK IS REFUSED, on the file and on the directory. Without this, "save my +// plan" is a file-overwrite primitive pointed at whatever the link targets. +func TestSavingRefusesToWriteThroughASymlink(t *testing.T) { + base := t.TempDir() + target := filepath.Join(base, "precious") + if err := os.WriteFile(target, []byte("do not clobber"), 0o600); err != nil { + t.Fatal(err) + } + dir := filepath.Join(base, "plans") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(dir, "sweep.json")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + + if _, err := SavePlan(dir, "sweep", savedPlanFixture(t)); err == nil { + t.Fatal("SavePlan wrote through a symlink") + } + body, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if string(body) != "do not clobber" { + t.Fatalf("the symlink target was overwritten: %q", body) + } + + // A linked DIRECTORY is refused too, or the file check is bypassed by + // pointing one level up. + linkedDir := filepath.Join(base, "linked") + if err := os.Symlink(dir, linkedDir); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + if _, err := SavePlan(linkedDir, "other", savedPlanFixture(t)); err == nil { + t.Fatal("SavePlan wrote into a symlinked directory") + } +} + +// ...and a symlinked plan file is not LOADED either, or a repo could point one +// at a file outside the workspace and have its contents parsed as a plan. +func TestLoadingRefusesASymlinkedPlanAndSaysSo(t *testing.T) { + base := t.TempDir() + target := filepath.Join(base, "elsewhere.json") + if err := os.WriteFile(target, []byte(`{"tasks":[]}`), 0o600); err != nil { + t.Fatal(err) + } + dir := filepath.Join(base, "plans") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(dir, "linked.json")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + + plans, problems := LoadPlans(PlanPaths{ProjectDir: dir}) + if len(plans) != 0 { + t.Fatalf("a symlinked plan was loaded: %+v", plans) + } + if len(problems) != 1 || !strings.Contains(problems[0], "symlink") { + t.Fatalf("the refusal must be reported, not silent: %v", problems) + } +} + +// MALFORMED IS AN ERROR, NEVER A SILENT SKIP. A plan file that does not parse is +// named, or a user believes they ran something they did not. +func TestAMalformedPlanFileIsReportedByName(t *testing.T) { + dir := filepath.Join(t.TempDir(), "plans") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "broken.json"), []byte("{not json"), 0o600); err != nil { + t.Fatal(err) + } + plans, problems := LoadPlans(PlanPaths{ProjectDir: dir}) + if len(plans) != 0 { + t.Fatalf("a malformed file produced a plan: %+v", plans) + } + if len(problems) != 1 || !strings.Contains(problems[0], "broken.json") { + t.Fatalf("the problem must name the file: %v", problems) + } + // And looking it up says so, rather than "you have no plan by that name" + // while it sits on disk. + _, err := FindSavedPlan(PlanPaths{ProjectDir: dir}, "broken") + if err == nil || !strings.Contains(err.Error(), "could not be read") { + t.Fatalf("a lookup past an unreadable file must say so: %v", err) + } +} + +// A saved plan is re-admitted against the CURRENT run's limits, so nothing that +// was legal when it was saved is grandfathered in. +func TestASavedPlanIsRevalidatedAgainstTheRunningLimits(t *testing.T) { + dir := filepath.Join(t.TempDir(), "plans") + plan := mustPlan(t, []any{ + task("a", "x"), task("b", "y"), task("c", "z"), task("d", "w"), task("e", "v"), task("f", "u"), + }, okBudget(), readOnlyLimits()) + if _, err := SavePlan(dir, "big", plan); err != nil { + t.Fatal(err) + } + stored, err := FindSavedPlan(PlanPaths{ProjectDir: dir}, "big") + if err != nil { + t.Fatal(err) + } + + // The tier has since been tightened: the stored plan is refused. + if _, err := ParsePlan(stored.Args, Limits{MaxTasks: 5, ParentTools: []string{"read_file"}}); err == nil { + t.Fatal("a stored plan bypassed the current run's task ceiling") + } + // And a grant it no longer holds is refused too. + narrow := mustPlan(t, []any{map[string]any{"id": "a", "prompt": "x", "tools": []any{"grep"}}}, + okBudget(), readOnlyLimits()) + if _, err := SavePlan(dir, "narrow", narrow); err != nil { + t.Fatal(err) + } + storedNarrow, err := FindSavedPlan(PlanPaths{ProjectDir: dir}, "narrow") + if err != nil { + t.Fatal(err) + } + if _, err := ParsePlan(storedNarrow.Args, Limits{MaxTasks: 20, ParentTools: []string{"read_file"}}); err == nil { + t.Fatal("a stored plan kept a tool grant this run does not hold") + } +} + +// The tool's `saved` argument is ONE path into the same constructor, not a +// second way to run a plan. +func TestTheToolRunsASavedPlanThroughTheSameValidation(t *testing.T) { + dir := filepath.Join(t.TempDir(), "plans") + if _, err := SavePlan(dir, "sweep", savedPlanFixture(t)); err != nil { + t.Fatal(err) + } + tool := &OrchestrateTool{Plans: PlanPaths{ProjectDir: dir}} + + resolved, err := tool.resolveSavedPlan(map[string]any{"saved": "sweep"}) + if err != nil { + t.Fatalf("resolveSavedPlan: %v", err) + } + plan, err := ParsePlan(resolved, readOnlyLimits()) + if err != nil { + t.Fatalf("the resolved plan did not admit: %v", err) + } + if plan.TaskCount() != 3 { + t.Fatalf("task count = %d, want 3", plan.TaskCount()) + } +} + +// A SAVED PLAN RUNS AS IT WAS SAVED. Merging a caller's field into it would mean +// "run the sweep plan" ran something else while the transcript still said sweep. +func TestASavedReferenceRefusesInlineOverrides(t *testing.T) { + dir := filepath.Join(t.TempDir(), "plans") + if _, err := SavePlan(dir, "sweep", savedPlanFixture(t)); err != nil { + t.Fatal(err) + } + tool := &OrchestrateTool{Plans: PlanPaths{ProjectDir: dir}} + + for _, field := range []string{"tasks", "budget", "name", "description"} { + args := map[string]any{"saved": "sweep", field: "anything"} + if _, err := tool.resolveSavedPlan(args); err == nil { + t.Errorf("a saved reference accepted an inline %q", field) + } + } +} + +// With no plan directories the refusal SAYS saved plans are unavailable, rather +// than "not found", which reads as "you never saved it". +func TestASavedReferenceWithoutStorageSaysSo(t *testing.T) { + tool := &OrchestrateTool{} + _, err := tool.resolveSavedPlan(map[string]any{"saved": "sweep"}) + if err == nil || !strings.Contains(err.Error(), "not available") { + t.Fatalf("err = %v; it must say saved plans are unavailable", err) + } +} + +// A plan with no `saved` reference is untouched — the ordinary path must not +// change shape because a new one exists. +func TestAnInlinePlanIsUnaffectedBySavedPlans(t *testing.T) { + tool := &OrchestrateTool{Plans: PlanPaths{ProjectDir: t.TempDir()}} + args := planArgs([]any{task("a", "x")}, okBudget()) + resolved, err := tool.resolveSavedPlan(args) + if err != nil { + t.Fatalf("resolveSavedPlan: %v", err) + } + if !reflect.DeepEqual(resolved, args) { + t.Fatalf("an inline plan was rewritten:\n%+v\nvs\n%+v", resolved, args) + } +} + +// The tool still refuses everything when the posture is off, saved plan or not: +// a stored plan must not become a way round the gate. +func TestASavedPlanCannotRunWithThePostureOff(t *testing.T) { + dir := filepath.Join(t.TempDir(), "plans") + if _, err := SavePlan(dir, "sweep", savedPlanFixture(t)); err != nil { + t.Fatal(err) + } + tool := &OrchestrateTool{Plans: PlanPaths{ProjectDir: dir}} + result := tool.Run(t.Context(), map[string]any{"saved": "sweep"}) + if result.Status != tools.StatusError || !strings.Contains(result.Output, "zeromaxing") { + t.Fatalf("a saved plan ran with the posture off: %+v", result) + } +} diff --git a/internal/specialist/plan_tool.go b/internal/specialist/plan_tool.go index 2441b8797..b3893d3c7 100644 --- a/internal/specialist/plan_tool.go +++ b/internal/specialist/plan_tool.go @@ -46,6 +46,11 @@ type OrchestrateTool struct { // Size is the configured plan-size tier. The zero value is the default tier, // so a caller that never wires it gets the same ceiling as before. Size config.PlanSize + // Plans locates saved plans. Both directories empty means saved plans are + // simply unavailable — the tool refuses a `saved` reference with a reason + // rather than searching nothing and reporting "not found", which would read + // as "you never saved it". + Plans PlanPaths // Limits overrides the default caps; nil uses them. Limits *Limits } @@ -109,12 +114,21 @@ func (tool *OrchestrateTool) Parameters() tools.Schema { Type: "array", Description: "The plan's tasks. Each has an id, a prompt, optional depends_on ids, an optional read-only tool subset, and an optional phase label.", }, + "saved": { + Type: "string", + Description: "Run a plan saved earlier, by name, instead of supplying tasks. " + + "Mutually exclusive with tasks/budget/name/description: a saved plan runs as it was saved.", + }, "budget": { Type: "object", Description: "Required. max_workers must be 1 (this phase executes sequentially). max_tokens and max_wall_seconds are optional bounds; omit them to run unbounded — spend is reported either way. max_stall_seconds bounds how long ONE task may emit nothing (default 180); it resets on every event, so a slow-but-working task is never stopped. max_retries (0-3, default 1) is how many extra attempts a STALLED task gets; a task that failed with a real error is never retried.", }, }, - Required: []string{"tasks", "budget"}, + // tasks and budget are no longer unconditionally required: a `saved` + // reference supplies both. ParsePlan still refuses a plan that has + // neither, so the rule is enforced where it can see the resolved + // arguments rather than by a schema that cannot. + Required: []string{}, AdditionalProperties: false, } } @@ -205,6 +219,33 @@ func (tool *OrchestrateTool) runnerForCall(options tools.RunOptions) PlanRunner } } +// resolveSavedPlan swaps a `saved` reference for the stored plan's arguments. +// +// Anything the caller supplied ALONGSIDE `saved` is refused rather than merged. +// A half-overridden plan is not the plan that was saved, and silently letting +// one field through would mean "run the sweep plan" ran something else — the +// name would still be right in the transcript. +func (tool *OrchestrateTool) resolveSavedPlan(args map[string]any) (map[string]any, error) { + name := planString(args, "saved") + if name == "" { + return args, nil + } + for _, field := range []string{"tasks", "budget", "name", "description"} { + if _, present := args[field]; present { + return nil, fmt.Errorf( + "a saved plan is run as it was saved: remove %q, or supply the plan inline instead of naming one", field) + } + } + if tool.Plans.ProjectDir == "" && tool.Plans.UserDir == "" { + return nil, fmt.Errorf("saved plans are not available in this run") + } + stored, err := FindSavedPlan(tool.Plans, name) + if err != nil { + return nil, err + } + return stored.Args, nil +} + // Limits supplies the hard caps a plan must fit inside. nil means the defaults. // // The task ceiling comes from the CONFIGURED TIER rather than a constant here. @@ -241,6 +282,15 @@ func (tool *OrchestrateTool) RunWithOptions(ctx context.Context, args map[string Output: "Error: orchestrate is only available under the zeromaxing posture. Turn it on with /effort zeromaxing.", } } + // A SAVED plan is loaded into the same argument shape and then validated by + // the same constructor. It is not a second way to run a plan: by the time + // ParsePlan sees it, a stored plan and a model-supplied one are + // indistinguishable, so the tier, the depth check, the read-only rule and + // the parent-grant intersection all apply to it unchanged. + args, err := tool.resolveSavedPlan(args) + if err != nil { + return tools.Result{Status: tools.StatusError, Output: "Error: " + err.Error()} + } // ParsePlan validates as part of parsing; there is no other constructor, so // this call cannot be bypassed by any argument shape. plan, err := ParsePlan(args, tool.limits(options)) diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 6359bf3de..9eaa382d6 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -120,13 +120,13 @@ var commandDefinitions = []commandDefinition{ }, { name: "/plans", - usage: "/plans [stop|pause|resume]", + usage: "/plans [stop|pause|resume|save |list|show |run ]", group: commandGroupSession, // Deliberately close to /plan, and they are different things: /plan is // planning-mode status (the update_plan TODO list), /plans is the // orchestrate plan's task graph. The description says so, because the // names alone do not. - description: "Show the running orchestrate plan, or stop / pause / resume it without stopping the turn.", + description: "Show or control the orchestrate plan: stop/pause/resume the running one, or save, list and run a named plan.", kind: commandPlans, }, { diff --git a/internal/tui/model.go b/internal/tui/model.go index cbfb80078..692b8a8e2 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -204,6 +204,9 @@ type model struct { transcript []transcriptRow transcriptDetailed bool helpOverlay bool // the `?` keyboard-shortcut overlay is open + // planPaths locates saved plans, project first. Set once at startup: the + // workspace does not move for the life of a session. + planPaths specialist.PlanPaths // orchestrateSelected is the plan task the sidebar's TASK section details. // Clicking a task row in the sidebar sets it; ctrl+g cycles it. orchestrateSelected int @@ -926,6 +929,7 @@ func newModel(ctx context.Context, options Options) model { sessionCompactor: options.SessionCompactor, runtimeMessageSink: options.RuntimeMessageSink, planProgress: options.PlanProgress, + planPaths: options.PlanPaths, permissionMode: permissionMode, reasoningEffort: options.ReasoningEffort, responseStyle: defaultedResponseStyle(options.ResponseStyle), @@ -4619,11 +4623,7 @@ func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) { // commandPrompt is queued while a run is pending — a slash command runs // immediately, which is exactly what "stop the plan that is running // right now" needs. - m.transcript = reduceTranscript(m.transcript, transcriptAction{ - kind: actionAppendSystem, - text: m.orchestrateControlText(command.text), - }) - return m, nil + return m.handlePlansCommand(command.text) case commandContext: m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.contextText()}) return m, nil diff --git a/internal/tui/options.go b/internal/tui/options.go index 6473f9ea8..b5fc9d43c 100644 --- a/internal/tui/options.go +++ b/internal/tui/options.go @@ -44,7 +44,10 @@ type Options struct { // The model re-attaches it to each run so plan lifecycle events become // transcript cards. nil disables the live plan view without affecting // execution — recording is best-effort everywhere on this path. - PlanProgress *PlanProgressBridge + PlanProgress *PlanProgressBridge + // PlanPaths locates saved plans. Empty means saved plans are unavailable, + // which the commands report as such rather than as "you have none". + PlanPaths specialist.PlanPaths PrepareRunCompletionWarning func() RunCompletionWarning func() string Registry *tools.Registry diff --git a/internal/tui/orchestrate_control.go b/internal/tui/orchestrate_control.go index caa9baf33..254910f8a 100644 --- a/internal/tui/orchestrate_control.go +++ b/internal/tui/orchestrate_control.go @@ -67,7 +67,9 @@ func (m model) orchestrateControlText(args string) string { return planControlNotice("info", "Resuming the plan.") default: return planControlNotice("warning", - "Unknown: /plans "+verb+"\nUse /plans on its own for the task graph, or /plans stop | pause | resume.") + "Unknown: /plans "+verb+"\nUse /plans on its own for the task graph, "+ + "/plans stop | pause | resume for the running plan, "+ + "or /plans save | list | show | run for saved ones.") } } diff --git a/internal/tui/orchestrate_saved.go b/internal/tui/orchestrate_saved.go new file mode 100644 index 000000000..138a0a2b6 --- /dev/null +++ b/internal/tui/orchestrate_saved.go @@ -0,0 +1,228 @@ +package tui + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + tea "charm.land/bubbletea/v2" + + "github.com/Gitlawb/zero/internal/specialist" +) + +// Saved plans, from the TUI: save the plan that just ran, list what is stored, +// read one back, and run one again. +// +// RUNNING ONE GOES THROUGH THE MODEL, not straight into the tool. `/plans run x` +// dispatches an ordinary prompt, the model calls orchestrate with `saved: "x"`, +// and the plan is re-admitted by the same ParsePlan every other plan goes +// through. A command that reached into tool execution directly would be a +// SECOND way to run a plan, with its own copy of the posture gate, the depth +// check, the grant intersection and the recorder wiring — and this feature's +// entire defect history is second call paths that carry less than the first. + +// handlePlansCommand dispatches `/plans [verb] [args]`. +// +// Split from orchestrateControlText because `run` has to start a turn, which +// needs a tea.Cmd; the rest only produce text. +func (m model) handlePlansCommand(args string) (tea.Model, tea.Cmd) { + verb, rest := splitPlansArgs(args) + switch verb { + case "save": + return m.appendPlansNotice(m.savePlanText(rest)), nil + case "list": + return m.appendPlansNotice(m.savedPlansText()), nil + case "show": + return m.appendPlansNotice(m.showSavedPlanText(rest)), nil + case "run": + return m.runSavedPlan(rest) + default: + return m.appendPlansNotice(m.orchestrateControlText(args)), nil + } +} + +func (m model) appendPlansNotice(text string) model { + m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text}) + return m +} + +func splitPlansArgs(args string) (verb, rest string) { + fields := strings.Fields(strings.TrimSpace(args)) + if len(fields) == 0 { + return "", "" + } + return strings.ToLower(fields[0]), strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(args), fields[0])) +} + +// savePlanText writes the plan that ran this session under the given name. +// +// It saves to the PROJECT directory, and that is the useful default: a plan is a +// piece of team knowledge about a repo — "the pre-release sweep" — not a +// personal preference. It is also the scope a user can see and review in a diff. +func (m model) savePlanText(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return planControlNotice("warning", "Name it: /plans save ") + } + args, planName, ok := m.planProgress.LastPlan() + if !ok { + return planControlNotice("warning", + "No plan has run this session, so there is nothing to save. Run one first — a saved plan is a plan that worked.") + } + dir := m.planPaths.ProjectDir + if dir == "" { + dir = m.planPaths.UserDir + } + if dir == "" { + return planControlNotice("warning", "There is nowhere to save plans in this run.") + } + // This converts args back into a Plan because SavePlan takes a Plan: its + // signature is what makes "only a validated plan can be written" true by + // construction rather than by convention. + // + // THE ERROR BRANCH IS UNREACHABLE FROM HERE and is not pretending otherwise. + // These args came from a Plan that ParsePlan already admitted, and + // savedPlanLimits is deliberately wider than any run's, so nothing can + // reject them. It is handled because ignoring a returned error is how a + // reachable version of this goes wrong later — not because it guards + // something today. The mutation sweep is what forced this to be stated: no + // test can kill this branch, and a comment claiming it protects the file on + // disk would have been the third wrong-mechanism comment in this feature. + // + // The branch that DOES fire is on the way back in — showSavedPlanText and + // the tool's own load — where a hand-edited file is re-admitted and can be + // refused. + plan, err := specialist.ParsePlan(args, m.savedPlanLimits()) + if err != nil { + return planControlNotice("warning", "That plan no longer validates, so it was not saved: "+err.Error()) + } + path, err := specialist.SavePlan(dir, name, plan) + if err != nil { + return planControlNotice("warning", "Could not save the plan: "+err.Error()) + } + label := planName + if label == "" { + label = "the last plan" + } + return planControlNotice("info", fmt.Sprintf( + "Saved %s as %q (%d tasks)\n%s\n\nRun it again with /plans run %s.", label, name, plan.TaskCount(), path, name)) +} + +// savedPlanLimits are the limits used to re-admit a plan for SAVING and for +// SHOWING — deliberately permissive on the tool grant, because saving is not +// running. The grant that matters is the one applied when the plan is actually +// executed, by the tool, against the run that runs it. +// +// MaxTasks is left unset for the same reason: refusing to save a plan that +// already ran, because the tier moved since, would be refusing to record +// history. +func (m model) savedPlanLimits() specialist.Limits { + return specialist.Limits{ParentTools: specialist.PlanReadOnlyToolNames()} +} + +func (m model) savedPlansText() string { + plans, problems := specialist.LoadPlans(m.planPaths) + if len(plans) == 0 && len(problems) == 0 { + return planControlNotice("info", + "No saved plans.\nRun a plan, then keep it with /plans save .") + } + var b strings.Builder + b.WriteString("Plans\nstatus: info\n") + if len(plans) == 0 { + b.WriteString("No readable saved plans.") + } + for _, plan := range plans { + scope := "user" + if plan.Project { + scope = "project" + } + fmt.Fprintf(&b, " %-20s %2d tasks %s", plan.Name, plan.TaskCount, scope) + if plan.Description != "" { + b.WriteString(" · " + plan.Description) + } + b.WriteString("\n") + } + // Unreadable files are NAMED. "No saved plans" while three sit on disk + // unparseable is a lie by omission, and the user is the only one who can fix + // the file. + if len(problems) > 0 { + b.WriteString("\ncould not be read:\n") + for _, problem := range problems { + b.WriteString(" " + problem + "\n") + } + } + b.WriteString("\n/plans show to read one · /plans run to run it") + return strings.TrimRight(b.String(), "\n") +} + +func (m model) showSavedPlanText(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return planControlNotice("warning", "Name it: /plans show ") + } + stored, err := specialist.FindSavedPlan(m.planPaths, name) + if err != nil { + return planControlNotice("warning", err.Error()) + } + // Shown through ParsePlan, so what is displayed is what would RUN — including + // the execution order, which is the part a stored task list does not show. + plan, err := specialist.ParsePlan(stored.Args, m.savedPlanLimits()) + if err != nil { + return planControlNotice("warning", fmt.Sprintf("%s does not validate: %v", stored.Path, err)) + } + var b strings.Builder + fmt.Fprintf(&b, "Plans\nstatus: info\n%s · %d tasks · %s\n", name, plan.TaskCount(), stored.Path) + if plan.Description() != "" { + b.WriteString(plan.Description() + "\n") + } + byID := map[string]specialist.Task{} + for _, task := range plan.Tasks() { + byID[task.ID] = task + } + for _, id := range plan.Order() { + task := byID[id] + fmt.Fprintf(&b, "\n %s", id) + if len(task.DependsOn) > 0 { + deps := append([]string(nil), task.DependsOn...) + sort.Strings(deps) + b.WriteString(" after " + strings.Join(deps, ", ")) + } + if summary := planTaskSummaryLine(task.Prompt); summary != "" { + b.WriteString("\n " + summary) + } + } + return b.String() +} + +// planTaskSummaryLine is the first line of a prompt, bounded. The full prompt +// stays in the file — a display surface must not become the data path. +func planTaskSummaryLine(prompt string) string { + summary := strings.TrimSpace(prompt) + if index := strings.IndexAny(summary, "\r\n"); index >= 0 { + summary = summary[:index] + } + return truncateRunes(summary, planTaskSummaryWidth) +} + +// runSavedPlan asks the MODEL to run it. See the note at the top of this file: +// a command that called the tool directly would be a second execution path. +func (m model) runSavedPlan(name string) (tea.Model, tea.Cmd) { + name = strings.TrimSpace(name) + if name == "" { + return m.appendPlansNotice(planControlNotice("warning", "Name it: /plans run ")), nil + } + // Resolved HERE so an unknown name is a plain refusal rather than a turn + // spent discovering the plan does not exist. + if _, err := specialist.FindSavedPlan(m.planPaths, name); err != nil { + return m.appendPlansNotice(planControlNotice("warning", err.Error())), nil + } + encoded, err := json.Marshal(name) + if err != nil { + return m.appendPlansNotice(planControlNotice("warning", "Could not reference that plan: "+err.Error())), nil + } + return m.dispatchCommand(parsedCommand{ + kind: commandPrompt, + text: "Run the saved plan " + string(encoded) + " by calling the orchestrate tool with the `saved` argument set to that name. Do not restate its tasks.", + }) +} diff --git a/internal/tui/orchestrate_saved_test.go b/internal/tui/orchestrate_saved_test.go new file mode 100644 index 000000000..d6221ed38 --- /dev/null +++ b/internal/tui/orchestrate_saved_test.go @@ -0,0 +1,170 @@ +package tui + +import ( + "os" + "path/filepath" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/Gitlawb/zero/internal/specialist" +) + +func savedPlanModel(t *testing.T) (model, specialist.PlanPaths) { + t.Helper() + root := t.TempDir() + paths := specialist.PlanPaths{ + ProjectDir: filepath.Join(root, ".zero", "plans"), + UserDir: filepath.Join(t.TempDir(), "zero", "plans"), + } + m := model{planProgress: NewPlanProgressBridge(), planPaths: paths} + m.planProgress.Attach(func(tea.Msg) {}, 1, nil, "") + return m, paths +} + +// SAVE-FROM-RUN. The panel holds a rendering of a plan; the bridge holds the +// plan. Saving from the panel's copy would write something that merely +// resembles what ran. +func TestSavingKeepsThePlanThatActuallyRan(t *testing.T) { + m, paths := savedPlanModel(t) + m.planProgress.PlanAdmitted(samplePlan(t)) + + text := m.savePlanText("sweep") + if !strings.Contains(text, "status: info") { + t.Fatalf("save failed: %s", text) + } + stored, err := specialist.FindSavedPlan(paths, "sweep") + if err != nil { + t.Fatalf("the saved plan is not findable: %v", err) + } + if stored.TaskCount != samplePlan(t).TaskCount() { + t.Fatalf("saved %d tasks, the plan had %d", stored.TaskCount, samplePlan(t).TaskCount()) + } + // It must be RUNNABLE, not merely present: re-admitted through the one + // constructor, exactly as the tool will do. + if _, err := specialist.ParsePlan(stored.Args, specialist.Limits{ + MaxTasks: 20, ParentTools: specialist.PlanReadOnlyToolNames()}); err != nil { + t.Fatalf("the saved plan does not re-admit: %v", err) + } +} + +// Saving with nothing to save must refuse. An empty file named after a plan the +// user believes they kept is worse than a refusal. +func TestSavingWithNoPlanRefuses(t *testing.T) { + m, paths := savedPlanModel(t) + text := m.savePlanText("sweep") + if !strings.Contains(text, "status: warning") || !strings.Contains(text, "nothing to save") { + t.Fatalf("save must refuse with a reason: %s", text) + } + if plans, _ := specialist.LoadPlans(paths); len(plans) != 0 { + t.Fatalf("a file was written anyway: %+v", plans) + } +} + +func TestSavingRequiresAName(t *testing.T) { + m, _ := savedPlanModel(t) + m.planProgress.PlanAdmitted(samplePlan(t)) + if text := m.savePlanText(" "); !strings.Contains(text, "/plans save ") { + t.Fatalf("an unnamed save must say how: %s", text) + } +} + +// An invalid name is refused by the STORE, and the surface reports it rather +// than swallowing it. +func TestSavingAnInvalidNameIsReported(t *testing.T) { + m, _ := savedPlanModel(t) + m.planProgress.PlanAdmitted(samplePlan(t)) + text := m.savePlanText("../escape") + if !strings.Contains(text, "status: warning") { + t.Fatalf("a traversal name must be refused: %s", text) + } +} + +func TestListingShowsScopeAndUnreadableFiles(t *testing.T) { + m, paths := savedPlanModel(t) + m.planProgress.PlanAdmitted(samplePlan(t)) + if text := m.savePlanText("sweep"); !strings.Contains(text, "status: info") { + t.Fatal(text) + } + if err := os.WriteFile(filepath.Join(paths.ProjectDir, "broken.json"), []byte("{"), 0o600); err != nil { + t.Fatal(err) + } + + text := m.savedPlansText() + if !strings.Contains(text, "sweep") || !strings.Contains(text, "project") { + t.Fatalf("the listing must name the plan and its scope: %s", text) + } + if !strings.Contains(text, "could not be read") || !strings.Contains(text, "broken.json") { + t.Fatalf("an unreadable file must be named, not hidden: %s", text) + } +} + +func TestListingWithNothingSavedSaysHowToSave(t *testing.T) { + m, _ := savedPlanModel(t) + text := m.savedPlansText() + if !strings.Contains(text, "No saved plans") || !strings.Contains(text, "/plans save") { + t.Fatalf("an empty listing must say how to fill it: %s", text) + } +} + +// SHOW renders through ParsePlan, so what is displayed is what would run — +// including the execution order, which a stored task list does not carry. +func TestShowRendersTheExecutionOrder(t *testing.T) { + m, _ := savedPlanModel(t) + m.planProgress.PlanAdmitted(samplePlan(t)) + if text := m.savePlanText("sweep"); !strings.Contains(text, "status: info") { + t.Fatal(text) + } + + text := m.showSavedPlanText("sweep") + if !strings.Contains(text, "sweep") { + t.Fatalf("show did not name the plan: %s", text) + } + for _, task := range samplePlan(t).Tasks() { + if !strings.Contains(text, task.ID) { + t.Fatalf("show omitted task %q: %s", task.ID, text) + } + } +} + +func TestShowAndRunRefuseAnUnknownName(t *testing.T) { + m, _ := savedPlanModel(t) + if text := m.showSavedPlanText("nope"); !strings.Contains(text, "no saved plan named") { + t.Fatalf("show must refuse by name: %s", text) + } + updated, cmd := m.runSavedPlan("nope") + if cmd != nil { + t.Fatal("running an unknown plan started a turn") + } + rendered := transcriptText(updated.(model).transcript) + if !strings.Contains(rendered, "no saved plan named") { + t.Fatalf("run must refuse by name: %s", rendered) + } +} + +// A hand-edited plan file that is VALID JSON but not a valid plan must be +// refused on the way back in, naming the file. This is the re-admit branch that +// actually fires — the one on the save side cannot, and says so. +func TestAHandEditedPlanIsRefusedOnTheWayBackIn(t *testing.T) { + m, paths := savedPlanModel(t) + if err := os.MkdirAll(paths.ProjectDir, 0o700); err != nil { + t.Fatal(err) + } + // Parseable JSON, impossible plan: a dependency on a task that is not there. + body := `{"name":"edited","tasks":[{"id":"a","prompt":"x","depends_on":["ghost"]}],"budget":{"max_workers":1}}` + if err := os.WriteFile(filepath.Join(paths.ProjectDir, "edited.json"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + + text := m.showSavedPlanText("edited") + if !strings.Contains(text, "status: warning") || !strings.Contains(text, "does not validate") { + t.Fatalf("an invalid stored plan must be refused: %s", text) + } + if !strings.Contains(text, "edited.json") { + t.Fatalf("the refusal must name the file: %s", text) + } + if !strings.Contains(text, "ghost") { + t.Fatalf("the refusal must say what is wrong with it: %s", text) + } +} diff --git a/internal/tui/plan_progress.go b/internal/tui/plan_progress.go index a9919ff14..8e23c3bec 100644 --- a/internal/tui/plan_progress.go +++ b/internal/tui/plan_progress.go @@ -55,6 +55,13 @@ type PlanProgressBridge struct { // still proceeds instead of blocking forever on a send nobody makes. paused bool resume chan struct{} + // lastPlan is the ARGUMENTS of the most recent plan admitted this session — + // what /plans save writes. Kept here rather than in the panel because the + // panel holds a RENDERING of a plan (ids, statuses, depths) and saving that + // would produce something that merely resembles what ran. The bridge is + // handed the real Plan, so it keeps the one thing that can be run again. + lastPlan map[string]any + lastPlanName string // dispatched counts tasks so each gets a unique temporary card key. The // child's real session id is not known until the child process creates it, // so the card is keyed by this and reconciled on completion — exactly how @@ -224,6 +231,21 @@ func (bridge *PlanProgressBridge) clearPauseLocked() { } } +// LastPlan returns the arguments of the most recent plan admitted this session, +// and its name. Reports false when no plan has run — the caller says so rather +// than saving an empty file. +func (bridge *PlanProgressBridge) LastPlan() (map[string]any, string, bool) { + if bridge == nil { + return nil, "", false + } + bridge.mu.Lock() + defer bridge.mu.Unlock() + if len(bridge.lastPlan) == 0 { + return nil, "", false + } + return bridge.lastPlan, bridge.lastPlanName, true +} + // RecordingError reports the first append failure, so a surface can say once // that the plan was not fully persisted rather than leaving it silent. func (bridge *PlanProgressBridge) RecordingError() error { @@ -257,6 +279,15 @@ func planTaskKey(n int) string { return fmt.Sprintf("plantask_%d", n) } // about to run rather than going silent until the first one finishes. func (bridge *PlanProgressBridge) PlanAdmitted(plan specialist.Plan) { bridge.record(specialist.PlanAdmittedEvent(plan)) + if bridge != nil { + // Args, not the Plan: what gets saved has to be re-admitted through + // ParsePlan on the way back in, so it is stored in the shape ParsePlan + // accepts and never as an object that could reach execution unvalidated. + bridge.mu.Lock() + bridge.lastPlan = plan.Args() + bridge.lastPlanName = plan.Name() + bridge.mu.Unlock() + } name := plan.Name() count := plan.TaskCount() limit := plan.Budget().MaxTokens From 1fd568ea2a0387d09266daf897e8e67e47158438 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:03:43 +0530 Subject: [PATCH 39/86] =?UTF-8?q?feat(specialist):=20resume=20a=20plan=20f?= =?UTF-8?q?rom=20where=20it=20stopped=20(gap=20report=20=C2=A75.2,=20read?= =?UTF-8?q?=20half)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The write half put the five lifecycle events in the session log from both surfaces. This reduces them back into plan state, which is the shape ARCHITECTURE.md asked for: a deterministic reduction over the five events, with no second store to disagree with. A DISPATCH WITH NO TERMINAL EVENT IS UNFINISHED, NEVER DONE. Dispatch is written before the child runs precisely so a task in flight when the process died is distinguishable from one that never started, and it is the task most likely to have been interrupted mid-work. Treating it as complete would silently drop exactly that one. A failed task is remaining too: unlike a success it produced nothing to keep. WHY RESUME NEEDS A SAVED PLAN, and it is a consequence of a decision made two commits ago rather than an oversight. The event payloads are deliberately small — ids, counts, durations, terminal status, not prompts — so the log knows which tasks finished and cannot know what they were asked to do. Rather than growing the log into a second copy of the plan (a fifty-task plan's prompts, written again, per run), the plan supplies the work and the log supplies the progress. Neither has to become the other. NARROWING REMOVES A COMPLETED TASK AND EVERY REFERENCE TO IT. Leaving the edge would produce a plan whose dependency names nothing, which ParsePlan refuses — correctly — so a resume would fail on its own output. Rewriting the edge to the next task along would invent an ordering nobody declared. A dependency on work already done is simply satisfied. The remainder goes back through ParsePlan, so it is re-validated against the CURRENT run: it cannot keep a tool grant this run does not hold, or a task count the tier no longer allows. The remainder is STAGED AS A SAVED PLAN rather than taught to the tool as a "skip these ids" argument. It is a plan — it validates, it runs, /plans show reads it before anyone spends a token — and it travels the one path every other plan travels. A skip list would have been a parallel notion of what a plan is, resolved somewhere other than ParsePlan. It overwrites a fixed name, because a directory of sweep-resume-1, -2, -3 is a directory nobody can tell apart. `/plans resume` is overloaded BY ARITY, and the two meanings are the same intention at two scales: bare, it un-pauses the plan running now; with a name, it picks up a saved plan where its last run stopped. Bare resume keeps exactly the meaning it had, which is asserted rather than assumed. Eleven mutations, all caught, including the two that would have been silent in production: an unfinished dispatch counted as done, and a second plan in one session inheriting the first plan's completions. --- internal/specialist/plan_resume.go | 208 ++++++++++++++++++++++ internal/specialist/plan_resume_test.go | 225 ++++++++++++++++++++++++ internal/tui/commands.go | 2 +- internal/tui/orchestrate_saved.go | 93 +++++++++- internal/tui/orchestrate_saved_test.go | 145 ++++++++++++++- 5 files changed, 665 insertions(+), 8 deletions(-) create mode 100644 internal/specialist/plan_resume.go create mode 100644 internal/specialist/plan_resume_test.go diff --git a/internal/specialist/plan_resume.go b/internal/specialist/plan_resume.go new file mode 100644 index 000000000..ee49b33d1 --- /dev/null +++ b/internal/specialist/plan_resume.go @@ -0,0 +1,208 @@ +package specialist + +import ( + "encoding/json" + "fmt" + "sort" + + "github.com/Gitlawb/zero/internal/sessions" +) + +// Resume: what a plan had already done when the process went away. +// +// This is the READ half of durability. The write half put the five lifecycle +// events in the session log from both surfaces; this reduces them back into +// plan state. ARCHITECTURE.md's rule is the shape of the whole thing — plan +// state is a DETERMINISTIC REDUCTION over the five events, and there is no +// second store to disagree with. +// +// WHAT THE EVENTS DO AND DO NOT CARRY. The payloads are deliberately small: ids, +// counts, durations, terminal status — not prompts. So the log says which tasks +// finished; it cannot say what they were asked to do. That is why resuming is +// built on a SAVED plan: the plan supplies the work, the log supplies the +// progress, and neither has to grow into the other. Putting prompts in the log +// would have multiplied a fifty-task plan's on-disk size to make the log a +// second copy of something already stored better. +// +// A DISPATCH WITHOUT A TERMINAL EVENT IS THE POINT. Dispatch is written before +// the child runs, so a task that was in flight when the process died is +// distinguishable from one that never started — and it is treated as UNFINISHED, +// never as done. Assuming otherwise would silently drop the one task most likely +// to have been interrupted mid-work. + +// PlanProgress is the state of one plan, reduced from its events. +type PlanProgress struct { + // Name is the plan's name from plan_admitted. + Name string + // Order is the execution order recorded at admission. + Order []string + // Succeeded holds the ids that reached task_completed. + Succeeded []string + // Unfinished holds ids that were dispatched and never reached a terminal + // event — the process went away mid-task. + Unfinished []string + // Failed holds ids that reached task_failed, whatever the outcome. A failed + // task is worth RE-RUNNING: unlike a success it produced no work to keep. + Failed []string + // Complete reports that a plan_completed event was seen, so the plan ended + // on purpose rather than being cut off. + Complete bool + // Status is the terminal status from plan_completed, empty if there was none. + Status string +} + +// Done reports whether there is anything left to run. +func (progress PlanProgress) Done() bool { + return len(progress.Unfinished) == 0 && len(progress.Failed) == 0 && + len(progress.Succeeded) == len(progress.Order) +} + +// Remaining lists the ids that still need to run, in the recorded execution +// order — everything that did not succeed. +func (progress PlanProgress) Remaining() []string { + succeeded := map[string]bool{} + for _, id := range progress.Succeeded { + succeeded[id] = true + } + out := []string{} + for _, id := range progress.Order { + if !succeeded[id] { + out = append(out, id) + } + } + return out +} + +// ReducePlanEvents folds a session's events into the state of its LAST plan. +// +// The last one, deliberately: a session can run several plans, and "resume" +// means the one that was interrupted, which is the most recent. plan_admitted +// RESETS the accumulated state rather than merging into it, so a second plan +// never inherits the first plan's completed tasks — which would mark work as +// done that this plan never did. +func ReducePlanEvents(events []sessions.Event) (PlanProgress, bool) { + var progress PlanProgress + seen := false + + // Sequence order, not file order: the reduction has to be deterministic and + // events are only meaningful in the order they happened. + ordered := append([]sessions.Event(nil), events...) + sort.SliceStable(ordered, func(i, j int) bool { return ordered[i].Sequence < ordered[j].Sequence }) + + dispatched := map[string]bool{} + terminal := map[string]bool{} + for _, event := range ordered { + switch event.Type { + case sessions.EventPlanAdmitted: + var payload struct { + Name string `json:"name"` + Order []string `json:"order"` + } + if json.Unmarshal(event.Payload, &payload) != nil { + // Malformed is not a silent skip: an admission we cannot read + // means we do not know this plan's shape, so the previous plan's + // state must not be reported as if it were this one's. + return PlanProgress{}, false + } + seen = true + progress = PlanProgress{Name: payload.Name, Order: payload.Order} + dispatched = map[string]bool{} + terminal = map[string]bool{} + case sessions.EventTaskDispatched: + if id := planEventTaskID(event); id != "" { + dispatched[id] = true + } + case sessions.EventTaskCompleted: + if id := planEventTaskID(event); id != "" { + terminal[id] = true + progress.Succeeded = append(progress.Succeeded, id) + } + case sessions.EventTaskFailed: + if id := planEventTaskID(event); id != "" { + terminal[id] = true + progress.Failed = append(progress.Failed, id) + } + case sessions.EventPlanCompleted: + var payload struct { + Status string `json:"status"` + } + _ = json.Unmarshal(event.Payload, &payload) + progress.Complete = true + progress.Status = payload.Status + } + } + if !seen { + return PlanProgress{}, false + } + // Dispatched with no terminal event: in flight when the process went away. + for _, id := range progress.Order { + if dispatched[id] && !terminal[id] { + progress.Unfinished = append(progress.Unfinished, id) + } + } + return progress, true +} + +func planEventTaskID(event sessions.Event) string { + var payload struct { + ID string `json:"id"` + } + if json.Unmarshal(event.Payload, &payload) != nil { + return "" + } + return payload.ID +} + +// RemainingPlan narrows a plan to the work that has not succeeded. +// +// A completed task is REMOVED, and every reference to it is removed with it. +// Leaving the edge behind would produce a plan whose dependency names nothing — +// ParsePlan refuses that, correctly — and rewriting the edge to point at the +// next task along would invent an ordering nobody declared. A dependency on +// work that is already done is simply satisfied. +// +// The result goes back through ParsePlan, so a narrowed plan is validated like +// any other: it cannot acquire a tool, a task count or a depth the original did +// not have, and a narrowing that produced a cycle would be caught rather than +// executed. +func RemainingPlan(plan Plan, progress PlanProgress, limits Limits) (Plan, error) { + succeeded := map[string]bool{} + for _, id := range progress.Succeeded { + succeeded[id] = true + } + + args := plan.Args() + rawTasks, _ := args["tasks"].([]any) + kept := make([]any, 0, len(rawTasks)) + for _, raw := range rawTasks { + entry, ok := raw.(map[string]any) + if !ok { + continue + } + id, _ := entry["id"].(string) + if succeeded[id] { + continue + } + if deps, ok := entry["depends_on"].([]any); ok { + remaining := make([]any, 0, len(deps)) + for _, dep := range deps { + name, _ := dep.(string) + if succeeded[name] { + continue + } + remaining = append(remaining, dep) + } + if len(remaining) == 0 { + delete(entry, "depends_on") + } else { + entry["depends_on"] = remaining + } + } + kept = append(kept, entry) + } + if len(kept) == 0 { + return Plan{}, fmt.Errorf("every task in plan %q already succeeded; there is nothing left to run", plan.Name()) + } + args["tasks"] = kept + return ParsePlan(args, limits) +} diff --git a/internal/specialist/plan_resume_test.go b/internal/specialist/plan_resume_test.go new file mode 100644 index 000000000..c5f59ecd6 --- /dev/null +++ b/internal/specialist/plan_resume_test.go @@ -0,0 +1,225 @@ +package specialist + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/sessions" +) + +// planEvent builds a session event the way the recorders write one, so the +// reducer is tested against the shape actually stored rather than a convenient +// one. Sequence is explicit: the reduction depends on order. +func planEvent(seq int, build func() (sessions.EventType, map[string]any)) sessions.Event { + eventType, payload := build() + body, _ := json.Marshal(payload) + return sessions.Event{Sequence: seq, Type: eventType, Payload: body} +} + +func resumeFixturePlan(t *testing.T) Plan { + t.Helper() + // a → (b, c) → d. A diamond, so narrowing has edges to rewrite. + return mustPlan(t, []any{ + task("a", "root"), + task("b", "left", "a"), + task("c", "right", "a"), + task("d", "join", "b", "c"), + }, okBudget(), readOnlyLimits()) +} + +// A DISPATCH WITH NO TERMINAL EVENT IS UNFINISHED, never done. It is the task +// most likely to have been interrupted mid-work, and treating it as complete +// would silently drop exactly that one. +func TestADispatchedTaskWithNoTerminalEventIsUnfinished(t *testing.T) { + plan := resumeFixturePlan(t) + events := []sessions.Event{ + planEvent(1, func() (sessions.EventType, map[string]any) { return PlanAdmittedEvent(plan) }), + planEvent(2, func() (sessions.EventType, map[string]any) { return TaskDispatchedEvent(Task{ID: "a"}) }), + planEvent(3, func() (sessions.EventType, map[string]any) { + return TaskCompletedEvent(TaskResult{ID: "a", Attempts: 1}) + }), + // b was dispatched and the process went away. + planEvent(4, func() (sessions.EventType, map[string]any) { return TaskDispatchedEvent(Task{ID: "b"}) }), + } + + progress, ok := ReducePlanEvents(events) + if !ok { + t.Fatal("the reducer found no plan") + } + if !reflect.DeepEqual(progress.Succeeded, []string{"a"}) { + t.Fatalf("succeeded = %v, want [a]", progress.Succeeded) + } + if !reflect.DeepEqual(progress.Unfinished, []string{"b"}) { + t.Fatalf("unfinished = %v, want [b] — a dispatch with no terminal event", progress.Unfinished) + } + if progress.Complete { + t.Fatal("a plan with no plan_completed must not report itself complete") + } + if !reflect.DeepEqual(progress.Remaining(), []string{"b", "c", "d"}) { + t.Fatalf("remaining = %v, want [b c d]", progress.Remaining()) + } +} + +// A FAILED task is remaining too. Unlike a success it produced no work to keep, +// so resuming should attempt it again. +func TestAFailedTaskIsRemaining(t *testing.T) { + plan := resumeFixturePlan(t) + events := []sessions.Event{ + planEvent(1, func() (sessions.EventType, map[string]any) { return PlanAdmittedEvent(plan) }), + planEvent(2, func() (sessions.EventType, map[string]any) { return TaskDispatchedEvent(Task{ID: "a"}) }), + planEvent(3, func() (sessions.EventType, map[string]any) { + return TaskFailedEvent(TaskResult{ID: "a", Outcome: TaskFailed}) + }), + } + progress, _ := ReducePlanEvents(events) + if !reflect.DeepEqual(progress.Failed, []string{"a"}) { + t.Fatalf("failed = %v, want [a]", progress.Failed) + } + if got := progress.Remaining(); got[0] != "a" { + t.Fatalf("remaining = %v; a failed task must be re-run", got) + } +} + +// A SECOND PLAN RESETS THE STATE. A session can run several plans, and resume +// means the last one — merging would mark this plan's tasks done because an +// earlier plan happened to use the same ids. +func TestASecondPlanDoesNotInheritTheFirstPlansProgress(t *testing.T) { + first := mustPlan(t, []any{task("a", "x"), task("b", "y")}, okBudget(), readOnlyLimits()) + second := mustPlan(t, []any{task("a", "different"), task("z", "new")}, okBudget(), readOnlyLimits()) + events := []sessions.Event{ + planEvent(1, func() (sessions.EventType, map[string]any) { return PlanAdmittedEvent(first) }), + planEvent(2, func() (sessions.EventType, map[string]any) { return TaskDispatchedEvent(Task{ID: "a"}) }), + planEvent(3, func() (sessions.EventType, map[string]any) { return TaskCompletedEvent(TaskResult{ID: "a"}) }), + planEvent(4, func() (sessions.EventType, map[string]any) { return TaskDispatchedEvent(Task{ID: "b"}) }), + planEvent(5, func() (sessions.EventType, map[string]any) { return TaskCompletedEvent(TaskResult{ID: "b"}) }), + planEvent(6, func() (sessions.EventType, map[string]any) { + return PlanCompletedEvent(first, PlanReport{Status: PlanCompleted, Succeeded: 2}) + }), + // A second plan starts and is interrupted immediately. + planEvent(7, func() (sessions.EventType, map[string]any) { return PlanAdmittedEvent(second) }), + planEvent(8, func() (sessions.EventType, map[string]any) { return TaskDispatchedEvent(Task{ID: "a"}) }), + } + + progress, _ := ReducePlanEvents(events) + if len(progress.Succeeded) != 0 { + t.Fatalf("succeeded = %v; the second plan inherited the first plan's completions", progress.Succeeded) + } + if progress.Complete { + t.Fatal("the first plan's completion was carried into the second") + } + if !reflect.DeepEqual(progress.Order, []string{"a", "z"}) { + t.Fatalf("order = %v; it must be the SECOND plan's", progress.Order) + } +} + +// The reduction is over SEQUENCE, not file order: a log read out of order must +// still produce the same state. +func TestTheReductionIsDeterministicRegardlessOfInputOrder(t *testing.T) { + plan := resumeFixturePlan(t) + ordered := []sessions.Event{ + planEvent(1, func() (sessions.EventType, map[string]any) { return PlanAdmittedEvent(plan) }), + planEvent(2, func() (sessions.EventType, map[string]any) { return TaskDispatchedEvent(Task{ID: "a"}) }), + planEvent(3, func() (sessions.EventType, map[string]any) { return TaskCompletedEvent(TaskResult{ID: "a"}) }), + } + shuffled := []sessions.Event{ordered[2], ordered[0], ordered[1]} + + want, _ := ReducePlanEvents(ordered) + got, _ := ReducePlanEvents(shuffled) + if !reflect.DeepEqual(want, got) { + t.Fatalf("the reduction depends on input order:\n%+v\nvs\n%+v", want, got) + } +} + +// A session with no plan events reports so, rather than an empty plan that a +// caller might narrow to nothing. +func TestASessionWithNoPlanReportsNoPlan(t *testing.T) { + if _, ok := ReducePlanEvents(nil); ok { + t.Fatal("an empty log produced a plan") + } + if _, ok := ReducePlanEvents([]sessions.Event{{Sequence: 1, Type: sessions.EventType("user_message")}}); ok { + t.Fatal("a log with no plan events produced a plan") + } +} + +// NARROWING REMOVES THE COMPLETED TASK AND EVERY REFERENCE TO IT. Leaving the +// edge behind would produce a plan whose dependency names nothing — which +// ParsePlan refuses, correctly — so a resume would fail on its own output. +func TestNarrowingDropsCompletedTasksAndTheirEdges(t *testing.T) { + plan := resumeFixturePlan(t) + progress := PlanProgress{Order: plan.Order(), Succeeded: []string{"a", "b"}} + + remaining, err := RemainingPlan(plan, progress, readOnlyLimits()) + if err != nil { + t.Fatalf("RemainingPlan: %v", err) + } + ids := remaining.Order() + if !reflect.DeepEqual(ids, []string{"c", "d"}) { + t.Fatalf("remaining order = %v, want [c d]", ids) + } + byID := map[string]Task{} + for _, task := range remaining.Tasks() { + byID[task.ID] = task + } + // c depended on a, which is done: the edge is gone, not rewritten. + if len(byID["c"].DependsOn) != 0 { + t.Fatalf("c still depends on %v; a satisfied dependency must be dropped", byID["c"].DependsOn) + } + // d depended on b (done) and c (not): only the live edge survives. + if !reflect.DeepEqual(byID["d"].DependsOn, []string{"c"}) { + t.Fatalf("d depends on %v, want [c]", byID["d"].DependsOn) + } +} + +// The narrowed plan goes back through ParsePlan, so it cannot acquire anything +// the original did not have — and the current run's limits still apply. +func TestANarrowedPlanIsRevalidated(t *testing.T) { + plan := resumeFixturePlan(t) + progress := PlanProgress{Order: plan.Order(), Succeeded: []string{"a"}} + + // A task that NAMES a tool the run does not hold is refused in the + // remainder exactly as it would be in the original. Named explicitly + // because admission only checks what a task asks for — an unqualified task + // inherits at dispatch, where planToolGrant does the refusing. + explicit := mustPlan(t, []any{ + map[string]any{"id": "a", "prompt": "x"}, + map[string]any{"id": "b", "prompt": "y", "depends_on": []any{"a"}, "tools": []any{"grep"}}, + }, okBudget(), readOnlyLimits()) + if _, err := RemainingPlan(explicit, PlanProgress{Order: explicit.Order(), Succeeded: []string{"a"}}, + Limits{MaxTasks: 20, ParentTools: []string{"read_file"}}); err == nil { + t.Fatal("a narrowed plan kept a tool grant this run does not hold") + } + // ...nor one whose tier no longer fits it. + if _, err := RemainingPlan(plan, progress, Limits{MaxTasks: 2, ParentTools: []string{"read_file"}}); err == nil { + t.Fatal("a narrowed plan bypassed the task ceiling") + } +} + +// Nothing left is not an error state to hide: it is the ordinary end of a plan, +// reported as such. +func TestNarrowingACompletedPlanSaysThereIsNothingLeft(t *testing.T) { + plan := resumeFixturePlan(t) + progress := PlanProgress{Order: plan.Order(), Succeeded: plan.Order()} + if !progress.Done() { + t.Fatal("a plan whose every task succeeded is not reported done") + } + _, err := RemainingPlan(plan, progress, readOnlyLimits()) + if err == nil || !strings.Contains(err.Error(), "nothing left to run") { + t.Fatalf("err = %v; it must say the plan is finished", err) + } +} + +// A malformed plan_admitted must not let the PREVIOUS plan's state be reported +// as this one's. Malformed persisted data is an error, never a silent skip. +func TestAMalformedAdmissionAbandonsTheReduction(t *testing.T) { + plan := resumeFixturePlan(t) + events := []sessions.Event{ + planEvent(1, func() (sessions.EventType, map[string]any) { return PlanAdmittedEvent(plan) }), + planEvent(2, func() (sessions.EventType, map[string]any) { return TaskCompletedEvent(TaskResult{ID: "a"}) }), + {Sequence: 3, Type: sessions.EventPlanAdmitted, Payload: json.RawMessage(`{"order":`)}, + } + if _, ok := ReducePlanEvents(events); ok { + t.Fatal("a malformed admission was skipped and the previous plan's state survived") + } +} diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 9eaa382d6..08da276e9 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -120,7 +120,7 @@ var commandDefinitions = []commandDefinition{ }, { name: "/plans", - usage: "/plans [stop|pause|resume|save |list|show |run ]", + usage: "/plans [stop|pause|save |list|show |run |resume ]", group: commandGroupSession, // Deliberately close to /plan, and they are different things: /plan is // planning-mode status (the update_plan TODO list), /plans is the diff --git a/internal/tui/orchestrate_saved.go b/internal/tui/orchestrate_saved.go index 138a0a2b6..900abf10e 100644 --- a/internal/tui/orchestrate_saved.go +++ b/internal/tui/orchestrate_saved.go @@ -36,7 +36,21 @@ func (m model) handlePlansCommand(args string) (tea.Model, tea.Cmd) { case "show": return m.appendPlansNotice(m.showSavedPlanText(rest)), nil case "run": - return m.runSavedPlan(rest) + return m.runSavedPlan(rest, false) + case "resume": + // TWO MEANINGS OF ONE WORD, split by arity, and they are the same + // intention at two scales: continue what was interrupted. + // + // /plans resume → un-pause the plan running right now + // /plans resume → pick up a saved plan where its last run + // stopped + // + // Bare resume keeps the meaning it already had, so the pause control + // shipped before this does not change under anyone. + if strings.TrimSpace(rest) == "" { + return m.appendPlansNotice(m.orchestrateControlText(args)), nil + } + return m.runSavedPlan(rest, true) default: return m.appendPlansNotice(m.orchestrateControlText(args)), nil } @@ -207,22 +221,89 @@ func planTaskSummaryLine(prompt string) string { // runSavedPlan asks the MODEL to run it. See the note at the top of this file: // a command that called the tool directly would be a second execution path. -func (m model) runSavedPlan(name string) (tea.Model, tea.Cmd) { +// +// resume narrows the plan to the work the session log says has not succeeded. +func (m model) runSavedPlan(name string, resume bool) (tea.Model, tea.Cmd) { + verb := "run" + if resume { + verb = "resume" + } name = strings.TrimSpace(name) if name == "" { - return m.appendPlansNotice(planControlNotice("warning", "Name it: /plans run ")), nil + return m.appendPlansNotice(planControlNotice("warning", "Name it: /plans "+verb+" ")), nil } // Resolved HERE so an unknown name is a plain refusal rather than a turn // spent discovering the plan does not exist. - if _, err := specialist.FindSavedPlan(m.planPaths, name); err != nil { + stored, err := specialist.FindSavedPlan(m.planPaths, name) + if err != nil { return m.appendPlansNotice(planControlNotice("warning", err.Error())), nil } - encoded, err := json.Marshal(name) + + target := name + instruction := "Run the saved plan %s by calling the orchestrate tool with the `saved` argument set to that name. Do not restate its tasks." + if resume { + remaining, notice, ok := m.resumeSavedPlan(stored) + if !ok { + return m.appendPlansNotice(notice), nil + } + target = remaining + instruction = "Resume the plan by calling the orchestrate tool with the `saved` argument set to %s. " + + "That is the saved plan narrowed to the tasks that had not finished. Do not restate its tasks." + m = m.appendPlansNotice(notice) + } + encoded, err := json.Marshal(target) if err != nil { return m.appendPlansNotice(planControlNotice("warning", "Could not reference that plan: "+err.Error())), nil } return m.dispatchCommand(parsedCommand{ kind: commandPrompt, - text: "Run the saved plan " + string(encoded) + " by calling the orchestrate tool with the `saved` argument set to that name. Do not restate its tasks.", + text: fmt.Sprintf(instruction, string(encoded)), }) } + +// resumeSavedPlan narrows a stored plan by the CURRENT session's plan events and +// saves the remainder under its own name. +// +// It writes a new saved plan rather than teaching the tool a second "skip these +// ids" argument. The remainder is a plan — it validates, it runs, it can be +// inspected with /plans show before anyone spends a token on it — and running it +// travels the one path every other plan travels. A skip list would have been a +// parallel notion of what a plan is, resolved somewhere other than ParsePlan. +func (m model) resumeSavedPlan(stored specialist.SavedPlan) (name string, notice string, ok bool) { + if m.sessionStore == nil || m.activeSession.SessionID == "" { + return "", planControlNotice("warning", + "This session has no event log, so there is no record of what already ran."), false + } + events, err := m.sessionStore.ReadEvents(m.activeSession.SessionID) + if err != nil { + return "", planControlNotice("warning", "Could not read this session's events: "+err.Error()), false + } + progress, found := specialist.ReducePlanEvents(events) + if !found { + return "", planControlNotice("warning", + "No plan has run in this session, so there is nothing to resume. Use /plans run "+stored.Name+" to run it from the start."), false + } + plan, err := specialist.ParsePlan(stored.Args, m.savedPlanLimits()) + if err != nil { + return "", planControlNotice("warning", fmt.Sprintf("%s does not validate: %v", stored.Path, err)), false + } + remaining, err := specialist.RemainingPlan(plan, progress, m.savedPlanLimits()) + if err != nil { + return "", planControlNotice("info", err.Error()), false + } + + dir := m.planPaths.ProjectDir + if dir == "" { + dir = m.planPaths.UserDir + } + // A FIXED name, overwritten each time. A resume that accumulated + // sweep-resume-1, -2, -3 would leave a directory of near-identical plans + // nobody can tell apart, and only the newest is ever the right one. + resumeName := stored.Name + "_resume" + if _, err := specialist.SavePlan(dir, resumeName, remaining); err != nil { + return "", planControlNotice("warning", "Could not stage the remaining plan: "+err.Error()), false + } + return resumeName, planControlNotice("info", fmt.Sprintf( + "Resuming %q: %d of %d tasks already succeeded, %d left.\nSaved the remainder as %q — /plans show %s to read it first.", + stored.Name, len(progress.Succeeded), len(progress.Order), remaining.TaskCount(), resumeName, resumeName)), true +} diff --git a/internal/tui/orchestrate_saved_test.go b/internal/tui/orchestrate_saved_test.go index d6221ed38..5a6d4770c 100644 --- a/internal/tui/orchestrate_saved_test.go +++ b/internal/tui/orchestrate_saved_test.go @@ -1,6 +1,7 @@ package tui import ( + "context" "os" "path/filepath" "strings" @@ -8,6 +9,7 @@ import ( tea "charm.land/bubbletea/v2" + "github.com/Gitlawb/zero/internal/sessions" "github.com/Gitlawb/zero/internal/specialist" ) @@ -133,7 +135,7 @@ func TestShowAndRunRefuseAnUnknownName(t *testing.T) { if text := m.showSavedPlanText("nope"); !strings.Contains(text, "no saved plan named") { t.Fatalf("show must refuse by name: %s", text) } - updated, cmd := m.runSavedPlan("nope") + updated, cmd := m.runSavedPlan("nope", false) if cmd != nil { t.Fatal("running an unknown plan started a turn") } @@ -168,3 +170,144 @@ func TestAHandEditedPlanIsRefusedOnTheWayBackIn(t *testing.T) { t.Fatalf("the refusal must say what is wrong with it: %s", text) } } + +// resumeModel is a model with a real session store, a real saved plan, and real +// plan events — the whole chain from "a plan ran and died" to "resume it". +func resumeModel(t *testing.T) (model, specialist.PlanPaths, specialist.Plan) { + t.Helper() + m, paths := savedPlanModel(t) + store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()}) + session, err := store.Create(sessions.CreateInput{Cwd: t.TempDir()}) + if err != nil { + t.Fatalf("create session: %v", err) + } + m.sessionStore = store + m.activeSession = session + m.planProgress.Attach(func(tea.Msg) {}, 1, store, session.SessionID) + + plan := samplePlan(t) + if _, err := specialist.SavePlan(paths.ProjectDir, "sweep", plan); err != nil { + t.Fatalf("SavePlan: %v", err) + } + return m, paths, plan +} + +// THE WHOLE CHAIN. A plan runs, one task finishes, the process dies mid-second +// task, and resuming stages exactly the work that was not done — using only the +// session log and the saved plan, with no third store between them. +func TestResumeStagesOnlyTheWorkThatDidNotFinish(t *testing.T) { + m, paths, plan := resumeModel(t) + order := plan.Order() + if len(order) < 2 { + t.Skip("the sample plan is too small to interrupt") + } + + // A real run, recorded through the real bridge: first task done, second + // dispatched and never finished. + m.planProgress.PlanAdmitted(plan) + m.planProgress.TaskDispatched(specialist.Task{ID: order[0]}) + m.planProgress.TaskCompleted(specialist.TaskResult{ID: order[0], Attempts: 1}) + m.planProgress.TaskDispatched(specialist.Task{ID: order[1]}) + if err := m.planProgress.RecordingError(); err != nil { + t.Fatalf("recording: %v", err) + } + + stored, err := specialist.FindSavedPlan(paths, "sweep") + if err != nil { + t.Fatal(err) + } + name, notice, ok := m.resumeSavedPlan(stored) + if !ok { + t.Fatalf("resume refused: %s", notice) + } + if name != "sweep_resume" { + t.Fatalf("staged as %q", name) + } + + remaining, err := specialist.FindSavedPlan(paths, name) + if err != nil { + t.Fatalf("the staged remainder is not findable: %v", err) + } + restored, err := specialist.ParsePlan(remaining.Args, m.savedPlanLimits()) + if err != nil { + t.Fatalf("the staged remainder does not validate: %v", err) + } + for _, id := range restored.Order() { + if id == order[0] { + t.Fatalf("the remainder re-runs %q, which already succeeded", id) + } + } + // The INTERRUPTED task is in the remainder: dispatched with no terminal + // event means unfinished, never done. + var sawInterrupted bool + for _, id := range restored.Order() { + if id == order[1] { + sawInterrupted = true + } + } + if !sawInterrupted { + t.Fatalf("the interrupted task %q was dropped from the remainder", order[1]) + } + if !strings.Contains(notice, "1 of") { + t.Fatalf("the notice must say how much was already done: %s", notice) + } +} + +// Resuming a plan that finished is not an error to hide — it is the ordinary +// end of a plan, said plainly, with nothing staged. +func TestResumingAFinishedPlanSaysThereIsNothingLeft(t *testing.T) { + m, paths, plan := resumeModel(t) + m.planProgress.PlanAdmitted(plan) + for _, id := range plan.Order() { + m.planProgress.TaskDispatched(specialist.Task{ID: id}) + m.planProgress.TaskCompleted(specialist.TaskResult{ID: id, Attempts: 1}) + } + + stored, _ := specialist.FindSavedPlan(paths, "sweep") + _, notice, ok := m.resumeSavedPlan(stored) + if ok { + t.Fatal("a finished plan was staged for resume") + } + if !strings.Contains(notice, "nothing left to run") { + t.Fatalf("notice = %s", notice) + } + if _, err := specialist.FindSavedPlan(paths, "sweep_resume"); err == nil { + t.Fatal("a remainder was written for a plan with no remainder") + } +} + +// With no plan in the log, resume refuses and POINTS AT run — the user wants +// this plan executed, and the difference is only where it starts. +func TestResumingWithNoRecordedPlanPointsAtRun(t *testing.T) { + m, paths, _ := resumeModel(t) + stored, _ := specialist.FindSavedPlan(paths, "sweep") + _, notice, ok := m.resumeSavedPlan(stored) + if ok { + t.Fatal("resume staged a plan with nothing recorded") + } + if !strings.Contains(notice, "/plans run sweep") { + t.Fatalf("the refusal must offer the way forward: %s", notice) + } +} + +// BARE `/plans resume` KEEPS ITS OLD MEANING. It un-pauses the running plan; the +// saved-plan resume is the form that takes a name. Overloading by arity must not +// break the control verb that shipped first. +func TestBareResumeStillUnpausesTheRunningPlan(t *testing.T) { + m, _ := savedPlanModel(t) + _, cancel := context.WithCancel(context.Background()) + defer cancel() + m.planProgress.PlanRunning(cancel) + m.planProgress.SetPlanPaused(true) + + updated, cmd := m.handlePlansCommand("resume") + if cmd != nil { + t.Fatal("bare resume started a turn") + } + if m.planProgress.PlanPaused() { + t.Fatal("bare /plans resume did not un-pause the running plan") + } + if !strings.Contains(transcriptText(updated.(model).transcript), "Resuming the plan") { + t.Fatalf("bare resume said something else: %s", transcriptText(updated.(model).transcript)) + } +} From 92f3f4433e75d465eea7efc39724c755200b0ef2 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:13:29 +0530 Subject: [PATCH 40/86] =?UTF-8?q?feat(specialist):=20ship=20one=20worked?= =?UTF-8?q?=20plan=20in=20the=20binary=20(gap=20report=20=C2=A75.14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plan format nobody has seen an example of is a format nobody writes. `/plans show research` reads the shape working, `/plans run research` runs it, and neither needs a file on disk. IT ENCODES THE HOUSE STYLE ON PURPOSE, because a starter plan that merely demonstrated the JSON would teach nothing worth teaching. The three searches are INDEPENDENT — by definition, by call site, by test — because one search angle finds one kind of thing, and a synthesis over three blind searches is the argument for fan-out at all. The last task is told to REFUTE and to default to refuted when uncertain, because a verifier that sets out to agree always does. That is the verified-vs-refuted distinction the report asked for. It fits the SMALL tier, asserted by test: a shipped example that a user with the tightest ceiling cannot run is an example for everyone except the people most likely to want one. SHADOWED, NEVER AUTHORITATIVE. Builtin loses to user, user loses to project, so shipping an example can never override something someone wrote — and the listing does not count bundled plans as the user's own, or a fresh install would read as though they already had plans and drop the line telling them how to save one. SavedPlan.Project became SavedPlan.Scope. A bool could say "project or not", and there are three origins now: "not project" would have meant both "the user's" and "shipped with the binary", which is the one distinction a listing exists to draw. Four mutations, all caught, including the ordering one that matters most — a bundled plan placed after the disk scan would silently replace a user's plan of the same name on the next release. --- internal/specialist/plan_builtin.go | 68 +++++++++++++++ internal/specialist/plan_store.go | 33 ++++++- internal/specialist/plan_store_test.go | 111 ++++++++++++++++++++++-- internal/specialist/plans/research.json | 37 ++++++++ internal/tui/orchestrate_saved.go | 18 ++-- internal/tui/orchestrate_saved_test.go | 25 +++++- 6 files changed, 271 insertions(+), 21 deletions(-) create mode 100644 internal/specialist/plan_builtin.go create mode 100644 internal/specialist/plans/research.json diff --git a/internal/specialist/plan_builtin.go b/internal/specialist/plan_builtin.go new file mode 100644 index 000000000..3729194a7 --- /dev/null +++ b/internal/specialist/plan_builtin.go @@ -0,0 +1,68 @@ +package specialist + +import ( + "embed" + "encoding/json" + "path/filepath" + "strings" +) + +// The bundled plan: one worked example, shipped in the binary. +// +// A plan format nobody has seen an example of is a format nobody writes. This +// is the shape working — a fan-out of independent searches, a synthesis, and a +// verification step — available immediately with /plans show research, and +// runnable with /plans run research. +// +// IT ENCODES THE HOUSE STYLE ON PURPOSE. The searches are deliberately +// independent because one search angle finds one kind of thing; the verify task +// is told to REFUTE and to default to refuted when uncertain, because a +// verifier that sets out to agree always does. Those are the two habits worth +// teaching, and a starter plan that merely demonstrated the JSON would teach +// neither. +// +// IT IS SHADOWED, never authoritative. A user or project plan of the same name +// wins, so shipping this can never override something someone wrote. + +//go:embed plans/*.json +var builtinPlanFS embed.FS + +// builtinPlans returns the plans compiled into the binary. +// +// A parse failure here is a BUILD defect, not a user's problem — these files +// ship with the binary — so an unreadable one is dropped rather than reported +// as a broken plan the user might go looking for. The test that parses every +// bundled plan is what stops one shipping broken. +func builtinPlans() []SavedPlan { + entries, err := builtinPlanFS.ReadDir("plans") + if err != nil { + return nil + } + var out []SavedPlan + for _, entry := range entries { + if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), planFileExt) { + continue + } + name := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())) + if !validPlanName(name) { + continue + } + raw, err := builtinPlanFS.ReadFile("plans/" + entry.Name()) + if err != nil { + continue + } + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + continue + } + out = append(out, SavedPlan{ + Name: name, + Description: planString(args, "description"), + TaskCount: savedTaskCount(args), + Path: "(bundled)", + Scope: PlanScopeBuiltin, + Args: args, + }) + } + return out +} diff --git a/internal/specialist/plan_store.go b/internal/specialist/plan_store.go index 0c3005ae1..fe9b42d7f 100644 --- a/internal/specialist/plan_store.go +++ b/internal/specialist/plan_store.go @@ -56,6 +56,21 @@ func DefaultPlanPaths(workspaceRoot, userConfigDir string) PlanPaths { return paths } +// PlanScope says where a saved plan came from. An ENUM rather than the bool it +// started as: there are three origins now, and "not project" would have meant +// both "the user's" and "shipped with the binary" — two things a listing has to +// tell apart. +type PlanScope string + +const ( + // PlanScopeBuiltin is bundled with the binary. Always shadowed. + PlanScopeBuiltin PlanScope = "builtin" + // PlanScopeUser is the user config directory. + PlanScopeUser PlanScope = "user" + // PlanScopeProject is .zero/plans in the workspace, checked in and shared. + PlanScopeProject PlanScope = "project" +) + // SavedPlan is a stored plan as found on disk. Args is the raw argument map, // not a Plan: it becomes a Plan only by going through ParsePlan. type SavedPlan struct { @@ -63,10 +78,13 @@ type SavedPlan struct { Description string TaskCount int Path string - Project bool + Scope PlanScope Args map[string]any } +// Project reports whether this plan came from the workspace. +func (plan SavedPlan) Project() bool { return plan.Scope == PlanScopeProject } + // validPlanName is an ALLOW-LIST, and it is the path guard as well as the name // guard: no separator, no dot, no traversal component can be spelled with these // characters, so "../../etc/passwd" is refused by the same rule that refuses a @@ -143,7 +161,12 @@ func refuseSymlink(path string) error { // not read — malformed data is reported, never silently skipped. func LoadPlans(paths PlanPaths) (plans []SavedPlan, problems []string) { byName := map[string]SavedPlan{} - // User first so a project plan of the same name overwrites it. + // BUILTIN FIRST, so anything on disk shadows it: a bundled plan is an + // example, never an override of something a user wrote. + for _, plan := range builtinPlans() { + byName[plan.Name] = plan + } + // Then user, then project, so a project plan of the same name wins. for _, dir := range []string{paths.UserDir, paths.ProjectDir} { if strings.TrimSpace(dir) == "" { continue @@ -192,12 +215,16 @@ func loadPlanDir(dir string, project bool) (plans []SavedPlan, problems []string problems = append(problems, fmt.Sprintf("%s: %v", path, err)) continue } + scope := PlanScopeUser + if project { + scope = PlanScopeProject + } plans = append(plans, SavedPlan{ Name: name, Description: planString(args, "description"), TaskCount: savedTaskCount(args), Path: path, - Project: project, + Scope: scope, Args: args, }) } diff --git a/internal/specialist/plan_store_test.go b/internal/specialist/plan_store_test.go index 977d9632b..15a7253a5 100644 --- a/internal/specialist/plan_store_test.go +++ b/internal/specialist/plan_store_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/Gitlawb/zero/internal/config" "github.com/Gitlawb/zero/internal/tools" ) @@ -90,17 +91,18 @@ func TestSavedPlansAreWrittenAndListedByScope(t *testing.T) { if len(problems) != 0 { t.Fatalf("unexpected problems: %v", problems) } + plans = onlyOnDisk(plans) if len(plans) != 2 { - t.Fatalf("loaded %d plans, want 2", len(plans)) + t.Fatalf("loaded %d on-disk plans, want 2", len(plans)) } byName := map[string]SavedPlan{} for _, plan := range plans { byName[plan.Name] = plan } - if !byName["sweep"].Project { + if !byName["sweep"].Project() { t.Fatal("the project plan is not marked as one") } - if byName["personal"].Project { + if byName["personal"].Project() { t.Fatal("the user plan is marked as a project plan") } if byName["sweep"].TaskCount != 3 { @@ -129,8 +131,8 @@ func TestAProjectPlanShadowsAUserPlanOfTheSameName(t *testing.T) { if err != nil { t.Fatalf("FindSavedPlan: %v", err) } - if !found.Project || found.TaskCount != 2 { - t.Fatalf("the user plan won: project=%v tasks=%d", found.Project, found.TaskCount) + if !found.Project() || found.TaskCount != 2 { + t.Fatalf("the user plan won: project=%v tasks=%d", found.Project(), found.TaskCount) } } @@ -213,8 +215,8 @@ func TestLoadingRefusesASymlinkedPlanAndSaysSo(t *testing.T) { } plans, problems := LoadPlans(PlanPaths{ProjectDir: dir}) - if len(plans) != 0 { - t.Fatalf("a symlinked plan was loaded: %+v", plans) + if got := onlyOnDisk(plans); len(got) != 0 { + t.Fatalf("a symlinked plan was loaded: %+v", got) } if len(problems) != 1 || !strings.Contains(problems[0], "symlink") { t.Fatalf("the refusal must be reported, not silent: %v", problems) @@ -232,8 +234,8 @@ func TestAMalformedPlanFileIsReportedByName(t *testing.T) { t.Fatal(err) } plans, problems := LoadPlans(PlanPaths{ProjectDir: dir}) - if len(plans) != 0 { - t.Fatalf("a malformed file produced a plan: %+v", plans) + if got := onlyOnDisk(plans); len(got) != 0 { + t.Fatalf("a malformed file produced a plan: %+v", got) } if len(problems) != 1 || !strings.Contains(problems[0], "broken.json") { t.Fatalf("the problem must name the file: %v", problems) @@ -356,3 +358,94 @@ func TestASavedPlanCannotRunWithThePostureOff(t *testing.T) { t.Fatalf("a saved plan ran with the posture off: %+v", result) } } + +// onlyOnDisk drops the bundled plans, which every load now includes. Written as +// a filter rather than by adjusting the expected counts so a test that means +// "nothing was written" keeps saying that rather than "one thing was". +func onlyOnDisk(plans []SavedPlan) []SavedPlan { + out := make([]SavedPlan, 0, len(plans)) + for _, plan := range plans { + if plan.Scope != PlanScopeBuiltin { + out = append(out, plan) + } + } + return out +} + +// THE BUNDLED PLAN MUST ACTUALLY ADMIT. A shipped example that does not parse is +// worse than none: it is the first thing anyone tries, and it would teach that +// the format does not work. +func TestEveryBundledPlanAdmits(t *testing.T) { + bundled := builtinPlans() + if len(bundled) == 0 { + t.Fatal("no plans are bundled with the binary") + } + for _, plan := range bundled { + admitted, err := ParsePlan(plan.Args, Limits{ + MaxTasks: 20, ParentTools: PlanReadOnlyToolNames()}) + if err != nil { + t.Errorf("bundled plan %q does not admit: %v", plan.Name, err) + continue + } + if admitted.TaskCount() != plan.TaskCount { + t.Errorf("%q: listing says %d tasks, the plan has %d", + plan.Name, plan.TaskCount, admitted.TaskCount()) + } + if strings.TrimSpace(plan.Description) == "" { + t.Errorf("bundled plan %q has no description; the listing is where it is discovered", plan.Name) + } + if plan.Scope != PlanScopeBuiltin { + t.Errorf("bundled plan %q is scoped %q", plan.Name, plan.Scope) + } + } +} + +// It has to fit the SMALLEST tier, or the shipped example is unusable for +// exactly the users who set the tightest ceiling. +func TestTheBundledPlansFitTheSmallestTier(t *testing.T) { + for _, plan := range builtinPlans() { + if _, err := ParsePlan(plan.Args, Limits{ + MaxTasks: config.PlanSizeSmall.MaxTasks(), ParentTools: PlanReadOnlyToolNames()}); err != nil { + t.Errorf("bundled plan %q does not fit the small tier: %v", plan.Name, err) + } + } +} + +// A BUNDLED PLAN IS AN EXAMPLE, NEVER AN OVERRIDE. Anything on disk with the +// same name wins, or shipping a new example could silently replace something +// someone wrote. +func TestABundledPlanIsShadowedByOneOnDisk(t *testing.T) { + bundled := builtinPlans() + if len(bundled) == 0 { + t.Skip("nothing bundled") + } + name := bundled[0].Name + dir := filepath.Join(t.TempDir(), "plans") + mine := mustPlan(t, []any{task("mine", "my own version")}, okBudget(), readOnlyLimits()) + if _, err := SavePlan(dir, name, mine); err != nil { + t.Fatal(err) + } + + found, err := FindSavedPlan(PlanPaths{UserDir: dir}, name) + if err != nil { + t.Fatal(err) + } + if found.Scope != PlanScopeUser || found.TaskCount != 1 { + t.Fatalf("the bundled plan won: scope=%q tasks=%d", found.Scope, found.TaskCount) + } +} + +// The bundled plan is reachable with NO directories configured at all — that is +// the point of shipping it in the binary. +func TestTheBundledPlanIsAvailableWithNoDirectories(t *testing.T) { + plans, problems := LoadPlans(PlanPaths{}) + if len(problems) != 0 { + t.Fatalf("problems: %v", problems) + } + if len(plans) == 0 { + t.Fatal("no plans are available without configured directories") + } + if _, err := FindSavedPlan(PlanPaths{}, plans[0].Name); err != nil { + t.Fatalf("the bundled plan is not findable: %v", err) + } +} diff --git a/internal/specialist/plans/research.json b/internal/specialist/plans/research.json new file mode 100644 index 000000000..9bd41ad12 --- /dev/null +++ b/internal/specialist/plans/research.json @@ -0,0 +1,37 @@ +{ + "name": "research", + "description": "Answer a question by searching several ways at once, then checking the answer against the code rather than against itself.", + "tasks": [ + { + "id": "by_name", + "phase": "search", + "prompt": "Find where THE SUBJECT is DEFINED. Search by identifier: type names, function names, constants, struct fields. Report every definition site as file:line with a one-line description. If you find nothing, say so plainly — a wrong guess is worse than an empty result." + }, + { + "id": "by_use", + "phase": "search", + "prompt": "Find where THE SUBJECT is USED. Search by call site, not by definition: who calls it, who reads it, who writes it, what configures it. Report every site as file:line. Independent of the definition search on purpose — one search angle finds one kind of thing." + }, + { + "id": "by_test", + "phase": "search", + "prompt": "Find what TESTS THE SUBJECT. Report each test as file:line with what it actually asserts, and name any that assert nothing meaningful. A behaviour with no test is a finding; say so." + }, + { + "id": "synthesise", + "phase": "answer", + "depends_on": ["by_name", "by_use", "by_test"], + "prompt": "Using the three searches above, answer the question. State the answer first, then the file:line evidence for each claim. Where the three searches disagree, say which one you believe and why — do not average them." + }, + { + "id": "refute", + "phase": "verify", + "depends_on": ["synthesise"], + "prompt": "Try to REFUTE the answer above. Default to refuted if uncertain. For each claim: open the cited file:line and check the claim is what the code actually does, look for a second call path that behaves differently, and look for a case the answer does not cover. Report each claim as VERIFIED with the line that proves it, or REFUTED with the line that disproves it. Refuting your own side's answer is the job; agreeing is not." + } + ], + "budget": { + "max_workers": 1, + "max_retries": 1 + } +} diff --git a/internal/tui/orchestrate_saved.go b/internal/tui/orchestrate_saved.go index 900abf10e..1a29b0cad 100644 --- a/internal/tui/orchestrate_saved.go +++ b/internal/tui/orchestrate_saved.go @@ -141,17 +141,22 @@ func (m model) savedPlansText() string { return planControlNotice("info", "No saved plans.\nRun a plan, then keep it with /plans save .") } + // The BUNDLED plans do not count as "yours". A listing showing only the + // shipped example while saying nothing about saving would read as though + // the user already had plans of their own. + saved := 0 + for _, plan := range plans { + if plan.Scope != specialist.PlanScopeBuiltin { + saved++ + } + } var b strings.Builder b.WriteString("Plans\nstatus: info\n") if len(plans) == 0 { b.WriteString("No readable saved plans.") } for _, plan := range plans { - scope := "user" - if plan.Project { - scope = "project" - } - fmt.Fprintf(&b, " %-20s %2d tasks %s", plan.Name, plan.TaskCount, scope) + fmt.Fprintf(&b, " %-20s %2d tasks %-8s", plan.Name, plan.TaskCount, plan.Scope) if plan.Description != "" { b.WriteString(" · " + plan.Description) } @@ -166,6 +171,9 @@ func (m model) savedPlansText() string { b.WriteString(" " + problem + "\n") } } + if saved == 0 { + b.WriteString("\nNothing saved yet — run a plan, then keep it with /plans save .") + } b.WriteString("\n/plans show to read one · /plans run to run it") return strings.TrimRight(b.String(), "\n") } diff --git a/internal/tui/orchestrate_saved_test.go b/internal/tui/orchestrate_saved_test.go index 5a6d4770c..7c8485f08 100644 --- a/internal/tui/orchestrate_saved_test.go +++ b/internal/tui/orchestrate_saved_test.go @@ -59,8 +59,11 @@ func TestSavingWithNoPlanRefuses(t *testing.T) { if !strings.Contains(text, "status: warning") || !strings.Contains(text, "nothing to save") { t.Fatalf("save must refuse with a reason: %s", text) } - if plans, _ := specialist.LoadPlans(paths); len(plans) != 0 { - t.Fatalf("a file was written anyway: %+v", plans) + // The bundled plans are always present; nothing must have been WRITTEN. + for _, plan := range mustLoad(t, paths) { + if plan.Scope != specialist.PlanScopeBuiltin { + t.Fatalf("a file was written anyway: %+v", plan) + } } } @@ -104,9 +107,14 @@ func TestListingShowsScopeAndUnreadableFiles(t *testing.T) { func TestListingWithNothingSavedSaysHowToSave(t *testing.T) { m, _ := savedPlanModel(t) + // The bundled example is listed, but it is not the user's — the listing must + // still say how to save one, or it reads as though they already have some. text := m.savedPlansText() - if !strings.Contains(text, "No saved plans") || !strings.Contains(text, "/plans save") { - t.Fatalf("an empty listing must say how to fill it: %s", text) + if !strings.Contains(text, "Nothing saved yet") || !strings.Contains(text, "/plans save") { + t.Fatalf("a listing with only bundled plans must say how to fill it: %s", text) + } + if !strings.Contains(text, "builtin") { + t.Fatalf("the bundled plan must be listed and labelled: %s", text) } } @@ -311,3 +319,12 @@ func TestBareResumeStillUnpausesTheRunningPlan(t *testing.T) { t.Fatalf("bare resume said something else: %s", transcriptText(updated.(model).transcript)) } } + +func mustLoad(t *testing.T, paths specialist.PlanPaths) []specialist.SavedPlan { + t.Helper() + plans, problems := specialist.LoadPlans(paths) + if len(problems) != 0 { + t.Fatalf("problems loading plans: %v", problems) + } + return plans +} From 5c3649397f83072bf2231202ddad8d9bbae23991 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:28:18 +0530 Subject: [PATCH 41/86] fix(cli): the model was never told the orchestrate tool exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by the user running the binary: the posture turned on and nothing orchestrated. AgentOptions.OrchestrateAvailable was declared, documented, and consumed by the reminder selector — and set by NEITHER production call site. So the enter notice that names the tool could not fire, in either surface, ever. The tool was advertised in the tool list and the conversation never mentioned it, and a model handed an unfamiliar tool it was never told about does not reach for it. This is invariant 1 for the SECOND time in this feature — the same shape as ParentTools, which was likewise declared, forwarded, consumed by two guards, and populated at neither site. Both were found the same way, and it was not by a test: a unit test on zeromaxingReminders passes the flag in itself, so it stayed green for the whole period the field was set nowhere. Derived from the registry rather than passed as a flag, and set ONCE in the TUI rather than beside each of the three places it assigns Zeromaxing. A bool a caller has to remember is how this happened; the registry is the thing that actually knows. ALSO FIXED, a regression from b2f22d2: adding the `saved` argument dropped both tasks and budget from Required, which emits no "required" key at all — so the model was handed a tool whose every argument looked optional. tools.Schema has no oneOf, so the constraint that is actually true, "tasks and budget, or saved, never neither", cannot be spelled there. It is spelled in Description, which is what a model reads for intent, along with when the tool is worth using at all. Four mutations, all caught, including the two that ARE the reported bug — exec not setting it, and the TUI not setting it. They are caught by driving Run() to the wire and by capturing the tui.Options the TUI is handed, because those are the only levels at which this was ever visible. --- internal/cli/app.go | 38 ++++- internal/cli/exec.go | 3 + internal/cli/orchestrate_notice_test.go | 193 ++++++++++++++++++++++++ internal/specialist/plan_tool.go | 22 ++- 4 files changed, 244 insertions(+), 12 deletions(-) create mode 100644 internal/cli/orchestrate_notice_test.go diff --git a/internal/cli/app.go b/internal/cli/app.go index ca6fc0171..91f828cd9 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -892,13 +892,19 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a MaxTurns: resolved.MaxTurns, Registry: registry, PermissionMode: permissionMode, - Autonomy: "low", - Sandbox: sandboxEngine, - FileTracker: fileTracker, - Hooks: hookDispatcher, - DeferThreshold: resolved.Tools.DeferThreshold, - Specialists: specialistRuntime.specialists, - Skills: pluginActivation.skillInfos(deps.skillsDir()), + // Set ONCE, here, rather than beside each of the three places the + // TUI assigns Zeromaxing: the registry is built per session and does + // not change per run, and three assignments of one fact is three + // chances for the next one to be forgotten — which is precisely how + // this field came to be set nowhere at all. + OrchestrateAvailable: orchestrateAvailable(registry), + Autonomy: "low", + Sandbox: sandboxEngine, + FileTracker: fileTracker, + Hooks: hookDispatcher, + DeferThreshold: resolved.Tools.DeferThreshold, + Specialists: specialistRuntime.specialists, + Skills: pluginActivation.skillInfos(deps.skillsDir()), }, // LoadSkills backs /skills and direct / invocation in the TUI. // It resolves against the same merged set (default dir + plugin skill @@ -1523,3 +1529,21 @@ func cachedSkillsLoader(load func() []skills.Skill) func() []skills.Skill { return cached } } + +// orchestrateAvailable reports whether this run actually holds the orchestrate +// tool, for the reminder that names it. +// +// DERIVED FROM THE REGISTRY, never hand-set. The field it feeds was declared, +// documented and consumed by the reminder selector — and set by NEITHER +// production call site, so the notice that tells the model the tool exists +// could not fire. The model was handed an advertised tool it was never told +// about, and did not use it. That is invariant 1 for the second time in this +// feature, and a bool a caller has to remember to pass is how it happened; the +// registry is the thing that actually knows. +func orchestrateAvailable(registry *tools.Registry) bool { + if registry == nil { + return false + } + _, found := registry.Get(specialist.OrchestrateToolName) + return found +} diff --git a/internal/cli/exec.go b/internal/cli/exec.go index 9fae62553..8abea3859 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -734,6 +734,9 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in // the process starts, runs once, exits, so there is no earlier turn for // it to have been active across. Zeromaxing: execZeromaxing(execProfile), + // Whether the enter notice may NAME the orchestrate tool. Derived from + // the registry rather than passed as a flag — see orchestrateAvailable. + OrchestrateAvailable: orchestrateAvailable(registry), // Headless exec: don't accept a no-tool-call turn as "done" while work // clearly remains (pending plan items / a mid-step continuation cue) — // nudge to continue, and finalize as INCOMPLETE rather than false success diff --git a/internal/cli/orchestrate_notice_test.go b/internal/cli/orchestrate_notice_test.go new file mode 100644 index 000000000..3b092359b --- /dev/null +++ b/internal/cli/orchestrate_notice_test.go @@ -0,0 +1,193 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/mcp" + "github.com/Gitlawb/zero/internal/tools" + "github.com/Gitlawb/zero/internal/tui" + "github.com/Gitlawb/zero/internal/zeroruntime" +) + +// The orchestrate notice, wired at both surfaces. +// +// AgentOptions.OrchestrateAvailable was declared, documented and consumed by the +// reminder selector — and set by NEITHER production call site. So the tool was +// advertised in the tool list and the model was never told it existed, which is +// exactly what a user found by running the binary: the posture turned on and +// nothing orchestrated. +// +// A unit test on zeromaxingReminders PASSED throughout that whole period, +// because it passes the flag in itself. So these tests drive the two +// constructions instead — Run() to the wire for exec, and the captured +// tui.Options for the TUI — which is the only level at which the defect was +// visible. Invariant 1, for the second time in this feature. + +// EXEC: the notice has to reach the actual request body. +func TestExecTellsTheModelAboutOrchestrate(t *testing.T) { + clearProviderEnv(t) + root := t.TempDir() + configDir := filepath.Join(root, ".zero") + if err := os.MkdirAll(configDir, 0o700); err != nil { + t.Fatal(err) + } + + var sent []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + if sent == nil { + sent = body + } + _, _ = io.WriteString(w, `data: {"choices":[{"delta":{"content":"ok"}}]}`+"\n\n") + _, _ = io.WriteString(w, "data: [DONE]\n\n") + })) + defer server.Close() + + providerConfig := `{ + "activeProvider": "local", + "providers": [{ + "name": "local", + "provider_kind": "openai-compatible", + "base_url": "` + server.URL + `", + "api_key": "sk-local", + "model": "local-model" + }] + }` + if err := os.WriteFile(filepath.Join(configDir, "config.json"), []byte(providerConfig), 0o600); err != nil { + t.Fatal(err) + } + + run := func(args ...string) string { + sent = nil + var stdout, stderr bytes.Buffer + Run(append([]string{"exec", "--cwd", root}, args...), &stdout, &stderr) + return string(sent) + } + + // POSTURE ON, specialist tools registered: the model is TOLD. + on := run("--reasoning-effort", "zeromaxing", "--auto", "high", "look at this") + if !strings.Contains(on, "orchestrate tool") { + t.Fatalf("the model was never told the orchestrate tool exists:\n%s", firstKB(on)) + } + // ...and the tool really is advertised, so the notice is not a promise the + // run cannot keep. + if !advertisesOrchestrate(t, on) { + t.Fatalf("the notice names a tool this run does not advertise:\n%s", firstKB(on)) + } + + // POSTURE OFF: neither the notice nor the tool. This is the direction that + // makes the first half mean something — a notice emitted unconditionally + // would satisfy the assertion above. + off := run("--auto", "high", "look at this") + if strings.Contains(off, "orchestrate") { + t.Fatalf("orchestrate leaked into a posture-off run:\n%s", firstKB(off)) + } + + // POSTURE ON but at an autonomy that registers NO specialist tools: the + // notice must not fire, or it promises a capability this run does not have. + unavailable := run("--reasoning-effort", "zeromaxing", "--auto", "low", "look at this") + if advertisesOrchestrate(t, unavailable) { + t.Skip("this autonomy level registers orchestrate after all; the case cannot be exercised here") + } + if strings.Contains(unavailable, "orchestrate tool") { + t.Fatalf("the notice fired for a run that does not hold the tool:\n%s", firstKB(unavailable)) + } +} + +func advertisesOrchestrate(t *testing.T, body string) bool { + t.Helper() + var request struct { + Tools []struct { + Function struct { + Name string `json:"name"` + } `json:"function"` + } `json:"tools"` + } + if json.Unmarshal([]byte(body), &request) != nil { + return false + } + for _, tool := range request.Tools { + if tool.Function.Name == "orchestrate" { + return true + } + } + return false +} + +func firstKB(body string) string { + if len(body) > 1200 { + return body[:1200] + } + return body +} + +// TUI: the same fact, at the only seam a test can reach. The TUI cannot be +// driven headlessly, so this asserts what it is HANDED — which is where the +// field was missing. +func TestTheTUIIsHandedTheOrchestrateAvailability(t *testing.T) { + var stdout, stderr bytes.Buffer + cwd := t.TempDir() + setCLIUserConfigRoot(t) + userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json") + var launched tui.Options + + exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{ + getwd: func() (string, error) { return cwd, nil }, + resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) { + return config.ResolvedConfig{ + MaxTurns: 12, + ActiveProvider: "local", + Provider: config.ProviderProfile{ + Name: "local", ProviderKind: config.ProviderKindOpenAICompatible, + BaseURL: "http://127.0.0.1/v1", Model: "m", + }, + }, nil + }, + newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { return nil, nil }, + userConfigPath: func() (string, error) { return userConfigPath, nil }, + registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) { + return noopMCPRuntime{}, nil + }, + runTUI: func(_ context.Context, options tui.Options) int { + launched = options + return 3 + }, + }) + if exitCode != 3 { + t.Fatalf("exit = %d, want 3: %s", exitCode, stderr.String()) + } + + // The TUI always registers specialist tools, so it always holds orchestrate; + // the flag must say so, or the notice can never fire in the surface where + // the posture is most used. + if !launched.AgentOptions.OrchestrateAvailable { + t.Fatal("the TUI was handed OrchestrateAvailable=false while holding the tool, so the model is never told it exists") + } + // ...and it agrees with the registry it was handed, rather than being a + // constant that happens to read true. + if _, found := launched.AgentOptions.Registry.Get("orchestrate"); !found { + t.Fatal("the TUI's registry does not hold orchestrate, so the flag is wrong in the other direction") + } +} + +// The derivation itself: false when the tool is absent, so the flag can never +// promise a capability a run does not have. +func TestOrchestrateAvailabilityFollowsTheRegistry(t *testing.T) { + if orchestrateAvailable(nil) { + t.Fatal("a nil registry reported the tool available") + } + empty := tools.NewRegistry() + if orchestrateAvailable(empty) { + t.Fatal("an empty registry reported the tool available") + } +} diff --git a/internal/specialist/plan_tool.go b/internal/specialist/plan_tool.go index b3893d3c7..83e4f33e4 100644 --- a/internal/specialist/plan_tool.go +++ b/internal/specialist/plan_tool.go @@ -71,7 +71,13 @@ func (tool *OrchestrateTool) Name() string { return OrchestrateToolName } func (tool *OrchestrateTool) Description() string { return "Execute a structured plan of read-only sub-agent tasks in dependency order. " + - "Tasks run sequentially; declare dependencies with depends_on so the plan records which work was independent." + "Tasks run sequentially; declare dependencies with depends_on so the plan records which work was independent. " + + // The either/or the schema cannot express, and the shape worth + // encouraging: a plan is only worth more than reading the code yourself + // when its tasks are genuinely independent. + "Supply EITHER tasks and budget, OR saved with the name of a stored plan — never neither. " + + "Use it when a question splits into parts that can be answered independently and then combined; " + + "a single lookup is faster done directly." } // Deferred hides the tool unless the posture is active — as a SECOND layer. @@ -124,10 +130,16 @@ func (tool *OrchestrateTool) Parameters() tools.Schema { Description: "Required. max_workers must be 1 (this phase executes sequentially). max_tokens and max_wall_seconds are optional bounds; omit them to run unbounded — spend is reported either way. max_stall_seconds bounds how long ONE task may emit nothing (default 180); it resets on every event, so a slow-but-working task is never stopped. max_retries (0-3, default 1) is how many extra attempts a STALLED task gets; a task that failed with a real error is never retried.", }, }, - // tasks and budget are no longer unconditionally required: a `saved` - // reference supplies both. ParsePlan still refuses a plan that has - // neither, so the rule is enforced where it can see the resolved - // arguments rather than by a schema that cannot. + // EMPTY, and the either/or lives in the DESCRIPTION instead. + // + // `saved` supplies tasks and budget, so neither is unconditionally + // required any more — but dropping them from Required emits no + // "required" key at all, and the model is then handed a tool whose every + // argument looks optional. tools.Schema has no oneOf, so the constraint + // that is actually true — "tasks and budget, or saved, never neither" — + // cannot be spelled here. It is spelled in Description, which is the + // thing a model reads for intent, and enforced in ParsePlan, which is + // the only place that can see the resolved arguments. Required: []string{}, AdditionalProperties: false, } From ae7a16c01d8723ef347cd2256ffa5e6a15f410db Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:36:04 +0530 Subject: [PATCH 42/86] refactor(specialist): delete the orchestrate limits override, which nothing set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found sweeping for siblings of the OrchestrateAvailable defect, and it is the same family: `OrchestrateTool.Limits *Limits` was read by limits() and set by NOTHING — not a production call site, not a test — so `if tool.Limits != nil` could not be true. It is worse than merely dead. It sat directly beside Size, which is the knob that actually works, so a reader looking for how a plan's caps are configured would find the one that does nothing first. Deleted rather than wired, because nothing needs it: every value it could have overridden is already derived from something real — the tier for the task ceiling, the run's grant for the tools, the run's depth for the headroom check. Wiring it would have been building a second way to set caps that no caller asked for. That is three instances of this family in this feature now: ParentTools (declared, forwarded, consumed, populated nowhere), OrchestrateAvailable (same), and this. The first two were found by running the binary; this one only by looking for them on purpose. --- internal/specialist/plan_tool.go | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/internal/specialist/plan_tool.go b/internal/specialist/plan_tool.go index 83e4f33e4..e091a2d1d 100644 --- a/internal/specialist/plan_tool.go +++ b/internal/specialist/plan_tool.go @@ -51,8 +51,6 @@ type OrchestrateTool struct { // rather than searching nothing and reporting "not found", which would read // as "you never saved it". Plans PlanPaths - // Limits overrides the default caps; nil uses them. - Limits *Limits } const ( @@ -258,7 +256,15 @@ func (tool *OrchestrateTool) resolveSavedPlan(args map[string]any) (map[string]a return stored.Args, nil } -// Limits supplies the hard caps a plan must fit inside. nil means the defaults. +// limits supplies the hard caps a plan must fit inside, DERIVED — there is no +// override field. +// +// There was one: a `Limits *Limits` that nothing ever set, in production or in +// a test, so `if tool.Limits != nil` could not be true. It was found sweeping +// for siblings of the OrchestrateAvailable defect and it is the same family — +// a knob a reader would trust, sitting next to Size, which is the knob that +// actually works. Deleted rather than wired: nothing needs it, because every +// value it could have overridden is already derived from something real. // // The task ceiling comes from the CONFIGURED TIER rather than a constant here. // It was a hard-coded 20 with no way to move it: too many for a metered @@ -277,9 +283,6 @@ func (tool *OrchestrateTool) limits(options tools.RunOptions) Limits { CurrentDepth: tool.Depth, ParentTools: tool.ParentTools, } - if tool.Limits != nil { - limits = *tool.Limits - } return limits } From 8ce0d894ab7ec462d405a25add6861cba9474c3a Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:46:21 +0530 Subject: [PATCH 43/86] =?UTF-8?q?feat(tui):=20/plans=20restart=20runs=20th?= =?UTF-8?q?e=20last=20plan=20from=20the=20beginning=20(gap=20report=20?= =?UTF-8?q?=C2=A75.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last of §5.5. It was left out of the control commit deliberately, because restarting needs the plan's ARGUMENTS and nothing stored them; named plans (b2f22d2) made the bridge keep them, so this is now a thin layer rather than a reconstruction. It stages the plan the BRIDGE holds, not the panel's rendering of it. The panel has ids, statuses and depths — restarting from that would run something that merely resembles what ran. Staged as a saved plan rather than replayed as a tool call, so `/plans show last_run` reads exactly what is about to happen and it travels the one execution path every other plan travels. RESTART AND RESUME ARE OPPOSITES and the test asserts them together, because the whole risk is that one silently becomes the other: restart ignores the session's recorded progress entirely, resume honours it. Refused while a plan is running. Two plans reporting into one panel keyed by task id means the second overwrites the first's rows, so the rule is explicit rather than letting the UI silently pick one. Four mutations. Two initially read as MISSED and were bad mutations, not missing tests — one was inert, and the other hit the FIRST `m.planProgress.LastPlan()` in the file, which belongs to savePlanText. That is the same wrong-occurrence trap this project hit once before; re-targeted at the last occurrence, both CAUGHT. --- internal/tui/commands.go | 2 +- internal/tui/orchestrate_control.go | 2 +- internal/tui/orchestrate_saved.go | 65 +++++++++++++++ internal/tui/orchestrate_saved_test.go | 105 +++++++++++++++++++++++++ 4 files changed, 172 insertions(+), 2 deletions(-) diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 08da276e9..f93bc3906 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -120,7 +120,7 @@ var commandDefinitions = []commandDefinition{ }, { name: "/plans", - usage: "/plans [stop|pause|save |list|show |run |resume ]", + usage: "/plans [stop|pause|restart|save |list|show |run |resume ]", group: commandGroupSession, // Deliberately close to /plan, and they are different things: /plan is // planning-mode status (the update_plan TODO list), /plans is the diff --git a/internal/tui/orchestrate_control.go b/internal/tui/orchestrate_control.go index 254910f8a..879d3a3cc 100644 --- a/internal/tui/orchestrate_control.go +++ b/internal/tui/orchestrate_control.go @@ -69,7 +69,7 @@ func (m model) orchestrateControlText(args string) string { return planControlNotice("warning", "Unknown: /plans "+verb+"\nUse /plans on its own for the task graph, "+ "/plans stop | pause | resume for the running plan, "+ - "or /plans save | list | show | run for saved ones.") + "or /plans save | list | show | run | restart for saved ones.") } } diff --git a/internal/tui/orchestrate_saved.go b/internal/tui/orchestrate_saved.go index 1a29b0cad..b23ad82da 100644 --- a/internal/tui/orchestrate_saved.go +++ b/internal/tui/orchestrate_saved.go @@ -37,6 +37,8 @@ func (m model) handlePlansCommand(args string) (tea.Model, tea.Cmd) { return m.appendPlansNotice(m.showSavedPlanText(rest)), nil case "run": return m.runSavedPlan(rest, false) + case "restart": + return m.restartLastPlan() case "resume": // TWO MEANINGS OF ONE WORD, split by arity, and they are the same // intention at two scales: continue what was interrupted. @@ -269,6 +271,69 @@ func (m model) runSavedPlan(name string, resume bool) (tea.Model, tea.Cmd) { }) } +// restartLastPlan runs the plan that last ran, from the beginning. +// +// It stages the plan the BRIDGE holds, not the panel's rendering of it — the +// panel has ids, statuses and depths, which would restart something that merely +// resembled what ran. Staging it as a saved plan rather than replaying the +// original tool call is what makes restart inspectable: /plans show last_run +// reads exactly what is about to happen, and it travels the one execution path +// every other plan travels. +// +// A FIXED name, overwritten each time, for the same reason the resume staging +// uses one: a directory of last_run_1, _2, _3 is a directory nobody can tell +// apart, and only the newest is ever the right one. +func (m model) restartLastPlan() (tea.Model, tea.Cmd) { + args, planName, ok := m.planProgress.LastPlan() + if !ok { + return m.appendPlansNotice(planControlNotice("warning", + "No plan has run this session, so there is nothing to restart. "+ + "/plans list shows what is saved, and /plans run starts one.")), nil + } + if m.planProgress.PlanRunningNow() { + // Restarting under a running plan would leave two plans reporting into + // one panel, and the panel is keyed by task id — the second would + // overwrite the first's rows. Stop it first, deliberately, rather than + // having the UI silently pick one. + return m.appendPlansNotice(planControlNotice("warning", + "A plan is still running. Stop it with /plans stop first, then restart.")), nil + } + plan, err := specialist.ParsePlan(args, m.savedPlanLimits()) + if err != nil { + return m.appendPlansNotice(planControlNotice("warning", + "That plan no longer validates, so it was not restarted: "+err.Error())), nil + } + dir := m.planPaths.ProjectDir + if dir == "" { + dir = m.planPaths.UserDir + } + if dir == "" { + return m.appendPlansNotice(planControlNotice("warning", + "There is nowhere to stage the plan in this run.")), nil + } + if _, err := specialist.SavePlan(dir, restartPlanName, plan); err != nil { + return m.appendPlansNotice(planControlNotice("warning", "Could not stage the plan: "+err.Error())), nil + } + label := planName + if label == "" { + label = "the last plan" + } + m = m.appendPlansNotice(planControlNotice("info", fmt.Sprintf( + "Restarting %s from the beginning (%d tasks). Staged as %q — /plans show %s to read it first.", + label, plan.TaskCount(), restartPlanName, restartPlanName))) + encoded, err := json.Marshal(restartPlanName) + if err != nil { + return m.appendPlansNotice(planControlNotice("warning", "Could not reference that plan: "+err.Error())), nil + } + return m.dispatchCommand(parsedCommand{ + kind: commandPrompt, + text: "Run the saved plan " + string(encoded) + " by calling the orchestrate tool with the `saved` argument set to that name. Do not restate its tasks.", + }) +} + +// restartPlanName is where a restart stages the last plan. +const restartPlanName = "last_run" + // resumeSavedPlan narrows a stored plan by the CURRENT session's plan events and // saves the remainder under its own name. // diff --git a/internal/tui/orchestrate_saved_test.go b/internal/tui/orchestrate_saved_test.go index 7c8485f08..1f435f232 100644 --- a/internal/tui/orchestrate_saved_test.go +++ b/internal/tui/orchestrate_saved_test.go @@ -328,3 +328,108 @@ func mustLoad(t *testing.T, paths specialist.PlanPaths) []specialist.SavedPlan { } return plans } + +// RESTART runs the last plan from the beginning, staged from the BRIDGE's copy +// — the panel holds a rendering, and restarting that would run something that +// merely resembles what ran. +func TestRestartStagesTheLastPlanFromTheBeginning(t *testing.T) { + m, paths, plan := resumeModel(t) + m.planProgress.PlanAdmitted(plan) + // A partly-finished run: restart must ignore the progress entirely, which is + // exactly what distinguishes it from resume. + m.planProgress.TaskDispatched(specialist.Task{ID: plan.Order()[0]}) + m.planProgress.TaskCompleted(specialist.TaskResult{ID: plan.Order()[0], Attempts: 1}) + + // The turn itself needs a provider this harness has none of, so what is + // asserted is what restart is responsible for: the staged plan and what the + // user is told. Whether dispatchCommand can start a run is the composer's + // business and is covered where a provider exists. + updated, _ := m.restartLastPlan() + staged, err := specialist.FindSavedPlan(paths, restartPlanName) + if err != nil { + t.Fatalf("nothing was staged: %v", err) + } + restored, err := specialist.ParsePlan(staged.Args, m.savedPlanLimits()) + if err != nil { + t.Fatalf("the staged plan does not validate: %v", err) + } + if restored.TaskCount() != plan.TaskCount() { + t.Fatalf("staged %d tasks, the plan had %d — restart must not narrow", + restored.TaskCount(), plan.TaskCount()) + } + if !strings.Contains(transcriptText(updated.(model).transcript), "from the beginning") { + t.Fatalf("restart must say it starts over: %s", transcriptText(updated.(model).transcript)) + } +} + +// Restart with nothing to restart refuses and points at what does exist. +func TestRestartWithNoPlanRefuses(t *testing.T) { + m, _ := savedPlanModel(t) + updated, cmd := m.restartLastPlan() + if cmd != nil { + t.Fatal("restart started a turn with no plan") + } + text := transcriptText(updated.(model).transcript) + if !strings.Contains(text, "nothing to restart") || !strings.Contains(text, "/plans run") { + t.Fatalf("the refusal must point somewhere: %s", text) + } +} + +// RESTARTING UNDER A RUNNING PLAN IS REFUSED. Two plans reporting into one +// panel keyed by task id means the second overwrites the first's rows, and the +// UI would silently pick one. +func TestRestartRefusesWhileAPlanIsRunning(t *testing.T) { + m, paths, plan := resumeModel(t) + m.planProgress.PlanAdmitted(plan) + _, cancel := context.WithCancel(context.Background()) + defer cancel() + m.planProgress.PlanRunning(cancel) + + updated, cmd := m.restartLastPlan() + if cmd != nil { + t.Fatal("restart started a second plan over a running one") + } + if !strings.Contains(transcriptText(updated.(model).transcript), "/plans stop") { + t.Fatalf("the refusal must say how to proceed: %s", transcriptText(updated.(model).transcript)) + } + if _, err := specialist.FindSavedPlan(paths, restartPlanName); err == nil { + t.Fatal("a refused restart staged a plan anyway") + } +} + +// Restart and resume are DIFFERENT: one ignores progress, the other honours it. +// Asserted together because the whole risk is that one silently becomes the +// other. +func TestRestartIgnoresProgressWhereResumeHonoursIt(t *testing.T) { + m, paths, plan := resumeModel(t) + if _, err := specialist.SavePlan(paths.ProjectDir, "sweep", plan); err != nil { + t.Fatal(err) + } + m.planProgress.PlanAdmitted(plan) + for _, id := range plan.Order()[:len(plan.Order())-1] { + m.planProgress.TaskDispatched(specialist.Task{ID: id}) + m.planProgress.TaskCompleted(specialist.TaskResult{ID: id, Attempts: 1}) + } + + m.restartLastPlan() + restarted, err := specialist.FindSavedPlan(paths, restartPlanName) + if err != nil { + t.Fatalf("restart staged nothing: %v", err) + } + full, _ := specialist.ParsePlan(restarted.Args, m.savedPlanLimits()) + + stored, _ := specialist.FindSavedPlan(paths, "sweep") + resumeName, _, ok := m.resumeSavedPlan(stored) + if !ok { + t.Fatal("resume refused") + } + resumed, _ := specialist.FindSavedPlan(paths, resumeName) + narrowed, _ := specialist.ParsePlan(resumed.Args, m.savedPlanLimits()) + + if full.TaskCount() != plan.TaskCount() { + t.Fatalf("restart narrowed the plan: %d of %d", full.TaskCount(), plan.TaskCount()) + } + if narrowed.TaskCount() >= full.TaskCount() { + t.Fatalf("resume did not narrow: %d vs restart's %d", narrowed.TaskCount(), full.TaskCount()) + } +} From f7aeb9bf1fbd2bcaa08f3b3059c9ff0b4bf0d1f9 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:43:13 +0530 Subject: [PATCH 44/86] =?UTF-8?q?feat:=20run=20a=20plan=20in=20the=20backg?= =?UTF-8?q?round=20and=20report=20it=20on=20a=20later=20turn=20(gap=20repo?= =?UTF-8?q?rt=20=C2=A75.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opt-in via `background: true`, default off, so nothing changes unless asked. THE CONTRACT IS INJECTION, of the three the report offered. The agent loop already drains post-edit diagnostics into the conversation tail as a user-role message — background work reporting into a later turn has a channel here, and a second one would be a second ordering and a second thing to budget. The completion goes down the same path, at the same point in the turn. THE SEAM IS A LAUNCHER, NOT A CONTEXT. A background plan must outlive its tool call and must not outlive its session, and only the surface running the session knows where that ends. So the surface supplies a launcher, keeps the context and drains on exit; the tool asks to be run and is told yes or no. Headless exec's refusal then falls out of the wiring rather than being special-cased: that process exits when the turn ends, so it supplies no launcher, and the refusal says exactly that. THREE DEFECTS FIXED THAT THE FEATURE WOULD HAVE LANDED ON, all found by sizing it before writing it: 1. Card keys collided. `dispatched` reset on every Attach, safe only while every plan lived inside one run; a background plan still dispatching when the next run attached would restart the counter and hand the new run's tasks keys the old plan was still using. Now monotonic for the bridge's life. 2. Progress was silently dropped. The stale-run guard discards anything whose runID is not the active one — right for a finished run's leftovers, wrong for a plan still working. The panel would have frozen with no error, no card, nothing. The guard is now background-aware in all four handlers, and a test asserts a stale FOREGROUND message is still dropped. 3. The goroutine would have outlived the session. The TUI root was context.Background(), never cancelled. Now a cancellable session root, and Close cancels AND WAITS — returning early would let the process exit while a child was still being torn down, which is how orphans happen. One at a time, by rule. Two background plans report into one panel keyed by task id and the second would overwrite the first's rows; refusing the second is stated, letting the UI silently pick one is a defect. A background plan returns "NOT finished", never a summary. Returning one would be reporting work that has not happened, which is this repo's oldest defect class at the point where it would be least visible. Eighteen mutations. G7 was a genuine miss — the terminal message's background mark, which has to be captured BEFORE the flag is cleared and carried down. Without it a background plan's panel freezes one row from the end: every task done, the plan never closing. It was caught earlier only because the compiler complained an unused variable, which is luckier than it deserved to be, and now has a test. G12 read as a miss and was an inert mutation; re-run as "never marked at all", CAUGHT. Verified in the real binary: headless exec refuses it with the reason. Additivity re-proven, five modes byte-identical. The TUI path needs a user run. --- internal/agent/loop.go | 13 ++ internal/agent/types.go | 7 + internal/cli/app.go | 38 ++++- internal/cli/plan_background.go | 107 +++++++++++++ internal/cli/plan_background_test.go | 190 +++++++++++++++++++++++ internal/specialist/plan.go | 8 + internal/specialist/plan_store_test.go | 130 ++++++++++++++++ internal/specialist/plan_tool.go | 61 ++++++++ internal/tui/model.go | 24 ++- internal/tui/orchestrate_control_test.go | 176 +++++++++++++++++++++ internal/tui/plan_messages.go | 12 ++ internal/tui/plan_progress.go | 106 ++++++++++++- 12 files changed, 854 insertions(+), 18 deletions(-) create mode 100644 internal/cli/plan_background.go create mode 100644 internal/cli/plan_background_test.go diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 4227de086..fe3edaaab 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -280,6 +280,19 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options) }) } + // A BACKGROUND PLAN that finished since the last turn. Same channel and + // the same point in the turn as the diagnostics nudge above: the model + // was told the plan was not finished and must not report it as done, so + // this is the message that makes that promise good. + if options.PlanCompletions != nil { + if finished := options.PlanCompletions(); finished != "" { + messages = append(messages, zeroruntime.Message{ + Role: zeroruntime.MessageRoleUser, + Content: finished, + }) + } + } + // Build the per-turn tool list first so proactive compaction can include // the tool-definition tokens (they ride on every request) in its estimate. // partitionTools depends only on registry/permissions/options/loaded, not on diff --git a/internal/agent/types.go b/internal/agent/types.go index 36904ce53..c8e157e57 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -369,6 +369,13 @@ type Options struct { // separate from Profile: zeromaxing arms no escalation triggers, so // Profile.Policy() returns nil for it and could not carry this. Zeromaxing Zeromaxing + // PlanCompletions, when set, is drained once per turn for background plans + // that finished since the last turn. Its text is appended to the + // conversation TAIL as a user-role message, on the same channel as the + // post-edit diagnostics nudge — background work reporting into a later turn + // already has a channel here, and a second one would be a second ordering + // and a second thing to budget. nil is a no-op. + PlanCompletions func() string // OrchestrateAvailable reports whether the orchestrate tool is actually // advertised this run, so the enter notice names it only when it exists. // Zero value false keeps every existing caller's prompt unchanged. diff --git a/internal/cli/app.go b/internal/cli/app.go index 91f828cd9..9f06ef5c0 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -712,6 +712,18 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a // run by the model — without it the TUI recorded NONE of the five plan // lifecycle events, so a plan ran completely invisibly (audit finding 9). planProgress := tui.NewPlanProgressBridge() + // A CANCELLABLE session root, replacing the context.Background() this used + // to hand the TUI. + // + // Never cancelling it was harmless while nothing outlived a run. A + // background plan does, and under an uncancellable root it would keep + // running — and keep spending — after the session ended. Close() cancels + // AND WAITS, so a plan is stopped and its terminal event written rather + // than the process exiting out from under it. + sessionCtx, cancelSession := context.WithCancel(context.Background()) + defer cancelSession() + planLaunch := newPlanLauncher(sessionCtx, planProgress) + defer planLaunch.Close() // Saved plans, resolved ONCE and handed to both consumers: the orchestrate // tool (which loads a plan named with `saved`) and the TUI (which saves, // lists and shows them). Two computations of the same pair of directories @@ -732,6 +744,9 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a // wiring reads. Project config may only have tightened it. Size: resolved.Profiles.PlanSizeTier(), Plans: planPaths, + // The TUI can carry a background plan: it has a session that + // outlives a turn. Headless exec supplies no launcher. + Launch: planLaunch.Launch, }) if err != nil { return writeAppError(stderr, "failed to initialize specialist tools: "+err.Error(), 1) @@ -834,7 +849,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a // notice when project hooks/plugins were dropped for an untrusted workspace. hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot, executionRunner) emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip) - return deps.runTUI(context.Background(), tui.Options{ + return deps.runTUI(sessionCtx, tui.Options{ Cwd: workspaceRoot, Version: version, Theme: theme, @@ -898,13 +913,16 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a // chances for the next one to be forgotten — which is precisely how // this field came to be set nowhere at all. OrchestrateAvailable: orchestrateAvailable(registry), - Autonomy: "low", - Sandbox: sandboxEngine, - FileTracker: fileTracker, - Hooks: hookDispatcher, - DeferThreshold: resolved.Tools.DeferThreshold, - Specialists: specialistRuntime.specialists, - Skills: pluginActivation.skillInfos(deps.skillsDir()), + // Background plans report into a later turn through this drain, on + // the same channel as the post-edit diagnostics nudge. + PlanCompletions: planProgress.DrainCompletedPlans, + Autonomy: "low", + Sandbox: sandboxEngine, + FileTracker: fileTracker, + Hooks: hookDispatcher, + DeferThreshold: resolved.Tools.DeferThreshold, + Specialists: specialistRuntime.specialists, + Skills: pluginActivation.skillInfos(deps.skillsDir()), }, // LoadSkills backs /skills and direct / invocation in the TUI. // It resolves against the same merged set (default dir + plugin skill @@ -1108,6 +1126,9 @@ type orchestrateWiring struct { // Plans locates saved plans. Empty means a `saved` reference is refused with // a reason rather than searched for in nowhere. Plans specialist.PlanPaths + // Launch runs a plan in the background. nil — the headless default — makes + // a background plan refuse rather than start one nothing can report. + Launch func(run func(ctx context.Context)) bool } // planParentTools is the run's grant: the tools a plan task may inherit. @@ -1188,6 +1209,7 @@ func registerSpecialistTools(registry *tools.Registry, workspaceRoot string, max Depth: planContext.Depth, Size: wiring.Size, Plans: wiring.Plans, + Launch: wiring.Launch, }) return &agentToolRuntime{specialist: runtime, swarm: sw, specialists: specialistSummaries(paths)}, nil } diff --git a/internal/cli/plan_background.go b/internal/cli/plan_background.go new file mode 100644 index 000000000..a48f2ad3e --- /dev/null +++ b/internal/cli/plan_background.go @@ -0,0 +1,107 @@ +package cli + +import ( + "context" + "sync" + + "github.com/Gitlawb/zero/internal/tui" +) + +// The background-plan launcher: who owns a background plan's LIFETIME. +// +// The tool does not, and that is the whole point of the seam. A background plan +// must outlive the tool call that started it and must NOT outlive the session, +// and the only thing that knows where the session ends is the surface that runs +// it. So the surface supplies a launcher, keeps the context, and drains on exit; +// the tool asks to be run and is told yes or no. +// +// It also makes headless exec's refusal fall out rather than be special-cased: +// that process exits when the turn ends, so it supplies no launcher at all, and +// a background plan there is refused with a reason instead of being started and +// orphaned. +// +// ONE AT A TIME, deliberately. The panel is keyed by task id and the bridge +// holds one plan's cancel; two concurrent background plans would report into +// one panel and the second would overwrite the first's rows. Refusing the +// second is a stated rule; letting the UI silently pick one is a defect. + +// planLauncher runs background plans under a session-scoped context. +type planLauncher struct { + mu sync.Mutex + // ctx is the SESSION root, not a tool call's. Cancelling it is what stops a + // background plan at exit — the failure this exists to prevent is a plan + // that outlives its session, spends budget nobody sees, and reports nothing. + ctx context.Context + // running holds the one in-flight plan's cancel; nil means none. + running context.CancelFunc + // closed stops new launches once shutdown has begun. Checked under the same + // lock that starts a plan, so a launch cannot slip past a concurrent Close. + closed bool + // wait lets Close block until the plan actually stops, rather than + // cancelling and exiting while a child process is still being torn down. + wait sync.WaitGroup + // bridge is told a background plan is starting, so every message it posts is + // marked as one and survives the panel's stale-run guard. + bridge *tui.PlanProgressBridge +} + +func newPlanLauncher(ctx context.Context, bridge *tui.PlanProgressBridge) *planLauncher { + return &planLauncher{ctx: ctx, bridge: bridge} +} + +// Launch starts run on its own goroutine and reports whether it did. +// +// False is returned — never a silent no-op — when the session is closing or a +// background plan is already running, so the tool can say which rather than +// handing back a run id for a plan nobody started. +func (launcher *planLauncher) Launch(run func(ctx context.Context)) bool { + if launcher == nil || run == nil { + return false + } + launcher.mu.Lock() + if launcher.closed || launcher.running != nil { + launcher.mu.Unlock() + return false + } + ctx, cancel := context.WithCancel(launcher.ctx) + launcher.running = cancel + launcher.wait.Add(1) + launcher.mu.Unlock() + + launcher.bridge.SetBackground(true) + go func() { + // Every goroutine gets recover(): a panic in a background plan must not + // take down the session it was launched from. + defer func() { _ = recover() }() + defer func() { + cancel() + launcher.mu.Lock() + launcher.running = nil + launcher.mu.Unlock() + launcher.wait.Done() + }() + run(ctx) + }() + return true +} + +// Close stops any background plan and WAITS for it. +// +// Waiting matters: cancelling and returning would let the process exit while a +// child is still being torn down, which is how orphans happen. The recorder's +// terminal event is written by the plan itself on the way out, so the wait is +// also what makes a cancelled background plan land in the session log rather +// than vanish. +func (launcher *planLauncher) Close() { + if launcher == nil { + return + } + launcher.mu.Lock() + launcher.closed = true + cancel := launcher.running + launcher.mu.Unlock() + if cancel != nil { + cancel() + } + launcher.wait.Wait() +} diff --git a/internal/cli/plan_background_test.go b/internal/cli/plan_background_test.go new file mode 100644 index 000000000..ab5cd07d8 --- /dev/null +++ b/internal/cli/plan_background_test.go @@ -0,0 +1,190 @@ +package cli + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/Gitlawb/zero/internal/specialist" + "github.com/Gitlawb/zero/internal/tui" +) + +// A background plan MUST NOT OUTLIVE ITS SESSION. That is the failure this +// whole seam exists to prevent — a plan still spending after the session ended, +// reporting to nobody. +func TestClosingTheLauncherStopsAndWaitsForTheBackgroundPlan(t *testing.T) { + launcher := newPlanLauncher(context.Background(), tui.NewPlanProgressBridge()) + + started := make(chan struct{}) + stopped := make(chan struct{}) + if !launcher.Launch(func(ctx context.Context) { + close(started) + <-ctx.Done() + // A real plan tears a child down here; the sleep stands in for that, and + // Close must not return until it is over. + time.Sleep(50 * time.Millisecond) + close(stopped) + }) { + t.Fatal("Launch refused") + } + <-started + + launcher.Close() + select { + case <-stopped: + default: + t.Fatal("Close returned while the plan was still stopping; that is how a child is orphaned") + } +} + +// ONE AT A TIME. Two background plans report into one panel keyed by task id and +// the second would overwrite the first's rows, so the second is refused rather +// than silently winning. +func TestASecondBackgroundPlanIsRefused(t *testing.T) { + launcher := newPlanLauncher(context.Background(), tui.NewPlanProgressBridge()) + release := make(chan struct{}) + defer close(release) + + if !launcher.Launch(func(context.Context) { <-release }) { + t.Fatal("the first launch was refused") + } + // Give the goroutine a moment to be counted; the refusal is decided under + // the lock at Launch, so this is about the test not the code. + time.Sleep(20 * time.Millisecond) + if launcher.Launch(func(context.Context) {}) { + t.Fatal("a second background plan was launched over a running one") + } +} + +// ...and once the first finishes, the slot frees. +func TestTheSlotFreesWhenABackgroundPlanEnds(t *testing.T) { + launcher := newPlanLauncher(context.Background(), tui.NewPlanProgressBridge()) + done := make(chan struct{}) + if !launcher.Launch(func(context.Context) { close(done) }) { + t.Fatal("the first launch was refused") + } + <-done + + deadline := time.After(3 * time.Second) + for { + if launcher.Launch(func(context.Context) {}) { + return + } + select { + case <-deadline: + t.Fatal("the slot never freed after the plan ended") + case <-time.After(10 * time.Millisecond): + } + } +} + +// A launch after Close must be REFUSED, not started into a cancelled context. +// Starting it would record an admission and a cancellation for a plan the user +// never saw. +func TestLaunchingAfterCloseIsRefused(t *testing.T) { + launcher := newPlanLauncher(context.Background(), tui.NewPlanProgressBridge()) + launcher.Close() + if launcher.Launch(func(context.Context) { t.Error("a plan ran after Close") }) { + t.Fatal("Launch succeeded after Close") + } +} + +// A PANIC IN A BACKGROUND PLAN MUST NOT TAKE THE SESSION DOWN, and must still +// free the slot — a crashed plan that held the slot forever would block every +// later one with "already running". +func TestAPanickingBackgroundPlanIsContainedAndFreesTheSlot(t *testing.T) { + launcher := newPlanLauncher(context.Background(), tui.NewPlanProgressBridge()) + if !launcher.Launch(func(context.Context) { panic("boom") }) { + t.Fatal("Launch refused") + } + + deadline := time.After(3 * time.Second) + for { + if launcher.Launch(func(context.Context) {}) { + return + } + select { + case <-deadline: + t.Fatal("a panicking plan held the slot forever") + case <-time.After(10 * time.Millisecond): + } + } +} + +// Cancelling the SESSION context cancels the plan, which is what makes the +// session root load-bearing rather than decorative. +func TestCancellingTheSessionCancelsTheBackgroundPlan(t *testing.T) { + sessionCtx, cancelSession := context.WithCancel(context.Background()) + launcher := newPlanLauncher(sessionCtx, tui.NewPlanProgressBridge()) + + observed := make(chan error, 1) + if !launcher.Launch(func(ctx context.Context) { + <-ctx.Done() + observed <- ctx.Err() + }) { + t.Fatal("Launch refused") + } + cancelSession() + select { + case err := <-observed: + if err == nil { + t.Fatal("the plan's context was not cancelled") + } + case <-time.After(3 * time.Second): + t.Fatal("cancelling the session did not reach the background plan") + } +} + +// The bridge is told BEFORE the plan runs, so its very first message is already +// marked background. Marking it after the goroutine started would race the +// plan's own admission message, and a dropped admission is a panel that never +// shows the plan at all. +func TestTheBridgeIsMarkedBackgroundBeforeThePlanRuns(t *testing.T) { + bridge := tui.NewPlanProgressBridge() + launcher := newPlanLauncher(context.Background(), bridge) + + var once sync.Once + markedAtStart := make(chan bool, 1) + if !launcher.Launch(func(context.Context) { + once.Do(func() { markedAtStart <- bridge.PlanIsBackground() }) + }) { + t.Fatal("Launch refused") + } + select { + case marked := <-markedAtStart: + if !marked { + t.Fatal("the plan started before the bridge was marked background") + } + case <-time.After(3 * time.Second): + t.Fatal("the plan never ran") + } +} + +// HEADLESS EXEC SUPPLIES NO LAUNCHER, which is what makes its refusal of a +// background plan fall out of the wiring rather than out of a special case. +// Asserted at the registered tool, because a comment saying "exec passes nil" +// is not evidence that exec passes nil. +func TestHeadlessExecSuppliesNoLauncher(t *testing.T) { + workspace := t.TempDir() + registry := newCoreRegistry(workspace) + runtime, err := registerSpecialistTools(registry, workspace, 0, nil, nil, nil, orchestrateWiring{ + Gate: &specialist.PostureGate{}, + }) + if err != nil { + t.Fatalf("registerSpecialistTools: %v", err) + } + t.Cleanup(func() { closeSpecialistRuntime(nil, runtime) }) + + registered, found := registry.Get(specialist.OrchestrateToolName) + if !found { + t.Fatal("orchestrate was not registered") + } + tool, ok := registered.(*specialist.OrchestrateTool) + if !ok { + t.Fatalf("orchestrate is %T", registered) + } + if tool.Launch != nil { + t.Fatal("a run wired without a launcher was given one; a background plan there could never report") + } +} diff --git a/internal/specialist/plan.go b/internal/specialist/plan.go index ba7307c8b..10bd6a127 100644 --- a/internal/specialist/plan.go +++ b/internal/specialist/plan.go @@ -543,3 +543,11 @@ func planIntSet(args map[string]any, key string) (int, bool) { return 0, false } } + +// planBool reads a boolean argument. Absent, or any other type, is false — a +// flag that changes where a plan RUNS must be opted into explicitly, never +// inferred from a value that happened to be there. +func planBool(args map[string]any, key string) bool { + value, _ := args[key].(bool) + return value +} diff --git a/internal/specialist/plan_store_test.go b/internal/specialist/plan_store_test.go index 15a7253a5..199fdf7dd 100644 --- a/internal/specialist/plan_store_test.go +++ b/internal/specialist/plan_store_test.go @@ -1,6 +1,7 @@ package specialist import ( + "context" "encoding/json" "os" "path/filepath" @@ -449,3 +450,132 @@ func TestTheBundledPlanIsAvailableWithNoDirectories(t *testing.T) { t.Fatalf("the bundled plan is not findable: %v", err) } } + +// A BACKGROUND PLAN RETURNS IMMEDIATELY AND SAYS IT IS NOT DONE. Returning a +// summary would be reporting work that has not happened — this repo's oldest +// defect class, at the point where it would be least visible. +func TestABackgroundPlanReturnsWithoutRunningAndSaysSo(t *testing.T) { + var launched func(context.Context) + tool := &OrchestrateTool{ + PostureActive: func() bool { return true }, + RunTask: func(context.Context, PlanTaskRequest) (TaskResult, error) { + t.Fatal("a background plan ran on the tool-call goroutine") + return TaskResult{}, nil + }, + ParentTools: []string{"read_file"}, + Launch: func(run func(context.Context)) bool { launched = run; return true }, + } + result := tool.Run(t.Context(), map[string]any{ + "name": "sweep", + "tasks": []any{task("a", "x")}, + "budget": map[string]any{"max_workers": float64(1)}, + "background": true, + }) + if result.Status != tools.StatusOK { + t.Fatalf("status = %q: %s", result.Status, result.Output) + } + if launched == nil { + t.Fatal("nothing was handed to the launcher") + } + for _, want := range []string{"background", "NOT finished", "later turn"} { + if !strings.Contains(result.Output, want) { + t.Errorf("the result must say %q: %q", want, result.Output) + } + } + if strings.Contains(result.Output, "succeeded") { + t.Fatalf("a background plan reported a result it does not have: %q", result.Output) + } + if result.Meta["plan_status"] != "background" { + t.Fatalf("plan_status = %q", result.Meta["plan_status"]) + } +} + +// NO LAUNCHER MEANS REFUSED, with the reason. A plan started where nothing can +// report it is the background failure mode itself. +func TestABackgroundPlanWithoutALauncherIsRefused(t *testing.T) { + tool := &OrchestrateTool{ + PostureActive: func() bool { return true }, + RunTask: func(context.Context, PlanTaskRequest) (TaskResult, error) { return TaskResult{}, nil }, + ParentTools: []string{"read_file"}, + } + result := tool.Run(t.Context(), map[string]any{ + "tasks": []any{task("a", "x")}, + "budget": map[string]any{"max_workers": float64(1)}, + "background": true, + }) + if result.Status != tools.StatusError { + t.Fatalf("status = %q; a background plan with no launcher must be refused", result.Status) + } + if !strings.Contains(result.Output, "not available in this run") { + t.Fatalf("the refusal must say why: %q", result.Output) + } +} + +// A REFUSED LAUNCH IS REPORTED, not turned into a run id for a plan nobody +// started. +func TestARefusedLaunchIsReportedAsAnError(t *testing.T) { + tool := &OrchestrateTool{ + PostureActive: func() bool { return true }, + RunTask: func(context.Context, PlanTaskRequest) (TaskResult, error) { return TaskResult{}, nil }, + ParentTools: []string{"read_file"}, + Launch: func(func(context.Context)) bool { return false }, + } + result := tool.Run(t.Context(), map[string]any{ + "tasks": []any{task("a", "x")}, + "budget": map[string]any{"max_workers": float64(1)}, + "background": true, + }) + if result.Status != tools.StatusError || !strings.Contains(result.Output, "not started") { + t.Fatalf("a refused launch must be an error saying so: %+v", result) + } +} + +// The launched closure runs the SAME plan through the SAME executor. Asserted by +// running it, because a launcher handed a closure that does nothing would +// satisfy every check above. +func TestTheLaunchedClosureActuallyRunsThePlan(t *testing.T) { + var launched func(context.Context) + ran := map[string]int{} + tool := &OrchestrateTool{ + PostureActive: func() bool { return true }, + RunTask: func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + ran[req.Task.ID]++ + return TaskResult{Outcome: TaskSucceeded}, nil + }, + ParentTools: []string{"read_file"}, + Launch: func(run func(context.Context)) bool { launched = run; return true }, + } + recorder := &recordingRecorder{} + tool.Recorder = recorder + tool.Run(t.Context(), map[string]any{ + "tasks": []any{task("a", "x"), task("b", "y", "a")}, + "budget": map[string]any{"max_workers": float64(1)}, + "background": true, + }) + launched(context.Background()) + + if ran["a"] != 1 || ran["b"] != 1 { + t.Fatalf("the launched closure ran %v; both tasks must run", ran) + } + if recorder.admitted != 1 || len(recorder.finished) != 1 { + t.Fatalf("a background plan must record its own admission and completion: %+v", recorder) + } +} + +// background is OPT-IN. Any value that is not a true boolean leaves the plan in +// the foreground, so a flag that changes where a plan runs can never be +// inferred from something that happened to be there. +func TestBackgroundIsOptInOnly(t *testing.T) { + for _, value := range []any{nil, "true", float64(1), "yes", false} { + args := map[string]any{"background": value} + if value == nil { + delete(args, "background") + } + if planBool(args, "background") { + t.Errorf("background was inferred from %#v", value) + } + } + if !planBool(map[string]any{"background": true}, "background") { + t.Fatal("an explicit true must be honoured") + } +} diff --git a/internal/specialist/plan_tool.go b/internal/specialist/plan_tool.go index e091a2d1d..9d40cd564 100644 --- a/internal/specialist/plan_tool.go +++ b/internal/specialist/plan_tool.go @@ -46,6 +46,21 @@ type OrchestrateTool struct { // Size is the configured plan-size tier. The zero value is the default tier, // so a caller that never wires it gets the same ceiling as before. Size config.PlanSize + // Launch runs a plan in the BACKGROUND, and nil means background plans are + // unavailable in this run — which is the honest default. + // + // THE SEAM IS A LAUNCHER, not a context, and that is deliberate. A tool that + // held a context would have to hold one that outlives the tool call, and the + // only thing that legitimately knows how long a background plan may live is + // the surface that owns the session. So the surface supplies the launcher, + // keeps the context, and can drain or cancel on exit; the tool only asks to + // be run. It also makes headless exec's refusal fall out for free: that + // process exits when the turn ends, so it supplies no launcher and a + // background plan there is refused rather than silently orphaned. + // + // It reports false when it will not run the work, so the tool can say so + // instead of returning a run id for a plan nobody started. + Launch func(run func(ctx context.Context)) bool // Plans locates saved plans. Both directories empty means saved plans are // simply unavailable — the tool refuses a `saved` reference with a reason // rather than searching nothing and reporting "not found", which would read @@ -123,6 +138,11 @@ func (tool *OrchestrateTool) Parameters() tools.Schema { Description: "Run a plan saved earlier, by name, instead of supplying tasks. " + "Mutually exclusive with tasks/budget/name/description: a saved plan runs as it was saved.", }, + "background": { + Type: "boolean", + Description: "Run the plan in the background and report when it finishes, instead of holding this turn. " + + "Only available in the interactive TUI; a headless run exits when the turn ends, so it refuses this.", + }, "budget": { Type: "object", Description: "Required. max_workers must be 1 (this phase executes sequentially). max_tokens and max_wall_seconds are optional bounds; omit them to run unbounded — spend is reported either way. max_stall_seconds bounds how long ONE task may emit nothing (default 180); it resets on every event, so a slow-but-working task is never stopped. max_retries (0-3, default 1) is how many extra attempts a STALLED task gets; a task that failed with a real error is never retried.", @@ -316,6 +336,47 @@ func (tool *OrchestrateTool) RunWithOptions(ctx context.Context, args map[string return tools.Result{Status: tools.StatusError, Output: "Error: orchestrate has no task runner wired."} } + // BACKGROUND, when asked for and when this surface can carry one. + // + // The runner is built HERE, on the tool-call goroutine, because it captures + // the call's RunOptions — the parent's model, effort and session id. Only + // the CONTEXT comes from the launcher; capturing a per-call context in a + // goroutine is the prototype's defect, and capturing per-call VALUES is + // exactly what must happen. + if planBool(args, "background") { + if tool.Launch == nil { + return tools.Result{ + Status: tools.StatusError, + Output: "Error: background plans are not available in this run — a headless run exits when the turn ends, " + + "so a plan launched into the background could never report. Run it in the foreground, or use the interactive TUI.", + } + } + run := tool.runnerForCall(options) + parentTools := tool.ParentTools + recorder := tool.Recorder + launched := tool.Launch(func(backgroundCtx context.Context) { + recordPlanAdmitted(recorder, plan) + report := ExecutePlan(backgroundCtx, plan, parentTools, run, recorder) + recordPlanCompleted(recorder, plan, report) + }) + if !launched { + return tools.Result{ + Status: tools.StatusError, + Output: "Error: the plan was not started — this session is shutting down, or a background plan is already running.", + } + } + // NOT StatusOK-with-a-summary: there is no result yet, and reporting one + // would be reporting work that has not happened. + return tools.Result{ + Status: tools.StatusOK, + Output: fmt.Sprintf( + "Plan %q started in the background with %d tasks. It is NOT finished — its result will arrive on a later turn. "+ + "Carry on with other work; do not wait for it and do not report it as done.", + plan.Name(), plan.TaskCount()), + Meta: map[string]string{"plan_status": "background"}, + } + } + recordPlanAdmitted(tool.Recorder, plan) report := ExecutePlan(ctx, plan, tool.ParentTools, tool.runnerForCall(options), tool.Recorder) recordPlanCompleted(tool.Recorder, plan, report) diff --git a/internal/tui/model.go b/internal/tui/model.go index 692b8a8e2..5679f3143 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -2674,7 +2674,11 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil case planAdmittedMsg: - if msg.runID != m.activeRunID { + // A BACKGROUND plan outlives the run that launched it, so the + // stale-run guard must not drop its progress: dropping it is + // right for a finished run's leftovers and wrong for a plan that + // is still working. Without this the panel simply freezes. + if !msg.background && msg.runID != m.activeRunID { return m, nil } m.orchestrate.admit(msg, m.now()) @@ -2686,7 +2690,11 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { }) return m, nil case planTaskStartMsg: - if msg.runID != m.activeRunID { + // A BACKGROUND plan outlives the run that launched it, so the + // stale-run guard must not drop its progress: dropping it is + // right for a finished run's leftovers and wrong for a plan that + // is still working. Without this the panel simply freezes. + if !msg.background && msg.runID != m.activeRunID { return m, nil } m.orchestrate.markStarted(msg.taskID, msg.summary, msg.cardKey, m.now()) @@ -2698,7 +2706,11 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m.planRunningCardKey = msg.cardKey return m, nil case planTaskDoneMsg: - if msg.runID != m.activeRunID { + // A BACKGROUND plan outlives the run that launched it, so the + // stale-run guard must not drop its progress: dropping it is + // right for a finished run's leftovers and wrong for a plan that + // is still working. Without this the panel simply freezes. + if !msg.background && msg.runID != m.activeRunID { return m, nil } m.orchestrate.markDone(msg.taskID, msg.outcome, msg.tokens, msg.attempts, m.now()) @@ -2729,7 +2741,11 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil case planCompletedMsg: - if msg.runID != m.activeRunID { + // A BACKGROUND plan outlives the run that launched it, so the + // stale-run guard must not drop its progress: dropping it is + // right for a finished run's leftovers and wrong for a plan that + // is still working. Without this the panel simply freezes. + if !msg.background && msg.runID != m.activeRunID { return m, nil } m.planRunningCardKey = "" diff --git a/internal/tui/orchestrate_control_test.go b/internal/tui/orchestrate_control_test.go index b82b07cc5..0ca45c72a 100644 --- a/internal/tui/orchestrate_control_test.go +++ b/internal/tui/orchestrate_control_test.go @@ -222,3 +222,179 @@ func TestPlansKeepsItsGraphAndRefusesUnknownVerbs(t *testing.T) { t.Fatalf("an unknown verb must be refused by name: %q", unknown) } } + +// PROBLEM 1: card keys collided. `dispatched` reset on every Attach, so a +// background plan still dispatching when the next run attached would restart the +// counter and hand the new run's tasks the same keys — one card overwriting +// another, which is the specialist-card collision defect in a new costume. +func TestCardKeysDoNotRestartWhenANewRunAttaches(t *testing.T) { + bridge := NewPlanProgressBridge() + bridge.Attach(func(tea.Msg) {}, 1, nil, "") + + seen := map[string]bool{} + collect := func(runID int) { + var got []tea.Msg + bridge.Attach(func(msg tea.Msg) { got = append(got, msg) }, runID, nil, "") + for i := 0; i < 3; i++ { + bridge.TaskDispatched(specialist.Task{ID: "t", Prompt: "x"}) + } + for _, msg := range got { + if start, ok := msg.(planTaskStartMsg); ok { + if seen[start.cardKey] { + t.Fatalf("card key %q was handed out twice", start.cardKey) + } + seen[start.cardKey] = true + } + } + } + collect(2) + collect(3) + if len(seen) != 6 { + t.Fatalf("got %d distinct card keys for 6 dispatches", len(seen)) + } +} + +// PROBLEM 2: a background plan's progress was dropped. The stale-run guard +// discards anything whose runID is not the active one, which is right for a +// finished run's leftovers and wrong for a plan that is still working — the +// panel would simply freeze with no error and no card. +func TestABackgroundPlansProgressSurvivesALaterRun(t *testing.T) { + m := model{now: func() time.Time { return time.Unix(1000, 0) }, activeRunID: 9, + planProgress: NewPlanProgressBridge()} + + // A plan admitted under an EARLIER run, marked background. + admitted := planAdmittedMsg{runID: 4, name: "bg", taskCount: 1, + tasks: []planGraphTask{{id: "a"}}, background: true} + updated, _ := m.Update(admitted) + after := updated.(model) + if after.orchestrate.isEmpty() { + t.Fatal("a background plan's admission was dropped by the stale-run guard; the panel would never show it") + } + + // ...and a FOREGROUND message from a stale run is still dropped, which is + // what makes the guard worth keeping at all. + stale := planAdmittedMsg{runID: 4, name: "old", taskCount: 1, tasks: []planGraphTask{{id: "z"}}} + fresh := model{now: func() time.Time { return time.Unix(1000, 0) }, activeRunID: 9, + planProgress: NewPlanProgressBridge()} + staleUpdated, _ := fresh.Update(stale) + if !staleUpdated.(model).orchestrate.isEmpty() { + t.Fatal("a stale foreground plan was accepted; the guard no longer guards") + } +} + +// The completion the MODEL is told about: drained once, never twice, and it +// names the plan — by the time it arrives the conversation has moved on. +func TestABackgroundCompletionIsDeliveredOnceAndNamesThePlan(t *testing.T) { + bridge := NewPlanProgressBridge() + bridge.Attach(func(tea.Msg) {}, 1, nil, "") + _, cancel := context.WithCancel(context.Background()) + defer cancel() + bridge.PlanRunning(cancel) + bridge.SetBackground(true) + + plan := samplePlan(t) + bridge.PlanCompleted(plan, specialist.PlanReport{Status: specialist.PlanPartial, Succeeded: 1, Failed: 1}) + + first := bridge.DrainCompletedPlans() + if !strings.Contains(first, plan.Name()) { + t.Fatalf("the completion must name the plan: %q", first) + } + if !strings.Contains(first, "partial") { + t.Fatalf("the completion must carry the result: %q", first) + } + if second := bridge.DrainCompletedPlans(); second != "" { + t.Fatalf("a completion was delivered twice: %q", second) + } +} + +// A FOREGROUND plan queues nothing: it already returned its result as the tool +// output, and telling the model again would be reporting the same work twice. +func TestAForegroundPlanQueuesNoCompletion(t *testing.T) { + bridge := NewPlanProgressBridge() + bridge.Attach(func(tea.Msg) {}, 1, nil, "") + _, cancel := context.WithCancel(context.Background()) + defer cancel() + bridge.PlanRunning(cancel) + + bridge.PlanCompleted(samplePlan(t), specialist.PlanReport{Status: specialist.PlanCompleted, Succeeded: 2}) + if got := bridge.DrainCompletedPlans(); got != "" { + t.Fatalf("a foreground plan queued a completion: %q", got) + } +} + +// The background flag CLEARS when the plan ends, so the next foreground plan is +// not silently treated as one that outlives its run. +func TestTheBackgroundFlagClearsWhenThePlanEnds(t *testing.T) { + bridge := NewPlanProgressBridge() + bridge.Attach(func(tea.Msg) {}, 1, nil, "") + _, cancel := context.WithCancel(context.Background()) + defer cancel() + bridge.PlanRunning(cancel) + bridge.SetBackground(true) + if !bridge.PlanIsBackground() { + t.Fatal("SetBackground did not take") + } + bridge.PlanCompleted(samplePlan(t), specialist.PlanReport{Status: specialist.PlanCompleted}) + if bridge.PlanIsBackground() { + t.Fatal("the background flag survived the plan") + } +} + +// THE TERMINAL MESSAGE MUST CARRY THE MARK TOO, and it is the easiest one to +// lose: by the time PlanCompleted builds it, the flag has already been cleared, +// so it has to be captured BEFORE the clear and carried down. Unmarked, the +// panel of a background plan freezes one row from the end — every task done, the +// plan never closing. +func TestTheTerminalMessageOfABackgroundPlanIsMarked(t *testing.T) { + var got []tea.Msg + bridge := NewPlanProgressBridge() + bridge.Attach(func(msg tea.Msg) { got = append(got, msg) }, 4, nil, "") + _, cancel := context.WithCancel(context.Background()) + defer cancel() + bridge.PlanRunning(cancel) + bridge.SetBackground(true) + + bridge.PlanCompleted(samplePlan(t), specialist.PlanReport{Status: specialist.PlanCompleted, Succeeded: 1}) + + var done planCompletedMsg + found := false + for _, msg := range got { + if typed, ok := msg.(planCompletedMsg); ok { + done, found = typed, true + } + } + if !found { + t.Fatal("no planCompletedMsg was posted") + } + if !done.background { + t.Fatal("the terminal message is not marked background; a later run's guard would drop it and the panel would never close") + } + + // ...and it really survives the guard, which is the behaviour that matters. + m := model{now: func() time.Time { return time.Unix(1000, 0) }, activeRunID: 99, + planProgress: NewPlanProgressBridge()} + m.orchestrate.admit(planAdmittedMsg{runID: 4, name: "bg", taskCount: 1, + tasks: []planGraphTask{{id: "a"}}, background: true}, m.now()) + updated, _ := m.Update(done) + if updated.(model).orchestrate.frozenAt.IsZero() && updated.(model).orchestrate.isEmpty() { + t.Fatal("the terminal message was dropped by the stale-run guard") + } +} + +// A FOREGROUND plan's terminal message is NOT marked, so the guard still drops a +// finished run's leftovers — which is the whole reason the guard exists. +func TestTheTerminalMessageOfAForegroundPlanIsNotMarked(t *testing.T) { + var got []tea.Msg + bridge := NewPlanProgressBridge() + bridge.Attach(func(msg tea.Msg) { got = append(got, msg) }, 4, nil, "") + _, cancel := context.WithCancel(context.Background()) + defer cancel() + bridge.PlanRunning(cancel) + + bridge.PlanCompleted(samplePlan(t), specialist.PlanReport{Status: specialist.PlanCompleted, Succeeded: 1}) + for _, msg := range got { + if typed, ok := msg.(planCompletedMsg); ok && typed.background { + t.Fatal("a foreground plan's terminal message was marked background") + } + } +} diff --git a/internal/tui/plan_messages.go b/internal/tui/plan_messages.go index f194c34af..fde97cf22 100644 --- a/internal/tui/plan_messages.go +++ b/internal/tui/plan_messages.go @@ -24,6 +24,9 @@ type planAdmittedMsg struct { taskCount int tasks []planGraphTask tokenLimit int + // background marks a plan that outlives the run that launched it, so the + // stale-run guard must not drop its progress. + background bool } // planGraphTask is one node of the admitted graph, in execution order. @@ -40,6 +43,9 @@ type planTaskStartMsg struct { taskID string summary string cardKey string + // background marks a plan that outlives the run that launched it, so the + // stale-run guard must not drop its progress. + background bool } // planTaskDoneMsg closes a task's card. @@ -65,6 +71,9 @@ type planTaskDoneMsg struct { // watchdog fired and the executor retried it. Carried so the detail can say // why an apparently single run took twice as long as its siblings. attempts int + // background marks a plan that outlives the run that launched it, so the + // stale-run guard must not drop its progress. + background bool } // planCompletedMsg carries the plan's terminal record. @@ -79,6 +88,9 @@ type planCompletedMsg struct { tokensUsed int tokenLimit int maxSpeedup float64 + // background marks a plan that outlives the run that launched it, so the + // stale-run guard must not drop its progress. + background bool } // planNoticeLine renders the one-line plan notices shown in the transcript. diff --git a/internal/tui/plan_progress.go b/internal/tui/plan_progress.go index 8e23c3bec..b3709813e 100644 --- a/internal/tui/plan_progress.go +++ b/internal/tui/plan_progress.go @@ -46,6 +46,15 @@ type PlanProgressBridge struct { // must never fail a plan, but a silent drop would let a user believe a plan // was persisted when it was not. recordErr error + // background marks the running plan as one that outlives the run that + // launched it. Every message it posts carries the flag, because the panel's + // stale-run guard drops anything whose runID is not the active one — which + // is right for a foreground plan's leftovers and wrong for a background + // plan that is still working. + background bool + // completed collects finished background plans for the model to be told + // about on a later turn. Drained by the agent loop, never by the event loop. + completed []string // cancelPlan stops THIS PLAN without stopping the turn. Held only while a // plan is running and dropped in PlanCompleted, so a stop arriving after the // plan ended cannot cancel a context that has since been reused. @@ -83,7 +92,12 @@ func (bridge *PlanProgressBridge) Attach(sink func(tea.Msg), runID int, store *s defer bridge.mu.Unlock() bridge.sink = sink bridge.runID = runID - bridge.dispatched = 0 + // dispatched is deliberately NOT reset. It was, and that was safe only while + // every plan lived inside one run: a background plan still dispatching when + // the next run attaches would restart the counter and hand the new run's + // tasks card keys the old plan is still using — one card overwriting + // another, which is the specialist-card collision defect in a new costume. + // A counter monotonic for the bridge's life costs nothing and cannot collide. bridge.store = store bridge.sessionID = sessionID bridge.recordErr = nil @@ -126,6 +140,39 @@ func (bridge *PlanProgressBridge) PlanRunning(cancel context.CancelFunc) { bridge.clearPauseLocked() } +// SetBackground marks the plan about to run as a background one. Called by the +// launcher before the plan starts, and cleared when it ends. +func (bridge *PlanProgressBridge) SetBackground(background bool) { + if bridge == nil { + return + } + bridge.mu.Lock() + bridge.background = background + bridge.mu.Unlock() +} + +// DrainCompletedPlans returns and clears what finished in the background since +// the last drain, for the agent loop to append to the conversation. +// +// Drained by the AGENT loop, on its own goroutine, at the same point the +// post-edit diagnostics nudge is drained — that is the existing channel for +// background work reporting into a later turn, and reusing it means a plan +// completion is budgeted, compacted and ordered exactly like every other +// tail message. +func (bridge *PlanProgressBridge) DrainCompletedPlans() string { + if bridge == nil { + return "" + } + bridge.mu.Lock() + done := bridge.completed + bridge.completed = nil + bridge.mu.Unlock() + if len(done) == 0 { + return "" + } + return strings.Join(done, "\n\n") +} + // WaitWhilePaused blocks the TOOL's goroutine — never the event loop — until // the user resumes or the plan is cancelled. func (bridge *PlanProgressBridge) WaitWhilePaused(ctx context.Context) { @@ -311,7 +358,7 @@ func (bridge *PlanProgressBridge) PlanAdmitted(plan specialist.Plan) { } bridge.send(func(runID int) tea.Msg { - return planAdmittedMsg{runID: runID, name: name, taskCount: count, tasks: graph, tokenLimit: limit} + return planAdmittedMsg{runID: runID, name: name, taskCount: count, tasks: graph, tokenLimit: limit, background: bridge.isBackground()} }) } @@ -328,7 +375,7 @@ func (bridge *PlanProgressBridge) TaskDispatched(task specialist.Task) { id, summary := task.ID, planTaskSummary(task) bridge.send(func(runID int) tea.Msg { - return planTaskStartMsg{runID: runID, taskID: id, summary: summary, cardKey: key} + return planTaskStartMsg{runID: runID, taskID: id, summary: summary, cardKey: key, background: bridge.isBackground()} }) } @@ -373,6 +420,7 @@ func (bridge *PlanProgressBridge) finish(result specialist.TaskResult, status sp reason: reason, tokens: tokens, attempts: attempts, + background: bridge.isBackground(), } }) } @@ -380,15 +428,34 @@ func (bridge *PlanProgressBridge) finish(result specialist.TaskResult, status sp // PlanCompleted reports the plan's terminal state. func (bridge *PlanProgressBridge) PlanCompleted(plan specialist.Plan, report specialist.PlanReport) { bridge.record(specialist.PlanCompletedEvent(plan, report)) - // The plan is over: drop the cancel and release any pause. Keeping a stale - // cancel would let a later "stop the plan" cancel a context that has since - // been reused, which is the PostureGate lifetime mistake in another costume. + + // wasBackground is read BEFORE the flag is cleared and carried down to the + // message. Reading it again afterwards returns false — the plan is over — + // and the terminal message would then be dropped by the stale-run guard, + // leaving a background plan's panel frozen one row from the end. Caught by + // the compiler complaining the second read was unused, which is luckier + // than it deserved to be. + wasBackground := false if bridge != nil { bridge.mu.Lock() + wasBackground = bridge.background + // The plan is over: drop the cancel and release any pause. Keeping a + // stale cancel would let a later stop cancel a context that has since + // been reused, which is the PostureGate lifetime mistake in another + // costume. bridge.cancelPlan = nil + bridge.background = false bridge.clearPauseLocked() + if wasBackground { + // The MODEL is told, on a later turn, because it was told the plan + // was not finished and must not report it as done until it is. A + // completion nobody delivers is the background failure mode that + // matters: spend nobody sees and no result. + bridge.completed = append(bridge.completed, backgroundPlanReport(plan, report)) + } bridge.mu.Unlock() } + name := plan.Name() status := string(report.Status) succeeded, failed := report.Succeeded, report.Failed @@ -400,10 +467,37 @@ func (bridge *PlanProgressBridge) PlanCompleted(plan specialist.Plan, report spe runID: runID, name: name, status: status, succeeded: succeeded, failed: failed, skipped: skipped, cancelled: cancelled, tokensUsed: tokens, tokenLimit: limit, maxSpeedup: speedup, + background: wasBackground, } }) } +// PlanIsBackground reports whether the running plan outlives its run, for a +// surface that wants to say so. +func (bridge *PlanProgressBridge) PlanIsBackground() bool { return bridge.isBackground() } + +// isBackground reports whether the running plan outlives its run. +func (bridge *PlanProgressBridge) isBackground() bool { + if bridge == nil { + return false + } + bridge.mu.Lock() + defer bridge.mu.Unlock() + return bridge.background +} + +// backgroundPlanReport is what the model is told when a background plan ends. +// The SAME summary a foreground plan returns, prefixed with which plan it was — +// by the time it arrives the conversation has moved on, and "Plan partial: 1 +// succeeded" with no name attached is a result the model cannot place. +func backgroundPlanReport(plan specialist.Plan, report specialist.PlanReport) string { + name := plan.Name() + if name == "" { + name = "(unnamed)" + } + return "The background plan " + name + " has finished. This is its result:\n\n" + report.Summary() +} + // planOutcomeStatus maps a task outcome onto the card status. Cancelled and // skipped are deliberately NOT specialistError: a user who stopped a plan did // not break it, and a task skipped because its dependency failed is not itself From 1d73e0d8ee5e6d312bc63378ed7407060e542071 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:10:42 +0530 Subject: [PATCH 45/86] fix(specialist): drop update_plan from the plan grant, which cost every task ~6.5KB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last open decision, settled by measuring instead of reasoning — and the premise I had been carrying was wrong. I had recorded this as "update_plan blocks the prompt saving; drop it or add a session-only capability". Driving a real plan and dumping the CHILD request showed the saving was not reaching plan tasks at all, and the reason was neither option: update_plan declares readOnlySafety("Updates in-memory planning state only") and sets NO capabilities, so CapabilitiesOf reports EffectUnknown — the fail-closed default — while its safety string says read-only. runCanMutate reads the CAPABILITY. One unclassified tool in an otherwise read-only grant made the whole child look mutating. Measured at the wire, same plan, before and after: child system prompt 18,118 -> 11,639 chars (-36%) confirmation policy present -> absent That is per TASK, multiplied by every task of every plan. DROPPED FROM THE GRANT rather than reclassified, and the distinction matters: the same capability drives concurrency eligibility, so promoting update_plan to EffectReadOnly would make it eligible for parallel execution it is not safe for — it REPLACES the whole plan list on every call, so concurrent callers race and the last one wins. The safety/capability mismatch is a pre-existing main defect and is filed as one, not fixed here. It also has no business in the grant on its own merits. A plan task is an investigator that reports to the parent; the parent owns the plan list. WHY NO TEST CAUGHT THIS. internal/agent's readOnlyRegistry enumerated its own copy of the plan grant, and the copy differed from the authoritative list by exactly one entry — update_plan, the entry that broke it. So TestAReadOnlyRunDropsTheConfirmationPolicy asserted the policy was dropped for a grant that was not the grant, and passed green for the whole period real plan children carried it. Invariant 5, in a test rather than in production. The helper now reads specialist.PlanReadOnlyToolNames. Three mutations. U1 — putting update_plan back — is MISSED by the agent tests and the fixture now says why in place: a grant name outside the scoped core set is invisible to it. That half proves "these read-only tools drop the policy"; the other half, that every name in the grant is read-only BY CAPABILITY, is TestEveryPlanGrantToolIsReadOnlyByCapability against the real registry, where the same mutation is CAUGHT. Neither alone is the guarantee. --- internal/agent/prompt_capability_test.go | 28 ++++++++++++++++--- internal/cli/plan_grant_test.go | 35 ++++++++++++++++++++++++ internal/specialist/plan.go | 24 +++++++++++++++- 3 files changed, 82 insertions(+), 5 deletions(-) diff --git a/internal/agent/prompt_capability_test.go b/internal/agent/prompt_capability_test.go index 68be99ac9..912a63ec5 100644 --- a/internal/agent/prompt_capability_test.go +++ b/internal/agent/prompt_capability_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "github.com/Gitlawb/zero/internal/specialist" "github.com/Gitlawb/zero/internal/tools" ) @@ -15,19 +16,38 @@ import ( // Deliberately not CoreReadOnlyToolsScoped, which despite its name also carries // ask_user and request_permissions (both EffectInteractive). A run holding // those genuinely can prompt, so the policy applies to it and the gate keeps -// it; that is why this helper enumerates the plan grant instead. +// it; that is why this helper narrows to the plan grant instead. +// +// IT READS THE AUTHORITATIVE LIST. It used to enumerate its own copy, and the +// copy differed from specialist.PlanReadOnlyToolNames by exactly one entry — +// update_plan — which is the entry that broke this. The test asserted the +// policy was dropped for a grant that was not the grant, and passed for the +// whole period a real plan child carried the policy. A test that builds its own +// version of the thing under test cannot catch the thing under test changing. func readOnlyRegistry(t *testing.T) *tools.Registry { t.Helper() - planTools := map[string]bool{ - "read_file": true, "read_minified_file": true, "list_directory": true, - "grep": true, "glob": true, "lsp_navigate": true, + planTools := map[string]bool{} + for _, name := range specialist.PlanReadOnlyToolNames() { + planTools[name] = true } registry := tools.NewRegistry() + registered := 0 for _, tool := range tools.CoreReadOnlyToolsScoped(t.TempDir(), nil) { if planTools[tool.Name()] { registry.Register(tool) + registered++ } } + // WHAT THIS CAN AND CANNOT PROVE, said plainly. A grant name that lives + // outside the scoped core set is invisible here — update_plan was exactly + // that, which is why re-adding it does not fail these tests. This half + // proves "a run holding these read-only tools drops the policy"; the other + // half, that every name in the grant IS read-only by capability, is + // TestEveryPlanGrantToolIsReadOnlyByCapability in internal/cli, against the + // real registry. Neither alone is the guarantee. + if registered == 0 { + t.Fatal("no plan-grant tool was registered; this fixture proves nothing") + } return registry } diff --git a/internal/cli/plan_grant_test.go b/internal/cli/plan_grant_test.go index df4a8fbb1..92fc84303 100644 --- a/internal/cli/plan_grant_test.go +++ b/internal/cli/plan_grant_test.go @@ -6,6 +6,7 @@ import ( "github.com/Gitlawb/zero/internal/config" "github.com/Gitlawb/zero/internal/specialist" + "github.com/Gitlawb/zero/internal/tools" ) // The plan tool's parent grant was declared, documented and validated against — @@ -184,3 +185,37 @@ func TestRegisteredOrchestrateToolCarriesTheConfiguredTier(t *testing.T) { t.Fatalf("registered tier = %q; want %q — the wiring dropped it", tool.Size, config.PlanSizeSmall) } } + +// EVERY TOOL A PLAN TASK MAY HOLD MUST BE CLASSIFIED READ-ONLY BY CAPABILITY, +// not merely by its safety string. +// +// This is the invariant that broke, and it broke silently. update_plan declared +// readOnlySafety and set no capabilities, so CapabilitiesOf reported +// EffectUnknown — the fail-closed default — while its safety said read-only. +// runCanMutate reads the CAPABILITY, so one unclassified tool in an otherwise +// read-only grant made the whole child look mutating, and ~6,500 characters of +// confirmation policy it can never need rode along on every task of every plan. +// +// Asserted against the REAL registry, because the defect was that two +// descriptions of one tool disagreed — checking the list against itself would +// have found nothing. +func TestEveryPlanGrantToolIsReadOnlyByCapability(t *testing.T) { + registry := newCoreRegistry(t.TempDir()) + checked := 0 + for _, name := range specialist.PlanReadOnlyToolNames() { + tool, found := registry.Get(name) + if !found { + // Not every read-only name is registered in a bare core registry; + // what matters is that the ones that are, classify correctly. + continue + } + checked++ + if effect := tools.CapabilitiesOf(tool).Effect; effect != tools.EffectReadOnly { + t.Errorf("plan grant holds %q whose capability is %v, not EffectReadOnly; "+ + "a plan child would carry the confirmation policy it can never need", name, effect) + } + } + if checked == 0 { + t.Fatal("no plan-grant tool was found in the registry; this test checked nothing") + } +} diff --git a/internal/specialist/plan.go b/internal/specialist/plan.go index 10bd6a127..bab422f81 100644 --- a/internal/specialist/plan.go +++ b/internal/specialist/plan.go @@ -118,8 +118,30 @@ var planReadOnlyTools = map[string]bool{ "list_directory": true, "grep": true, "glob": true, - "update_plan": true, "lsp_navigate": true, + // update_plan is DELIBERATELY ABSENT, and it was here. + // + // TWO REASONS, and the second was measured rather than reasoned. + // + // It is not read-only. It REPLACES the parent's whole plan list on every + // call, so N tasks each calling it race and the last one wins — and a plan + // task is an investigator that reports to the parent, which owns the list. + // Nothing about the job needs it. + // + // And it was the single reason a plan child carried the confirmation + // policy. update_plan declares readOnlySafety but sets no capabilities, so + // CapabilitiesOf reports EffectUnknown — the fail-closed default — while its + // safety says read-only. runCanMutate reads the CAPABILITY, so one + // unclassified tool in an otherwise read-only grant made the whole child + // look mutating, and ~2,500 tokens of policy it can never need rode along on + // every task of every plan. + // + // That mismatch is a PRE-EXISTING MAIN DEFECT and is filed as one rather + // than fixed here: the same capability drives concurrency eligibility, and + // promoting update_plan to EffectReadOnly would make it eligible for + // parallel execution it is not safe for. Removing it from this grant fixes + // the plan path without touching a classification that means something else + // somewhere else. } // Name, Description, Tasks and Budget expose a validated plan for execution and From f747fe833d67398b746266e75947b7676c4fbd05 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:32:15 +0530 Subject: [PATCH 46/86] feat(tui): a permission prompt can show what it is approving (write-task arc, 1/3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PermissionRequest has carried an Args map since it was written and NOTHING has ever read it — verified across permission_prompt.go, transcript.go and rendering.go. So the card names a tool and states a static reason. For a one-file write that is survivable, because the path already surfaces as the grant scope. For a plan it is not: a twenty-task plan and a one-task plan produce the identical card, which is precisely the argument plan_tool.go makes for NOT prompting on orchestrate today — an approval that cannot show what it approves trains click-through, and that is worse than no dialog because it also erodes the prompts that do carry information. STEP 1 OF THREE, and it is a step rather than a product. Step 2 is worktree isolation; step 3 flips plan tasks to allow write tools with both as preconditions. The order is forced: the gate cannot be justified until the renderer exists, so this lands first and is wired by step 3. A REGISTRY KEYED BY TOOL NAME, and a tool with no entry renders exactly what it rendered before. That is what keeps every existing permission prompt byte-identical, and it is asserted with Args deliberately PRESENT so the test proves the renderer lookup is the gate rather than the absence of arguments. THE ARGUMENTS ARE UNTRUSTED. Every value came from the model, so text is collapsed to one printable line: newlines dropped (or the model writes its own rows), control characters and ESC dropped (or it repaints the screen around the card), truncated on RUNE boundaries. The row count is bounded and says what it hid — a card a plan can make taller than the terminal is a card whose options an attacker can push off screen, leaving only the key still visible. A malformed task is SHOWN as malformed rather than skipped: a task the card silently drops is a task the user approved without seeing. An unbounded budget is NAMED, because "no token limit" is the thing worth reading on an approval and omitting it reads as though a limit exists. A `saved` reference says which plan and where to read it rather than inventing a summary of tasks the card never saw. Nine mutations, all caught. The one that mattered most was P2 — pointing every tool at the plan renderer — because the whole no-regression claim rests on that lookup being the gate. --- internal/tui/permission_detail.go | 212 +++++++++++++++++++++++++ internal/tui/permission_detail_test.go | 205 ++++++++++++++++++++++++ internal/tui/rendering.go | 9 ++ 3 files changed, 426 insertions(+) create mode 100644 internal/tui/permission_detail.go create mode 100644 internal/tui/permission_detail_test.go diff --git a/internal/tui/permission_detail.go b/internal/tui/permission_detail.go new file mode 100644 index 000000000..664c43218 --- /dev/null +++ b/internal/tui/permission_detail.go @@ -0,0 +1,212 @@ +package tui + +import ( + "fmt" + "sort" + "strings" + + "github.com/Gitlawb/zero/internal/agent" +) + +// What a permission prompt SHOWS about the thing it is approving. +// +// PermissionRequest has carried an Args map since it was written and no renderer +// has ever read it — verified across permission_prompt.go, transcript.go and +// rendering.go. So the card can name a tool and state a static reason, and +// nothing more. That is survivable for a one-file write, where the path is +// already surfaced as the grant scope, and it is not survivable for a plan: a +// twenty-task plan and a one-task plan produce the identical card. +// +// This is STEP 1 OF THREE. On its own it renders a plan into a decision surface +// that does not yet gate plans — orchestrate returns PermissionAllow, because +// today its tasks are read-only and a prompt that adds friction without adding +// safety trains click-through. Step 2 is worktree isolation; step 3 flips plan +// tasks to allow write tools with both as preconditions. The order is forced: +// an approval gate that cannot show the plan is the thing plan_tool.go argues +// against, so the renderer has to exist before the gate can be justified. +// +// UNTRUSTED INPUT. Every value here came from the model. It is truncated on +// RUNE boundaries, stripped of anything that could move the cursor, and bounded +// in row count — a card that a plan can make taller than the terminal is a card +// an attacker can use to push the options off screen. + +// permissionDetailRenderer turns a tool's arguments into card lines. +type permissionDetailRenderer func(args map[string]any, width int) []string + +// permissionDetailRenderers is keyed by tool name. A tool with no entry renders +// EXACTLY as before — that is what keeps every existing prompt byte-identical, +// and it is asserted rather than assumed. +var permissionDetailRenderers = map[string]permissionDetailRenderer{ + "orchestrate": planPermissionDetail, +} + +// permissionDetailLines returns the tool-specific detail for a request, or +// nothing when the tool has no renderer. +func permissionDetailLines(request agent.PermissionRequest, width int) []string { + render := permissionDetailRenderers[strings.TrimSpace(request.ToolName)] + if render == nil || len(request.Args) == 0 { + return nil + } + return render(request.Args, width) +} + +// permissionDetailMaxRows bounds the detail. The options must stay on screen: +// a prompt whose choices have been pushed past the bottom is a prompt that can +// only be answered by the one key still visible. +const permissionDetailMaxRows = 12 + +// planPermissionDetail renders an orchestrate plan: what it will run, in what +// order, and what it may spend. +func planPermissionDetail(args map[string]any, width int) []string { + room := maxInt(16, width-4) + + if saved := permissionString(args, "saved"); saved != "" { + // A saved plan's tasks are on disk, not in the arguments — there is + // nothing here to render, and inventing a summary would describe a plan + // this card never saw. + return []string{ + " " + zeroTheme.muted.Render(truncateRunes("saved plan: "+saved, room)), + " " + zeroTheme.faint.Render(truncateRunes("run /plans show "+saved+" to read it", room)), + } + } + + rawTasks, _ := args["tasks"].([]any) + lines := []string{} + + head := fmt.Sprintf("%d task(s)", len(rawTasks)) + if name := permissionString(args, "name"); name != "" { + head = name + " · " + head + } + if budget := planBudgetSummary(args); budget != "" { + head += " · " + budget + } + lines = append(lines, " "+zeroTheme.ink.Render(truncateRunes(head, room))) + + if description := permissionString(args, "description"); description != "" { + lines = append(lines, " "+zeroTheme.muted.Render(truncateRunes(description, room))) + } + + shown := 0 + for _, raw := range rawTasks { + if shown >= permissionDetailMaxRows { + break + } + task, ok := raw.(map[string]any) + if !ok { + // A malformed entry is SHOWN as malformed, not skipped. A task the + // card silently drops is a task the user approved without seeing. + lines = append(lines, " "+zeroTheme.faint.Render("· (unreadable task entry)")) + shown++ + continue + } + lines = append(lines, " "+zeroTheme.faint.Render(truncateRunes(planTaskDetailLine(task), room))) + shown++ + } + if hidden := len(rawTasks) - shown; hidden > 0 { + lines = append(lines, " "+zeroTheme.faint.Render(fmt.Sprintf("… and %d more not shown", hidden))) + } + return lines +} + +// planTaskDetailLine renders one task: its id, what it waits on, and the first +// line of its prompt. +func planTaskDetailLine(task map[string]any) string { + id := permissionString(task, "id") + if id == "" { + id = "(no id)" + } + line := "· " + id + if deps := permissionStrings(task, "depends_on"); len(deps) > 0 { + sort.Strings(deps) + line += " after " + strings.Join(deps, ",") + } + // The task's TOOLS are shown when it names any, because "which tools" is + // half of what an approval is deciding. + if granted := permissionStrings(task, "tools"); len(granted) > 0 { + sort.Strings(granted) + line += " [" + strings.Join(granted, " ") + "]" + } + if prompt := permissionString(task, "prompt"); prompt != "" { + line += " — " + prompt + } + return line +} + +// planBudgetSummary renders the bounds a plan asked for, naming the unbounded +// case rather than omitting it: "no token limit" is the thing worth seeing. +func planBudgetSummary(args map[string]any) string { + budget, ok := args["budget"].(map[string]any) + if !ok { + return "" + } + parts := []string{} + if tokens := permissionInt(budget, "max_tokens"); tokens > 0 { + parts = append(parts, fmt.Sprintf("%d tokens", tokens)) + } else { + parts = append(parts, "no token limit") + } + if wall := permissionInt(budget, "max_wall_seconds"); wall > 0 { + parts = append(parts, fmt.Sprintf("%ds wall", wall)) + } + if background, _ := budget["background"].(bool); background { + parts = append(parts, "background") + } + return strings.Join(parts, ", ") +} + +// permissionString reads a string argument and makes it SAFE TO DRAW: one line, +// no control characters, no escape sequences. The value came from the model, and +// a card is a place where a stray \r or an ANSI reset rewrites the screen around +// it. +func permissionString(args map[string]any, key string) string { + raw, _ := args[key].(string) + return sanitizeCardText(raw) +} + +// sanitizeCardText collapses a value to a single printable line. +func sanitizeCardText(raw string) string { + if raw == "" { + return "" + } + if index := strings.IndexAny(raw, "\r\n"); index >= 0 { + raw = raw[:index] + } + var out strings.Builder + for _, r := range raw { + switch { + case r == '\t': + out.WriteRune(' ') + case r < 0x20 || r == 0x7f: + // Control characters, including ESC — the one that starts an ANSI + // sequence. Dropped rather than escaped: nothing here needs them. + continue + default: + out.WriteRune(r) + } + } + return strings.TrimSpace(out.String()) +} + +func permissionStrings(args map[string]any, key string) []string { + raw, _ := args[key].([]any) + out := make([]string, 0, len(raw)) + for _, item := range raw { + if text, ok := item.(string); ok { + if clean := sanitizeCardText(text); clean != "" { + out = append(out, clean) + } + } + } + return out +} + +func permissionInt(args map[string]any, key string) int { + switch value := args[key].(type) { + case float64: + return int(value) + case int: + return value + default: + return 0 + } +} diff --git a/internal/tui/permission_detail_test.go b/internal/tui/permission_detail_test.go new file mode 100644 index 000000000..99e1c5ad6 --- /dev/null +++ b/internal/tui/permission_detail_test.go @@ -0,0 +1,205 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/agent" +) + +func planPermissionRequest(args map[string]any) agent.PermissionRequest { + return agent.PermissionRequest{ + ToolName: "orchestrate", + SideEffect: "shell", + Reason: "Runs a plan of read-only specialist sub-agents.", + Args: args, + AvailableDecisions: []agent.PermissionDecisionAction{ + agent.PermissionDecisionAllow, agent.PermissionDecisionDeny, + }, + } +} + +func samplePlanArgs() map[string]any { + return map[string]any{ + "name": "sweep", + "description": "look at the tier", + "tasks": []any{ + map[string]any{"id": "by_name", "prompt": "find where it is defined"}, + map[string]any{"id": "by_use", "prompt": "find where it is read", "tools": []any{"grep", "glob"}}, + map[string]any{"id": "join", "prompt": "combine", "depends_on": []any{"by_use", "by_name"}}, + }, + "budget": map[string]any{"max_workers": float64(1), "max_tokens": float64(5000)}, + } +} + +// THE DEFECT THIS EXISTS FOR: a twenty-task plan and a one-task plan produced +// the identical card, because nothing read Args. +func TestThePermissionCardShowsWhatThePlanWillDo(t *testing.T) { + card, _ := renderFocusedPermissionPrompt(planPermissionRequest(samplePlanArgs()), 0, false, "", 100) + for _, want := range []string{"sweep", "3 task(s)", "by_name", "by_use", "join", "5000 tokens"} { + if !strings.Contains(card, want) { + t.Errorf("the card must show %q:\n%s", want, card) + } + } + // The DEPENDENCY and the TOOLS are half of what an approval decides. + if !strings.Contains(card, "after by_name,by_use") { + t.Errorf("the card must show what a task waits on:\n%s", card) + } + // Sorted, so the same grant always reads the same way on the card. + if !strings.Contains(card, "glob grep") { + t.Errorf("the card must show the tools a task asked for:\n%s", card) + } +} + +// TWO DIFFERENT PLANS MUST PRODUCE TWO DIFFERENT CARDS. Asserting a card +// contains a string proves the string is there; this proves the card is about +// the plan. +func TestTwoPlansDoNotRenderTheSameCard(t *testing.T) { + small, _ := renderFocusedPermissionPrompt(planPermissionRequest(map[string]any{ + "name": "small", + "tasks": []any{map[string]any{"id": "a", "prompt": "one thing"}}, + "budget": map[string]any{"max_workers": float64(1)}, + }), 0, false, "", 100) + big, _ := renderFocusedPermissionPrompt(planPermissionRequest(samplePlanArgs()), 0, false, "", 100) + if small == big { + t.Fatal("a one-task plan and a three-task plan rendered identically") + } +} + +// EVERY OTHER PROMPT IS UNCHANGED. A tool with no registered renderer must +// produce exactly the card it produced before, or this change is a rewrite of +// every permission prompt in the product. +func TestAToolWithoutARendererIsUnchanged(t *testing.T) { + for _, name := range []string{"bash", "write_file", "web_fetch", "edit_file", ""} { + request := agent.PermissionRequest{ + ToolName: name, + SideEffect: "shell", + Reason: "does a thing", + Scope: "src/main.go", + // Args PRESENT, so the test proves the renderer lookup is what gates + // this rather than the absence of arguments. + Args: map[string]any{"path": "src/main.go", "tasks": []any{map[string]any{"id": "x"}}}, + } + if lines := permissionDetailLines(request, 100); len(lines) != 0 { + t.Errorf("tool %q rendered detail it has no renderer for: %v", name, lines) + } + } +} + +// A PLAN CANNOT PUSH THE OPTIONS OFF SCREEN. A card a plan can make taller than +// the terminal is a card whose choices an attacker can hide. +func TestALargePlanCannotPushTheOptionsOffTheCard(t *testing.T) { + tasks := make([]any, 0, 60) + for i := 0; i < 60; i++ { + tasks = append(tasks, map[string]any{"id": strings.Repeat("t", i%8+1), "prompt": "x"}) + } + args := map[string]any{"tasks": tasks, "budget": map[string]any{"max_workers": float64(1)}} + lines := planPermissionDetail(args, 100) + if len(lines) > permissionDetailMaxRows+4 { + t.Fatalf("a 60-task plan drew %d detail lines; it must stay bounded", len(lines)) + } + joined := strings.Join(lines, "\n") + if !strings.Contains(joined, "more not shown") { + t.Fatalf("a truncated plan must say so:\n%s", joined) + } + // ...and the options are still on the card. + card, offsets := renderFocusedPermissionPrompt(planPermissionRequest(args), 0, false, "", 100) + if len(offsets) == 0 { + t.Fatal("the options were lost") + } + // The deny option, by the label it actually carries. + if !strings.Contains(card, "No, continue without running it") { + t.Fatalf("the options were pushed off the card:\n%s", card) + } +} + +// UNTRUSTED INPUT. Every value came from the model, so nothing it can put in a +// plan may move the cursor, clear the screen, or forge a second card. +func TestModelSuppliedTextCannotRewriteTheCard(t *testing.T) { + nasty := "evil\x1b[2J\x1b[H FORGED\r\nsecond line\ttab" + args := map[string]any{ + "name": nasty, + "tasks": []any{map[string]any{"id": nasty, "prompt": nasty}}, + "budget": map[string]any{"max_workers": float64(1)}, + } + // THE SANITISER, checked on its own. The rendered lines legitimately carry + // lipgloss's own escapes, so asserting "no \x1b in the output" would fail + // against correct code and pass against nothing useful — the question is + // what survives of the MODEL's text. + clean := sanitizeCardText(nasty) + if strings.ContainsAny(clean, "\x1b\r\n\t") { + t.Fatalf("a control character survived sanitising: %q", clean) + } + if strings.Contains(clean, "second line") { + t.Fatalf("a newline let the model add its own line: %q", clean) + } + if !strings.Contains(clean, "evil") { + t.Fatalf("sanitising removed the readable text too: %q", clean) + } + + // ...and nothing the model supplied reaches a rendered line as a control + // character or as an extra row. + lines := planPermissionDetail(args, 100) + for _, line := range lines { + if strings.ContainsAny(line, "\r\n") { + t.Fatalf("a rendered line carries a control character: %q", line) + } + } + if joined := strings.Join(lines, "\n"); !strings.Contains(joined, "evil") { + t.Fatalf("the readable text was lost: %q", joined) + } +} + +// A MALFORMED TASK IS SHOWN AS MALFORMED, never dropped. A task the card +// silently omits is a task the user approved without seeing. +func TestAMalformedTaskIsShownNotDropped(t *testing.T) { + args := map[string]any{ + "tasks": []any{map[string]any{"id": "good", "prompt": "fine"}, "not-an-object", float64(7)}, + "budget": map[string]any{"max_workers": float64(1)}, + } + joined := strings.Join(planPermissionDetail(args, 100), "\n") + if !strings.Contains(joined, "3 task(s)") { + t.Fatalf("the count must include unreadable entries: %s", joined) + } + if strings.Count(joined, "unreadable task entry") != 2 { + t.Fatalf("both unreadable entries must be shown: %s", joined) + } +} + +// AN UNBOUNDED BUDGET IS NAMED. "no token limit" is the thing worth seeing on an +// approval; omitting it reads as though a limit exists. +func TestAnUnboundedBudgetSaysSo(t *testing.T) { + args := map[string]any{ + "tasks": []any{map[string]any{"id": "a", "prompt": "x"}}, + "budget": map[string]any{"max_workers": float64(1)}, + } + joined := strings.Join(planPermissionDetail(args, 100), "\n") + if !strings.Contains(joined, "no token limit") { + t.Fatalf("an unbounded plan must say so: %s", joined) + } +} + +// A SAVED plan's tasks live on disk, not in the arguments. The card says which +// plan and where to read it rather than inventing a summary of tasks it cannot +// see. +func TestASavedPlanReferenceSaysWhereToReadIt(t *testing.T) { + joined := strings.Join(planPermissionDetail(map[string]any{"saved": "sweep"}, 100), "\n") + if !strings.Contains(joined, "saved plan: sweep") || !strings.Contains(joined, "/plans show sweep") { + t.Fatalf("a saved reference must point at the plan: %s", joined) + } +} + +// Narrow terminals must not panic or produce garbage. +func TestPermissionDetailSurvivesNarrowWidths(t *testing.T) { + for _, width := range []int{0, 1, 10, 24, 40, 200} { + lines := planPermissionDetail(samplePlanArgs(), width) + if len(lines) == 0 { + t.Fatalf("width %d produced no detail", width) + } + for _, line := range lines { + if strings.ContainsAny(line, "\n\r") { + t.Fatalf("width %d produced a multi-line entry: %q", width, line) + } + } + } +} diff --git a/internal/tui/rendering.go b/internal/tui/rendering.go index a5e6ec4ab..dc98838d8 100644 --- a/internal/tui/rendering.go +++ b/internal/tui/rendering.go @@ -1144,6 +1144,15 @@ func renderFocusedPermissionPrompt(request agent.PermissionRequest, cursor int, if scope := strings.TrimSpace(request.Scope); scope != "" { lines = append(lines, fill(zeroTheme.muted).Render(permissionScopeLine(request, scope))) } + // WHAT IS ACTUALLY BEING APPROVED, for tools that can say. PermissionRequest + // has carried an Args map since it was written and nothing read it, so the + // card could name a tool and state a static reason and nothing more. A tool + // with no registered renderer produces no lines, which is what keeps every + // existing prompt byte-identical. + if detail := permissionDetailLines(request, width); len(detail) > 0 { + lines = append(lines, "") + lines = append(lines, detail...) + } lines = append(lines, "") From f9d6579291bf6f2cc8041da185f7dbd2989f1bd5 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:48:04 +0530 Subject: [PATCH 47/86] feat(specialist): a write-capable plan gets a worktree, or does not run (write-task arc, 2/3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit STEP 2 OF THREE. Step 1 gave the approval prompt something to show; this gives a write-capable plan somewhere to write that is not the user's tree; step 3 allows write tools with both as preconditions. THE REQUIREMENT IS DERIVED FROM THE PLAN, not declared by a caller, and that is what makes step 3 safe by construction. RequiresIsolation reads the plan's own tools: a task naming anything outside the read-only set can change something, which is exactly the condition isolation exists for. Today validateTaskTools rejects such a task so it is always false; the moment step 3 stops rejecting them it becomes true, with no second place needing to be updated. Three defects in this feature have been a rule that lived in one place and a condition that lived in another. PER PLAN, NOT PER TASK. Per-task isolation is more isolation and less use: this phase is sequential and a later task routinely needs an earlier one's output, so per-task trees would either hide that or need a merge step nobody wrote. A plan is the unit a user reviews, so a plan is the unit that gets a tree — one branch, one diff. FAIL CLOSED, four ways, because a precondition that degrades is not one. No isolator, a prepare that errors, an isolator reporting success with no path, and one reporting a path it did not isolate are all REFUSED — never run in the parent tree with a warning. The third and fourth are what a buggy isolator produces, and honouring either would put write tasks exactly where this exists to keep them out of. BOTH DISPATCH PATHS. Foreground and background resolve the same workspace by the same rule, and the background path resolves it BEFORE launching so a plan that cannot be isolated is refused now, with a reason the model reads, rather than failing invisibly on a later turn. It reuses internal/worktrees — the package `zero exec --worktree` already uses — rather than shelling out to git again. Release does NOT delete the tree: a plan that wrote produced work nobody has reviewed, and removing it would remove the only copy. Eleven mutations. I7 initially MISSED and it is the same shape as the Stalled flag: every test drove ExecutePlanIn with a FAKE runner that reads req.Cwd, so all of them passed against a real runner ignoring it. Fixed with a test that drives NewPlanRunner and asserts on the `--cwd` argument the child actually receives — both directions, since a runner that used only the override would put read-only children nowhere. A read-only plan still runs in the parent tree, verified with the real binary: no worktree is created for one. --- internal/agent/zeromaxing.go | 25 ++- internal/cli/app.go | 5 + internal/cli/exec.go | 3 + internal/cli/plan_grant_test.go | 55 +++++ internal/cli/plan_isolate.go | 89 ++++++++ internal/specialist/plan_exec.go | 16 ++ internal/specialist/plan_runner.go | 16 +- internal/specialist/plan_tool.go | 31 ++- internal/specialist/plan_worktree.go | 103 +++++++++ internal/specialist/plan_worktree_test.go | 241 ++++++++++++++++++++++ 10 files changed, 569 insertions(+), 15 deletions(-) create mode 100644 internal/cli/plan_isolate.go create mode 100644 internal/specialist/plan_worktree.go create mode 100644 internal/specialist/plan_worktree_test.go diff --git a/internal/agent/zeromaxing.go b/internal/agent/zeromaxing.go index c094d5d90..8ef6d43ec 100644 --- a/internal/agent/zeromaxing.go +++ b/internal/agent/zeromaxing.go @@ -55,22 +55,25 @@ const ( // ZeromaxingEnterNotice announces the flip. It fires once, on the first turn // of the run in which the posture was selected. ZeromaxingEnterNotice = "The zeromaxing execution posture is now active for this session. " + - "You have a substantially larger tool-turn budget than usual, so prefer verifying your work over assuming it: " + - "read the code you are about to change, run the checks that would catch a mistake, and follow up on anything you noticed but did not confirm." + "You have a substantially larger tool-turn budget than usual. Use it for depth on the task that was asked: " + + "read before changing, run the checks that would catch a mistake, and follow up on anything you noticed but could not confirm." // ZeromaxingBudgetNotice states the budget guideline alongside the enter // notice. Split from it so the two can be asserted independently, and so a // future budget change touches one string. - ZeromaxingBudgetNotice = "Budget guideline under zeromaxing: the larger turn budget is for depth on one task, not for widening its scope. " + - "Finish what was asked, verify it, and stop — do not start adjacent work because there are turns left." + ZeromaxingBudgetNotice = "Budget guideline under zeromaxing: the larger turn budget is for verifying one task thoroughly, not for starting extra work. " + + "Finish what was asked, confirm it, and stop." // ZeromaxingOrchestrateNotice is appended to the enter notice ONLY when the - // orchestrate tool is actually available this run. Phase 1 deliberately - // promised no orchestration; naming a tool the run does not have would be a - // prompt-level lie the model would try to act on, which is why this is - // conditional rather than folded into the enter notice. - ZeromaxingOrchestrateNotice = "You also have the orchestrate tool: it runs a declared plan of read-only sub-agent tasks in dependency order, sequentially. " + - "Use it when a task splits into independent read-heavy pieces; declare depends_on so the plan records which work was genuinely independent." + // orchestrate tool is actually available this run. It is the behavioral + // directive that turns zeromaxing from "more turns" into "multi-agent depth". + // Naming a tool the run does not have would be a prompt-level lie the model + // would try to act on, which is why this is conditional rather than folded + // into the enter notice. + ZeromaxingOrchestrateNotice = "You also have the orchestrate tool and background task support. " + + "For substantive work, split independent read/analyze pieces into a planned DAG with explicit depends_on, run them through the orchestrate tool, and verify the merged findings before acting. " + + "For long-running commands, prefer Bash with run_in_background. " + + "Work alone only on conversational turns or trivial single-file edits." // ZeromaxingStillOnNotice repeats on every continuing turn. // @@ -82,7 +85,7 @@ const ( // ZeromaxingExitNotice announces the flip back. It fires once, on the first // turn of the run after the posture was turned off. ZeromaxingExitNotice = "The zeromaxing execution posture is no longer active. " + - "The tool-turn budget has returned to its normal value; work at the usual depth." + "The tool-turn budget has returned to its normal value; work at the usual depth and avoid launching heavy multi-step side tasks." ) // zeromaxingReminders returns the reminder lines to append before the given diff --git a/internal/cli/app.go b/internal/cli/app.go index 9f06ef5c0..307d08f80 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -747,6 +747,8 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a // The TUI can carry a background plan: it has a session that // outlives a turn. Headless exec supplies no launcher. Launch: planLaunch.Launch, + // A write-capable plan gets a worktree of its own, or is refused. + Isolate: newPlanIsolator(workspaceRoot), }) if err != nil { return writeAppError(stderr, "failed to initialize specialist tools: "+err.Error(), 1) @@ -1126,6 +1128,8 @@ type orchestrateWiring struct { // Plans locates saved plans. Empty means a `saved` reference is refused with // a reason rather than searched for in nowhere. Plans specialist.PlanPaths + // Isolate prepares a worktree for a write-capable plan. nil refuses one. + Isolate specialist.PlanIsolator // Launch runs a plan in the background. nil — the headless default — makes // a background plan refuse rather than start one nothing can report. Launch func(run func(ctx context.Context)) bool @@ -1210,6 +1214,7 @@ func registerSpecialistTools(registry *tools.Registry, workspaceRoot string, max Size: wiring.Size, Plans: wiring.Plans, Launch: wiring.Launch, + Isolate: wiring.Isolate, }) return &agentToolRuntime{specialist: runtime, swarm: sw, specialists: specialistSummaries(paths)}, nil } diff --git a/internal/cli/exec.go b/internal/cli/exec.go index 8abea3859..8012ddf29 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -279,6 +279,9 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in // The SAME pair of directories the TUI uses, so a plan saved in // one surface is found by the other. Plans: execPlanPaths(workspaceRoot), + // Headless runs isolate too: --worktree already exists here, and + // a write-capable plan must not be the one path that skips it. + Isolate: newPlanIsolator(workspaceRoot), }) if err != nil { return writeExecProviderError(stdout, stderr, options.outputFormat, "specialist_error", err.Error()) diff --git a/internal/cli/plan_grant_test.go b/internal/cli/plan_grant_test.go index 92fc84303..c26e0efcd 100644 --- a/internal/cli/plan_grant_test.go +++ b/internal/cli/plan_grant_test.go @@ -219,3 +219,58 @@ func TestEveryPlanGrantToolIsReadOnlyByCapability(t *testing.T) { t.Fatal("no plan-grant tool was found in the registry; this test checked nothing") } } + +// BOTH SURFACES SUPPLY AN ISOLATOR. A write-capable plan is refused where none +// exists, so a surface that forgot to wire one would refuse every write plan +// with "this run cannot isolate" — correct, and useless. +func TestBothSurfacesWireAnIsolator(t *testing.T) { + if newPlanIsolator("") != nil { + t.Fatal("an empty workspace root produced an isolator") + } + if newPlanIsolator(t.TempDir()) == nil { + t.Fatal("a real workspace root produced no isolator") + } + + workspace := t.TempDir() + registry := newCoreRegistry(workspace) + runtime, err := registerSpecialistTools(registry, workspace, 0, nil, nil, nil, orchestrateWiring{ + Gate: &specialist.PostureGate{}, + Isolate: newPlanIsolator(workspace), + }) + if err != nil { + t.Fatalf("registerSpecialistTools: %v", err) + } + t.Cleanup(func() { closeSpecialistRuntime(nil, runtime) }) + registered, _ := registry.Get(specialist.OrchestrateToolName) + tool, ok := registered.(*specialist.OrchestrateTool) + if !ok { + t.Fatalf("orchestrate is %T", registered) + } + if tool.Isolate == nil { + t.Fatal("the wiring dropped the isolator; every write-capable plan would be refused") + } +} + +// The worktree NAME is derived, not taken. A plan name is model-supplied and +// reaches a filesystem path, so what comes out has to be a name — the allow-list +// in worktrees.Prepare is the guarantee, and this is what feeds it something it +// will accept rather than something it will reject. +func TestAPlanNameBecomesAUsableWorktreeName(t *testing.T) { + for _, tc := range []struct{ in, want string }{ + {"sweep", "plan-sweep"}, + {"pre release", "plan-prerelease"}, + {"../../etc/passwd", "plan-etcpasswd"}, + {"", "plan"}, + {"!!!", "plan"}, + {"a/b\\c;d", "plan-abcd"}, + } { + if got := planWorktreeName(tc.in); got != tc.want { + t.Errorf("planWorktreeName(%q) = %q, want %q", tc.in, got, tc.want) + } + } + // Long names are bounded, so a plan cannot produce a path component the + // filesystem refuses. + if got := planWorktreeName(strings.Repeat("x", 300)); len(got) > 64 { + t.Fatalf("a long plan name produced a %d-character worktree name", len(got)) + } +} diff --git a/internal/cli/plan_isolate.go b/internal/cli/plan_isolate.go new file mode 100644 index 000000000..4fa57740e --- /dev/null +++ b/internal/cli/plan_isolate.go @@ -0,0 +1,89 @@ +package cli + +import ( + "context" + "fmt" + "strings" + + "github.com/Gitlawb/zero/internal/specialist" + "github.com/Gitlawb/zero/internal/worktrees" +) + +// The isolator: a git worktree for a plan that may write. +// +// It reuses internal/worktrees, the same package `zero exec --worktree` already +// uses, rather than shelling out to git again. That matters beyond tidiness — +// worktrees.Prepare already handles name validation, the base directory, repo +// discovery from a subdirectory, and the difference between a checkout and a +// worktree. A second implementation would eventually disagree with the first +// about which of those a plan gets. +// +// NOT DELETED ON RELEASE. A plan that wrote something produced work the user has +// not seen yet, and removing the tree would remove the only copy of it. Release +// exists for the case where a plan is refused after the tree was prepared; the +// tree a plan actually wrote in stays, and its path is reported so it can be +// reviewed, merged, or thrown away deliberately. + +// planWorktreeName derives a worktree name from a plan name. +// +// worktrees.Prepare enforces its own allow-list on the result, so this only has +// to produce a candidate; anything it cannot clean is handed over as a prefix +// and Prepare supplies the rest. Deliberately NOT a sanitiser that promises +// safety — the guarantee lives in one place, where it is tested. +func planWorktreeName(planName string) string { + cleaned := strings.Map(func(r rune) rune { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + return r + case r == '-', r == '_': + return r + default: + return -1 + } + }, planName) + cleaned = strings.TrimSpace(cleaned) + if cleaned == "" { + return "plan" + } + if len(cleaned) > 40 { + cleaned = cleaned[:40] + } + return "plan-" + cleaned +} + +// newPlanIsolator returns an isolator rooted at the run's workspace, or nil when +// this run cannot isolate at all. +// +// nil is the meaningful answer, not an error: specialist refuses a plan that +// requires isolation when the isolator is nil, with a reason. Returning a +// non-nil isolator that always fails would move the same refusal later and make +// it read like a malfunction. +func newPlanIsolator(workspaceRoot string) specialist.PlanIsolator { + if strings.TrimSpace(workspaceRoot) == "" { + return nil + } + return func(ctx context.Context, planName string) (specialist.PlanWorkspace, error) { + prepared, err := worktrees.Prepare(ctx, worktrees.Options{ + Cwd: workspaceRoot, + Name: planWorktreeName(planName), + }) + if err != nil { + return specialist.PlanWorkspace{}, err + } + if strings.TrimSpace(prepared.Path) == "" { + // Defence in depth against a Prepare that reports success with no + // path: specialist refuses an un-isolated workspace, and this makes + // sure it sees one rather than an empty string it might read as the + // parent's own directory. + return specialist.PlanWorkspace{}, fmt.Errorf("prepared worktree has no path") + } + return specialist.PlanWorkspace{ + Path: prepared.Path, + Isolated: true, + Describe: fmt.Sprintf("worktree %s at %s", prepared.Name, prepared.Path), + // Release does NOT remove the tree. A plan that wrote produced work + // nobody has reviewed; deleting it would delete the only copy. + Release: func() {}, + }, nil + } +} diff --git a/internal/specialist/plan_exec.go b/internal/specialist/plan_exec.go index a4ffe0223..60a8bbedd 100644 --- a/internal/specialist/plan_exec.go +++ b/internal/specialist/plan_exec.go @@ -137,6 +137,12 @@ type PlanTaskRequest struct { ParentSessionID string ParentModel string ParentReasoningEffort string + // Cwd overrides where this task runs. Empty means the parent's workspace, + // which is every read-only plan. A write-capable plan sets it to its + // isolated worktree, and it is carried per REQUEST rather than captured in + // PlanTaskContext because the workspace belongs to a plan, not to the + // process — the same reason ParentModel lives here. + Cwd string // StallTimeout bounds how long this task may emit nothing. Resolved by // ExecutePlan from the plan's budget so every task in a plan shares one // answer, rather than each runner re-deriving it. @@ -162,6 +168,13 @@ type PlanRecorder interface { // The order comes from the same Kahn pass that proved the graph acyclic, so // admission and execution cannot disagree about it. func ExecutePlan(ctx context.Context, plan Plan, parentTools []string, run PlanRunner, recorder PlanRecorder) PlanReport { + return ExecutePlanIn(ctx, plan, PlanWorkspace{}, parentTools, run, recorder) +} + +// ExecutePlanIn is ExecutePlan with the plan's WORKSPACE named. ExecutePlan is +// the read-only case — the workspace a plan does not need — kept as the name +// every existing caller and test already uses. +func ExecutePlanIn(ctx context.Context, plan Plan, workspace PlanWorkspace, parentTools []string, run PlanRunner, recorder PlanRecorder) PlanReport { // THE PLAN'S OWN CONTEXT, derived here rather than by the caller. // // Cancelling it abandons the PLAN and leaves the TURN alive; cancelling the @@ -276,6 +289,7 @@ func ExecutePlan(ctx context.Context, plan Plan, parentTools []string, run PlanR result, err := runTaskWithRetries(ctx, retryPolicy{ task: task, tools: granted, + cwd: workspace.Path, stallTimeout: stallTimeout, maxRetries: plan.Budget().MaxRetries, deadline: deadline, @@ -332,6 +346,7 @@ func ExecutePlan(ctx context.Context, plan Plan, parentTools []string, run PlanR type retryPolicy struct { task Task tools []string + cwd string stallTimeout time.Duration maxRetries int deadline time.Time @@ -360,6 +375,7 @@ func runTaskWithRetries(ctx context.Context, policy retryPolicy, run PlanRunner) result, err = run(ctx, PlanTaskRequest{ Task: policy.task, Tools: policy.tools, + Cwd: policy.cwd, StallTimeout: policy.stallTimeout, }) if result.Duration == 0 { diff --git a/internal/specialist/plan_runner.go b/internal/specialist/plan_runner.go index ad8153d02..25e87f0d7 100644 --- a/internal/specialist/plan_runner.go +++ b/internal/specialist/plan_runner.go @@ -81,8 +81,11 @@ func NewPlanRunner(planCtx PlanTaskContext) PlanRunner { ParentModel: req.ParentModel, ParentReasoningEffort: req.ParentReasoningEffort, CurrentDepth: planCtx.Depth, - Cwd: planCtx.Cwd, - PermissionMode: planCtx.PermissionMode, + // The plan's own workspace when it has one, the parent's otherwise. + // A write-capable plan runs in an isolated worktree, and this is + // the line that puts its children there. + Cwd: planTaskCwd(planCtx.Cwd, req.Cwd), + PermissionMode: planCtx.PermissionMode, // THE SECOND HALF of the same defect. The Task tool forwards its // caller's progress callback here (task_tool.go); this path built // the same struct and omitted the field, so a plan task's child @@ -135,6 +138,15 @@ func NewPlanRunner(planCtx PlanTaskContext) PlanRunner { } } +// planTaskCwd prefers the plan's own workspace over the parent's. An empty +// override is the read-only case and means "wherever the parent runs". +func planTaskCwd(parentCwd, override string) string { + if strings.TrimSpace(override) != "" { + return override + } + return parentCwd +} + // planTaskManifest builds the inline manifest a plan task runs under. The tool // list is the ALREADY-INTERSECTED grant ExecutePlan computed, so this cannot // widen it — it only carries it. diff --git a/internal/specialist/plan_tool.go b/internal/specialist/plan_tool.go index 9d40cd564..9b7a5abe1 100644 --- a/internal/specialist/plan_tool.go +++ b/internal/specialist/plan_tool.go @@ -61,6 +61,10 @@ type OrchestrateTool struct { // It reports false when it will not run the work, so the tool can say so // instead of returning a run id for a plan nobody started. Launch func(run func(ctx context.Context)) bool + // Isolate prepares a worktree for a plan that may write. nil means this run + // cannot isolate, and a plan requiring it is REFUSED rather than run in the + // parent's tree — see resolvePlanWorkspace. + Isolate PlanIsolator // Plans locates saved plans. Both directories empty means saved plans are // simply unavailable — the tool refuses a `saved` reference with a reason // rather than searching nothing and reporting "not found", which would read @@ -351,14 +355,27 @@ func (tool *OrchestrateTool) RunWithOptions(ctx context.Context, args map[string "so a plan launched into the background could never report. Run it in the foreground, or use the interactive TUI.", } } + // THE SAME WORKSPACE RULE. Resolved here rather than inside the + // goroutine so a plan that cannot be isolated is refused NOW, with a + // reason the model reads, instead of failing invisibly on a later turn. + // Applying the rule to only one of the two dispatch paths is the defect + // this feature has produced three times. + workspace, err := resolvePlanWorkspace(ctx, plan, tool.Isolate) + if err != nil { + return tools.Result{Status: tools.StatusError, Output: "Error: " + err.Error()} + } run := tool.runnerForCall(options) parentTools := tool.ParentTools recorder := tool.Recorder launched := tool.Launch(func(backgroundCtx context.Context) { + defer workspace.Release() recordPlanAdmitted(recorder, plan) - report := ExecutePlan(backgroundCtx, plan, parentTools, run, recorder) + report := ExecutePlanIn(backgroundCtx, plan, workspace, parentTools, run, recorder) recordPlanCompleted(recorder, plan, report) }) + if !launched { + workspace.Release() + } if !launched { return tools.Result{ Status: tools.StatusError, @@ -377,8 +394,18 @@ func (tool *OrchestrateTool) RunWithOptions(ctx context.Context, args map[string } } + // WHERE IT RUNS. A read-only plan runs where the parent runs; one that can + // write gets a tree of its own or does not run at all. Resolved BEFORE the + // admission is recorded, so a refused plan leaves no record of having + // started. + workspace, err := resolvePlanWorkspace(ctx, plan, tool.Isolate) + if err != nil { + return tools.Result{Status: tools.StatusError, Output: "Error: " + err.Error()} + } + defer workspace.Release() + recordPlanAdmitted(tool.Recorder, plan) - report := ExecutePlan(ctx, plan, tool.ParentTools, tool.runnerForCall(options), tool.Recorder) + report := ExecutePlanIn(ctx, plan, workspace, tool.ParentTools, tool.runnerForCall(options), tool.Recorder) recordPlanCompleted(tool.Recorder, plan, report) result := tools.Result{ diff --git a/internal/specialist/plan_worktree.go b/internal/specialist/plan_worktree.go new file mode 100644 index 000000000..c20b87e9c --- /dev/null +++ b/internal/specialist/plan_worktree.go @@ -0,0 +1,103 @@ +package specialist + +import ( + "context" + "fmt" +) + +// Where a plan's tasks RUN, when they may write. +// +// STEP 2 OF THREE. Step 1 gave the approval prompt something to show; this +// gives a write-capable plan somewhere to write that is not the user's tree; +// step 3 allows write tools with both as preconditions. Nothing requires +// isolation today, and that is not an oversight — RequiresIsolation is DERIVED +// from the plan's own tools, so it is false while validation rejects write +// tools and becomes true the moment step 3 stops rejecting them. The +// requirement arrives with the capability rather than depending on someone +// remembering to set a flag. +// +// PER PLAN, NOT PER TASK, and the choice matters. Per-task isolation is more +// isolation and less use: this phase executes sequentially and a later task +// routinely needs an earlier one's output, so per-task worktrees would either +// hide that or need a merge step nobody wrote. A plan is the unit of work a +// user reviews, so a plan is the unit that gets a tree — one branch, one diff. +// +// FAIL CLOSED. A plan that requires isolation and cannot get it is REFUSED, +// never run in the parent tree with a warning. The whole point of the +// precondition is that it is one. + +// PlanWorkspace is the directory a plan's tasks run in. +type PlanWorkspace struct { + // Path is the working directory for every task in the plan. Empty means the + // parent's own workspace — the only valid answer for a plan that does not + // require isolation. + Path string + // Isolated reports that Path is a tree of its own rather than the parent's. + Isolated bool + // Describe is a short human phrase for the approval card and the run + // summary: the user is being asked to approve writes SOMEWHERE, and where is + // half the question. + Describe string + // Release is called when the plan ends. Never nil after a successful + // prepare, so a caller can defer it without a check. + Release func() +} + +// PlanIsolator prepares an isolated workspace for a plan. +// +// nil means this run CANNOT isolate — a headless run in a non-git directory, +// for instance — and a plan that requires isolation is then refused with that +// reason rather than quietly running in the user's tree. +type PlanIsolator func(ctx context.Context, planName string) (PlanWorkspace, error) + +// RequiresIsolation reports whether this plan may write, and therefore must not +// run in the parent's tree. +// +// DERIVED FROM THE PLAN, not declared by the caller. A task that names any tool +// outside the read-only set is a task that can change something, and that is +// exactly the condition isolation exists for. Today validateTaskTools rejects +// such a task, so this is always false; when step 3 permits write tools it +// becomes true without a second place needing to be updated — which is the +// class of defect this feature has produced three times. +func (p Plan) RequiresIsolation() bool { + for _, task := range p.tasks { + for _, name := range task.Tools { + if !planReadOnlyTools[name] { + return true + } + } + } + return false +} + +// resolvePlanWorkspace decides where a plan runs, refusing rather than +// degrading when a plan that must be isolated cannot be. +func resolvePlanWorkspace(ctx context.Context, plan Plan, isolate PlanIsolator) (PlanWorkspace, error) { + if !plan.RequiresIsolation() { + // A read-only plan runs where the parent runs. Preparing a worktree for + // it would cost a checkout to protect a tree nothing can touch. + return PlanWorkspace{Release: func() {}}, nil + } + if isolate == nil { + return PlanWorkspace{}, fmt.Errorf( + "plan %q contains tasks that can write, and this run cannot isolate them: "+ + "a write-capable plan runs in a git worktree of its own, and one is not available here. "+ + "Remove the write tools, or run from a git repository", plan.Name()) + } + workspace, err := isolate(ctx, plan.Name()) + if err != nil { + return PlanWorkspace{}, fmt.Errorf( + "plan %q contains tasks that can write and its isolated workspace could not be prepared: %w", plan.Name(), err) + } + if !workspace.Isolated || workspace.Path == "" { + // An isolator that returns success without an isolated path is a bug in + // the isolator, and honouring it would run write tasks in the user's + // tree — the exact outcome the precondition exists to prevent. + return PlanWorkspace{}, fmt.Errorf( + "plan %q requires isolation and the isolator returned none; refusing to run write-capable tasks in the parent workspace", plan.Name()) + } + if workspace.Release == nil { + workspace.Release = func() {} + } + return workspace, nil +} diff --git a/internal/specialist/plan_worktree_test.go b/internal/specialist/plan_worktree_test.go new file mode 100644 index 000000000..41724280e --- /dev/null +++ b/internal/specialist/plan_worktree_test.go @@ -0,0 +1,241 @@ +package specialist + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/Gitlawb/zero/internal/streamjson" +) + +// writePlan is a plan whose task names a write tool. It cannot be built through +// ParsePlan today — validateTaskTools refuses it, which is step 3's job to +// relax — so it is constructed directly to exercise the isolation rule that +// must already be correct when that happens. +func writePlan(t *testing.T, name string) Plan { + t.Helper() + base := mustPlan(t, []any{task("a", "x")}, okBudget(), readOnlyLimits()) + base.name = name + base.tasks[0].Tools = []string{"write_file"} + return base +} + +// ISOLATION IS DERIVED FROM THE PLAN, not declared by a caller. That is what +// makes step 3 safe by construction: the moment write tools are permitted, the +// requirement appears without a second place needing to be updated. +func TestIsolationIsRequiredExactlyWhenAPlanCanWrite(t *testing.T) { + readOnly := mustPlan(t, []any{ + map[string]any{"id": "a", "prompt": "x", "tools": []any{"grep", "read_file"}}, + }, okBudget(), readOnlyLimits()) + if readOnly.RequiresIsolation() { + t.Fatal("a read-only plan asked for isolation it does not need") + } + if !writePlan(t, "w").RequiresIsolation() { + t.Fatal("a plan holding write_file did not require isolation") + } + // A task with NO explicit tools inherits the parent's read-only grant, so it + // cannot write and must not force a worktree. + inherited := mustPlan(t, []any{task("a", "x")}, okBudget(), readOnlyLimits()) + if inherited.RequiresIsolation() { + t.Fatal("a task inheriting the read-only grant asked for isolation") + } +} + +// A read-only plan runs where the parent runs. Preparing a worktree for it would +// cost a checkout to protect a tree nothing can touch. +func TestAReadOnlyPlanIsNotIsolated(t *testing.T) { + called := false + isolate := func(context.Context, string) (PlanWorkspace, error) { + called = true + return PlanWorkspace{Path: "/tmp/nope", Isolated: true}, nil + } + workspace, err := resolvePlanWorkspace(context.Background(), + mustPlan(t, []any{task("a", "x")}, okBudget(), readOnlyLimits()), isolate) + if err != nil { + t.Fatalf("resolvePlanWorkspace: %v", err) + } + if called { + t.Fatal("a read-only plan prepared a worktree") + } + if workspace.Path != "" || workspace.Isolated { + t.Fatalf("a read-only plan got an isolated workspace: %+v", workspace) + } + if workspace.Release == nil { + t.Fatal("Release must always be callable") + } +} + +// FAIL CLOSED, and this is the whole point of the precondition. A plan that can +// write and cannot be isolated is REFUSED — never run in the user's tree with a +// warning. +func TestAWritePlanWithNoIsolatorIsRefused(t *testing.T) { + _, err := resolvePlanWorkspace(context.Background(), writePlan(t, "sweep"), nil) + if err == nil { + t.Fatal("a write-capable plan ran without isolation") + } + for _, want := range []string{"sweep", "cannot isolate", "git repository"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal must say %q: %v", want, err) + } + } +} + +// ...and one whose preparation FAILS is refused too, carrying the reason rather +// than falling back. +func TestAWritePlanWhoseWorktreeFailsIsRefused(t *testing.T) { + isolate := func(context.Context, string) (PlanWorkspace, error) { + return PlanWorkspace{}, context.DeadlineExceeded + } + _, err := resolvePlanWorkspace(context.Background(), writePlan(t, "sweep"), isolate) + if err == nil { + t.Fatal("a write-capable plan ran after its worktree failed to prepare") + } + if !strings.Contains(err.Error(), "could not be prepared") { + t.Fatalf("the refusal must carry the reason: %v", err) + } +} + +// AN ISOLATOR THAT REPORTS SUCCESS WITHOUT ISOLATING IS REFUSED. Honouring it +// would run write tasks in the parent tree — the exact outcome the precondition +// exists to prevent — and it is the failure a buggy isolator produces, not a +// hostile one. +func TestAnIsolatorThatDidNotIsolateIsRefused(t *testing.T) { + for _, bad := range []PlanWorkspace{ + {Path: "/tmp/x", Isolated: false}, + {Path: "", Isolated: true}, + {}, + } { + _, err := resolvePlanWorkspace(context.Background(), writePlan(t, "sweep"), + func(context.Context, string) (PlanWorkspace, error) { return bad, nil }) + if err == nil { + t.Errorf("an isolator returning %+v was honoured", bad) + } + } +} + +// A prepared workspace is always releasable, so a caller can defer it without a +// nil check — the check nobody writes. +func TestAPreparedWorkspaceIsAlwaysReleasable(t *testing.T) { + workspace, err := resolvePlanWorkspace(context.Background(), writePlan(t, "sweep"), + func(context.Context, string) (PlanWorkspace, error) { + return PlanWorkspace{Path: "/tmp/x", Isolated: true}, nil + }) + if err != nil { + t.Fatalf("resolvePlanWorkspace: %v", err) + } + workspace.Release() +} + +// THE WORKSPACE REACHES THE TASKS. A worktree prepared and not used is the +// isolation equivalent of a guard that cannot fire. +func TestTheWorkspaceReachesEveryTask(t *testing.T) { + plan := mustPlan(t, []any{task("a", "x"), task("b", "y", "a")}, okBudget(), readOnlyLimits()) + seen := []string{} + ExecutePlanIn(context.Background(), plan, PlanWorkspace{Path: "/plan/tree", Isolated: true}, + []string{"read_file"}, func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + seen = append(seen, req.Cwd) + return TaskResult{Outcome: TaskSucceeded}, nil + }, nil) + if len(seen) != 2 { + t.Fatalf("ran %d tasks", len(seen)) + } + for _, cwd := range seen { + if cwd != "/plan/tree" { + t.Fatalf("a task ran in %q, not the plan's workspace", cwd) + } + } +} + +// ...and a plan with no workspace hands the tasks nothing, so the runner falls +// back to the parent's directory rather than to an empty string. +func TestNoWorkspaceMeansTheParentsDirectory(t *testing.T) { + plan := mustPlan(t, []any{task("a", "x")}, okBudget(), readOnlyLimits()) + var got string + ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + got = req.Cwd + return TaskResult{Outcome: TaskSucceeded}, nil + }, nil) + if got != "" { + t.Fatalf("a read-only task was handed cwd %q", got) + } + if resolved := planTaskCwd("/parent", ""); resolved != "/parent" { + t.Fatalf("an empty override must fall back to the parent: %q", resolved) + } + if resolved := planTaskCwd("/parent", "/plan/tree"); resolved != "/plan/tree" { + t.Fatalf("the plan's workspace must win: %q", resolved) + } +} + +// THE TOOL REFUSES A WRITE PLAN IT CANNOT ISOLATE, on the FOREGROUND path. +func TestTheToolRefusesAnUnisolatableWritePlan(t *testing.T) { + tool := &OrchestrateTool{ + PostureActive: func() bool { return true }, + RunTask: func(context.Context, PlanTaskRequest) (TaskResult, error) { + t.Fatal("a write-capable plan ran without isolation") + return TaskResult{}, nil + }, + ParentTools: []string{"read_file"}, + } + // Reaching the workspace check needs a plan that admits, so this exercises + // resolvePlanWorkspace directly against the tool's own isolator field. + if _, err := resolvePlanWorkspace(context.Background(), writePlan(t, "w"), tool.Isolate); err == nil { + t.Fatal("the tool would have run a write plan with no isolator") + } +} + +// THE REAL RUNNER MUST PUT THE CHILD IN THE PLAN'S WORKSPACE. +// +// Every test above drives ExecutePlanIn with a FAKE runner that reads req.Cwd, +// so all of them pass against a real runner that ignores it — the same shape as +// the Stalled flag, where a fake that fabricates its own inputs cannot test the +// producer. This drives NewPlanRunner and reads the argument the child actually +// receives. +func TestTheRealRunnerLaunchesTheChildInThePlansWorkspace(t *testing.T) { + capture := func(t *testing.T, override string) []string { + t.Helper() + var childArgs []string + executor := Executor{ + BinaryPath: "/bin/true", + NewSessionID: func() (string, error) { return "specialist_0000000000000000000000ww", nil }, + Load: func(LoadOptions) (LoadResult, error) { return LoadResult{}, nil }, + RunChild: func(_ context.Context, _ string, args []string, _ func(streamjson.Event)) (ChildRunResult, error) { + childArgs = args + return ChildRunResult{Started: true, ExitCode: 0}, nil + }, + } + runner := NewPlanRunner(PlanTaskContext{ + Executor: executor, Cwd: "/parent/workspace", SpecialistName: "explorer", + }) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if _, err := runner(ctx, PlanTaskRequest{ + Task: Task{ID: "a", Prompt: "x"}, + Tools: []string{"read_file"}, + Cwd: override, + StallTimeout: time.Minute, + }); err != nil { + t.Fatalf("runner: %v", err) + } + return childArgs + } + + cwdArg := func(args []string) string { + for index, arg := range args { + if arg == "--cwd" && index+1 < len(args) { + return args[index+1] + } + } + return "" + } + + if got := cwdArg(capture(t, "/plan/tree")); got != "/plan/tree" { + t.Fatalf("the child ran in %q, not the plan's isolated workspace", got) + } + // ...and with no override it is the parent's, not empty — an empty --cwd + // would put a child somewhere neither the plan nor the parent chose. + if got := cwdArg(capture(t, "")); got != "/parent/workspace" { + t.Fatalf("a read-only task ran in %q, not the parent's workspace", got) + } +} From 7595c82afa4aa030193e16f40f3227955d16f3b0 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:07:18 +0530 Subject: [PATCH 48/86] feat(specialist): a plan task may write, if it asks and is isolated (write-task arc, 3/3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last step, and the only one that changes what a plan may DO. It is safe to make because the previous two exist: an approval that can show the plan (b52ed77) and a worktree of the plan's own (b2673f8). Making it first would have been asking a user to approve something the screen could not describe, into a tree they had not agreed to have changed. WRITING IS NAMED, NEVER INHERITED. A task that names no tools still gets the read-only grant and nothing else; a task that wants to change something says which tool. Otherwise every unqualified task in every plan becomes write-capable the day the parent grant widens — and the grant DID widen here, which is exactly when that would have happened. AN ALLOW-LIST, not "anything not read-only": write_file, edit_file, apply_patch, bash, exec_command. The negative form would hand a plan task every future tool the moment it is registered, including ones nobody considered when this was written. Every deny-list in this repository has leaked. BOTH GRANT SITES MOVED TOGETHER. validateTaskTools permits a named write tool and planToolGrant delivers one; relaxing admission alone would have produced a task that validated and then ran with less than it asked for, silently. That pair is the sibling rule this feature exists to respect. PROMPTING IS ARGUMENT-AWARE because it has to be: Safety() sees no arguments, so it can only describe the tool, and "this plan writes" is a property of the PLAN. A read-only plan keeps PermissionAllow — friction with nothing to show for it is the click-through problem. An unreadable plan, or a `saved` one whose tasks are in a file the check has not opened, errs toward ASKING: a wrong guess that way costs one prompt, the other way runs write tasks unasked. THE ADDITIVITY GUARD THIS NEEDED. Implementing ArgsPermissioner makes permanentlyDenied return false — deliberately, since only a tool with no per-argument override can be ruled out from its static permission. But orchestrate is gated on the POSTURE, which is a session state and not an argument, so it would have counted as held in every run merely for implementing the interface, and the confirmation policy would have appeared in read-only runs that never turned the feature on. tools.PermanentDenier lets a tool say what ArgsPermissioner cannot, and it is consulted first. Five modes still byte-identical. Seven mutations, all caught. Two existing tests encoded the OLD rule and were rewritten to the new one rather than around it — and TestBothGrantEnforcement PointsAgree now CALLS validateTaskTools instead of restating it, so the next time the rule moves the test moves with it rather than failing against correct code. Verified in the real binary: a write-capable plan outside a git repository is refused with the reason, and inside one it gets a worktree named for the plan. --- internal/agent/system_prompt.go | 9 ++ internal/cli/app.go | 7 +- internal/cli/plan_grant_test.go | 33 ++++- internal/specialist/plan.go | 61 ++++++++- internal/specialist/plan_exec.go | 24 +++- internal/specialist/plan_grant_test.go | 12 +- internal/specialist/plan_test.go | 55 ++++++-- internal/specialist/plan_tool.go | 60 +++++++++ internal/specialist/plan_write_test.go | 172 +++++++++++++++++++++++++ internal/tools/types.go | 16 +++ 10 files changed, 423 insertions(+), 26 deletions(-) create mode 100644 internal/specialist/plan_write_test.go diff --git a/internal/agent/system_prompt.go b/internal/agent/system_prompt.go index d69be0314..5261f0986 100644 --- a/internal/agent/system_prompt.go +++ b/internal/agent/system_prompt.go @@ -117,6 +117,15 @@ func runCanMutate(options Options) bool { // succeed, and only a tool with no per-argument override can be ruled out from // its static permission alone. Fail closed. func permanentlyDenied(tool tools.Tool) bool { + // A tool that KNOWS it cannot fire is believed first, before the + // argument-varying escape below. Otherwise a tool gated on something that is + // not an argument — the zeromaxing posture is a session state, not a + // parameter — would count as held in every run merely for implementing + // ArgsPermissioner, and the confirmation policy would appear in read-only + // runs that do not have the feature turned on at all. + if denier, ok := tool.(tools.PermanentDenier); ok && denier.PermanentlyDenied() { + return true + } if _, varies := tool.(tools.ArgsPermissioner); varies { return false } diff --git a/internal/cli/app.go b/internal/cli/app.go index 307d08f80..738752650 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -1145,7 +1145,12 @@ type orchestrateWiring struct { // left nil, which silently disabled the narrowing rule entirely. func planParentTools(registry *tools.Registry, enabledTools, disabledTools []string) []string { grant := []string{} - for _, name := range specialist.PlanReadOnlyToolNames() { + // EVERY GRANTABLE NAME, read-only and write alike. The write tools are not + // thereby granted to anything: a task inherits only the read-only ones + // (planToolGrant) and must NAME a write tool to hold it. Narrowing this list + // to read-only would instead make a named write tool undeliverable — the + // task would validate and then run with less than it asked for. + for _, name := range specialist.PlanGrantableToolNames() { if _, found := registry.Get(name); !found { continue } diff --git a/internal/cli/plan_grant_test.go b/internal/cli/plan_grant_test.go index c26e0efcd..9fe2d0065 100644 --- a/internal/cli/plan_grant_test.go +++ b/internal/cli/plan_grant_test.go @@ -47,9 +47,27 @@ func TestRegisteredOrchestrateToolCarriesTheRunsGrant(t *testing.T) { if len(tool.ParentTools) == 0 { t.Fatal("the registered orchestrate tool holds no parent grant, so the narrowing rule cannot fire") } - want := specialist.PlanReadOnlyToolNames() - if strings.Join(tool.ParentTools, ",") != strings.Join(want, ",") { - t.Fatalf("unfiltered grant = %v, want the full read-only set %v", tool.ParentTools, want) + // The grant is every GRANTABLE name this registry holds — read-only plus the + // write tools a task may name — intersected with what the registry actually + // has. Comparing against the full list would fail for a registry missing one + // of them, so it is asserted as a subset with the read-only half required. + grantable := map[string]bool{} + for _, name := range specialist.PlanGrantableToolNames() { + grantable[name] = true + } + for _, name := range tool.ParentTools { + if !grantable[name] { + t.Fatalf("grant contains %q, which a plan task may never hold", name) + } + } + held := map[string]bool{} + for _, name := range tool.ParentTools { + held[name] = true + } + for _, name := range specialist.PlanReadOnlyToolNames() { + if _, found := newCoreRegistry(t.TempDir()).Get(name); found && !held[name] { + t.Fatalf("the unfiltered grant is missing read-only tool %q", name) + } } } @@ -89,9 +107,14 @@ func TestRegisteredOrchestrateGrantIsEmptyWhenTheRunHoldsNoReadTools(t *testing. // The candidate set comes from specialist's exported list, not a second copy // maintained here. Two duplicated lists drift (invariant 5). -func TestPlanParentToolsNeverExceedsThePlanReadOnlySet(t *testing.T) { +func TestPlanParentToolsNeverExceedsWhatAPlanMayHold(t *testing.T) { + // The bound is PlanGrantableToolNames, not PlanReadOnlyToolNames, and the + // difference is the point of the write-task arc: a plan task inherits only + // read-only tools, and may NAME one of a short write allow-list. The grant + // has to be able to deliver a named write tool or the task would validate + // and then run with less than it asked for. allowed := map[string]bool{} - for _, name := range specialist.PlanReadOnlyToolNames() { + for _, name := range specialist.PlanGrantableToolNames() { allowed[name] = true } for _, name := range planParentTools(newCoreRegistry(t.TempDir()), nil, nil) { diff --git a/internal/specialist/plan.go b/internal/specialist/plan.go index bab422f81..b68b9639b 100644 --- a/internal/specialist/plan.go +++ b/internal/specialist/plan.go @@ -368,12 +368,23 @@ func validateTaskTools(task Task, limits Limits) error { parent[name] = true } for _, name := range task.Tools { - // READ-ONLY, by allow-list. Phase 2 tasks cannot write, edit or run - // shell: granting that is an authority widening that needs its own - // decision, not a side effect of shipping plan capture. - if !planReadOnlyTools[name] { - return fmt.Errorf("task %q requests tool %q; plan tasks are read-only in this phase and may only use: %s", - task.ID, name, strings.Join(sortedReadOnlyTools(), ", ")) + // A TASK MAY NOW NAME A WRITE TOOL, and only by naming it. + // + // The read-only allow-list used to be the whole rule. It is now the + // DEFAULT — a task that names nothing still inherits read-only tools and + // nothing else (planToolGrant) — and a task that wants to change + // something has to say which tool, by name. Writing is opted into per + // task, never inherited. + // + // What still bounds it: the tool must be on the GRANTABLE allow-list — + // read-only, or one of the few write tools a plan may name — and it must + // be one the PARENT holds (below). A plan containing any such task + // cannot run outside an isolated worktree (Plan.RequiresIsolation) or + // without an approval that shows it (PermissionForArgs). Those two are + // why this line could be relaxed at all. + if !planReadOnlyTools[name] && !planWriteTools[name] { + return fmt.Errorf("task %q requests tool %q, which a plan task may never hold; it may use %s, or name one of %s to write", + task.ID, name, strings.Join(sortedReadOnlyTools(), ", "), strings.Join(PlanWriteToolNames(), ", ")) } // UNCONDITIONAL. Guarding this on len(limits.ParentTools) > 0 is what // made the rule inert: neither production call site supplied a grant, @@ -412,6 +423,44 @@ func sortedReadOnlyTools() []string { return names } +// PlanWriteToolNames is the sorted set of MUTATING tools a plan task may hold, +// and only ever by naming one explicitly. +// +// An ALLOW-LIST, deliberately, and a short one. The alternative — "anything the +// parent holds that is not read-only" — would hand a plan task every future +// tool the moment it is registered, including ones nobody considered when this +// was written. Every deny-list in this repository has leaked. +// +// Kept narrow on purpose: editing files and running commands is what a +// write-capable plan is for. Network, browser and process-retention tools are +// not on it, and adding one is a decision, not a configuration. +func PlanWriteToolNames() []string { + names := make([]string, 0, len(planWriteTools)) + for name := range planWriteTools { + names = append(names, name) + } + sort.Strings(names) + return names +} + +var planWriteTools = map[string]bool{ + "write_file": true, + "edit_file": true, + "apply_patch": true, + "bash": true, + "exec_command": true, +} + +// PlanGrantableToolNames is every tool a plan task may hold by any route: the +// read-only default plus the write tools that must be named. ONE list for the +// caller building a parent grant, so the grant and the validator cannot come to +// disagree about what is grantable. +func PlanGrantableToolNames() []string { + names := append(PlanReadOnlyToolNames(), PlanWriteToolNames()...) + sort.Strings(names) + return names +} + // PlanReadOnlyToolNames is the sorted set of tools a plan task may ever hold. // // Exported so a caller building Limits.ParentTools intersects against the SAME diff --git a/internal/specialist/plan_exec.go b/internal/specialist/plan_exec.go index 60a8bbedd..c08c596ab 100644 --- a/internal/specialist/plan_exec.go +++ b/internal/specialist/plan_exec.go @@ -459,6 +459,11 @@ func planToolGrant(task Task, parentTools []string) ([]string, error) { } out := []string{} if len(task.Tools) == 0 { + // THE DEFAULT STAYS READ-ONLY even though a named write tool is now + // permitted. A task that asked for nothing must not inherit the ability + // to change things — writing is opted into per task, by name, or every + // unqualified task in every plan silently becomes write-capable the day + // the parent grant widens. for _, name := range parentTools { if planReadOnlyTools[name] { out = append(out, name) @@ -466,11 +471,22 @@ func planToolGrant(task Task, parentTools []string) ([]string, error) { } } else { for _, name := range task.Tools { - // UNCONDITIONAL on both sides: read-only AND held by the parent. + // THE PARENT'S GRANT, unconditionally. The read-only half of this + // check moved out with its sibling in validateTaskTools — a named + // write tool is now permitted — and the two had to move TOGETHER: + // admission permitting what dispatch drops would produce a task that + // validated and then ran with less than it asked for, silently. + // // The old "only check the parent when it supplied a list" form was - // what let a task widen its authority whenever the grant was - // unwired. - if !planReadOnlyTools[name] || !parent[name] { + // what let a task widen its authority whenever the grant was unwired, + // and that half is untouched. + // The same two bounds as admission, in the same order: grantable at + // all, then held by the parent. Dropping rather than refusing is + // what makes this the narrowing layer. + if !planReadOnlyTools[name] && !planWriteTools[name] { + continue + } + if !parent[name] { continue } out = append(out, name) diff --git a/internal/specialist/plan_grant_test.go b/internal/specialist/plan_grant_test.go index 9cc9109aa..e06c68c4a 100644 --- a/internal/specialist/plan_grant_test.go +++ b/internal/specialist/plan_grant_test.go @@ -252,8 +252,16 @@ func TestBothGrantEnforcementPointsAgree(t *testing.T) { return } for _, name := range granted { - if !planReadOnlyTools[name] { - t.Fatalf("dispatch granted %q, which is not read-only; admission would have rejected it", name) + // ADMISSION IS ASKED, not re-described. This used to assert + // "granted implies read-only", which was a restatement of the + // rule as it stood — so when the rule changed to permit a named + // write tool, the test failed against correct code and would + // have been "fixed" by loosening the very thing it guards. + // Calling validateTaskTools makes the assertion the invariant + // itself: whatever dispatch hands over, admission would have + // allowed, whatever admission currently means. + if err := validateTaskTools(Task{ID: "a", Prompt: "p", Tools: []string{name}}, limits); err != nil { + t.Fatalf("dispatch granted %q, which admission would have rejected: %v", name, err) } if !containsGrant(tc.parent, name) { t.Fatalf("dispatch granted %q, which the parent does not hold %v; "+ diff --git a/internal/specialist/plan_test.go b/internal/specialist/plan_test.go index 0dc63cc3d..d1e421bd3 100644 --- a/internal/specialist/plan_test.go +++ b/internal/specialist/plan_test.go @@ -287,18 +287,57 @@ func TestTaskCountAgreesBetweenAdmissionAndExecution(t *testing.T) { } // (n) A write tool is rejected — Phase 2 tasks are read-only. -func TestParsePlanRejectsWriteTools(t *testing.T) { +func TestAWriteToolIsPermittedOnlyWhenTheParentHoldsIt(t *testing.T) { + // THE RULE CHANGED, and this test changed with it rather than around it. + // + // It used to assert that a write tool is rejected "even if the PARENT holds + // it", because plan tasks were read-only by construction. Write-capable + // tasks are now permitted — gated on an approval that can show the plan and + // on an isolated worktree — so the remaining bound is the parent's grant, + // and that is what this now pins. for _, tool := range []string{"write_file", "edit_file", "apply_patch", "bash", "exec_command"} { raw := task("a", "x") raw["tools"] = []any{tool} - limits := readOnlyLimits() - limits.ParentTools = append(limits.ParentTools, tool) // even if the PARENT holds it - _, err := ParsePlan(planArgs([]any{raw}, okBudget()), limits) - if err == nil { - t.Fatalf("tool %q must be rejected: plan tasks are read-only", tool) + + held := readOnlyLimits() + held.ParentTools = append(held.ParentTools, tool) + plan, err := ParsePlan(planArgs([]any{raw}, okBudget()), held) + if err != nil { + t.Fatalf("tool %q is held by the parent and must be permitted: %v", tool, err) } - if !strings.Contains(err.Error(), "read-only") { - t.Fatalf("the error must say why: %v", err) + // ...and naming it is what makes the plan require isolation, which is + // the precondition that let this rule relax at all. + if !plan.RequiresIsolation() { + t.Fatalf("a plan naming %q does not require isolation", tool) + } + + withheld := readOnlyLimits() + if _, err := ParsePlan(planArgs([]any{raw}, okBudget()), withheld); err == nil { + t.Fatalf("tool %q is NOT held by the parent and must be refused", tool) + } + } +} + +// A task that names NOTHING stays read-only. Writing is opted into per task, by +// name — otherwise every unqualified task in every plan becomes write-capable +// the day the parent grant widens. +func TestAnUnqualifiedTaskNeverInheritsWriteTools(t *testing.T) { + limits := readOnlyLimits() + limits.ParentTools = append(limits.ParentTools, "write_file") + plan, err := ParsePlan(planArgs([]any{task("a", "x")}, okBudget()), limits) + if err != nil { + t.Fatalf("ParsePlan: %v", err) + } + if plan.RequiresIsolation() { + t.Fatal("a task that named no tools was treated as write-capable") + } + granted, err := planToolGrant(plan.Tasks()[0], limits.ParentTools) + if err != nil { + t.Fatalf("planToolGrant: %v", err) + } + for _, name := range granted { + if name == "write_file" { + t.Fatal("an unqualified task inherited write_file from the parent grant") } } } diff --git a/internal/specialist/plan_tool.go b/internal/specialist/plan_tool.go index 9b7a5abe1..0230cdeb2 100644 --- a/internal/specialist/plan_tool.go +++ b/internal/specialist/plan_tool.go @@ -216,6 +216,66 @@ func (tool *OrchestrateTool) Safety() tools.Safety { } } +// PermanentlyDenied reports that no arguments can make this tool callable while +// the posture is off. The posture is a session state, not a parameter, and +// ArgsPermissioner cannot express that — see tools.PermanentDenier. +func (tool *OrchestrateTool) PermanentlyDenied() bool { return !tool.postureActive() } + +// PermissionForArgs is what makes a WRITE-CAPABLE plan ask, and a read-only one +// not. +// +// Safety() cannot decide this: it sees no arguments, so it can only describe the +// tool, and "orchestrate may write" is a property of the PLAN. A read-only plan +// keeps PermissionAllow — prompting for it would add friction with no safety to +// show for it, which is the click-through argument Safety() records. A plan that +// can write is a different decision and gets a different answer. +// +// The card that answers it can now show the plan (the permission detail +// renderer) and the work lands in a worktree of the plan's own (the isolator). +// Prompting before either existed would have been asking a user to approve +// something the screen could not describe. +func (tool *OrchestrateTool) PermissionForArgs(args map[string]any) tools.Permission { + if !tool.postureActive() { + return tools.PermissionDeny + } + if tool.argsCanWrite(args) { + return tools.PermissionPrompt + } + return tools.PermissionAllow +} + +// argsCanWrite reports whether the arguments describe a plan that could change +// something. +// +// It reads the ARGUMENTS rather than a parsed Plan because the permission +// decision happens before parsing, and it errs toward PROMPTING: a saved plan +// whose tasks live on disk, or arguments this cannot read, are treated as +// possibly-writing. A wrong guess in that direction costs one prompt; the other +// direction runs write tasks without asking. +func (tool *OrchestrateTool) argsCanWrite(args map[string]any) bool { + if planString(args, "saved") != "" { + // The tasks are in a file this has not opened. Ask. + return true + } + rawTasks, ok := args["tasks"].([]any) + if !ok { + return false + } + for _, raw := range rawTasks { + task, ok := raw.(map[string]any) + if !ok { + // Unreadable entry: ask rather than assume it is harmless. + return true + } + for _, name := range planStrings(task, "tools") { + if !planReadOnlyTools[name] { + return true + } + } + } + return false +} + func (tool *OrchestrateTool) Run(ctx context.Context, args map[string]any) tools.Result { return tool.RunWithOptions(ctx, args, tools.RunOptions{}) } diff --git a/internal/specialist/plan_write_test.go b/internal/specialist/plan_write_test.go new file mode 100644 index 000000000..ecb61708b --- /dev/null +++ b/internal/specialist/plan_write_test.go @@ -0,0 +1,172 @@ +package specialist + +import ( + "context" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/tools" +) + +func writeCapableArgs() map[string]any { + return map[string]any{ + "name": "fixit", + "tasks": []any{ + map[string]any{"id": "a", "prompt": "read it"}, + map[string]any{"id": "b", "prompt": "fix it", "depends_on": []any{"a"}, "tools": []any{"write_file"}}, + }, + "budget": map[string]any{"max_workers": float64(1)}, + } +} + +func writeCapableTool(t *testing.T, isolate PlanIsolator) *OrchestrateTool { + t.Helper() + return &OrchestrateTool{ + PostureActive: func() bool { return true }, + RunTask: func(context.Context, PlanTaskRequest) (TaskResult, error) { + return TaskResult{Outcome: TaskSucceeded}, nil + }, + ParentTools: append(PlanReadOnlyToolNames(), "write_file"), + Isolate: isolate, + } +} + +// THE ARC, ASSERTED AS ONE THING. A write-capable plan must PROMPT (step 1 gave +// the card something to show) and must be ISOLATED (step 2 gave it somewhere to +// write). Either missing and the relaxation in step 3 was not safe to make. +func TestAWriteCapablePlanPromptsAndIsIsolated(t *testing.T) { + tool := writeCapableTool(t, nil) + + if got := tool.PermissionForArgs(writeCapableArgs()); got != tools.PermissionPrompt { + t.Fatalf("permission = %v; a plan that can write must ask", got) + } + plan, err := ParsePlan(writeCapableArgs(), Limits{MaxTasks: 20, ParentTools: tool.ParentTools}) + if err != nil { + t.Fatalf("a write-capable plan must now admit: %v", err) + } + if !plan.RequiresIsolation() { + t.Fatal("a write-capable plan does not require isolation") + } + // ...and with no isolator it does not run at all. + if _, err := resolvePlanWorkspace(context.Background(), plan, nil); err == nil { + t.Fatal("a write-capable plan ran with no isolation available") + } +} + +// A READ-ONLY PLAN IS UNCHANGED by all of it: no prompt, no worktree. The +// friction is paid only by the plans that earn it. +func TestAReadOnlyPlanStillDoesNotPromptOrIsolate(t *testing.T) { + tool := writeCapableTool(t, nil) + args := map[string]any{ + "tasks": []any{map[string]any{"id": "a", "prompt": "look", "tools": []any{"grep"}}}, + "budget": map[string]any{"max_workers": float64(1)}, + } + if got := tool.PermissionForArgs(args); got != tools.PermissionAllow { + t.Fatalf("permission = %v; a read-only plan must not prompt", got) + } + plan, err := ParsePlan(args, Limits{MaxTasks: 20, ParentTools: tool.ParentTools}) + if err != nil { + t.Fatalf("ParsePlan: %v", err) + } + if plan.RequiresIsolation() { + t.Fatal("a read-only plan asked for a worktree") + } +} + +// THE POSTURE STILL GATES EVERYTHING. PermissionForArgs must deny with the +// posture off whatever the arguments say, or the args-aware permission has +// become a way around the gate. +func TestThePostureGateSurvivesTheArgsAwarePermission(t *testing.T) { + off := &OrchestrateTool{PostureActive: func() bool { return false }} + for _, args := range []map[string]any{writeCapableArgs(), {}, {"saved": "x"}} { + if got := off.PermissionForArgs(args); got != tools.PermissionDeny { + t.Fatalf("permission = %v with the posture off; it must deny", got) + } + } + if !off.PermanentlyDenied() { + t.Fatal("with the posture off the tool must report itself permanently denied") + } + on := &OrchestrateTool{PostureActive: func() bool { return true }} + if on.PermanentlyDenied() { + t.Fatal("with the posture on the tool must not report itself denied") + } +} + +// ERRING TOWARD ASKING. A saved plan's tasks are in a file the permission check +// has not opened, and an unreadable entry could be anything. A wrong guess +// toward prompting costs one prompt; the other way runs write tasks unasked. +func TestAnUnreadablePlanErrsTowardPrompting(t *testing.T) { + tool := writeCapableTool(t, nil) + for _, args := range []map[string]any{ + {"saved": "sweep"}, + {"tasks": []any{"not-an-object"}}, + {"tasks": []any{map[string]any{"id": "a", "tools": []any{"something_new"}}}}, + } { + if got := tool.PermissionForArgs(args); got != tools.PermissionPrompt { + t.Errorf("args %v gave %v; an unreadable plan must ask", args, got) + } + } +} + +// WRITE TOOLS ARE AN ALLOW-LIST. "Anything not read-only" would hand a plan +// every future tool the moment it is registered. +func TestOnlyNamedWriteToolsArePermitted(t *testing.T) { + limits := Limits{MaxTasks: 20, ParentTools: append(PlanGrantableToolNames(), "browser_open", "web_fetch")} + for _, name := range []string{"browser_open", "web_fetch", "kill_shell", "made_up"} { + args := map[string]any{ + "tasks": []any{map[string]any{"id": "a", "prompt": "x", "tools": []any{name}}}, + "budget": map[string]any{"max_workers": float64(1)}, + } + if _, err := ParsePlan(args, limits); err == nil { + t.Errorf("tool %q was permitted; the write set is an allow-list", name) + } + } + for _, name := range PlanWriteToolNames() { + args := map[string]any{ + "tasks": []any{map[string]any{"id": "a", "prompt": "x", "tools": []any{name}}}, + "budget": map[string]any{"max_workers": float64(1)}, + } + if _, err := ParsePlan(args, limits); err != nil { + t.Errorf("write tool %q must be permitted when the parent holds it: %v", name, err) + } + } +} + +// The refusal NAMES what is available, both halves — a message that says "no" +// without saying "these instead" makes the caller guess. +func TestTheRefusalNamesBothToolSets(t *testing.T) { + args := map[string]any{ + "tasks": []any{map[string]any{"id": "a", "prompt": "x", "tools": []any{"browser_open"}}}, + "budget": map[string]any{"max_workers": float64(1)}, + } + _, err := ParsePlan(args, Limits{MaxTasks: 20, ParentTools: []string{"browser_open", "read_file"}}) + if err == nil { + t.Fatal("expected a refusal") + } + if !strings.Contains(err.Error(), "read_file") || !strings.Contains(err.Error(), "write_file") { + t.Fatalf("the refusal must name what IS available: %v", err) + } +} + +// A NAMED WRITE TOOL ACTUALLY REACHES THE TASK. Admission permitting what +// dispatch drops would produce a task that validated and then ran with less than +// it asked for — silently. +func TestANamedWriteToolReachesTheTask(t *testing.T) { + plan, err := ParsePlan(writeCapableArgs(), Limits{MaxTasks: 20, ParentTools: append(PlanReadOnlyToolNames(), "write_file")}) + if err != nil { + t.Fatalf("ParsePlan: %v", err) + } + var writer Task + for _, task := range plan.Tasks() { + if task.ID == "b" { + writer = task + } + } + granted, err := planToolGrant(writer, append(PlanReadOnlyToolNames(), "write_file")) + if err != nil { + t.Fatalf("planToolGrant: %v", err) + } + if len(granted) != 1 || granted[0] != "write_file" { + t.Fatalf("granted %v; the named write tool must reach the task", granted) + } +} diff --git a/internal/tools/types.go b/internal/tools/types.go index c0e0533e1..56e20e48a 100644 --- a/internal/tools/types.go +++ b/internal/tools/types.go @@ -146,6 +146,22 @@ type ArgsPermissioner interface { PermissionForArgs(args map[string]any) Permission } +// PermanentDenier lets a tool state that NO arguments can make it callable in +// this run. +// +// It exists because ArgsPermissioner cannot say this. A tool that varies its +// permission by argument is never treated as permanently denied — the question +// is whether any call could succeed, and only a tool with no per-argument +// override can be ruled out from its static permission alone. But a tool can be +// gated on something that is not an argument at all: the zeromaxing posture is +// a session state, not a parameter, and with it off no arguments make +// orchestrate callable. Without this, implementing ArgsPermissioner would make +// such a tool count as HELD in a run where it can never fire, which changes the +// assembled prompt of runs that do not have the feature turned on. +type PermanentDenier interface { + PermanentlyDenied() bool +} + // PrePermissionRejecter lets a tool reject a call that cannot safely or validly // run before any permission prompt is shown. Implementations must be purely // local and deterministic: no filesystem, process, DNS, or network access. From 844ce7e2d064b3cffec4a4fb0a6054f73a20c658 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:30:14 +0530 Subject: [PATCH 49/86] fix(specialist): tell a plan task to USE its tools, not merely that it has them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by the user running a fifteen-task plan: 260 seconds on the first task with ZERO tool calls. The task was "find every definition and call site and quote the file:line of each" — and the child answered it by thinking. The prompt said "You have read-only tools" and stopped there. That states a fact and asks for nothing, so a model is free to reason from its own weights about what the code probably says. It now says USE THEM: search before answering, start with a tool call rather than prose, back every claim with something read in THIS run and quoted with its file:line, and say plainly when something cannot be found. The honest-failure clause is the half that matters most. A guess and a finding are indistinguishable once they reach the plan's report, so a task that cannot find something has to be able to say so without that reading as failure. THE STALL WATCHDOG CANNOT CATCH THIS, and that is worth recording rather than fixing blind. It keys on SILENCE — deliberately, because a task reading forty files for four minutes is working — and a model writing prose is not silent. A task that monologues indefinitely without calling a tool trips nothing. The bound that would have applied is max_wall_seconds, which the model omitted. Verified at the wire, and my first probe was WRONG in a way worth noting: it searched only system-role messages, found nothing, and I briefly concluded the manifest prompt never reached the child at all. It does — WrapSystemPrompt puts it in the user message under "## Specialist Instructions". Checking every role showed all four new phrases arriving. A probe that looks in one place and reports absence is how a non-defect becomes a rewrite. Whether this changes glm-5.2's behaviour on a real plan is NOT proven here; the stub cannot answer it. It needs a run. --- internal/specialist/plan_runner.go | 21 +++++++++++++++- internal/specialist/plan_worktree_test.go | 29 +++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/internal/specialist/plan_runner.go b/internal/specialist/plan_runner.go index 25e87f0d7..82a5fe2ea 100644 --- a/internal/specialist/plan_runner.go +++ b/internal/specialist/plan_runner.go @@ -160,7 +160,26 @@ func planTaskManifest(name string, grantedTools []string) Manifest { Description: "Read-only plan task.", Tools: grantedTools, }, - SystemPrompt: "You are executing one task of a larger plan. You have read-only tools. " + + // IT MUST SAY "USE THEM". The first version said "You have read-only + // tools" and stopped there, which states a fact and asks for nothing. A + // task told to "find every definition and quote the file:line" can + // answer that from its own weights, and a real run did: 260 seconds of + // generation with ZERO tool calls on the first task of a fifteen-task + // plan. The stall watchdog cannot catch it either — it keys on silence, + // and a model writing prose is not silent. + // + // So the instruction is now an obligation with a named failure: search + // before answering, and say you could not find it rather than produce + // something plausible. A plan task exists to go and look; a plan task + // that reasons from memory is worse than no task, because its output + // reads exactly like the one that looked. + SystemPrompt: "You are executing one task of a larger plan. You have read-only tools: " + + "USE THEM. Search and read the actual files before you answer — do not rely on memory or " + + "inference about what the code probably says. Start with a tool call, not with prose. " + + "Every claim you make must be backed by something you read in this run, quoted with its " + + "file:line. If you cannot find something, say so plainly; an honest \"not found\" is worth " + + "more than a plausible guess, and a guess is indistinguishable from a finding once it " + + "reaches the plan's report. " + "Complete exactly the task described and report what you found; do not attempt to modify anything.", Location: LocationBuiltin, FilePath: "(plan)", diff --git a/internal/specialist/plan_worktree_test.go b/internal/specialist/plan_worktree_test.go index 41724280e..0c79cf151 100644 --- a/internal/specialist/plan_worktree_test.go +++ b/internal/specialist/plan_worktree_test.go @@ -239,3 +239,32 @@ func TestTheRealRunnerLaunchesTheChildInThePlansWorkspace(t *testing.T) { t.Fatalf("a read-only task ran in %q, not the parent's workspace", got) } } + +// THE PLAN TASK'S PROMPT MUST DEMAND TOOL USE, not merely mention tools. +// +// The first version said "You have read-only tools" and asked for nothing. A +// real fifteen-task run then spent 260 seconds on its first task with ZERO tool +// calls — the model answered a "find every definition and quote the file:line" +// task from its own weights. The stall watchdog cannot catch that: it keys on +// silence, and a model writing prose is not silent. +func TestThePlanTaskPromptDemandsToolUse(t *testing.T) { + manifest := planTaskManifest("explorer", []string{"read_file", "grep"}) + prompt := manifest.SystemPrompt + + // The obligation, not just the offer. + for _, want := range []string{"USE THEM", "before you answer", "file:line"} { + if !strings.Contains(prompt, want) { + t.Errorf("the plan task prompt must contain %q:\n%s", want, prompt) + } + } + // And the honest-failure clause, which is what stops a task that cannot find + // something from inventing it — a guess is indistinguishable from a finding + // once it reaches the plan's report. + if !strings.Contains(prompt, "not found") { + t.Errorf("the prompt must license an honest failure:\n%s", prompt) + } + // The read-only obligation is unchanged: this task may look, never modify. + if !strings.Contains(prompt, "do not attempt to modify anything") { + t.Errorf("the prompt lost its read-only instruction:\n%s", prompt) + } +} From fd4231bf474ee8b8564a2da71d1c04be39239faf Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:53:25 +0530 Subject: [PATCH 50/86] =?UTF-8?q?feat(specialist):=20run=20independent=20p?= =?UTF-8?q?lan=20tasks=20in=20parallel=20(gap=20report=20=C2=A75.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admission, scheduler and worker cap in ONE commit, because splitting them the way the plan suggested would have shipped a regression: relaxing max_workers while the executor stayed sequential means a plan asking for 8 silently runs with 1, which is exactly what the old rejection existed to prevent. ONE SCHEDULER, NOT TWO. With a single worker it walks plan.Order() one task at a time, applying the same checks in the same order and producing the same report — so the entire existing suite, written against the sequential executor, IS the equivalence oracle. It passed unchanged except for the one test that encoded "max_workers must be 1". A second executor would have been easier to write and impossible to keep in step; a duplicated rule drifts, and a duplicated executor drifts faster. THE WALK IS WHAT PRESERVES ORDER. Rather than keeping a ready SET and picking from it — which would let task 7 start before task 3 because its dependencies happened to resolve first — the scheduler walks the validated topological order and, for each task in turn, waits until that task's dependencies have resolved AND a worker is free. With one worker the wait is "until the previous task finished", which is today exactly. TWO NUMBERS, BOTH REPORTED. max_workers is what the plan asked for; the machine may carry less. A plan that asked for sixteen and ran six has not been given sixteen, so the report carries Workers and WorkersRequested rather than letting the request stand as if honoured. Out of range is REJECTED, not trimmed, for the same reason the old rule rejected: a trimmed number is one nobody can reason about afterwards. THE TUI STOPS ATTRIBUTING RATHER THAN ATTRIBUTING WRONGLY. Child progress events carry no task identity — the loop's callback holds only the parent's tool-call id — so with two tasks in flight there is nothing to route them by. Pointing every child's output at whichever task started last would show one task's tool calls under another's name. A card that says nothing is honest; a card that says something false is not. Per-task progress needs the child's identity threaded through the callback, and that is its own change. Eight mutations, all caught, including the two that would be invisible in production: dependencies ignored, and one worker overlapping anyway. Race detector clean at -count=10. MEASURED IN THE REAL BINARY, same plan, same build — a five-task diamond: max_workers=1 WALL 40.0s max_workers=4 WALL 27.0s Note what this spends: MaxSpeedup was defined as the theoretical ceiling measured FROM a sequential run, and it was the evidence the decision to build this rested on. Once plans run parallel that instrument no longer measures a ceiling. The 1.67x it reports for this diamond still matches the observed 40→27; on plans that are mostly chain it will not. --- internal/specialist/plan.go | 29 ++- internal/specialist/plan_concurrent_test.go | 268 ++++++++++++++++++++ internal/specialist/plan_exec.go | 147 ++++++++--- internal/specialist/plan_schedule.go | 133 ++++++++++ internal/specialist/plan_test.go | 25 +- internal/specialist/plan_tool.go | 12 +- internal/specialist/plan_worktree.go | 2 +- internal/tui/model.go | 30 ++- internal/tui/plan_messages.go | 9 +- internal/tui/plan_progress.go | 4 +- 10 files changed, 596 insertions(+), 63 deletions(-) create mode 100644 internal/specialist/plan_concurrent_test.go create mode 100644 internal/specialist/plan_schedule.go diff --git a/internal/specialist/plan.go b/internal/specialist/plan.go index b68b9639b..75e39f75e 100644 --- a/internal/specialist/plan.go +++ b/internal/specialist/plan.go @@ -56,9 +56,11 @@ type Task struct { // Budget bounds a plan. Every field is required to be sane at admission and // MaxTokens is enforced again at dispatch. type Budget struct { - // MaxWorkers must be 1. Phase 2 is sequential, and a plan asking for more is - // REJECTED rather than coerced — coercion would let a caller believe it got - // concurrency, and would make the field meaningless for Phase 3. + // MaxWorkers is how many tasks the plan may run at once, 1 to maxPlanWorkers. + // + // It is what the PLAN asked for, not what it will get: the machine's own + // capacity may be lower, and the report says which number actually applied + // rather than letting the request stand as if it were honoured. MaxWorkers int MaxTokens int MaxWall time.Duration @@ -504,13 +506,22 @@ func planBudget(args map[string]any, limits Limits) (Budget, error) { } budget.MaxRetries = retries } - // MaxWorkers must be exactly 1. Rejecting rather than coercing keeps the - // field meaningful: a caller that asked for 8 and silently got 1 would have - // been told nothing, and Phase 3 would inherit a field nobody trusts. - if budget.MaxWorkers != 1 { + // MaxWorkers is a RANGE now, and still rejected rather than coerced outside + // it. The rule it replaces — "must be exactly 1" — existed because the + // executor was sequential and a caller that asked for 8 and silently got 1 + // would have been told nothing. That reasoning is unchanged; only the + // executor changed, so the bound moved rather than dissolved. + // + // The ceiling is absolute and small. A plan is one tool call, and every task + // it runs is a child process inheriting a 320-turn budget under this + // posture; sixteen of those at once is already a great deal of machine and + // a great deal of money. A plan asking for more is refused, not trimmed, + // because a trimmed number is a number nobody can reason about afterwards. + if budget.MaxWorkers < 1 || budget.MaxWorkers > maxPlanWorkers { return Budget{}, fmt.Errorf( - "budget.max_workers must be 1: this phase executes plan tasks sequentially, and a plan asking for %d is rejected rather than quietly run with one worker", - budget.MaxWorkers) + "budget.max_workers must be between 1 and %d; %d was requested. "+ + "1 runs the plan sequentially, which is the right answer unless its tasks are genuinely independent", + maxPlanWorkers, budget.MaxWorkers) } // max_tokens is OPTIONAL and unbounded by default. // diff --git a/internal/specialist/plan_concurrent_test.go b/internal/specialist/plan_concurrent_test.go new file mode 100644 index 000000000..8b62c5077 --- /dev/null +++ b/internal/specialist/plan_concurrent_test.go @@ -0,0 +1,268 @@ +package specialist + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" +) + +// concurrencyProbe records the maximum number of tasks in flight at once, and +// the order dispatches began. +type concurrencyProbe struct { + mu sync.Mutex + inFlight int + peak int + started []string + release chan struct{} +} + +func (p *concurrencyProbe) runner() PlanRunner { + return func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + p.mu.Lock() + p.inFlight++ + if p.inFlight > p.peak { + p.peak = p.inFlight + } + p.started = append(p.started, req.Task.ID) + p.mu.Unlock() + + if p.release != nil { + <-p.release + } + p.mu.Lock() + p.inFlight-- + p.mu.Unlock() + return TaskResult{Outcome: TaskSucceeded}, nil + } +} + +func fanOutPlan(t *testing.T, workers int, n int) Plan { + t.Helper() + tasks := make([]any, 0, n) + for i := 0; i < n; i++ { + tasks = append(tasks, task(string(rune('a'+i)), "x")) + } + budget := map[string]any{"max_workers": float64(workers)} + return mustPlan(t, tasks, budget, readOnlyLimits()) +} + +// ONE WORKER IS STILL ONE AT A TIME. The whole equivalence claim rests on this: +// there is one scheduler, and with a single worker it must never overlap. +func TestOneWorkerNeverOverlaps(t *testing.T) { + probe := &concurrencyProbe{} + report := ExecutePlan(context.Background(), fanOutPlan(t, 1, 6), []string{"read_file"}, probe.runner(), nil) + if probe.peak != 1 { + t.Fatalf("peak concurrency %d with one worker; it must be 1", probe.peak) + } + if report.Succeeded != 6 { + t.Fatalf("report = %+v", report) + } + if report.Workers != 1 || report.WorkersRequested != 1 { + t.Fatalf("workers reported %d/%d", report.Workers, report.WorkersRequested) + } +} + +// ...and one worker dispatches in the plan's validated order, which is what +// makes the sequential path reproducible rather than merely serial. +func TestOneWorkerDispatchesInPlanOrder(t *testing.T) { + probe := &concurrencyProbe{} + plan := fanOutPlan(t, 1, 6) + ExecutePlan(context.Background(), plan, []string{"read_file"}, probe.runner(), nil) + for index, id := range plan.Order() { + if probe.started[index] != id { + t.Fatalf("dispatch order %v, want the plan's order %v", probe.started, plan.Order()) + } + } +} + +// TASKS ACTUALLY RUN AT ONCE. Asserting only that a plan with 4 workers +// completes would pass against a scheduler that ignored the setting entirely. +func TestIndependentTasksRunConcurrently(t *testing.T) { + probe := &concurrencyProbe{release: make(chan struct{})} + plan := fanOutPlan(t, 4, 4) + + done := make(chan PlanReport, 1) + go func() { + done <- ExecutePlan(context.Background(), plan, []string{"read_file"}, probe.runner(), nil) + }() + + // Wait for all four to be in flight together, which can only happen if they + // genuinely overlap. + deadline := time.After(5 * time.Second) + for { + probe.mu.Lock() + peak := probe.peak + probe.mu.Unlock() + if peak >= 4 { + break + } + select { + case <-deadline: + t.Fatalf("peak concurrency reached only %d of 4", peak) + case <-time.After(5 * time.Millisecond): + } + } + close(probe.release) + report := <-done + if report.Succeeded != 4 { + t.Fatalf("report = %+v", report) + } +} + +// DEPENDENCIES STILL HOLD. Concurrency may overlap independent work and must +// never start a task before what it waits on has finished. +func TestADependentTaskNeverStartsEarly(t *testing.T) { + // a -> (b, c) -> d, with room to run everything at once if the scheduler + // forgot the edges. + plan := mustPlan(t, []any{ + task("a", "root"), task("b", "left", "a"), task("c", "right", "a"), task("d", "join", "b", "c"), + }, map[string]any{"max_workers": float64(4)}, readOnlyLimits()) + + var mu sync.Mutex + finished := map[string]bool{} + var violations []string + + ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + mu.Lock() + for _, dep := range req.Task.DependsOn { + if !finished[dep] { + violations = append(violations, req.Task.ID+" started before "+dep) + } + } + mu.Unlock() + time.Sleep(5 * time.Millisecond) + mu.Lock() + finished[req.Task.ID] = true + mu.Unlock() + return TaskResult{Outcome: TaskSucceeded}, nil + }, nil) + + if len(violations) > 0 { + t.Fatalf("dependency order broken: %v", violations) + } +} + +// A DIAMOND OVERLAPS ITS MIDDLE. b and c are independent, so the scheduler must +// run them together — otherwise concurrency is wired but inert. +func TestADiamondsIndependentTasksOverlap(t *testing.T) { + plan := mustPlan(t, []any{ + task("a", "root"), task("b", "left", "a"), task("c", "right", "a"), task("d", "join", "b", "c"), + }, map[string]any{"max_workers": float64(4)}, readOnlyLimits()) + + var mu sync.Mutex + inFlight, peak := 0, 0 + ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + mu.Lock() + inFlight++ + if inFlight > peak { + peak = inFlight + } + mu.Unlock() + time.Sleep(20 * time.Millisecond) + mu.Lock() + inFlight-- + mu.Unlock() + return TaskResult{Outcome: TaskSucceeded}, nil + }, nil) + + if peak < 2 { + t.Fatalf("peak concurrency %d on a diamond; b and c are independent and must overlap", peak) + } +} + +// THE WORKER COUNT IS A CAP. A plan asking for 2 must never run 3, however many +// tasks are ready. +func TestTheWorkerCountIsRespected(t *testing.T) { + probe := &concurrencyProbe{} + report := ExecutePlan(context.Background(), fanOutPlan(t, 2, 8), []string{"read_file"}, probe.runner(), nil) + if probe.peak > 2 { + t.Fatalf("peak concurrency %d exceeded the 2 workers asked for", probe.peak) + } + if report.Succeeded != 8 { + t.Fatalf("report = %+v", report) + } +} + +// THE MACHINE'S CAPACITY BOUNDS THE REQUEST, and both numbers are reported. A +// plan that asked for sixteen and ran six has not been given sixteen. +func TestTheEffectiveWorkerCountIsReported(t *testing.T) { + if got := effectivePlanWorkers(1); got != 1 { + t.Fatalf("one worker must stay one on every host, got %d", got) + } + if got := effectivePlanWorkers(maxPlanWorkers); got > machinePlanWorkers() { + t.Fatalf("effective workers %d exceeds the machine's %d", got, machinePlanWorkers()) + } + if machinePlanWorkers() < minPlanWorkers { + t.Fatalf("machine workers %d fell below the floor", machinePlanWorkers()) + } + + report := ExecutePlan(context.Background(), fanOutPlan(t, maxPlanWorkers, 2), []string{"read_file"}, + func(context.Context, PlanTaskRequest) (TaskResult, error) { + return TaskResult{Outcome: TaskSucceeded}, nil + }, nil) + if report.WorkersRequested != maxPlanWorkers { + t.Fatalf("requested %d, want %d", report.WorkersRequested, maxPlanWorkers) + } + if report.Workers > report.WorkersRequested || report.Workers < 1 { + t.Fatalf("effective workers %d is not a bound on %d", report.Workers, report.WorkersRequested) + } +} + +// A PANICKING TASK MUST NOT HANG THE PLAN. The slot has to come back, or the +// scheduler waits forever for a worker that will never report. +func TestAPanickingTaskFreesItsWorker(t *testing.T) { + plan := fanOutPlan(t, 2, 4) + var runs atomic.Int32 + done := make(chan PlanReport, 1) + go func() { + done <- ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + if runs.Add(1) == 1 { + panic("boom") + } + return TaskResult{Outcome: TaskSucceeded}, nil + }, nil) + }() + select { + case report := <-done: + if report.Failed != 1 { + t.Fatalf("report = %+v; the panicking task must be recorded as failed", report) + } + if report.Succeeded != 3 { + t.Fatalf("report = %+v; the other tasks must still run", report) + } + case <-time.After(10 * time.Second): + t.Fatal("a panicking task hung the plan; its worker slot was never returned") + } +} + +// EVERY TASK IS HARVESTED before the report is assembled. A plan that reported +// while work was still in flight would report on tasks that had not finished. +func TestNoTaskIsLeftInFlightWhenThePlanReports(t *testing.T) { + plan := fanOutPlan(t, 4, 12) + var running atomic.Int32 + report := ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(context.Context, PlanTaskRequest) (TaskResult, error) { + running.Add(1) + time.Sleep(2 * time.Millisecond) + running.Add(-1) + return TaskResult{Outcome: TaskSucceeded}, nil + }, nil) + if left := running.Load(); left != 0 { + t.Fatalf("%d tasks still running when the plan reported", left) + } + if len(report.Tasks) != 12 || report.Succeeded != 12 { + t.Fatalf("report = %+v", report) + } + // The report is in the PLAN's order, not completion order — a concurrent run + // finishes out of order and the record must not. + for index, id := range plan.Order() { + if report.Tasks[index].ID != id { + t.Fatalf("report order %v, want plan order %v", report.Tasks[index].ID, id) + } + } +} diff --git a/internal/specialist/plan_exec.go b/internal/specialist/plan_exec.go index c08c596ab..d75c7849a 100644 --- a/internal/specialist/plan_exec.go +++ b/internal/specialist/plan_exec.go @@ -107,6 +107,14 @@ type PlanReport struct { // 0 regardless and would decide nothing. MaxSpeedup float64 TokensUsed int + // Workers is how many tasks this plan ACTUALLY ran at once, and + // WorkersRequested is what it asked for. Both, because the machine's + // capacity may be lower than the request and a plan that asked for sixteen + // and ran six has not been given sixteen — reporting only the request would + // make the number a fiction, which is the same reason max_workers is + // rejected outside its range rather than trimmed into it. + Workers int + WorkersRequested int } // PlanTaskRequest is everything one task needs to run. @@ -214,9 +222,88 @@ func ExecutePlanIn(ctx context.Context, plan Plan, workspace PlanWorkspace, pare stallTimeout := stallTimeoutFor(plan.Budget()) cancelled := false + + // THE WORKER POOL. One worker is the sequential path: every wait below + // becomes "until the previous task finished", and the walk applies the same + // checks in the same order it always has. + workers := effectivePlanWorkers(plan.Budget().MaxWorkers) + report.Workers = workers + report.WorkersRequested = plan.Budget().MaxWorkers + slots := newPlanSlots(workers) + + // harvest applies one completed dispatch: its spend, its outcome, its + // record. Called only from THIS goroutine, so results, failed, report and + // budgetLeft are never touched concurrently and need no lock — the + // concurrency is in the dispatches, not in the bookkeeping. + harvest := func(completion taskCompletion) { + slots.release() + id, result, err := completion.id, completion.result, completion.err + result.ID = id + if result.Duration == 0 { + result.Duration = time.Since(completion.started) + } + report.SequentialTotal += result.Duration + budgetLeft -= result.Tokens + report.TokensUsed += result.Tokens + + if err != nil || result.Outcome == TaskFailed { + // A task cut short by cancellation is CANCELLED, not failed. The + // distinction survives all the way to the terminal status, so a + // stopped plan never reports as a broken one. + if ctx.Err() != nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + cancelled = true + result.Outcome = TaskCancelled + if result.Err == "" { + result.Err = "cancelled: the run was stopped while this task was running" + } + results[id] = result + failed[id] = true + report.Cancelled++ + recordFailed(recorder, result) + return + } + result.Outcome = TaskFailed + if result.Err == "" && err != nil { + result.Err = err.Error() + } + results[id] = result + failed[id] = true + report.Failed++ + recordFailed(recorder, result) + return + } + result.Outcome = TaskSucceeded + results[id] = result + report.Succeeded++ + recordCompleted(recorder, result) + } + + // resolved reports whether every dependency of a task has finished, one way + // or another. A task waits for its dependencies to RESOLVE, not to succeed: + // a failed dependency resolves it too, and the skip check below turns that + // into the recorded dependency_failed. + resolved := func(task Task) bool { + for _, dep := range task.DependsOn { + if _, done := results[dep]; !done { + return false + } + } + return true + } + for _, id := range plan.Order() { task := tasks[id] + // WAIT FOR THIS TASK'S TURN: until its dependencies have resolved and a + // worker is free. Waiting on the task the WALK reached — rather than + // picking whichever ready task appeared first — is what keeps dispatch + // in the plan's validated order, so one worker reproduces the sequential + // executor exactly and more workers only overlap what was already + // adjacent. + for slots.busy() && (!resolved(task) || slots.full()) { + harvest(<-slots.done) + } + // PAUSE, at the task boundary, BEFORE the cancellation check — so a user // who stops a paused plan is not left waiting for a resume that will // never come. WaitWhilePaused returns on ctx, and the check below then @@ -286,49 +373,37 @@ func ExecutePlanIn(ctx context.Context, plan Plan, workspace PlanWorkspace, pare } recordDispatched(recorder, task) - result, err := runTaskWithRetries(ctx, retryPolicy{ + policy := retryPolicy{ task: task, tools: granted, cwd: workspace.Path, stallTimeout: stallTimeout, maxRetries: plan.Budget().MaxRetries, deadline: deadline, - }, run) - result.ID = id - report.SequentialTotal += result.Duration - budgetLeft -= result.Tokens - report.TokensUsed += result.Tokens - - if err != nil || result.Outcome == TaskFailed { - // A task cut short by cancellation is CANCELLED, not failed. The - // distinction survives all the way to the terminal status, so a - // stopped plan never reports as a broken one. - if ctx.Err() != nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - cancelled = true - result.Outcome = TaskCancelled - if result.Err == "" { - result.Err = "cancelled: the run was stopped while this task was running" - } - results[id] = result - failed[id] = true - report.Cancelled++ - recordFailed(recorder, result) - continue - } - result.Outcome = TaskFailed - if result.Err == "" && err != nil { - result.Err = err.Error() - } - results[id] = result - failed[id] = true - report.Failed++ - recordFailed(recorder, result) - continue } - result.Outcome = TaskSucceeded - results[id] = result - report.Succeeded++ - recordCompleted(recorder, result) + slots.take() + started := time.Now() + go func(id string) { + // Every goroutine gets recover(): a panic in one task must not take + // the plan with it, and the slot must be returned either way or the + // scheduler waits forever on a worker that will never report. + defer func() { + if panicked := recover(); panicked != nil { + slots.done <- taskCompletion{ + id: id, started: started, + result: TaskResult{Outcome: TaskFailed, Err: fmt.Sprintf("task panicked: %v", panicked)}, + } + } + }() + result, err := runTaskWithRetries(ctx, policy, run) + slots.done <- taskCompletion{id: id, result: result, err: err, started: started} + }(id) + } + + // DRAIN. Everything still in flight is harvested before the report is + // assembled, or a plan would report on tasks that had not finished. + for slots.busy() { + harvest(<-slots.done) } for _, id := range plan.Order() { diff --git a/internal/specialist/plan_schedule.go b/internal/specialist/plan_schedule.go new file mode 100644 index 000000000..bfc411d6c --- /dev/null +++ b/internal/specialist/plan_schedule.go @@ -0,0 +1,133 @@ +package specialist + +import ( + "runtime" + "sync" + "time" +) + +// How many tasks a plan runs at once. +// +// THE SEQUENTIAL PATH IS NOT A SEPARATE PATH. There is one scheduler, and with +// one worker it walks plan.Order() one task at a time, applying the same checks +// in the same order and producing the same report — which is why every test +// written against the sequential executor is the oracle for this. Two executors +// would have been safer to write and impossible to keep in step: invariant 5 +// says a duplicated rule drifts, and a duplicated EXECUTOR drifts faster. +// +// The walk is the thing that preserves order. Rather than maintaining a ready +// SET and choosing from it — which would let task 7 start before task 3 merely +// because its dependencies resolved first — the scheduler walks the validated +// topological order and, for each task in turn, waits until that task's +// dependencies are resolved and a worker is free. With one worker the wait is +// "until the previous task finished", which is exactly today. + +const ( + // maxPlanWorkers is the absolute ceiling on what a plan may ASK for. Small + // on purpose: each task is a child process inheriting a 320-turn budget + // under this posture. + maxPlanWorkers = 16 + // minPlanWorkers keeps the machine cap from collapsing to zero on a + // single-core box, where the answer must still be "run the plan". + minPlanWorkers = 2 +) + +// machinePlanWorkers is what the HOST can carry, independent of what a plan +// asked for. Leaving two cores is not superstition: the parent agent, the TUI +// render loop and every child's own I/O all want one. +func machinePlanWorkers() int { + available := runtime.NumCPU() - 2 + if available < minPlanWorkers { + available = minPlanWorkers + } + if available > maxPlanWorkers { + available = maxPlanWorkers + } + return available +} + +// effectivePlanWorkers is what the plan will ACTUALLY run with: the smaller of +// what it asked for and what the machine can carry. +// +// The two numbers are kept apart and both reported. A plan that asked for +// sixteen and ran six has not been given sixteen, and saying so is the +// difference between a bound and a fiction — the same reason max_workers is +// rejected outside its range rather than trimmed into it. +func effectivePlanWorkers(requested int) int { + if requested < 1 { + requested = 1 + } + if requested == 1 { + // Never consult the machine for a sequential plan. One worker must mean + // one worker on every host, or the sequential path stops being + // reproducible. + return 1 + } + if machine := machinePlanWorkers(); requested > machine { + return machine + } + return requested +} + +// planSlots is the worker pool: a counting semaphore plus the completions. +// +// A channel of results rather than a WaitGroup, because the scheduler needs to +// ACT on each completion as it lands — a finished task unblocks its dependents +// and returns its spend to the budget — and a WaitGroup only says "all done". +type planSlots struct { + limit int + inFlight int + done chan taskCompletion + mu sync.Mutex +} + +// taskCompletion is one finished dispatch on its way back to the scheduler. +type taskCompletion struct { + id string + result TaskResult + err error + // started is when the dispatch began, so a runner that reported no duration + // is measured by the scheduler rather than recorded as instantaneous. + started time.Time +} + +func newPlanSlots(limit int) *planSlots { + if limit < 1 { + limit = 1 + } + return &planSlots{limit: limit, done: make(chan taskCompletion, limit)} +} + +// full reports whether every worker is busy. +func (slots *planSlots) full() bool { + slots.mu.Lock() + defer slots.mu.Unlock() + return slots.inFlight >= slots.limit +} + +// busy reports whether anything is still running, which is what tells the +// scheduler it must keep draining before it can finish. +func (slots *planSlots) busy() bool { + slots.mu.Lock() + defer slots.mu.Unlock() + return slots.inFlight > 0 +} + +// take claims a worker slot. +func (slots *planSlots) take() { + slots.mu.Lock() + slots.inFlight++ + slots.mu.Unlock() +} + +// release frees a slot. Called by the scheduler when it harvests a completion, +// not by the goroutine that produced it — so a slot is free exactly when the +// scheduler has finished acting on the result, never while it is still applying +// it to the budget. +func (slots *planSlots) release() { + slots.mu.Lock() + if slots.inFlight > 0 { + slots.inFlight-- + } + slots.mu.Unlock() +} diff --git a/internal/specialist/plan_test.go b/internal/specialist/plan_test.go index d1e421bd3..4c6c45d40 100644 --- a/internal/specialist/plan_test.go +++ b/internal/specialist/plan_test.go @@ -144,16 +144,33 @@ func TestParsePlanRejectsIDsOutsideTheAllowList(t *testing.T) { // (i) MaxWorkers > 1 is REJECTED, not coerced. Coercion would let a caller // believe it got concurrency and would make the field meaningless for Phase 3. -func TestParsePlanRejectsMoreThanOneWorker(t *testing.T) { - for _, workers := range []float64{0, 2, 8} { +func TestMaxWorkersIsARangeAndIsRejectedOutsideIt(t *testing.T) { + // THE RULE MOVED, IT DID NOT DISSOLVE. It used to be "must be exactly 1", + // because the executor was sequential and a caller who asked for 8 and + // silently got 1 would have been told nothing. The executor changed; that + // reasoning did not, so the bound became a range and is still REJECTED + // outside it rather than trimmed into it — a trimmed number is one nobody + // can reason about afterwards. + for _, workers := range []float64{0, -1, maxPlanWorkers + 1, 100} { budget := okBudget() budget["max_workers"] = workers _, err := ParsePlan(planArgs([]any{task("a", "x")}, budget), readOnlyLimits()) if err == nil { t.Fatalf("max_workers %v must be rejected", workers) } - if !strings.Contains(err.Error(), "sequentially") { - t.Fatalf("the error must say why: %v", err) + if !strings.Contains(err.Error(), "between 1 and") { + t.Fatalf("the error must state the range: %v", err) + } + } + for _, workers := range []float64{1, 2, 8, maxPlanWorkers} { + budget := okBudget() + budget["max_workers"] = workers + plan, err := ParsePlan(planArgs([]any{task("a", "x")}, budget), readOnlyLimits()) + if err != nil { + t.Fatalf("max_workers %v must be accepted: %v", workers, err) + } + if plan.Budget().MaxWorkers != int(workers) { + t.Fatalf("max_workers parsed as %d, want %v", plan.Budget().MaxWorkers, workers) } } } diff --git a/internal/specialist/plan_tool.go b/internal/specialist/plan_tool.go index 0230cdeb2..f149f6ceb 100644 --- a/internal/specialist/plan_tool.go +++ b/internal/specialist/plan_tool.go @@ -88,7 +88,7 @@ func (tool *OrchestrateTool) Name() string { return OrchestrateToolName } func (tool *OrchestrateTool) Description() string { return "Execute a structured plan of read-only sub-agent tasks in dependency order. " + - "Tasks run sequentially; declare dependencies with depends_on so the plan records which work was independent. " + + "Independent tasks run in parallel up to budget.max_workers; declare dependencies with depends_on and a task never starts before what it waits on has finished. " + // The either/or the schema cannot express, and the shape worth // encouraging: a plan is only worth more than reading the code yourself // when its tasks are genuinely independent. @@ -149,7 +149,7 @@ func (tool *OrchestrateTool) Parameters() tools.Schema { }, "budget": { Type: "object", - Description: "Required. max_workers must be 1 (this phase executes sequentially). max_tokens and max_wall_seconds are optional bounds; omit them to run unbounded — spend is reported either way. max_stall_seconds bounds how long ONE task may emit nothing (default 180); it resets on every event, so a slow-but-working task is never stopped. max_retries (0-3, default 1) is how many extra attempts a STALLED task gets; a task that failed with a real error is never retried.", + Description: "Required. max_workers (1-16) is how many tasks may run at once; 1 is sequential and is the right answer unless the tasks are genuinely independent. The machine's own capacity may be lower and the report says which number applied. max_tokens and max_wall_seconds are optional bounds; omit them to run unbounded — spend is reported either way. max_stall_seconds bounds how long ONE task may emit nothing (default 180); it resets on every event, so a slow-but-working task is never stopped. max_retries (0-3, default 1) is how many extra attempts a STALLED task gets; a task that failed with a real error is never retried.", }, }, // EMPTY, and the either/or lives in the DESCRIPTION instead. @@ -290,9 +290,13 @@ func (tool *OrchestrateTool) Run(ctx context.Context, args map[string]any) tools // // The callback is shared by every task rather than keyed per task, because the // loop's callback carries only the parent's tool-call id and has no room for a -// sub-key. That is sound HERE and only here: MaxWorkers is validated to be 1, +// sub-key. THE CONSEQUENCE, now that plans can be concurrent: with more than one +// worker a consumer CANNOT attribute an event to a task, and the TUI stops +// trying rather than attributing every child to whichever task started last. +// Threading the child's identity through the loop's callback is what would fix +// it properly. Previously this read: MaxWorkers is validated to be 1, // so exactly one task is in flight at any moment and the consumer can attribute -// events to the task it last saw dispatched. Stage 2d must revisit this the +// events to the task it last saw dispatched — which is no longer true. // moment two tasks can run at once — see the note on the TUI plan recorder. func (tool *OrchestrateTool) runnerForCall(options tools.RunOptions) PlanRunner { run := tool.RunTask diff --git a/internal/specialist/plan_worktree.go b/internal/specialist/plan_worktree.go index c20b87e9c..517e43ed0 100644 --- a/internal/specialist/plan_worktree.go +++ b/internal/specialist/plan_worktree.go @@ -17,7 +17,7 @@ import ( // remembering to set a flag. // // PER PLAN, NOT PER TASK, and the choice matters. Per-task isolation is more -// isolation and less use: this phase executes sequentially and a later task +// isolation and less use: a later task // routinely needs an earlier one's output, so per-task worktrees would either // hide that or need a merge step nobody wrote. A plan is the unit of work a // user reviews, so a plan is the unit that gets a tree — one branch, one diff. diff --git a/internal/tui/model.go b/internal/tui/model.go index 5679f3143..d2f252388 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -130,9 +130,21 @@ type model struct { // planRunningCardKey is the card key of the plan task currently in flight. // A plan's child progress events arrive keyed by the ORCHESTRATE tool call // (the loop's callback carries only the parent's tool-call id), so they are - // attributed to this. Sound only because MaxWorkers is validated to be 1 — - // exactly one task runs at a time. Stage 2d must revisit it. + // attributed to this. + // + // EMPTY WHEN THE PLAN IS CONCURRENT. With one worker exactly one task runs + // at a time and the attribution is sound. With more, the events carry no + // task identity at all, so attributing them to whichever task was dispatched + // last would show one task's tool calls under another's name. No attribution + // is worse than none only if you have never seen the wrong one: a card that + // says nothing is honest, a card that says something false is not. Per-task + // progress needs the child's identity threaded through the loop's callback, + // which is its own change. planRunningCardKey string + // planConcurrent records that the running plan has more than one worker, so + // the attribution above stays off for its whole life rather than being + // re-enabled by the next task that starts. + planConcurrent bool // planProgress is the shared recorder the orchestrate tool holds. A POINTER // for the same reason PostureGate is one: the TUI model is a value type // copied on every update, so a closure over it would freeze the first run. @@ -2682,6 +2694,9 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.orchestrate.admit(msg, m.now()) + // More than one worker means child progress cannot be attributed to a + // task; see planRunningCardKey. + m.planConcurrent = msg.workers > 1 m.transcript = appendTranscriptRow(m.transcript, transcriptRow{ kind: rowSystem, runID: msg.runID, @@ -2701,9 +2716,14 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m.specialists.start(msg.taskID, msg.summary, msg.cardKey, m.now()) // Remember which task is in flight so the plan's progress events — which // arrive keyed by the ORCHESTRATE tool call, not by task — can be - // attributed. Sound only because MaxWorkers is validated to be 1, so - // exactly one task runs at a time. Stage 2d must revisit this. - m.planRunningCardKey = msg.cardKey + // attributed. Sound ONLY when one task runs at a time; a concurrent plan + // leaves this empty rather than pointing every child's output at + // whichever task started last. + if m.planConcurrent { + m.planRunningCardKey = "" + } else { + m.planRunningCardKey = msg.cardKey + } return m, nil case planTaskDoneMsg: // A BACKGROUND plan outlives the run that launched it, so the diff --git a/internal/tui/plan_messages.go b/internal/tui/plan_messages.go index fde97cf22..36ca9c732 100644 --- a/internal/tui/plan_messages.go +++ b/internal/tui/plan_messages.go @@ -19,9 +19,12 @@ import ( // the order — so sending it here means the panel is complete before the first // task starts rather than assembling itself as tasks finish. type planAdmittedMsg struct { - runID int - name string - taskCount int + runID int + name string + taskCount int + // workers is how many tasks this plan may run at once. More than one means + // child progress CANNOT be attributed to a task — see planRunningCardKey. + workers int tasks []planGraphTask tokenLimit int // background marks a plan that outlives the run that launched it, so the diff --git a/internal/tui/plan_progress.go b/internal/tui/plan_progress.go index b3709813e..53c49018a 100644 --- a/internal/tui/plan_progress.go +++ b/internal/tui/plan_progress.go @@ -338,6 +338,7 @@ func (bridge *PlanProgressBridge) PlanAdmitted(plan specialist.Plan) { name := plan.Name() count := plan.TaskCount() limit := plan.Budget().MaxTokens + workers := plan.Budget().MaxWorkers // Copied into the message in EXECUTION ORDER, with the dependency edges, so // the panel can draw the graph without reaching back into the plan — which @@ -358,7 +359,8 @@ func (bridge *PlanProgressBridge) PlanAdmitted(plan specialist.Plan) { } bridge.send(func(runID int) tea.Msg { - return planAdmittedMsg{runID: runID, name: name, taskCount: count, tasks: graph, tokenLimit: limit, background: bridge.isBackground()} + return planAdmittedMsg{runID: runID, name: name, taskCount: count, tasks: graph, tokenLimit: limit, + workers: workers, background: bridge.isBackground()} }) } From a02420ae4416039a41c2ff030cfc8b3d682c1bb4 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:11:44 +0530 Subject: [PATCH 51/86] =?UTF-8?q?feat(tui):=20each=20plan=20task's=20progr?= =?UTF-8?q?ess=20lands=20on=20its=20own=20card=20(gap=20report=20=C2=A75.1?= =?UTF-8?q?,=202/2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A child's stream events carry no task identity. The agent loop's progress callback holds the parent's TOOL-CALL id, which is identical for every task in a plan, so a display could only guess which task an event belonged to — and the guess it made was "whichever task was dispatched last". Sound while exactly one task ran at a time; a lie the moment two could. THE IDENTITY NOW TRAVELS WITH THE EVENT, from the only place that has it. The recorder opened the cards, so the recorder knows which card belongs to which task; the runner tags each event with its task id on the way out and the recorder resolves it. Nothing at the display end reconstructs anything. SO THE GUESS IS DELETED, not conditioned. planRunningCardKey and the planConcurrent flag that briefly protected it are both gone. The previous commit turned attribution OFF for concurrent plans because a card that says nothing is honest while one that says something false is not; this makes that compromise unnecessary, and leaving the field behind would have left a second, wrong way to answer the same question. BOTH CALLBACKS FIRE, and they are not alternatives. My first version routed only to the recorder, and a test caught it: a plan task's child must stream under the same contract a Task sub-agent's child does, and restoring that was a fix in its own right (finding: plan children streamed to nobody). Sending only to the recorder would have quietly undone it for every caller without one — the headless case. The recorder gets the event WITH the task id; the caller's own callback still fires as it always did. Only tool calls reach a card. A message per streamed token would put the event loop under a load for a display that shows neither the tokens nor the count of them. Seven mutations, all caught, including the two that would look right on screen and be wrong: all tasks sharing one card, and progress routed to whichever card was newest. Race detector clean at -count=5 across both packages. Additivity re-proven, five modes byte-identical. --- internal/specialist/plan_exec.go | 24 ++++++ internal/specialist/plan_tool.go | 24 +++++- internal/tui/model.go | 61 +++++-------- internal/tui/orchestrate_control_test.go | 105 +++++++++++++++++++++++ internal/tui/plan_messages.go | 14 +++ internal/tui/plan_progress.go | 42 +++++++++ 6 files changed, 231 insertions(+), 39 deletions(-) diff --git a/internal/specialist/plan_exec.go b/internal/specialist/plan_exec.go index d75c7849a..eefb3119b 100644 --- a/internal/specialist/plan_exec.go +++ b/internal/specialist/plan_exec.go @@ -668,6 +668,30 @@ func recordFailed(recorder PlanRecorder, result TaskResult) { } } +// PlanTaskProgressRecorder is the optional PER-TASK streaming half of a +// recorder. +// +// It exists because a child's stream events carry no task identity. The agent +// loop's progress callback holds the parent's TOOL-CALL id, which is the same +// for every task in a plan, so a display attributing those events could only +// guess — and the guess it made was "whichever task was dispatched last". That +// was sound while exactly one task ran at a time and became a lie the moment +// two could. +// +// The recorder already knows which card belongs to which task, because it +// opened them. So the identity travels with the event from the one place that +// has it, instead of being reconstructed at the other end from a guess. +type PlanTaskProgressRecorder interface { + TaskProgress(taskID string, event streamjson.Event) +} + +// planTaskProgress is best-effort and nil-safe, like every other recorder call. +func planTaskProgress(recorder PlanRecorder, taskID string, event streamjson.Event) { + if progress, ok := recorder.(PlanTaskProgressRecorder); ok && progress != nil { + progress.TaskProgress(taskID, event) + } +} + // PlanController is the optional CONTROL half of a recorder. // // Stopping a plan meant stopping the whole turn: Ctrl-C cancels the run, and diff --git a/internal/specialist/plan_tool.go b/internal/specialist/plan_tool.go index f149f6ceb..addb1e047 100644 --- a/internal/specialist/plan_tool.go +++ b/internal/specialist/plan_tool.go @@ -6,6 +6,7 @@ import ( "strconv" "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/streamjson" "github.com/Gitlawb/zero/internal/tools" ) @@ -303,8 +304,29 @@ func (tool *OrchestrateTool) runnerForCall(options tools.RunOptions) PlanRunner if run == nil { return nil } + recorder := tool.Recorder return func(ctx context.Context, req PlanTaskRequest) (TaskResult, error) { - req.Progress = options.Progress + // PER TASK, not per call. The loop's own callback carries the parent's + // tool-call id and nothing else, so every task's events look alike to a + // consumer; routing them through the recorder with the task id attached + // is what lets a display tell them apart. The recorder falls back to + // doing nothing when a surface has no live UI, which is the headless + // case. + taskID := req.Task.ID + callerProgress := options.Progress + req.Progress = func(event streamjson.Event) { + // BOTH, and they are not alternatives. The recorder gets the event + // WITH the task id so a display can route it; the caller's own + // callback still fires because that is the contract a plan task's + // child streams under — the same one a Task sub-agent's child does, + // and restoring it was a fix in its own right. Sending only to the + // recorder would have quietly undone that for every caller without + // one, which is the headless case. + planTaskProgress(recorder, taskID, event) + if callerProgress != nil { + callerProgress(event) + } + } // The parent's identity, read from the same RunOptions fields the Task // tool reads (task_tool.go). A plan task inherits the model its parent // is running on; without this it fell back to the child's own config, diff --git a/internal/tui/model.go b/internal/tui/model.go index d2f252388..304ea1c66 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -132,19 +132,11 @@ type model struct { // (the loop's callback carries only the parent's tool-call id), so they are // attributed to this. // - // EMPTY WHEN THE PLAN IS CONCURRENT. With one worker exactly one task runs - // at a time and the attribution is sound. With more, the events carry no - // task identity at all, so attributing them to whichever task was dispatched - // last would show one task's tool calls under another's name. No attribution - // is worse than none only if you have never seen the wrong one: a card that - // says nothing is honest, a card that says something false is not. Per-task - // progress needs the child's identity threaded through the loop's callback, - // which is its own change. - planRunningCardKey string - // planConcurrent records that the running plan has more than one worker, so - // the attribution above stays off for its whole life rather than being - // re-enabled by the next task that starts. - planConcurrent bool + // GONE, and deliberately: a plan's child progress now arrives as + // planTaskProgressMsg carrying its own task id, resolved by the recorder + // that opened the card. What stood here guessed "whichever task was + // dispatched last", which stopped being true the moment two tasks could run + // at once. // planProgress is the shared recorder the orchestrate tool holds. A POINTER // for the same reason PostureGate is one: the TUI model is a value type // copied on every update, so a closure over it would freeze the first run. @@ -2694,9 +2686,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.orchestrate.admit(msg, m.now()) - // More than one worker means child progress cannot be attributed to a - // task; see planRunningCardKey. - m.planConcurrent = msg.workers > 1 m.transcript = appendTranscriptRow(m.transcript, transcriptRow{ kind: rowSystem, runID: msg.runID, @@ -2714,16 +2703,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { } m.orchestrate.markStarted(msg.taskID, msg.summary, msg.cardKey, m.now()) m.specialists.start(msg.taskID, msg.summary, msg.cardKey, m.now()) - // Remember which task is in flight so the plan's progress events — which - // arrive keyed by the ORCHESTRATE tool call, not by task — can be - // attributed. Sound ONLY when one task runs at a time; a concurrent plan - // leaves this empty rather than pointing every child's output at - // whichever task started last. - if m.planConcurrent { - m.planRunningCardKey = "" - } else { - m.planRunningCardKey = msg.cardKey - } return m, nil case planTaskDoneMsg: // A BACKGROUND plan outlives the run that launched it, so the @@ -2734,9 +2713,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.orchestrate.markDone(msg.taskID, msg.outcome, msg.tokens, msg.attempts, m.now()) - if m.planRunningCardKey == msg.cardKey { - m.planRunningCardKey = "" - } cardKey := msg.cardKey if !msg.dispatched { // Never started, so it has no card. Give it its own key and open one @@ -2768,7 +2744,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { if !msg.background && msg.runID != m.activeRunID { return m, nil } - m.planRunningCardKey = "" m.orchestrate.complete(msg, m.now()) m.transcript = appendTranscriptRow(m.transcript, transcriptRow{ kind: rowSystem, @@ -2777,17 +2752,28 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { text: planCompletedLine(msg), }) return m, nil - case specialistProgressMsg: - if msg.runID != m.activeRunID { + case planTaskProgressMsg: + // A BACKGROUND plan outlives the run that launched it, so the stale-run + // guard must not drop its progress. + if !msg.background && msg.runID != m.activeRunID { return m, nil } - // A plan's progress arrives keyed by the orchestrate tool call, which has - // no card of its own; attribute it to the task currently in flight. - if _, ok := m.specialists.getBySessionID(msg.toolCallID); !ok && m.planRunningCardKey != "" { - m.specialists.incrementToolCount(m.planRunningCardKey) - m.specialists.setCurrentTool(m.planRunningCardKey, msg.toolName, msg.detail) + // Already routed to the right card by the recorder, which is the only + // thing that knows which card belongs to which task. Nothing here has to + // guess, which is the whole point of the message existing. + m.specialists.incrementToolCount(msg.cardKey) + m.specialists.setCurrentTool(msg.cardKey, msg.toolName, msg.detail) + return m, nil + case specialistProgressMsg: + if msg.runID != m.activeRunID { return m, nil } + // A PLAN's progress no longer arrives here. It comes as + // planTaskProgressMsg, already carrying the task it belongs to. This + // used to attribute an unrecognised tool-call id to "whichever task was + // dispatched last", which was sound while one task ran at a time and + // became a lie the moment two could — so the guess is gone rather than + // conditioned. // Each progress message is one specialist tool call (OnToolProgress fires only // for EventToolCall); bump the card's tool-call counter so it stops showing a // permanent "0 tool calls" (M18). The tracker is still keyed by the tool-call @@ -5041,7 +5027,6 @@ func (m model) beginRun(cancel context.CancelFunc) model { // previous turn don't bleed into the new one. m.specialists.clear() m.plan.clear() - m.planRunningCardKey = "" m.orchestrate.clear() // Re-bind the plan recorder to THIS run. The orchestrate tool holds the // bridge for the process's life (the registry is built once per session), diff --git a/internal/tui/orchestrate_control_test.go b/internal/tui/orchestrate_control_test.go index 0ca45c72a..2faa96483 100644 --- a/internal/tui/orchestrate_control_test.go +++ b/internal/tui/orchestrate_control_test.go @@ -9,6 +9,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/Gitlawb/zero/internal/specialist" + "github.com/Gitlawb/zero/internal/streamjson" ) // runningBridge is a bridge with a plan in flight, holding a cancel whose @@ -398,3 +399,107 @@ func TestTheTerminalMessageOfAForegroundPlanIsNotMarked(t *testing.T) { } } } + +// PER-TASK PROGRESS, which is the whole point: two tasks in flight, and each +// child's tool calls land on ITS OWN card. +// +// The display used to attribute an unrecognised event to "whichever task was +// dispatched last" — sound while one task ran at a time, a lie the moment two +// could. This asserts the identity travels with the event. +func TestEachTasksProgressLandsOnItsOwnCard(t *testing.T) { + var got []tea.Msg + bridge := NewPlanProgressBridge() + bridge.Attach(func(msg tea.Msg) { got = append(got, msg) }, 3, nil, "") + + bridge.TaskDispatched(specialist.Task{ID: "alpha", Prompt: "one"}) + bridge.TaskDispatched(specialist.Task{ID: "beta", Prompt: "two"}) + + // Interleaved, which is what concurrency actually produces. + bridge.TaskProgress("beta", streamjson.Event{Type: streamjson.EventToolCall, Name: "grep"}) + bridge.TaskProgress("alpha", streamjson.Event{Type: streamjson.EventToolCall, Name: "read_file"}) + bridge.TaskProgress("beta", streamjson.Event{Type: streamjson.EventToolCall, Name: "glob"}) + + cardOf := map[string]string{} + for _, msg := range got { + if start, ok := msg.(planTaskStartMsg); ok { + cardOf[start.taskID] = start.cardKey + } + } + if cardOf["alpha"] == "" || cardOf["beta"] == "" || cardOf["alpha"] == cardOf["beta"] { + t.Fatalf("the two tasks must have distinct cards: %v", cardOf) + } + + byCard := map[string][]string{} + for _, msg := range got { + if progress, ok := msg.(planTaskProgressMsg); ok { + byCard[progress.cardKey] = append(byCard[progress.cardKey], progress.toolName) + } + } + if want := []string{"read_file"}; len(byCard[cardOf["alpha"]]) != 1 || byCard[cardOf["alpha"]][0] != want[0] { + t.Fatalf("alpha's card got %v, want %v", byCard[cardOf["alpha"]], want) + } + if got := byCard[cardOf["beta"]]; len(got) != 2 || got[0] != "grep" || got[1] != "glob" { + t.Fatalf("beta's card got %v, want [grep glob]", got) + } +} + +// A task the bridge never dispatched has no card, and inventing one would put a +// row on screen for work the panel never admitted. +func TestProgressForAnUnknownTaskIsDropped(t *testing.T) { + var got []tea.Msg + bridge := NewPlanProgressBridge() + bridge.Attach(func(msg tea.Msg) { got = append(got, msg) }, 3, nil, "") + bridge.TaskProgress("ghost", streamjson.Event{Type: streamjson.EventToolCall, Name: "grep"}) + for _, msg := range got { + if _, ok := msg.(planTaskProgressMsg); ok { + t.Fatal("progress for an undispatched task produced a message") + } + } +} + +// Only TOOL CALLS are forwarded. A message per streamed token would put the +// event loop under a load the card cannot even display. +func TestOnlyToolCallsReachTheCard(t *testing.T) { + var got []tea.Msg + bridge := NewPlanProgressBridge() + bridge.Attach(func(msg tea.Msg) { got = append(got, msg) }, 3, nil, "") + bridge.TaskDispatched(specialist.Task{ID: "a", Prompt: "x"}) + for _, kind := range []string{"assistant", "reasoning", "usage", ""} { + bridge.TaskProgress("a", streamjson.Event{Type: streamjson.EventType(kind), Name: "noise"}) + } + for _, msg := range got { + if _, ok := msg.(planTaskProgressMsg); ok { + t.Fatal("a non-tool-call event reached the card") + } + } + bridge.TaskProgress("a", streamjson.Event{Type: streamjson.EventToolCall, Name: "grep"}) + found := false + for _, msg := range got { + if _, ok := msg.(planTaskProgressMsg); ok { + found = true + } + } + if !found { + t.Fatal("a tool call did not reach the card") + } +} + +// The MODEL routes by the card the recorder resolved, with no guessing left. +func TestTheModelRoutesTaskProgressByCard(t *testing.T) { + m := model{now: func() time.Time { return time.Unix(1000, 0) }, activeRunID: 5} + m.specialists.start("alpha", "one", "card_a", m.now()) + m.specialists.start("beta", "two", "card_b", m.now()) + + updated, _ := m.Update(planTaskProgressMsg{runID: 5, taskID: "beta", cardKey: "card_b", + toolName: "grep", detail: "pattern"}) + after := updated.(model) + + alpha, _ := after.specialists.getBySessionID("card_a") + beta, _ := after.specialists.getBySessionID("card_b") + if beta.toolCount != 1 || beta.currentTool != "grep" { + t.Fatalf("beta's card = %d calls / %q; the event must land there", beta.toolCount, beta.currentTool) + } + if alpha.toolCount != 0 || alpha.currentTool != "" { + t.Fatalf("alpha's card was touched: %d calls / %q", alpha.toolCount, alpha.currentTool) + } +} diff --git a/internal/tui/plan_messages.go b/internal/tui/plan_messages.go index 36ca9c732..e7fd8fabf 100644 --- a/internal/tui/plan_messages.go +++ b/internal/tui/plan_messages.go @@ -79,6 +79,20 @@ type planTaskDoneMsg struct { background bool } +// planTaskProgressMsg is one tool call made by ONE task's child, already +// resolved to that task's card. The routing happens at the recorder, which is +// the only place that knows which card belongs to which task. +type planTaskProgressMsg struct { + runID int + taskID string + cardKey string + toolName string + detail string + // background marks a plan that outlives the run that launched it, so the + // stale-run guard must not drop its progress. + background bool +} + // planCompletedMsg carries the plan's terminal record. type planCompletedMsg struct { runID int diff --git a/internal/tui/plan_progress.go b/internal/tui/plan_progress.go index 53c49018a..cb5e430e8 100644 --- a/internal/tui/plan_progress.go +++ b/internal/tui/plan_progress.go @@ -10,6 +10,7 @@ import ( "github.com/Gitlawb/zero/internal/sessions" "github.com/Gitlawb/zero/internal/specialist" + "github.com/Gitlawb/zero/internal/streamjson" ) // PlanProgressBridge turns plan lifecycle events into TUI messages. @@ -71,6 +72,11 @@ type PlanProgressBridge struct { // handed the real Plan, so it keeps the one thing that can be run again. lastPlan map[string]any lastPlanName string + // cardByTask maps a task id to the card it opened, so a child's stream event + // can be routed to the right row. The recorder is the only thing that knows + // this pairing — it created it — which is why per-task progress travels + // through here rather than being guessed at the display end. + cardByTask map[string]string // dispatched counts tasks so each gets a unique temporary card key. The // child's real session id is not known until the child process creates it, // so the card is keyed by this and reconciled on completion — exactly how @@ -293,6 +299,38 @@ func (bridge *PlanProgressBridge) LastPlan() (map[string]any, string, bool) { return bridge.lastPlan, bridge.lastPlanName, true } +// TaskProgress routes one of a task's child events to that task's card. +// +// Called from the TASK's goroutine — several at once when a plan runs +// concurrently — so it takes the lock, resolves the card, and hands a message +// to the sink. Nothing here renders or blocks, which is the same contract every +// other method on this bridge keeps. +func (bridge *PlanProgressBridge) TaskProgress(taskID string, event streamjson.Event) { + if bridge == nil || event.Type != streamjson.EventToolCall { + // Only tool calls: the card counts tool calls and names the current one, + // and forwarding every token would be a message per token on the event + // loop for a display that shows neither. + return + } + bridge.mu.Lock() + card := bridge.cardByTask[taskID] + background := bridge.background + bridge.mu.Unlock() + if card == "" { + // No card: the task was never dispatched through this bridge. Silently + // dropping is right — inventing one would put a row on screen for work + // the panel never admitted. + return + } + name, detail := event.Name, toolCallSummary(event) + bridge.send(func(runID int) tea.Msg { + return planTaskProgressMsg{ + runID: runID, taskID: taskID, cardKey: card, + toolName: name, detail: detail, background: background, + } + }) +} + // RecordingError reports the first append failure, so a surface can say once // that the plan was not fully persisted rather than leaving it silent. func (bridge *PlanProgressBridge) RecordingError() error { @@ -373,6 +411,10 @@ func (bridge *PlanProgressBridge) TaskDispatched(task specialist.Task) { bridge.mu.Lock() bridge.dispatched++ key := planTaskKey(bridge.dispatched) + if bridge.cardByTask == nil { + bridge.cardByTask = map[string]string{} + } + bridge.cardByTask[task.ID] = key bridge.mu.Unlock() id, summary := task.ID, planTaskSummary(task) From e045428ff139cc9d831dcea2047aab9b12befad5 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:22:28 +0530 Subject: [PATCH 52/86] =?UTF-8?q?test:=20prove=20resume=20survives=20a=20p?= =?UTF-8?q?artial=20concurrent=20run=20(gap=20report=20=C2=A75.1,=20closin?= =?UTF-8?q?g)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last item from the fan-out plan, and the one I had not closed: does the resume reducer still narrow correctly when events come from tasks running at once? It does, and the reason is a property worth pinning rather than assuming. THE EVENT LOG STAYS SINGLE-WRITER. Tasks run on their own goroutines; the five lifecycle events do not. Dispatch is recorded by the walk before the goroutine starts, and every terminal event by the harvest the same walk performs. So the log is written by one goroutine in a defined order however many tasks overlap — which is why ReducePlanEvents can rely on Sequence and why none of that bookkeeping needs a lock. Asserted by recording which goroutine each recorder call arrives on. RESUME AFTER A FOUR-WIDE CRASH. Several tasks can be in flight when a process dies, so the reducer must report EVERY unfinished one rather than the last, the remainder must re-run all of them, a joining task must keep the edges to the predecessors that did not finish and lose the ones that did, and the remainder must still be concurrent — narrowing a fan-out into a chain would be a silent downgrade. Four mutations. R4 — dropping the lock around the bridge's card map — initially MISSED under -race, and the test was at fault: each task's goroutine made five quick calls and finished before the next dispatch, so the reader and the writer never actually met. Made to spin until signalled, the race is found immediately. A concurrency test whose threads do not overlap is not a concurrency test, and "passes under -race" meant nothing here until the overlap was forced. Also adds the combination nothing else exercised: a real bridge recording a concurrent plan into a real session store while per-task progress arrives from every task's goroutine. Race-clean at -count=10. --- internal/specialist/plan_concurrent_test.go | 153 ++++++++++++++++++++ internal/tui/plan_durability_test.go | 79 ++++++++++ 2 files changed, 232 insertions(+) diff --git a/internal/specialist/plan_concurrent_test.go b/internal/specialist/plan_concurrent_test.go index 8b62c5077..56ae46a43 100644 --- a/internal/specialist/plan_concurrent_test.go +++ b/internal/specialist/plan_concurrent_test.go @@ -2,10 +2,13 @@ package specialist import ( "context" + "runtime" "sync" "sync/atomic" "testing" "time" + + "github.com/Gitlawb/zero/internal/sessions" ) // concurrencyProbe records the maximum number of tasks in flight at once, and @@ -266,3 +269,153 @@ func TestNoTaskIsLeftInFlightWhenThePlanReports(t *testing.T) { } } } + +// THE EVENT LOG STAYS SINGLE-WRITER under concurrency, and that is what makes +// the resume reducer safe. +// +// Tasks run on their own goroutines; the five lifecycle events do NOT. Dispatch +// is recorded by the walk before the goroutine starts, and every terminal event +// by the harvest that the same walk performs. So the log is written by one +// goroutine in a well-defined order however many tasks overlap — which is why +// ReducePlanEvents can rely on Sequence and why nothing here needs a lock. +func TestLifecycleEventsAreWrittenBySingleGoroutine(t *testing.T) { + plan := fanOutPlan(t, 4, 12) + recorder := &goroutineWitness{} + ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(context.Context, PlanTaskRequest) (TaskResult, error) { + time.Sleep(2 * time.Millisecond) + return TaskResult{Outcome: TaskSucceeded}, nil + }, recorder) + + recorder.mu.Lock() + defer recorder.mu.Unlock() + if len(recorder.goroutines) != 1 { + t.Fatalf("the event log was written from %d goroutines: %v", len(recorder.goroutines), recorder.goroutines) + } + if recorder.dispatched != 12 || recorder.completed != 12 { + t.Fatalf("recorded %d dispatches and %d completions for 12 tasks", recorder.dispatched, recorder.completed) + } +} + +// goroutineWitness records which goroutine each recorder call arrived on. +type goroutineWitness struct { + mu sync.Mutex + goroutines map[string]bool + dispatched int + completed int +} + +func (w *goroutineWitness) note() { + w.mu.Lock() + defer w.mu.Unlock() + if w.goroutines == nil { + w.goroutines = map[string]bool{} + } + w.goroutines[goroutineLabel()] = true +} + +func (w *goroutineWitness) TaskDispatched(Task) { + w.note() + w.mu.Lock() + w.dispatched++ + w.mu.Unlock() +} + +func (w *goroutineWitness) TaskCompleted(TaskResult) { + w.note() + w.mu.Lock() + w.completed++ + w.mu.Unlock() +} + +func (w *goroutineWitness) TaskFailed(TaskResult) { w.note() } + +// goroutineLabel identifies the calling goroutine from its stack header. Only a +// test uses this; production code has no business knowing which goroutine it is +// on, which is exactly the property being asserted. +func goroutineLabel() string { + buffer := make([]byte, 64) + n := runtime.Stack(buffer, false) + return string(buffer[:n])[:20] +} + +// RESUME AFTER A PARTIAL CONCURRENT RUN. Several tasks can be in flight when the +// process dies, so the reducer must report EVERY unfinished one — not just the +// last — and the remainder must re-run all of them. +func TestResumeAfterAPartialConcurrentRun(t *testing.T) { + plan := mustPlan(t, []any{ + task("a", "one"), task("b", "two"), task("c", "three"), task("d", "four"), + task("e", "join", "a", "b", "c", "d"), + }, map[string]any{"max_workers": float64(4)}, readOnlyLimits()) + + // a and c finished; b and d were dispatched and never came back — the shape + // a crash during a four-wide fan-out actually leaves. + events := []sessions.Event{ + planEvent(1, func() (sessions.EventType, map[string]any) { return PlanAdmittedEvent(plan) }), + planEvent(2, func() (sessions.EventType, map[string]any) { return TaskDispatchedEvent(Task{ID: "a"}) }), + planEvent(3, func() (sessions.EventType, map[string]any) { return TaskDispatchedEvent(Task{ID: "b"}) }), + planEvent(4, func() (sessions.EventType, map[string]any) { return TaskDispatchedEvent(Task{ID: "c"}) }), + planEvent(5, func() (sessions.EventType, map[string]any) { return TaskDispatchedEvent(Task{ID: "d"}) }), + // Terminal events INTERLEAVED and out of dispatch order, which is what + // concurrency produces. + planEvent(6, func() (sessions.EventType, map[string]any) { return TaskCompletedEvent(TaskResult{ID: "c"}) }), + planEvent(7, func() (sessions.EventType, map[string]any) { return TaskCompletedEvent(TaskResult{ID: "a"}) }), + } + + progress, ok := ReducePlanEvents(events) + if !ok { + t.Fatal("the reducer found no plan") + } + if len(progress.Unfinished) != 2 { + t.Fatalf("unfinished = %v; BOTH in-flight tasks must be reported", progress.Unfinished) + } + unfinished := map[string]bool{} + for _, id := range progress.Unfinished { + unfinished[id] = true + } + if !unfinished["b"] || !unfinished["d"] { + t.Fatalf("unfinished = %v, want b and d", progress.Unfinished) + } + + remaining, err := RemainingPlan(plan, progress, readOnlyLimits()) + if err != nil { + t.Fatalf("RemainingPlan: %v", err) + } + got := map[string]bool{} + for _, id := range remaining.Order() { + got[id] = true + } + for _, id := range []string{"b", "d", "e"} { + if !got[id] { + t.Fatalf("the remainder %v must re-run %q", remaining.Order(), id) + } + } + for _, id := range []string{"a", "c"} { + if got[id] { + t.Fatalf("the remainder %v re-runs %q, which already succeeded", remaining.Order(), id) + } + } + // e depended on all four; the two that succeeded are stripped and the two + // that did not survive as edges, or the remainder would run e before its + // real predecessors. + for _, task := range remaining.Tasks() { + if task.ID != "e" { + continue + } + deps := map[string]bool{} + for _, dep := range task.DependsOn { + deps[dep] = true + } + if !deps["b"] || !deps["d"] { + t.Fatalf("e depends on %v; the unfinished predecessors must survive", task.DependsOn) + } + if deps["a"] || deps["c"] { + t.Fatalf("e depends on %v; a satisfied dependency must be dropped", task.DependsOn) + } + } + // And the remainder still runs concurrently — narrowing must not quietly + // serialise what was a fan-out. + if remaining.Budget().MaxWorkers != 4 { + t.Fatalf("the remainder's max_workers = %d, want 4", remaining.Budget().MaxWorkers) + } +} diff --git a/internal/tui/plan_durability_test.go b/internal/tui/plan_durability_test.go index 5324ac71d..5fc9216a7 100644 --- a/internal/tui/plan_durability_test.go +++ b/internal/tui/plan_durability_test.go @@ -2,6 +2,8 @@ package tui import ( "encoding/json" + "fmt" + "sync" "testing" "time" @@ -9,6 +11,7 @@ import ( "github.com/Gitlawb/zero/internal/sessions" "github.com/Gitlawb/zero/internal/specialist" + "github.com/Gitlawb/zero/internal/streamjson" ) func durableBridge(t *testing.T) (*PlanProgressBridge, *sessions.Store, string) { @@ -221,3 +224,79 @@ func TestTheBridgeIsBoundToTheActiveSession(t *testing.T) { t.Fatalf("recorded %v; beginRun did not bind the bridge to the active session", got) } } + +// A CONCURRENT PLAN RECORDED THROUGH THE REAL BRIDGE, into a real store, with +// per-task progress arriving from the task goroutines at the same time. +// +// This is the combination nothing else exercises: the five lifecycle events are +// written from the executor's single walk goroutine while TaskProgress is called +// from every task's own. Under -race it is also the proof that the bridge's lock +// actually covers what it claims to. +func TestAConcurrentPlanIsRecordedCompletely(t *testing.T) { + bridge, store, sessionID := durableBridge(t) + + const tasks = 8 + plan := samplePlan(t) + bridge.PlanAdmitted(plan) + + // THE OVERLAP HAS TO BE REAL. A first version let each task's goroutine make + // five quick calls and finish before the next dispatch, so the reader and + // the writer of the card map never actually met and -race had nothing to + // find. These spin until told to stop, so later dispatches provably run + // while earlier tasks are streaming. + var wg sync.WaitGroup + stop := make(chan struct{}) + for index := 0; index < tasks; index++ { + id := fmt.Sprintf("t%d", index) + // Dispatch from the single "walk" goroutine, exactly as the executor + // does — the ordering guarantee under test comes from that, not luck. + bridge.TaskDispatched(specialist.Task{ID: id, Prompt: "x"}) + wg.Add(1) + go func(id string) { + defer wg.Done() + for { + select { + case <-stop: + return + default: + bridge.TaskProgress(id, streamjson.Event{Type: streamjson.EventToolCall, Name: "grep"}) + } + } + }(id) + } + time.Sleep(20 * time.Millisecond) + close(stop) + wg.Wait() + for index := 0; index < tasks; index++ { + bridge.TaskCompleted(specialist.TaskResult{ID: fmt.Sprintf("t%d", index), Attempts: 1}) + } + bridge.PlanCompleted(plan, specialist.PlanReport{Status: specialist.PlanCompleted, Succeeded: tasks}) + + if err := bridge.RecordingError(); err != nil { + t.Fatalf("recording failed: %v", err) + } + got := recordedTypes(t, store, sessionID) + dispatched, completed := 0, 0 + for _, eventType := range got { + switch eventType { + case sessions.EventTaskDispatched: + dispatched++ + case sessions.EventTaskCompleted: + completed++ + } + } + if dispatched != tasks || completed != tasks { + t.Fatalf("recorded %d dispatches and %d completions for %d tasks: %v", dispatched, completed, tasks, got) + } + // Every dispatch precedes its completion in the log, which is what makes an + // interrupted plan's in-flight tasks distinguishable on resume. + firstCompleted := -1 + for index, eventType := range got { + if eventType == sessions.EventTaskCompleted && firstCompleted < 0 { + firstCompleted = index + } + if eventType == sessions.EventTaskDispatched && firstCompleted >= 0 { + t.Fatalf("a dispatch was recorded after a completion: %v", got) + } + } +} From b762685f8ef538df33d482b3ed2dcc513378f056 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:31:17 +0530 Subject: [PATCH 53/86] fix(credstore): serialize the credential read-modify-write across processes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first of §5.1's three prerequisites, and the one that starts to bite the moment max_workers > 1 gives you concurrent child PROCESSES. REPRODUCED FIRST, and it is worse than the audit recorded. Set and Delete are read-modify-write over a file published by rename, with no lock of any kind — not a mutex, not flock. A hundred concurrent Set calls kept ONE, reliably, three runs running: every writer read the same map, added its own provider, and the last rename published a file missing all the others. An exclusive advisory lock now spans the WHOLE read-modify-write. Spanning only the write would change nothing: two writers would still each read the same map and the second rename would still publish a file missing the first key. THE LOCK FILE IS BESIDE THE DATA FILE, NEVER THE DATA FILE. write publishes by os.Rename, which replaces the inode; a lock taken on the data file would be attached to something the next writer has already replaced, so every writer would appear to hold it and the fix would look like it worked. A mutation proving that is in the sweep. flock is per open file description, so two opens inside ONE process contend exactly as two processes do — which is why this needs no separate mutex, and why the in-process test is meaningful rather than merely convenient. Windows gets LockFileEx on a fixed one-byte region, blocking, matching the sessions store that already does this. The keyring backend is untouched: the OS keyring serializes its own writes. TESTED ACROSS REAL PROCESSES, because that is the case that matters and the one neither the race detector nor an in-process test can see: four child processes writing twenty-five keys each, all hundred present afterwards. Four mutations. L2 — removing the lock from Delete — initially MISSED, and the test was at fault: it asserted that keys nobody deleted survived, and every reader sees those. The case that actually breaks is a Delete whose stale read predates a concurrent Set, writing back a map that never held the new key. The assertion moved to the SETS surviving, plus a check that the deletes were not no-ops that would make it vacuous. --- internal/credstore/concurrency_test.go | 215 +++++++++++++++++++++++++ internal/credstore/credstore.go | 27 ++++ internal/credstore/filelock_unix.go | 39 +++++ internal/credstore/filelock_windows.go | 39 +++++ 4 files changed, 320 insertions(+) create mode 100644 internal/credstore/concurrency_test.go create mode 100644 internal/credstore/filelock_unix.go create mode 100644 internal/credstore/filelock_windows.go diff --git a/internal/credstore/concurrency_test.go b/internal/credstore/concurrency_test.go new file mode 100644 index 000000000..eaf5c886f --- /dev/null +++ b/internal/credstore/concurrency_test.go @@ -0,0 +1,215 @@ +package credstore + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" +) + +func fileStore(t *testing.T, dir string) *Store { + t.Helper() + store, err := New(Options{Dir: dir, Storage: "file"}) + if err != nil { + t.Fatalf("New: %v", err) + } + return store +} + +// THE REPRODUCTION, kept as the regression. Before the lock this reliably kept +// 1 of 100: every writer read the same map, added its own provider, and the +// last rename published a file missing all the others. +func TestConcurrentSetKeepsEveryKey(t *testing.T) { + dir := t.TempDir() + store := fileStore(t, dir) + + const n = 100 + var wg sync.WaitGroup + errs := make(chan error, n) + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + if err := store.Set(fmt.Sprintf("p%03d", i), fmt.Sprintf("k%03d", i)); err != nil { + errs <- err + } + }(i) + } + wg.Wait() + close(errs) + for err := range errs { + t.Fatalf("Set: %v", err) + } + + got, err := store.Providers() + if err != nil { + t.Fatalf("Providers: %v", err) + } + if len(got) != n { + t.Fatalf("kept %d of %d concurrent writes", len(got), n) + } + // ...and every value is the one its own writer wrote, not a neighbour's. + for i := 0; i < n; i++ { + key, ok, err := store.Get(fmt.Sprintf("p%03d", i)) + if err != nil { + t.Fatalf("Get: %v", err) + } + if !ok || key != fmt.Sprintf("k%03d", i) { + t.Fatalf("p%03d = %q (present=%v); a writer's own value must survive", i, key, ok) + } + } +} + +// DELETE RACES SET, and an unlocked delete loses the other writer's key. +// +// A first version of this only checked that keys nobody deleted survived — and +// every reader sees those, so it passed with the lock removed from Delete. The +// case that actually breaks is a Delete whose stale read predates a concurrent +// Set: it writes back a map that never contained the new key, and the Set is +// gone. So the assertion is on the SETS surviving, not on the bystanders. +func TestADeleteCannotClobberAConcurrentSet(t *testing.T) { + dir := t.TempDir() + store := fileStore(t, dir) + + // Throwaway keys for the deleters to remove, written first so a delete + // always has something to do. + const churn = 40 + for i := 0; i < churn; i++ { + if err := store.Set(fmt.Sprintf("churn%02d", i), "v"); err != nil { + t.Fatal(err) + } + } + + const adds = 60 + var wg sync.WaitGroup + for i := 0; i < adds; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + _ = store.Set(fmt.Sprintf("added%02d", i), "v") + }(i) + } + for i := 0; i < churn; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + _, _ = store.Delete(fmt.Sprintf("churn%02d", i)) + }(i) + } + wg.Wait() + + got, err := store.Providers() + if err != nil { + t.Fatal(err) + } + survivors := map[string]bool{} + for _, name := range got { + survivors[name] = true + } + missing := []string{} + for i := 0; i < adds; i++ { + name := fmt.Sprintf("added%02d", i) + if !survivors[name] { + missing = append(missing, name) + } + } + if len(missing) > 0 { + t.Fatalf("%d of %d concurrent Set calls were clobbered by a racing Delete: %v", + len(missing), adds, missing) + } + // ...and every churn key really was removed, so the deletes were not + // no-ops that would make the check above vacuous. + for i := 0; i < churn; i++ { + if survivors[fmt.Sprintf("churn%02d", i)] { + t.Fatalf("churn%02d survived; the deletes did nothing and this test proves nothing", i) + } + } +} + +// ACROSS PROCESSES, which is the case that matters: a plan running with +// max_workers > 1 spawns child PROCESSES, and an in-process mutex would not +// have helped any of them. The race detector cannot see this one either — it is +// two OS processes — so it is driven for real. +func TestConcurrentSetAcrossProcesses(t *testing.T) { + if testing.Short() { + t.Skip("spawns processes") + } + helper := os.Getenv("ZERO_CREDSTORE_HELPER_DIR") + if helper != "" { + // Child mode: write our slice of the keys and exit. + store, err := New(Options{Dir: helper, Storage: "file"}) + if err != nil { + os.Exit(3) + } + prefix := os.Getenv("ZERO_CREDSTORE_HELPER_PREFIX") + for i := 0; i < 25; i++ { + if err := store.Set(fmt.Sprintf("%s%02d", prefix, i), "v"); err != nil { + os.Exit(4) + } + } + os.Exit(0) + } + + dir := t.TempDir() + const children = 4 + var wg sync.WaitGroup + failures := make(chan string, children) + for c := 0; c < children; c++ { + wg.Add(1) + go func(c int) { + defer wg.Done() + cmd := exec.Command(os.Args[0], "-test.run=TestConcurrentSetAcrossProcesses", "-test.v") + cmd.Env = append(os.Environ(), + "ZERO_CREDSTORE_HELPER_DIR="+dir, + fmt.Sprintf("ZERO_CREDSTORE_HELPER_PREFIX=c%d_", c), + ) + if out, err := cmd.CombinedOutput(); err != nil { + failures <- fmt.Sprintf("child %d: %v\n%s", c, err, out) + } + }(c) + } + wg.Wait() + close(failures) + for failure := range failures { + t.Fatal(failure) + } + + store := fileStore(t, dir) + got, err := store.Providers() + if err != nil { + t.Fatal(err) + } + if len(got) != children*25 { + t.Fatalf("kept %d of %d keys written by %d concurrent processes", len(got), children*25, children) + } +} + +// The lock lives BESIDE the data file, never on it. write publishes by rename, +// so a lock taken on the data file would be attached to an inode the next +// writer has already replaced — every writer would appear to hold it. +func TestTheLockIsNotTheDataFile(t *testing.T) { + dir := t.TempDir() + store := fileStore(t, dir) + if store.lockPath() == store.file { + t.Fatal("the lock is the data file; rename would carry it away") + } + if !strings.HasPrefix(filepath.Base(store.lockPath()), filepath.Base(store.file)) { + t.Fatalf("lock %q is not beside the data file %q", store.lockPath(), store.file) + } + if err := store.Set("p", "k"); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(store.lockPath()); err != nil { + t.Fatalf("the lock file was not created: %v", err) + } + // ...and it survives a write, since the rename replaces only the data file. + if err := store.Set("q", "k"); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(store.lockPath()); err != nil { + t.Fatalf("the lock file did not survive a write: %v", err) + } +} diff --git a/internal/credstore/credstore.go b/internal/credstore/credstore.go index 96530dbc5..6fa3dd2ec 100644 --- a/internal/credstore/credstore.go +++ b/internal/credstore/credstore.go @@ -131,8 +131,19 @@ func (s *Store) Set(provider, key string) error { return fmt.Errorf("credstore: provider is required") } if s.backend == "keyring" { + // The OS keyring serializes its own writes; the file lock guards the + // file backends only. return s.kr.Set(keyringService, keyringPrefix+provider, key) } + // READ AND WRITE UNDER ONE LOCK. Separately locking each half would still + // lose keys: two writers would each read the same map, each add their own + // provider, and the second rename would publish a file missing the first. + // Measured before this existed: 1 of 100 concurrent Set calls survived. + release, err := s.acquireFileLock() + if err != nil { + return err + } + defer release() data, err := s.read() if err != nil { return err @@ -167,6 +178,13 @@ func (s *Store) Delete(provider string) (bool, error) { if s.backend == "keyring" { return s.kr.Delete(keyringService, keyringPrefix+provider) } + // The same lock as Set: a delete is a read-modify-write too, and one racing + // a set would otherwise resurrect or erase an unrelated provider. + release, err := s.acquireFileLock() + if err != nil { + return false, err + } + defer release() data, err := s.read() if err != nil { return false, err @@ -264,6 +282,15 @@ func (s *Store) write(data map[string]string) error { return nil } +// lockPath is the advisory lock guarding a read-modify-write of the credential +// file. Beside the data file, never the data file itself — write publishes by +// rename and would carry the lock away with the old inode. +func (s *Store) lockPath() string { return s.file + ".lock" } + +// filepathDir is filepath.Dir, named locally so the two platform lock files can +// share it without either importing path/filepath for one call. +func filepathDir(path string) string { return filepath.Dir(path) } + func normalizeProvider(provider string) string { return strings.ToLower(strings.TrimSpace(provider)) } diff --git a/internal/credstore/filelock_unix.go b/internal/credstore/filelock_unix.go new file mode 100644 index 000000000..f704a3662 --- /dev/null +++ b/internal/credstore/filelock_unix.go @@ -0,0 +1,39 @@ +//go:build !windows + +package credstore + +import ( + "fmt" + "os" + + "golang.org/x/sys/unix" +) + +// acquireFileLock takes an exclusive advisory lock (flock) so a read-modify-write +// of the credential file is serialized against every other one — across +// processes AND across goroutines, since flock is held per open file +// description and two opens in one process contend exactly as two processes do. +// +// THE LOCK FILE IS SEPARATE FROM THE DATA FILE, and that is not tidiness. write +// publishes by os.Rename, which replaces the inode; a lock taken on the data +// file would be attached to an inode that the next writer has already replaced, +// so every writer would appear to hold it. The lock lives on a file nothing +// renames. +func (s *Store) acquireFileLock() (func(), error) { + path := s.lockPath() + if err := os.MkdirAll(filepathDir(path), 0o700); err != nil { + return nil, fmt.Errorf("credstore: lock dir: %w", err) + } + file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600) + if err != nil { + return nil, fmt.Errorf("credstore: open lock: %w", err) + } + if err := unix.Flock(int(file.Fd()), unix.LOCK_EX); err != nil { + _ = file.Close() + return nil, fmt.Errorf("credstore: lock: %w", err) + } + return func() { + _ = unix.Flock(int(file.Fd()), unix.LOCK_UN) + _ = file.Close() + }, nil +} diff --git a/internal/credstore/filelock_windows.go b/internal/credstore/filelock_windows.go new file mode 100644 index 000000000..70fe0c05e --- /dev/null +++ b/internal/credstore/filelock_windows.go @@ -0,0 +1,39 @@ +//go:build windows + +package credstore + +import ( + "fmt" + "os" + + "golang.org/x/sys/windows" +) + +// acquireFileLock takes an exclusive OS lock (LockFileEx) so a read-modify-write +// of the credential file is serialized, matching the flock behaviour on unix. +// +// The lock file is SEPARATE from the data file for the same reason: write +// publishes by rename, and a lock on the renamed file would be attached to +// something the next writer has already replaced. +func (s *Store) acquireFileLock() (func(), error) { + path := s.lockPath() + if err := os.MkdirAll(filepathDir(path), 0o700); err != nil { + return nil, fmt.Errorf("credstore: lock dir: %w", err) + } + file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600) + if err != nil { + return nil, fmt.Errorf("credstore: open lock: %w", err) + } + handle := windows.Handle(file.Fd()) + overlapped := new(windows.Overlapped) + // A fixed 1-byte region, blocking (no LOCKFILE_FAIL_IMMEDIATELY) so a + // waiter queues rather than failing the write. + if err := windows.LockFileEx(handle, windows.LOCKFILE_EXCLUSIVE_LOCK, 0, 1, 0, overlapped); err != nil { + _ = file.Close() + return nil, fmt.Errorf("credstore: lock: %w", err) + } + return func() { + _ = windows.UnlockFileEx(handle, 0, 1, 0, overlapped) + _ = file.Close() + }, nil +} From f85d40a15ecb70fb7322c5b8cde8049c4f6bd927 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:42:01 +0530 Subject: [PATCH 54/86] fix(sandbox): a temporary grant is refcounted, so a sibling's cleanup cannot revoke it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second of §5.1's three prerequisites — and it turns out NOT to be a prerequisite for plan fan-out at all. It is a live defect on the parallel tool-call path, which is a different thing, and worth fixing on its own merits. A CORRECTION TO WHAT I SAID EARLIER. I claimed this blocked combining max_workers > 1 with write-capable tasks. It does not: plan tasks are separate PROCESSES (osexec.CommandContext), each with its own sandbox Engine and Scope, so concurrent plan tasks never share this state. What does share it is internal/agent/parallel_tools.go, which runs up to eight read-only tools concurrently in ONE process — and a read-only tool blocked outside the workspace prompts and takes a temporary read grant. THE DEFECT. AddTemporaryRead appended the root and returned an undo that removed it, with no notion of who else needed it. The second caller to ask for a root already present got a NO-OP undo, so when the first caller finished its cleanup removed the root out from under the second — which was still running. Two read-only tools in one parallel batch, both blocked on the same directory, is exactly that shape. Reproduced before fixing: two holders, then A's cleanup, and the root goes present -> absent while B still holds a grant. Temporary roots are now reference counted and removed only when the last holder releases. A caller whose request is COVERED by an existing temporary root takes a reference on the covering root rather than a no-op undo, so the broader-root case — the same defect one level up — is closed too. A permanent root is never refcounted and never removed by a temporary holder, which is asserted. Write roots get the identical rule, or a write grant would keep the bug reads no longer have. THE TEST SETUP IS THE INTERESTING PART. A first version used t.TempDir() and passed against completely unfixed code: /tmp and $TMPDIR are default write roots, so every grant for a path under one is a no-op and every assertion held vacuously. That is RULES §2.3 in its exact documented form, met head-on. The helper now builds its directories outside any default root and skips when it cannot, rather than quietly proving nothing. Five mutations, all caught. Race detector clean at -count=20 with eight concurrent holders of one root. --- internal/sandbox/scope.go | 96 +++++++++- internal/sandbox/scope_temporary_test.go | 228 +++++++++++++++++++++++ 2 files changed, 320 insertions(+), 4 deletions(-) create mode 100644 internal/sandbox/scope_temporary_test.go diff --git a/internal/sandbox/scope.go b/internal/sandbox/scope.go index af9b364ec..c70dd5280 100644 --- a/internal/sandbox/scope.go +++ b/internal/sandbox/scope.go @@ -19,6 +19,17 @@ type Scope struct { workspaceRoot string readRoots []string extraRoots []string + // tempReads / tempWrites count how many LIVE temporary grants depend on a + // root, so one holder's cleanup cannot revoke another's access. + // + // Without them a temporary grant was add-then-remove with no notion of who + // still needed it: the second caller to ask for a root already present got a + // NO-OP undo, and the first caller's cleanup removed the root out from under + // it. Two read-only tools in the same parallel batch, both blocked on the + // same directory, is exactly that shape — and read-only tools are precisely + // the ones the batch runs concurrently. + tempReads map[string]int + tempWrites map[string]int } // NewScope builds a scope for workspaceRoot plus the given extra roots. The @@ -119,15 +130,32 @@ func (s *Scope) AddTemporaryRead(path string) (string, func(), error) { s.mu.Lock() defer s.mu.Unlock() if s.writeRootCoversLocked(root) { + // A write root already covers it, permanently. Nothing to release. return root, func() {}, nil } for _, existing := range s.readRoots { - if pathWithinRoot(existing, root) { - return root, func() {}, nil + if !pathWithinRoot(existing, root) { + continue } + // Already covered — but by WHAT decides whether this caller has + // something to release. A permanent read root outlives every grant, so + // the undo is genuinely nothing. A TEMPORARY one is held by another + // caller who will release it, and that caller must not be able to + // revoke this one's access: take a reference on the covering root, so + // it survives until the last holder is done. + if _, temporary := s.tempReads[existing]; temporary { + s.tempReads[existing]++ + covering := existing + return root, func() { s.releaseTemporaryRead(covering) }, nil + } + return root, func() {}, nil } s.readRoots = append(s.readRoots, root) - return root, func() { s.removeReadRoot(root) }, nil + if s.tempReads == nil { + s.tempReads = map[string]int{} + } + s.tempReads[root] = 1 + return root, func() { s.releaseTemporaryRead(root) }, nil } func (s *Scope) AddTemporaryWrite(path string) (string, func(), error) { @@ -138,10 +166,25 @@ func (s *Scope) AddTemporaryWrite(path string) (string, func(), error) { s.mu.Lock() defer s.mu.Unlock() if s.writeRootCoversLocked(root) { + // Covered already. If a TEMPORARY write grant is what covers it, take a + // reference so the covering holder's cleanup cannot revoke this one — + // the same rule as reads, and it has to be the same rule or a write + // grant would keep the bug reads no longer have. + for existing := range s.tempWrites { + if pathWithinRoot(existing, root) { + s.tempWrites[existing]++ + covering := existing + return root, func() { s.releaseTemporaryWrite(covering) }, nil + } + } return root, func() {}, nil } s.extraRoots = append(s.extraRoots, root) - return root, func() { s.removeWriteRoot(root) }, nil + if s.tempWrites == nil { + s.tempWrites = map[string]int{} + } + s.tempWrites[root] = 1 + return root, func() { s.releaseTemporaryWrite(root) }, nil } func (s *Scope) writeRootCoversLocked(root string) bool { @@ -153,6 +196,51 @@ func (s *Scope) writeRootCoversLocked(root string) bool { return false } +// releaseTemporaryRead drops one holder's reference and removes the root only +// when the last one is gone. IDEMPOTENT per holder is not the property here — +// each undo is called exactly once — but a double call must not remove a root +// another holder still needs, so the count floors at zero rather than going +// negative. +func (s *Scope) releaseTemporaryRead(root string) { + s.mu.Lock() + remaining, tracked := s.tempReads[root] + if !tracked { + s.mu.Unlock() + return + } + remaining-- + if remaining > 0 { + s.tempReads[root] = remaining + s.mu.Unlock() + return + } + delete(s.tempReads, root) + s.mu.Unlock() + s.removeReadRoot(root) +} + +// releaseTemporaryWrite is releaseTemporaryRead for write roots. Two functions +// rather than one generic helper because they guard different slices and the +// generic version would take the slice by name — which is how the wrong one +// gets passed. +func (s *Scope) releaseTemporaryWrite(root string) { + s.mu.Lock() + remaining, tracked := s.tempWrites[root] + if !tracked { + s.mu.Unlock() + return + } + remaining-- + if remaining > 0 { + s.tempWrites[root] = remaining + s.mu.Unlock() + return + } + delete(s.tempWrites, root) + s.mu.Unlock() + s.removeWriteRoot(root) +} + func (s *Scope) removeReadRoot(root string) { s.mu.Lock() defer s.mu.Unlock() diff --git a/internal/sandbox/scope_temporary_test.go b/internal/sandbox/scope_temporary_test.go new file mode 100644 index 000000000..f21e69f88 --- /dev/null +++ b/internal/sandbox/scope_temporary_test.go @@ -0,0 +1,228 @@ +package sandbox + +import ( + "os" + "path/filepath" + "sync" + "testing" +) + +// scopeOutsideRoots builds a workspace and an unrelated directory that no +// default write root already covers. +// +// NOT t.TempDir(). /tmp and $TMPDIR are default write roots, so a temporary +// grant for a path under one is a no-op — every assertion here would pass +// against code that grants nothing at all. That is RULES §2.3 in its exact +// form, and it caught the first version of this test. +func scopeOutsideRoots(t *testing.T) (workspace string, outside string) { + t.Helper() + base, err := os.MkdirTemp("/Users/Shared", "zeromax-scope-") + if err != nil { + base, err = os.MkdirTemp("/var/empty", "zeromax-scope-") + if err != nil { + t.Skipf("no directory outside a default write root is available: %v", err) + } + } + t.Cleanup(func() { _ = os.RemoveAll(base) }) + workspace = filepath.Join(base, "ws") + outside = filepath.Join(base, "outside") + for _, dir := range []string{workspace, outside} { + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Skipf("cannot prepare %s: %v", dir, err) + } + } + return workspace, outside +} + +func hasReadRoot(scope *Scope, root string) bool { + for _, existing := range scope.ReadRoots() { + if existing == root { + return true + } + } + return false +} + +// THE DEFECT: one holder's cleanup revoked another's access. Two read-only +// tools in the same parallel batch, both blocked on the same directory, is +// exactly this — and read-only tools are the ones the batch runs concurrently. +func TestATemporaryReadSurvivesASiblingsCleanup(t *testing.T) { + workspace, outside := scopeOutsideRoots(t) + scope, err := NewScope(workspace, nil) + if err != nil { + t.Fatal(err) + } + // The probe that the fix must make unnecessary: without it this reads + // true -> false. + if hasReadRoot(scope, outside) { + t.Fatal("the outside root is already covered; this test proves nothing") + } + + _, undoA, err := scope.AddTemporaryRead(outside) + if err != nil { + t.Fatal(err) + } + _, undoB, err := scope.AddTemporaryRead(outside) + if err != nil { + t.Fatal(err) + } + if !hasReadRoot(scope, outside) { + t.Fatal("the grant did not take effect") + } + + undoA() + if !hasReadRoot(scope, outside) { + t.Fatal("A's cleanup revoked the root while B still held a grant") + } + undoB() + if hasReadRoot(scope, outside) { + t.Fatal("the root outlived its last holder") + } +} + +// A BROADER temporary grant covering a narrower request is the same defect one +// level up: the narrower caller gets no root of its own, so it must hold a +// reference on whatever covers it. +func TestANarrowerRequestHoldsTheCoveringGrant(t *testing.T) { + workspace, outside := scopeOutsideRoots(t) + nested := filepath.Join(outside, "nested") + if err := os.MkdirAll(nested, 0o700); err != nil { + t.Skip(err) + } + scope, err := NewScope(workspace, nil) + if err != nil { + t.Fatal(err) + } + + _, undoBroad, err := scope.AddTemporaryRead(outside) + if err != nil { + t.Fatal(err) + } + _, undoNarrow, err := scope.AddTemporaryRead(nested) + if err != nil { + t.Fatal(err) + } + undoBroad() + if !hasReadRoot(scope, outside) { + t.Fatal("the broad holder's cleanup revoked coverage the narrow holder still needs") + } + undoNarrow() + if hasReadRoot(scope, outside) { + t.Fatal("the root outlived its last holder") + } +} + +// A PERMANENT root is not refcounted and must never be removed by a temporary +// holder's cleanup — the undo for a request it already covers is genuinely +// nothing. +func TestATemporaryGrantNeverRevokesAPermanentRoot(t *testing.T) { + workspace, outside := scopeOutsideRoots(t) + scope, err := NewScope(workspace, nil) + if err != nil { + t.Fatal(err) + } + if _, err := scope.AddRead(outside); err != nil { + t.Fatal(err) + } + _, undo, err := scope.AddTemporaryRead(outside) + if err != nil { + t.Fatal(err) + } + undo() + if !hasReadRoot(scope, outside) { + t.Fatal("a temporary holder's cleanup removed a permanent read root") + } +} + +// The same rule for WRITE roots, or a write grant keeps the bug reads no longer +// have. +func TestATemporaryWriteSurvivesASiblingsCleanup(t *testing.T) { + workspace, outside := scopeOutsideRoots(t) + scope, err := NewScope(workspace, nil) + if err != nil { + t.Fatal(err) + } + covered := func() bool { + for _, existing := range scope.Roots() { + if existing == outside { + return true + } + } + return false + } + _, undoA, err := scope.AddTemporaryWrite(outside) + if err != nil { + t.Fatal(err) + } + _, undoB, err := scope.AddTemporaryWrite(outside) + if err != nil { + t.Fatal(err) + } + if !covered() { + t.Fatal("the write grant did not take effect") + } + undoA() + if !covered() { + t.Fatal("A's cleanup revoked the write root while B still held a grant") + } + undoB() + if covered() { + t.Fatal("the write root outlived its last holder") + } +} + +// UNDER REAL CONCURRENCY, which is the shape the parallel tool batch produces: +// eight holders taking and releasing the same root in overlapping windows. The +// root must be present the whole time at least one holder has it, and gone once +// the last releases. +func TestConcurrentHoldersOfOneRoot(t *testing.T) { + workspace, outside := scopeOutsideRoots(t) + scope, err := NewScope(workspace, nil) + if err != nil { + t.Fatal(err) + } + + const holders = 8 + var wg sync.WaitGroup + start := make(chan struct{}) + release := make(chan struct{}) + failures := make(chan string, holders) + for i := 0; i < holders; i++ { + wg.Add(1) + go func() { + defer wg.Done() + <-start + _, undo, err := scope.AddTemporaryRead(outside) + if err != nil { + failures <- err.Error() + return + } + // Every holder must SEE its own grant for as long as it holds it. + if !hasReadRoot(scope, outside) { + failures <- "a holder could not see the root it had just been granted" + } + <-release + if !hasReadRoot(scope, outside) { + failures <- "a holder lost the root while still holding it" + } + undo() + }() + } + close(start) + // Let every holder acquire before any releases, so the windows overlap. + for { + if !hasReadRoot(scope, outside) { + continue + } + break + } + close(release) + wg.Wait() + close(failures) + for failure := range failures { + t.Fatal(failure) + } + if hasReadRoot(scope, outside) { + t.Fatal("the root outlived every holder") + } +} From b0d96ac5d061ac6e0d2b4c3f8570e19414770b01 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:01:04 +0530 Subject: [PATCH 55/86] fix(worktrees): a cancelled git dies as a process group, not just a signal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The checking-before-building found the opposite of what was expected, and this is the small piece that survived it. WHAT THE CHECK FOUND. The third §5.1 prerequisite was recorded as ~60 unhardened exec sites, framed as a blocker on plan fan-out. It is not: the site that launches every plan task, specialist/exec.go:842, already calls hardenSpecialistChild — Setpgid, WaitDelay, and a Cancel that kills the whole group. Textbook, and it also means the stall watchdog works as designed, since without WaitDelay a grandchild holding the pipes would hang Wait after the watchdog cancelled the task. WHAT IT ALSO FOUND. worktrees.go had no hardening at all, and I put it on the plan path two commits ago with the isolator. git is short-lived and rarely forks, so the risk is small — but a write-capable plan can be stopped at any moment by /plans stop, and a cancelled `git worktree add` on a large repo should not outlive it. The repo-wide sweep is NOT done here. Sixty sites is a campaign with its own enforcement rule, on its own schedule; this closes the one site a change of mine made relevant. TWO MUTATIONS INITIALLY MISSED, and both improved the test rather than the code. H5 — killing the direct child instead of the group — passed, because the test only asserted that Wait RETURNED. WaitDelay alone guarantees that while leaving the grandchild running, which is the orphan the hardening exists to prevent. The test now records the grandchild's pid and requires it to be dead. H1 — defaultRunGit building its own unhardened command — could not be caught by any runtime assertion here, because defaultRunGit returns a result and not the command it used. That is the wiring gap this feature has produced five times. It is enforced at the SOURCE instead: exactly one place in the package may build a subprocess, checked by scanning the package's own files. That is the small, local form of the AST enforcement the repo-wide sweep would need. Windows vet caught the last one: the POSIX test named syscall.Kill in a file that has to compile everywhere, and a runtime skip does not help a compile error. Split behind a build tag, with the portable assertions left in place. --- internal/worktrees/run_git_test.go | 97 ++++++++++++++++++++ internal/worktrees/run_git_unix.go | 39 +++++++++ internal/worktrees/run_git_unix_test.go | 112 ++++++++++++++++++++++++ internal/worktrees/run_git_windows.go | 21 +++++ internal/worktrees/worktrees.go | 21 ++++- 5 files changed, 288 insertions(+), 2 deletions(-) create mode 100644 internal/worktrees/run_git_test.go create mode 100644 internal/worktrees/run_git_unix.go create mode 100644 internal/worktrees/run_git_unix_test.go create mode 100644 internal/worktrees/run_git_windows.go diff --git a/internal/worktrees/run_git_test.go b/internal/worktrees/run_git_test.go new file mode 100644 index 000000000..ffdc653e6 --- /dev/null +++ b/internal/worktrees/run_git_test.go @@ -0,0 +1,97 @@ +package worktrees + +import ( + "context" + "fmt" + "os" + "os/exec" + "runtime" + "strings" + "testing" +) + +type discardWriter struct{} + +func (discardWriter) Write(p []byte) (int, error) { return len(p), nil } + +// The constructor every git call goes through must carry the hardening, so a +// future call site cannot get an unhardened command by using it. +func TestTheGitConstructorHardens(t *testing.T) { + command := newHardenedCommand(context.Background(), t.TempDir(), "git", "status") + if command.WaitDelay == 0 { + t.Fatal("WaitDelay is unset; a cancelled git can block Wait indefinitely") + } + if command.Dir == "" { + t.Fatal("the working directory was dropped") + } + // The process-group half is POSIX-only and is asserted in + // run_git_unix_test.go, which can name Setpgid at all — this file must + // compile on Windows, where it does not exist. + if runtime.GOOS != "windows" && command.Cancel == nil { + t.Fatal("Cancel is unset; the process group is never signalled") + } +} + +// ...and the git runner itself still works, so the hardening did not break the +// ordinary path. +func TestHardenedGitStillRuns(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skipf("no git: %v", err) + } + result, err := defaultRunGit(context.Background(), t.TempDir(), "--version") + if err != nil { + t.Fatalf("git --version: %v", err) + } + if result.ExitCode != 0 || result.Stdout == "" { + t.Fatalf("result = %+v", result) + } +} + +// EXACTLY ONE PLACE BUILDS A SUBPROCESS IN THIS PACKAGE. +// +// Every behavioural test above goes through newHardenedCommand, so all of them +// pass against a defaultRunGit that quietly builds its own unhardened command — +// which is the wiring gap this feature has produced five times, and the only one +// no runtime assertion here can reach: defaultRunGit returns a result, not the +// command it used. +// +// So the invariant is enforced at the SOURCE. This is the small, local form of +// the AST enforcement the repo-wide hardening sweep would need, applied to the +// one package a plan reaches. +func TestOnlyTheHardenedConstructorBuildsSubprocesses(t *testing.T) { + entries, err := os.ReadDir(".") + if err != nil { + t.Fatal(err) + } + offenders := []string{} + checked := 0 + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + raw, err := os.ReadFile(name) + if err != nil { + t.Fatal(err) + } + checked++ + for index, line := range strings.Split(string(raw), "\n") { + if !strings.Contains(line, "exec.Command") { + continue + } + // The one permitted site is inside newHardenedCommand, which + // hardens what it builds. + if name == "worktrees.go" && strings.Contains(line, "exec.CommandContext(ctx, name, args...)") { + continue + } + offenders = append(offenders, fmt.Sprintf("%s:%d: %s", name, index+1, strings.TrimSpace(line))) + } + } + if checked == 0 { + t.Fatal("no source files were scanned; this test checked nothing") + } + if len(offenders) > 0 { + t.Fatalf("a subprocess is built outside newHardenedCommand, so it is not hardened:\n %s", + strings.Join(offenders, "\n ")) + } +} diff --git a/internal/worktrees/run_git_unix.go b/internal/worktrees/run_git_unix.go new file mode 100644 index 000000000..042cb2937 --- /dev/null +++ b/internal/worktrees/run_git_unix.go @@ -0,0 +1,39 @@ +//go:build !windows + +package worktrees + +import ( + "errors" + "os/exec" + "syscall" + "time" +) + +// worktreeWaitDelay bounds how long Wait blocks for the child's stdout/stderr +// pipes to drain after the process exits or its context is cancelled, so a +// leaked grandchild cannot hang the parent past cancel/timeout. Var (not const) +// so tests can shorten it. +var worktreeWaitDelay = 2 * time.Second + +// hardenWorktreeGit makes a git subprocess killable as a single unit. Setpgid +// puts the child into its own process group, so on cancel/timeout we signal the +// whole group (negative pid) — any long-running git subprocess dies with it +// instead of being orphaned. WaitDelay is the backstop if a grandchild still +// holds a pipe after the group is killed. Must be called before command.Run. +func hardenWorktreeGit(command *exec.Cmd) { + if command.SysProcAttr == nil { + command.SysProcAttr = &syscall.SysProcAttr{} + } + command.SysProcAttr.Setpgid = true + command.WaitDelay = worktreeWaitDelay + command.Cancel = func() error { + if command.Process == nil { + return nil + } + // Negative pid targets the whole process group led by the child. + if err := syscall.Kill(-command.Process.Pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) { + return err + } + return nil + } +} diff --git a/internal/worktrees/run_git_unix_test.go b/internal/worktrees/run_git_unix_test.go new file mode 100644 index 000000000..58a476ef7 --- /dev/null +++ b/internal/worktrees/run_git_unix_test.go @@ -0,0 +1,112 @@ +//go:build !windows + +package worktrees + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" +) + +// THE HARDENING HAS TO WORK, not merely be configured. +// +// A leaked grandchild holding the pipes is what makes an unhardened cancel hang: +// CommandContext signals the direct child, Wait blocks until the pipes reach +// EOF, and the grandchild holds them open. So this launches exactly that shape — +// a shell that spawns a long sleeper inheriting stdout — cancels, and requires +// the call to return. +// +// It goes through newHardenedCommand, the constructor defaultRunGit uses. A test +// calling hardenWorktreeGit directly would prove three fields are set and prove +// nothing about whether anything calls it. +// TestTheGitConstructorSetsAProcessGroup is the POSIX half of the constructor +// assertion; the portable half lives in run_git_test.go. +func TestTheGitConstructorSetsAProcessGroup(t *testing.T) { + command := newHardenedCommand(context.Background(), t.TempDir(), "git", "status") + if command.SysProcAttr == nil || !command.SysProcAttr.Setpgid { + t.Fatal("Setpgid is unset; a cancel signals only the direct child") + } + if command.Cancel == nil { + t.Fatal("Cancel is unset; the process group is never signalled") + } +} + +func TestACancelledCommandKillsTheWholeProcessGroup(t *testing.T) { + if _, err := exec.LookPath("sh"); err != nil { + t.Skipf("no shell: %v", err) + } + previous := worktreeWaitDelay + worktreeWaitDelay = 500 * time.Millisecond + t.Cleanup(func() { worktreeWaitDelay = previous }) + + dir := t.TempDir() + pidFile := filepath.Join(dir, "grandchild.pid") + ctx, cancel := context.WithCancel(context.Background()) + + // A grandchild that outlives its parent shell and inherits stdout — the + // exact shape that hangs an unhardened Wait AND survives a kill aimed at + // the direct child only. + script := "sleep 60 & echo $! > " + pidFile + "; sleep 60" + command := newHardenedCommand(ctx, dir, "sh", "-c", script) + var sink discardWriter + command.Stdout = &sink + command.Stderr = &sink + if err := command.Start(); err != nil { + t.Fatalf("start: %v", err) + } + + // Wait for the grandchild to exist before cancelling, or the test proves + // nothing about what happens to it. + var grandchild int + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + raw, err := os.ReadFile(pidFile) + if err == nil { + if pid, convErr := strconv.Atoi(strings.TrimSpace(string(raw))); convErr == nil && pid > 0 { + grandchild = pid + break + } + } + time.Sleep(20 * time.Millisecond) + } + if grandchild == 0 { + t.Skip("the grandchild never reported its pid") + } + + cancel() + + // FIRST: the call must return. An unhardened Wait blocks on the pipes the + // grandchild holds open. + done := make(chan error, 1) + go func() { done <- command.Wait() }() + select { + case <-done: + case <-time.After(15 * time.Second): + t.Fatal("Wait did not return after cancel; a leaked grandchild is holding the pipes open") + } + + // SECOND, and this is the property WaitDelay alone does not give: the + // grandchild must be DEAD. Returning promptly while leaving a process + // running is the orphan this hardening exists to prevent, and a test that + // only checked the return would pass against a cancel aimed at the direct + // child alone. + alive := false + for attempt := 0; attempt < 50; attempt++ { + if err := syscall.Kill(grandchild, 0); err != nil { + alive = false + break + } + alive = true + time.Sleep(20 * time.Millisecond) + } + if alive { + _ = syscall.Kill(grandchild, syscall.SIGKILL) + t.Fatalf("grandchild %d survived the cancel; the signal did not reach the process group", grandchild) + } +} diff --git a/internal/worktrees/run_git_windows.go b/internal/worktrees/run_git_windows.go new file mode 100644 index 000000000..b03088335 --- /dev/null +++ b/internal/worktrees/run_git_windows.go @@ -0,0 +1,21 @@ +//go:build windows + +package worktrees + +import ( + "os/exec" + "time" +) + +// worktreeWaitDelay bounds how long Wait blocks for the child's stdout/stderr +// pipes to drain after the process exits or its context is cancelled, so a +// leaked grandchild cannot hang the parent past cancel/timeout. Var (not const) +// so tests can shorten it. +var worktreeWaitDelay = 2 * time.Second + +// hardenWorktreeGit sets WaitDelay so a leaked grandchild cannot block Wait +// indefinitely. Windows lacks POSIX process groups; tree-killing is not wired +// for this path, so the default Cancel (Process.Kill) plus WaitDelay is used. +func hardenWorktreeGit(command *exec.Cmd) { + command.WaitDelay = worktreeWaitDelay +} diff --git a/internal/worktrees/worktrees.go b/internal/worktrees/worktrees.go index 1ab9eca81..447d44716 100644 --- a/internal/worktrees/worktrees.go +++ b/internal/worktrees/worktrees.go @@ -245,9 +245,26 @@ func gitCommonDir(ctx context.Context, runGit GitRunner, dir string) (string, er return filepath.Clean(resolved), nil } -func defaultRunGit(ctx context.Context, dir string, args ...string) (CommandResult, error) { - command := exec.CommandContext(ctx, "git", args...) +// newHardenedCommand builds a subprocess that dies as one unit on cancel. +// +// Extracted so a test can exercise the SAME constructor defaultRunGit uses. A +// test that only called hardenWorktreeGit would prove the helper sets three +// fields and prove nothing about whether anything calls it — which is the +// wiring gap this feature has produced four times. +func newHardenedCommand(ctx context.Context, dir string, name string, args ...string) *exec.Cmd { + command := exec.CommandContext(ctx, name, args...) command.Dir = dir + // Without this a cancelled git is signalled but its Wait can still block on + // pipes a grandchild holds open. git is short-lived and rarely forks, so + // this is a small risk — but the worktree path is now reached by a plan, + // and a plan can be cancelled at any moment by /plans stop or by the run + // ending. + hardenWorktreeGit(command) + return command +} + +func defaultRunGit(ctx context.Context, dir string, args ...string) (CommandResult, error) { + command := newHardenedCommand(ctx, dir, "git", args...) // Capture stdout and stderr separately: callers parse Stdout for values // (rev-parse output) and prefer Stderr for error messages. CombinedOutput // merged the two, letting git's stderr warnings pollute parsed output and From 65ba3f9d1a336107973b4dcaaea3f6354c71e682 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:17:00 +0530 Subject: [PATCH 56/86] fix(specialist): an orchestrate approval must never be remembered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A defect I introduced two commits ago and did not notice until a stale build plan made me look at the permission layer again. permissionSupportsPersistentDecision refuses "always allow" for bash, exec_command, write_stdin and apply_patch — deliberately, because a remembered approval for those is too broad a standing grant. orchestrate fell through to the default and was offered it. THE COMPOSITION IS THE PROBLEM. A read-only plan does not prompt at all, so every prompt orchestrate produces is for a plan that CAN WRITE — and a write-capable plan may name bash, which is in planWriteTools. So "always allow orchestrate" is a strictly broader standing grant than the one the name list exists to refuse, reached by a different route. One keystroke would have permanently disabled the write-plan approval gate that the whole three-step arc was built to justify. DECLARED BY THE TOOL, NOT LISTED BY NAME. A name list cannot describe a tool whose reach depends on its arguments, and it cannot cover a tool that RUNS the ones already refused — the next such tool would be silently persistable too. tools.PersistentPermissionRefuser lets a tool state its own reach, which is the same reason ChildProgressStreamer exists and the same mistake `call.Name == "Task"` was. The name list is unchanged and still decides for tools that declare nothing, so this adds a rule rather than replacing one. A nil registry falls back to it rather than refusing everything. The plan stays approvable for THIS call and for the session. Only the permanent form is withheld — exactly how bash behaves. Four mutations, all caught, including the two that would be silent: orchestrate declaring false, and the agent ignoring the declaration. The specialist-side test also asserts bash is still in PlanWriteToolNames, because the argument above stops holding if it ever leaves — and then this rule needs rethinking rather than quietly persisting. --- internal/agent/loop.go | 14 ++- internal/agent/persistent_permission_test.go | 104 +++++++++++++++++++ internal/specialist/plan_tool.go | 14 +++ internal/specialist/plan_write_test.go | 31 ++++++ internal/tools/types.go | 18 ++++ 5 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 internal/agent/persistent_permission_test.go diff --git a/internal/agent/loop.go b/internal/agent/loop.go index fe3edaaab..3537c5923 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -2736,7 +2736,7 @@ func availablePermissionDecisions(event PermissionEvent, args map[string]any, op decisions = append(decisions, PermissionDecisionAlwaysAllowPrefix) } } - if options.Sandbox.CanPersistGrants() && permissionSupportsPersistentDecision(event.ToolName) && !filesystemSandboxPrompt(event) && !inlineAdditionalPermissions { + if options.Sandbox.CanPersistGrants() && permissionSupportsPersistentDecision(event.ToolName, options) && !filesystemSandboxPrompt(event) && !inlineAdditionalPermissions { decisions = append(decisions, PermissionDecisionAlwaysAllow) } } @@ -2868,7 +2868,17 @@ func grantFilesystemForSandboxPrompt(event PermissionEvent, scope sandbox.Permis }, scope) } -func permissionSupportsPersistentDecision(toolName string) bool { +func permissionSupportsPersistentDecision(toolName string, options Options) bool { + // THE TOOL'S OWN DECLARATION FIRST. A name list cannot describe a tool whose + // reach depends on its arguments, and it cannot cover a tool that RUNS the + // ones already refused below — see tools.PersistentPermissionRefuser. + if options.Registry != nil { + if tool, found := options.Registry.Get(toolName); found { + if refuser, ok := tool.(tools.PersistentPermissionRefuser); ok && refuser.RefusesPersistentPermission() { + return false + } + } + } switch toolName { case "bash", "exec_command", "write_stdin", "apply_patch": return false diff --git a/internal/agent/persistent_permission_test.go b/internal/agent/persistent_permission_test.go new file mode 100644 index 000000000..7c3eb2e19 --- /dev/null +++ b/internal/agent/persistent_permission_test.go @@ -0,0 +1,104 @@ +package agent + +import ( + "context" + "testing" + + "github.com/Gitlawb/zero/internal/sandbox" + "github.com/Gitlawb/zero/internal/tools" +) + +// refusingTool declares that its approval must never be remembered. +type refusingTool struct{ name string } + +func (t refusingTool) Name() string { return t.name } +func (refusingTool) Description() string { return "" } +func (refusingTool) Parameters() tools.Schema { return tools.Schema{} } +func (refusingTool) Safety() tools.Safety { return tools.Safety{Permission: tools.PermissionPrompt} } +func (refusingTool) Run(context.Context, map[string]any) tools.Result { return tools.Result{} } +func (refusingTool) RefusesPersistentPermission() bool { return true } + +// ordinaryTool declares nothing, so the name list decides for it. +type ordinaryTool struct{ name string } + +func (t ordinaryTool) Name() string { return t.name } +func (ordinaryTool) Description() string { return "" } +func (ordinaryTool) Parameters() tools.Schema { return tools.Schema{} } +func (ordinaryTool) Safety() tools.Safety { return tools.Safety{Permission: tools.PermissionPrompt} } +func (ordinaryTool) Run(context.Context, map[string]any) tools.Result { return tools.Result{} } + +// A TOOL'S OWN REFUSAL WINS over the name list. +// +// The list refuses bash, exec_command, write_stdin and apply_patch because +// "always allow" is too broad a standing grant for them. It cannot describe a +// tool that RUNS those — and orchestrate can be given bash by a plan, so +// remembering it would be strictly broader than the refusal the list enforces. +func TestAToolCanRefuseToBeRemembered(t *testing.T) { + registry := tools.NewRegistry() + registry.Register(refusingTool{name: "refuses"}) + registry.Register(ordinaryTool{name: "ordinary"}) + options := Options{Registry: registry} + + if permissionSupportsPersistentDecision("refuses", options) { + t.Fatal("a tool that refuses persistence was offered it") + } + if !permissionSupportsPersistentDecision("ordinary", options) { + t.Fatal("an ordinary tool lost its persistent approval") + } +} + +// The NAME LIST still applies to tools that declare nothing, so this adds a +// rule rather than replacing one. +func TestTheExistingNameRefusalsAreUnchanged(t *testing.T) { + options := Options{Registry: tools.NewRegistry()} + for _, name := range []string{"bash", "exec_command", "write_stdin", "apply_patch"} { + if permissionSupportsPersistentDecision(name, options) { + t.Errorf("%q must not be persistently approvable", name) + } + } + for _, name := range []string{"read_file", "write_file", "edit_file"} { + if !permissionSupportsPersistentDecision(name, options) { + t.Errorf("%q lost its persistent approval", name) + } + } +} + +// A nil registry must not crash and must not start refusing everything — the +// name list is the fallback, not an empty answer. +func TestPersistentDecisionsSurviveANilRegistry(t *testing.T) { + if permissionSupportsPersistentDecision("bash", Options{}) { + t.Fatal("bash became persistable with no registry") + } + if !permissionSupportsPersistentDecision("read_file", Options{}) { + t.Fatal("read_file lost persistence with no registry") + } +} + +// THE CONSEQUENCE, at the decision list a prompt actually offers. Asserting the +// predicate alone would pass against a caller that ignored it. +func TestARefusingToolIsNeverOfferedAlwaysAllow(t *testing.T) { + registry := tools.NewRegistry() + registry.Register(refusingTool{name: "refuses"}) + registry.Register(ordinaryTool{name: "ordinary"}) + engine := sandbox.NewEngine(sandbox.EngineOptions{}) + options := Options{Registry: registry, Sandbox: engine} + + offered := func(name string) bool { + event := PermissionEvent{ToolName: name, Action: PermissionActionPrompt} + for _, decision := range availablePermissionDecisions(event, nil, options) { + if decision == PermissionDecisionAlwaysAllow { + return true + } + } + return false + } + if offered("refuses") { + t.Fatal("a refusing tool was offered always-allow in the prompt") + } + if !engine.CanPersistGrants() { + t.Skip("this engine cannot persist grants, so the positive case is unreachable") + } + if !offered("ordinary") { + t.Fatal("an ordinary tool lost always-allow; the refusal is being applied to everything") + } +} diff --git a/internal/specialist/plan_tool.go b/internal/specialist/plan_tool.go index addb1e047..2886c8c8d 100644 --- a/internal/specialist/plan_tool.go +++ b/internal/specialist/plan_tool.go @@ -235,6 +235,20 @@ func (tool *OrchestrateTool) PermanentlyDenied() bool { return !tool.postureActi // renderer) and the work lands in a worktree of the plan's own (the isolator). // Prompting before either existed would have been asking a user to approve // something the screen could not describe. +// RefusesPersistentPermission reports that an approval for this tool must never +// be remembered. +// +// A read-only plan does not prompt at all, so EVERY prompt this tool produces is +// for a plan that can write — and a plan that can write can be given bash, for +// which the permission layer already refuses to persist an approval. Letting +// "always allow orchestrate" be remembered would be a strictly broader standing +// grant than the one that refusal exists to prevent, and it would disable the +// approval gate permanently with a single keystroke. +// +// The plan is still approvable for THIS call and for the session; only the +// permanent form is withheld, which is exactly how bash behaves. +func (tool *OrchestrateTool) RefusesPersistentPermission() bool { return true } + func (tool *OrchestrateTool) PermissionForArgs(args map[string]any) tools.Permission { if !tool.postureActive() { return tools.PermissionDeny diff --git a/internal/specialist/plan_write_test.go b/internal/specialist/plan_write_test.go index ecb61708b..b7e5a2a3e 100644 --- a/internal/specialist/plan_write_test.go +++ b/internal/specialist/plan_write_test.go @@ -170,3 +170,34 @@ func TestANamedWriteToolReachesTheTask(t *testing.T) { t.Fatalf("granted %v; the named write tool must reach the task", granted) } } + +// ORCHESTRATE MUST NOT BE REMEMBERABLE, and the reason is compositional: a +// read-only plan never prompts, so every prompt this tool produces is for a +// plan that can write — and a plan that can write can be given bash, for which +// the permission layer already refuses to persist an approval. +// +// Remembering "always allow orchestrate" would therefore be a strictly broader +// standing grant than the one that refusal exists to enforce, and it would +// disable the write-plan approval gate permanently with one keystroke. +func TestOrchestrateRefusesAPersistentApproval(t *testing.T) { + tool := &OrchestrateTool{PostureActive: func() bool { return true }} + if !tool.RefusesPersistentPermission() { + t.Fatal("orchestrate allows its approval to be remembered, which permanently disables the write-plan gate") + } + // It stays approvable for THIS call, which is the whole point — only the + // permanent form is withheld, exactly as bash behaves. + if got := tool.PermissionForArgs(writeCapableArgs()); got != tools.PermissionPrompt { + t.Fatalf("permission = %v; a write plan must still be approvable per call", got) + } + // And bash really is one of the tools a plan may name, which is what makes + // the argument above true rather than merely plausible. + granted := false + for _, name := range PlanWriteToolNames() { + if name == "bash" { + granted = true + } + } + if !granted { + t.Fatal("bash is not in the plan write set; the compositional argument no longer holds and this rule needs rethinking") + } +} diff --git a/internal/tools/types.go b/internal/tools/types.go index 56e20e48a..f2dd9c689 100644 --- a/internal/tools/types.go +++ b/internal/tools/types.go @@ -162,6 +162,24 @@ type PermanentDenier interface { PermanentlyDenied() bool } +// PersistentPermissionRefuser lets a tool refuse to be REMEMBERED. +// +// "Always allow" persists a grant that future calls skip the prompt on, which +// is right for a narrow tool and wrong for one whose blast radius is decided by +// its arguments. permissionSupportsPersistentDecision already refuses it for +// bash, exec_command, write_stdin and apply_patch by name, for exactly that +// reason. +// +// A name list cannot cover a tool that RUNS those. orchestrate can be given +// bash by a plan, so remembering it is a strictly broader standing grant than +// the one the name list refuses — and the next tool with that property would be +// silently persistable too. A tool that knows its own reach declares it here +// instead of internal/agent knowing its name, which is the same reason +// ChildProgressStreamer exists. +type PersistentPermissionRefuser interface { + RefusesPersistentPermission() bool +} + // PrePermissionRejecter lets a tool reject a call that cannot safely or validly // run before any permission prompt is shown. Implementations must be purely // local and deterministic: no filesystem, process, DNS, or network access. From dcd6d912f330d96e607a62caae45da70a74909ec Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:42:54 +0530 Subject: [PATCH 57/86] feat(tui): the posture chip leaves amber and shimmers under the cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes, and the first is about meaning rather than taste. IT IS NOT AMBER ANY MORE. Amber fills the PERMISSION badge, so in this UI it is the colour of "something needs your attention". The posture is a standing MODE, not a caution, and sharing the fill made it read as the wrong sentence — a warning that never resolves. It sits on the brand accent now, still filled, so "is it on?" is still answerable from across the room. THE HOVER IS A SPECTRUM, not another flat fill. A flat hover on an already-filled chip is two solid blocks differing by a shade, which is close to invisible; letters that each take their own hue cannot be mistaken for the resting state, and they say "this does something" louder than any single colour can. The chip opens /effort, and a clickable thing that looks like a label never teaches anyone it can be pressed. EVERY COLOUR COMES FROM THE PALETTE, never hex. The ramp is built in buildTheme from accent/green/blue/amber/red/gitAdd, so the effect stays coherent on dracula, on a light theme, and on whatever is added next — the same reason the permission card stopped tinting its body with an amber wash. The shimmer rotates on the pulse the chip already rides, so it costs NO TIMER: an idle session still schedules none, which is the rule the pulse was built around, and reduced motion pins it still like every other animation here. Width is unchanged, which is what keeps the chip clickable — colour adds ANSI bytes and no cells, and the span finder strips ANSI before locating the label. Asserted directly: the hit region must be identical resting and hovered. Six mutations, all caught, including the two that would look fine in a screenshot and be wrong: the spectrum collapsing to one hue, and the shimmer running on an idle session. --- internal/tui/theme.go | 17 +++- internal/tui/zeromaxing_glow.go | 61 +++++++++++++- internal/tui/zeromaxing_glow_test.go | 117 +++++++++++++++++++++++++++ 3 files changed, 189 insertions(+), 6 deletions(-) diff --git a/internal/tui/theme.go b/internal/tui/theme.go index 9e708c3b3..b974ab073 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -67,7 +67,12 @@ type tuiTheme struct { delText lipgloss.Style // delInk as bare foreground (stderr-ish output) // Permission surfaces. - permBadge lipgloss.Style // PERMISSION chip: onAccent on amber, bold + permBadge lipgloss.Style // PERMISSION chip: onAccent on amber, bold + postureBadge lipgloss.Style // ZEROMAXING chip: onAccent on accent, bold + // spectrum is a hue ramp drawn from THIS palette, for the posture chip's + // hover shimmer. Palette colours rather than fixed hex, so the effect stays + // coherent on dracula, on a light theme, and on anything added later. + spectrum []lipgloss.Style permBg lipgloss.Style // permission card body tint permBorder lipgloss.Style // permission card border (amber-mixed line) @@ -179,7 +184,15 @@ func buildTheme(p palette) tuiTheme { delSign: lipgloss.NewStyle().Foreground(col(p.red)).Background(col(p.delBg)), delText: fg(p.delInk), - permBadge: lipgloss.NewStyle().Background(col(p.amber)).Foreground(col(p.onAccent)).Bold(true), + permBadge: lipgloss.NewStyle().Background(col(p.amber)).Foreground(col(p.onAccent)).Bold(true), + // NOT amber. Amber is this UI's caution colour — it fills the PERMISSION + // badge — and the posture is not a caution, it is a mode. Sharing the + // fill made "zeromaxing is on" read as "something needs your attention", + // which is the wrong sentence for a standing state. + postureBadge: lipgloss.NewStyle().Background(col(p.accent)).Foreground(col(p.onAccent)).Bold(true), + spectrum: []lipgloss.Style{ + fg(p.accent), fg(p.green), fg(p.blue), fg(p.amber), fg(p.red), fg(p.gitAdd), + }, permBg: lipgloss.NewStyle().Background(col(p.permBg)), permBorder: fg(p.cardPerm), diff --git a/internal/tui/zeromaxing_glow.go b/internal/tui/zeromaxing_glow.go index ce6010809..a41dccf1b 100644 --- a/internal/tui/zeromaxing_glow.go +++ b/internal/tui/zeromaxing_glow.go @@ -13,7 +13,12 @@ import ( // It raises a cost multiplier — 320 turns per run, inherited by every sub-agent // — so "is it on?" must be answerable from across the room, not by reading a // word in the same weight as everything beside it. A filled badge reads as lit -// where amber text reads as a label. +// where coloured text reads as a label. +// +// IT IS NOT AMBER, and that is a meaning fix rather than a taste one. Amber +// fills the PERMISSION badge, so it is this UI's colour for "something needs +// your attention". The posture is a standing MODE, not a caution, and sharing +// the fill made it read as the wrong sentence. It sits on the brand accent now. // // THE PULSE IS FREE. It advances on the spinner tick that is already running // while a turn is in flight, and holds a steady lit state when nothing is @@ -47,12 +52,60 @@ func (m model) zeromaxingGlowChip() string { // under the cursor. Without a hover state a clickable chip is // indistinguishable from a label, and the user never learns it can be // pressed. + // + // The hover is a SPECTRUM across the label rather than another flat fill. + // A flat hover on an already-filled chip is nearly invisible: two solid + // blocks differing by a shade. Letters that each take their own hue cannot + // be mistaken for the resting state, and it says "this does something" + // louder than any single colour can. if m.hover.kind == hoverZeromaxingChip { - return zeroTheme.hover.Render(body) + return m.zeromaxingSpectrumLabel(marker) } - // A filled badge: the label sits ON the amber rather than in it, which is + // A filled badge: the label sits ON the accent rather than in it, which is // what makes it read as lit rather than as another word in the row. - return zeroTheme.permBadge.Render(body) + return zeroTheme.postureBadge.Render(body) +} + +// zeromaxingSpectrumLabel paints each character its own hue. +// +// WIDTH IS UNCHANGED, which is what keeps the chip clickable: colour adds ANSI +// bytes and no cells, and zeromaxingChipSpan strips ANSI before locating the +// label, so the hit test sees the same plain string either way. +// +// The ramp ROTATES on the same tick the glyph breathes on, so the chip shimmers +// while a turn is in flight and holds a static rainbow when idle. That costs +// nothing: it reads the clock the spinner already schedules and adds no timer +// of its own — an idle session still schedules none, which is the rule the +// pulse was built around. +func (m model) zeromaxingSpectrumLabel(marker string) string { + ramp := zeroTheme.spectrum + if len(ramp) == 0 { + return zeroTheme.hover.Render(" " + marker + " " + zeromaxingChipLabel + " ") + } + var out strings.Builder + out.WriteString(zeroTheme.faint.Render(" ")) + out.WriteString(ramp[m.zeromaxingSpectrumOffset()%len(ramp)].Bold(true).Render(marker)) + out.WriteString(zeroTheme.faint.Render(" ")) + for index, letter := range zeromaxingChipLabel { + style := ramp[(index+m.zeromaxingSpectrumOffset())%len(ramp)] + out.WriteString(style.Bold(true).Render(string(letter))) + } + out.WriteString(zeroTheme.faint.Render(" ")) + return out.String() +} + +// zeromaxingSpectrumOffset rotates the ramp with the pulse. Pinned to 0 under +// reduced motion and when nothing is running, like every other animation here. +func (m model) zeromaxingSpectrumOffset() int { + if m.reducedMotion || !m.pending { + return 0 + } + period := zeromaxingPulsePeriod.Milliseconds() + if period <= 0 { + return 0 + } + step := m.now().UnixMilli() % period + return int(step * int64(len(zeroTheme.spectrum)) / period) } // zeromaxingChipWidth is the chip's rendered cell width, used to hit-test it. diff --git a/internal/tui/zeromaxing_glow_test.go b/internal/tui/zeromaxing_glow_test.go index ac4c268e1..b8e7ede18 100644 --- a/internal/tui/zeromaxing_glow_test.go +++ b/internal/tui/zeromaxing_glow_test.go @@ -258,3 +258,120 @@ func TestClickingTheChipMidTurnOpensNothing(t *testing.T) { t.Fatal("a picker opened mid-turn, where it cannot be acted on") } } + +// NOT AMBER, and the reason is meaning rather than taste: amber fills the +// PERMISSION badge, so it is this UI's colour for "something needs your +// attention". The posture is a standing mode, not a caution. +func TestThePostureChipDoesNotWearThePermissionColour(t *testing.T) { + m := glowModel(t) + m.width, m.height = 100, 30 + + chip := m.zeromaxingGlowChip() + permission := zeroTheme.permBadge.Render(" PERMISSION ") + amberFill := backgroundSequence(permission) + if amberFill == "" { + t.Skip("this theme renders no background fill, so the colours cannot be compared") + } + if strings.Contains(chip, amberFill) { + t.Fatal("the posture chip is filled with the permission badge's colour; a mode must not read as a caution") + } + // ...and it is still FILLED, so dropping amber did not turn it back into text. + if backgroundSequence(chip) == "" { + t.Fatalf("the posture chip lost its fill and is now text: %q", chip) + } +} + +// backgroundSequence extracts the first background-colour escape from a +// rendered string, so two styles can be compared without hard-coding either. +func backgroundSequence(rendered string) string { + index := strings.Index(rendered, "48;2;") + if index < 0 { + return "" + } + end := strings.IndexByte(rendered[index:], 'm') + if end < 0 { + return "" + } + return rendered[index : index+end] +} + +// THE HOVER IS A SPECTRUM, not another flat fill. A flat hover on an +// already-filled chip is two solid blocks differing by a shade; letters that +// each take their own hue cannot be mistaken for the resting state. +func TestTheHoveredChipPaintsEachLetterItsOwnHue(t *testing.T) { + m := glowModel(t) + m.width, m.height = 100, 30 + m.hover = hoverTarget{kind: hoverZeromaxingChip} + + hovered := m.zeromaxingGlowChip() + if !strings.Contains(ansi.Strip(hovered), zeromaxingChipLabel) { + t.Fatalf("the hovered chip lost its label: %q", ansi.Strip(hovered)) + } + distinct := map[string]bool{} + for _, field := range strings.Split(hovered, "38;2;") { + if end := strings.IndexByte(field, 'm'); end > 0 { + distinct[field[:end]] = true + } + } + if len(distinct) < 3 { + t.Fatalf("the hovered chip uses %d foreground colours; a spectrum needs several: %q", len(distinct), hovered) + } +} + +// THE WIDTH IS UNCHANGED, which is what keeps the chip clickable. Colour adds +// ANSI bytes and no cells, and the span finder strips ANSI before locating the +// label — so a hovered chip must still be hit-testable at the same span. +func TestTheSpectrumDoesNotMoveTheChipsHitRegion(t *testing.T) { + m := glowModel(t) + m.width, m.height = 100, 30 + m.altScreen = true + + plainStart, plainEnd, ok := m.zeromaxingChipSpan() + if !ok { + t.Fatal("the chip span could not be located while resting") + } + m.hover = hoverTarget{kind: hoverZeromaxingChip} + hoverStart, hoverEnd, ok := m.zeromaxingChipSpan() + if !ok { + t.Fatal("the chip span could not be located while hovered; the spectrum broke the hit test") + } + if plainStart != hoverStart || plainEnd != hoverEnd { + t.Fatalf("the hit region moved on hover: resting [%d,%d) hovered [%d,%d)", + plainStart, plainEnd, hoverStart, hoverEnd) + } + if ansi.StringWidth(m.zeromaxingGlowChip()) != zeromaxingChipWidth() { + t.Fatalf("the hovered chip is %d cells wide, want %d", + ansi.StringWidth(m.zeromaxingGlowChip()), zeromaxingChipWidth()) + } +} + +// The shimmer rides the pulse the chip already has, so it costs no timer — and +// it holds STILL when nothing is running or motion is reduced, like every other +// animation here. +func TestTheSpectrumHoldsStillWhenNothingIsRunning(t *testing.T) { + m := glowModel(t) + if got := m.zeromaxingSpectrumOffset(); got != 0 { + t.Fatalf("offset = %d on an idle session; the shimmer must hold still", got) + } + m.pending = true + m.reducedMotion = true + if got := m.zeromaxingSpectrumOffset(); got != 0 { + t.Fatalf("offset = %d under reduced motion; it must hold still", got) + } + + m.reducedMotion = false + seen := map[int]bool{} + for step := 0; step < int(zeromaxingPulsePeriod.Milliseconds()); step += 50 { + at := time.Unix(0, 0).Add(time.Duration(step) * time.Millisecond) + moving := m + moving.now = func() time.Time { return at } + offset := moving.zeromaxingSpectrumOffset() + if offset < 0 || offset >= len(zeroTheme.spectrum) { + t.Fatalf("offset %d is outside the ramp of %d", offset, len(zeroTheme.spectrum)) + } + seen[offset] = true + } + if len(seen) < 2 { + t.Fatalf("the shimmer never advanced across a full period: %v", seen) + } +} From 69815d254a4ccaeba0ba471e03f85554d6dce6ee Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:55:41 +0530 Subject: [PATCH 58/86] feat(tui): the posture is a live word, not a coloured box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrects the last change rather than extending it. I read "no yellow, colourful hover" as "same badge, different fill, spectrum on hover" — the ask was for the WORD to animate, and for the box to go entirely. NO BACKGROUND FILL AT ALL. The badge signalled "on" by being a solid slab; the word signals it by MOVING, which the eye catches at least as well and which costs the footer none of its calm. A footer is a row of quiet labels and a lit block in it is a shout; letters that walk their colours are noticed without being loud. The ramp rotates on the pulse the chip already rides, so it costs NO TIMER. It walks while a turn is in flight and holds a static rainbow when idle — deliberately, and not a compromise: an idle session schedules no timer, which is the rule this pulse was built around, and a footer animating forever would be CPU nobody asked for. The word is alive exactly while the posture is doing something, which is also what it means. HOVER IS AN UNDERLINE. The chip opens /effort so it must say it is pressable, and a background fill is the one thing this deliberately does not have — so it cannot be the hover signal either. Colours come from the palette, never hex, so the word stays coherent on dracula and on light themes. postureBadge is DELETED rather than left unused: a style nothing renders is a knob someone later trusts. One real consequence, checked before touching anything: per-letter colouring means the label is no longer a contiguous run in the styled output. Both production hit-testers already strip ANSI first, so clicking is unaffected — but a footer test was asserting Contains on the RAW string, which was asserting an accident of how the chip happened to be styled. It strips now, as it should always have. Five mutations, all caught, including the box returning and the animation running on an idle session. --- internal/tui/theme.go | 14 +++-- internal/tui/zeromaxing_glow.go | 78 ++++++++++++++-------------- internal/tui/zeromaxing_glow_test.go | 77 ++++++++++++++------------- internal/tui/zeromaxing_test.go | 14 +++-- 4 files changed, 96 insertions(+), 87 deletions(-) diff --git a/internal/tui/theme.go b/internal/tui/theme.go index b974ab073..5b9c208b3 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -67,11 +67,14 @@ type tuiTheme struct { delText lipgloss.Style // delInk as bare foreground (stderr-ish output) // Permission surfaces. - permBadge lipgloss.Style // PERMISSION chip: onAccent on amber, bold - postureBadge lipgloss.Style // ZEROMAXING chip: onAccent on accent, bold + permBadge lipgloss.Style // PERMISSION chip: onAccent on amber, bold // spectrum is a hue ramp drawn from THIS palette, for the posture chip's - // hover shimmer. Palette colours rather than fixed hex, so the effect stays + // animated word. Palette colours rather than fixed hex, so the effect stays // coherent on dracula, on a light theme, and on anything added later. + // + // The posture chip has NO fill of its own: it went from an amber badge to a + // live word, so there is no postureBadge style here — the letters carry the + // whole signal and a style nothing renders would be a knob someone trusts. spectrum []lipgloss.Style permBg lipgloss.Style // permission card body tint permBorder lipgloss.Style // permission card border (amber-mixed line) @@ -185,11 +188,6 @@ func buildTheme(p palette) tuiTheme { delText: fg(p.delInk), permBadge: lipgloss.NewStyle().Background(col(p.amber)).Foreground(col(p.onAccent)).Bold(true), - // NOT amber. Amber is this UI's caution colour — it fills the PERMISSION - // badge — and the posture is not a caution, it is a mode. Sharing the - // fill made "zeromaxing is on" read as "something needs your attention", - // which is the wrong sentence for a standing state. - postureBadge: lipgloss.NewStyle().Background(col(p.accent)).Foreground(col(p.onAccent)).Bold(true), spectrum: []lipgloss.Style{ fg(p.accent), fg(p.green), fg(p.blue), fg(p.amber), fg(p.red), fg(p.gitAdd), }, diff --git a/internal/tui/zeromaxing_glow.go b/internal/tui/zeromaxing_glow.go index a41dccf1b..f4d9b4deb 100644 --- a/internal/tui/zeromaxing_glow.go +++ b/internal/tui/zeromaxing_glow.go @@ -5,20 +5,25 @@ import ( "time" tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" ) -// The zeromaxing posture gets a LIT chip rather than coloured text. +// The zeromaxing posture is a LIVE WORD, not a filled box. // // It raises a cost multiplier — 320 turns per run, inherited by every sub-agent -// — so "is it on?" must be answerable from across the room, not by reading a -// word in the same weight as everything beside it. A filled badge reads as lit -// where coloured text reads as a label. +// — so "is it on?" must be answerable from across the room. A badge did that by +// being a solid slab; this does it by MOVING, which the eye catches at least as +// well and which costs the footer none of its calm. There is no background +// fill: the letters carry the whole signal. // -// IT IS NOT AMBER, and that is a meaning fix rather than a taste one. Amber -// fills the PERMISSION badge, so it is this UI's colour for "something needs -// your attention". The posture is a standing MODE, not a caution, and sharing -// the fill made it read as the wrong sentence. It sits on the brand accent now. +// It is also no longer amber, and that part was a meaning fix rather than a +// taste one — amber fills the PERMISSION badge, so it is this UI's colour for +// "something needs your attention", and a standing mode is not a caution. +// +// EVERY COLOUR COMES FROM THE PALETTE. The ramp is built in buildTheme from +// accent/green/blue/amber/red, so the word stays coherent on dracula, on a +// light theme, and on whatever is added next. // // THE PULSE IS FREE. It advances on the spinner tick that is already running // while a turn is in flight, and holds a steady lit state when nothing is @@ -47,23 +52,12 @@ func (m model) zeromaxingGlowChip() string { return "" } marker := m.zeromaxingPulseGlyph() - body := " " + marker + " " + zeromaxingChipLabel + " " - // HOVER: the chip is clickable — it opens /effort — so it has to say so - // under the cursor. Without a hover state a clickable chip is - // indistinguishable from a label, and the user never learns it can be - // pressed. - // - // The hover is a SPECTRUM across the label rather than another flat fill. - // A flat hover on an already-filled chip is nearly invisible: two solid - // blocks differing by a shade. Letters that each take their own hue cannot - // be mistaken for the resting state, and it says "this does something" - // louder than any single colour can. - if m.hover.kind == hoverZeromaxingChip { - return m.zeromaxingSpectrumLabel(marker) - } - // A filled badge: the label sits ON the accent rather than in it, which is - // what makes it read as lit rather than as another word in the row. - return zeroTheme.postureBadge.Render(body) + // HOVER is an UNDERLINE, not a box. The chip is clickable — it opens + // /effort — so it has to say so under the cursor, and a background fill is + // the one thing this deliberately does not have. An underline reads as + // "pressable" without reintroducing the slab. + underline := m.hover.kind == hoverZeromaxingChip + return m.zeromaxingSpectrumLabel(marker, underline) } // zeromaxingSpectrumLabel paints each character its own hue. @@ -72,25 +66,33 @@ func (m model) zeromaxingGlowChip() string { // bytes and no cells, and zeromaxingChipSpan strips ANSI before locating the // label, so the hit test sees the same plain string either way. // -// The ramp ROTATES on the same tick the glyph breathes on, so the chip shimmers -// while a turn is in flight and holds a static rainbow when idle. That costs -// nothing: it reads the clock the spinner already schedules and adds no timer -// of its own — an idle session still schedules none, which is the rule the -// pulse was built around. -func (m model) zeromaxingSpectrumLabel(marker string) string { +// The ramp ROTATES on the same tick the glyph breathes on, so the word walks +// its colours while a turn is in flight and holds a static rainbow when idle. +// That costs nothing: it reads the clock the spinner already schedules and adds +// no timer of its own — an idle session still schedules none, which is the rule +// the pulse was built around, and a footer that animated forever would be a +// timer nobody asked for. +func (m model) zeromaxingSpectrumLabel(marker string, underline bool) string { ramp := zeroTheme.spectrum + paint := func(style lipgloss.Style, text string) string { + style = style.Bold(true) + if underline { + style = style.Underline(true) + } + return style.Render(text) + } if len(ramp) == 0 { - return zeroTheme.hover.Render(" " + marker + " " + zeromaxingChipLabel + " ") + return paint(zeroTheme.accent, " "+marker+" "+zeromaxingChipLabel+" ") } + offset := m.zeromaxingSpectrumOffset() var out strings.Builder - out.WriteString(zeroTheme.faint.Render(" ")) - out.WriteString(ramp[m.zeromaxingSpectrumOffset()%len(ramp)].Bold(true).Render(marker)) - out.WriteString(zeroTheme.faint.Render(" ")) + out.WriteString(paint(zeroTheme.faint, " ")) + out.WriteString(paint(ramp[offset%len(ramp)], marker)) + out.WriteString(paint(zeroTheme.faint, " ")) for index, letter := range zeromaxingChipLabel { - style := ramp[(index+m.zeromaxingSpectrumOffset())%len(ramp)] - out.WriteString(style.Bold(true).Render(string(letter))) + out.WriteString(paint(ramp[(index+offset)%len(ramp)], string(letter))) } - out.WriteString(zeroTheme.faint.Render(" ")) + out.WriteString(paint(zeroTheme.faint, " ")) return out.String() } diff --git a/internal/tui/zeromaxing_glow_test.go b/internal/tui/zeromaxing_glow_test.go index b8e7ede18..9f28c043a 100644 --- a/internal/tui/zeromaxing_glow_test.go +++ b/internal/tui/zeromaxing_glow_test.go @@ -20,14 +20,12 @@ func glowModel(t *testing.T) model { return m } -// The chip is LIT, not merely coloured: a filled badge reads as on from across -// the room, where amber text reads as another word in the row. The posture -// raises a cost multiplier, so "is it on?" has to be answerable at a glance. +// The chip is a LIVE WORD with NO BACKGROUND BOX. A badge signalled "on" by +// being a solid slab; this signals it by moving, and the footer keeps its calm. // -// Asserted on the RENDERED FOOTER, not on the helper: the first version called -// zeromaxingGlowChip directly, and reverting the footer to plain amber text -// passed it. -func TestTheZeromaxingChipIsAFilledBadge(t *testing.T) { +// Asserted on the RENDERED FOOTER, not on the helper: an earlier version called +// zeromaxingGlowChip directly, and reverting the footer to plain text passed it. +func TestTheZeromaxingChipIsAnUnboxedLiveWord(t *testing.T) { m := glowModel(t) m.width, m.height = 100, 30 @@ -35,13 +33,23 @@ func TestTheZeromaxingChipIsAFilledBadge(t *testing.T) { if !strings.Contains(ansi.Strip(footer), zeromaxingChipLabel) { t.Fatalf("the footer does not carry the posture label:\n%s", ansi.Strip(footer)) } - // A background fill on the label's own run is what distinguishes a lit - // badge from coloured text. - if !strings.Contains(footer, "48;2;") { - t.Fatalf("the footer chip has no background fill, so it is text and not a badge") + chip := m.zeromaxingGlowChip() + if !strings.Contains(footer, chip) { + t.Fatalf("the footer does not render the chip it was given:\n%s", ansi.Strip(footer)) + } + // NO BACKGROUND FILL anywhere in the chip: the letters carry the signal. + if strings.Contains(chip, "48;2;") { + t.Fatalf("the posture chip still paints a background box: %q", chip) + } + // ...and it is genuinely multi-coloured rather than one flat colour. + distinct := map[string]bool{} + for _, field := range strings.Split(chip, "38;2;") { + if end := strings.IndexByte(field, 'm'); end > 0 { + distinct[field[:end]] = true + } } - if chip := m.zeromaxingGlowChip(); !strings.Contains(footer, chip) { - t.Fatalf("the footer does not render the glow chip it was given:\n%s", ansi.Strip(footer)) + if len(distinct) < 3 { + t.Fatalf("the chip uses %d colours; the word is meant to be a spectrum: %q", len(distinct), chip) } } @@ -268,16 +276,11 @@ func TestThePostureChipDoesNotWearThePermissionColour(t *testing.T) { chip := m.zeromaxingGlowChip() permission := zeroTheme.permBadge.Render(" PERMISSION ") - amberFill := backgroundSequence(permission) - if amberFill == "" { - t.Skip("this theme renders no background fill, so the colours cannot be compared") - } - if strings.Contains(chip, amberFill) { - t.Fatal("the posture chip is filled with the permission badge's colour; a mode must not read as a caution") + if fill := backgroundSequence(permission); fill != "" && strings.Contains(chip, fill) { + t.Fatal("the posture chip wears the permission badge's colour; a mode must not read as a caution") } - // ...and it is still FILLED, so dropping amber did not turn it back into text. - if backgroundSequence(chip) == "" { - t.Fatalf("the posture chip lost its fill and is now text: %q", chip) + if backgroundSequence(chip) != "" { + t.Fatalf("the posture chip paints a background box: %q", chip) } } @@ -295,26 +298,28 @@ func backgroundSequence(rendered string) string { return rendered[index : index+end] } -// THE HOVER IS A SPECTRUM, not another flat fill. A flat hover on an -// already-filled chip is two solid blocks differing by a shade; letters that -// each take their own hue cannot be mistaken for the resting state. -func TestTheHoveredChipPaintsEachLetterItsOwnHue(t *testing.T) { +// HOVER IS AN UNDERLINE, not a box. The chip opens /effort, so it must say it +// is pressable — and a background fill is the one thing this deliberately does +// not have, so it cannot be the hover signal either. +func TestTheHoveredChipUnderlinesWithoutABox(t *testing.T) { m := glowModel(t) m.width, m.height = 100, 30 - m.hover = hoverTarget{kind: hoverZeromaxingChip} + resting := m.zeromaxingGlowChip() + m.hover = hoverTarget{kind: hoverZeromaxingChip} hovered := m.zeromaxingGlowChip() - if !strings.Contains(ansi.Strip(hovered), zeromaxingChipLabel) { - t.Fatalf("the hovered chip lost its label: %q", ansi.Strip(hovered)) + + if resting == hovered { + t.Fatal("the chip renders identically hovered and not, so hovering it says nothing") } - distinct := map[string]bool{} - for _, field := range strings.Split(hovered, "38;2;") { - if end := strings.IndexByte(field, 'm'); end > 0 { - distinct[field[:end]] = true - } + if !strings.Contains(hovered, "\x1b[4") && !strings.Contains(hovered, ";4m") && !strings.Contains(hovered, "4;") { + t.Fatalf("the hovered chip carries no underline: %q", hovered) } - if len(distinct) < 3 { - t.Fatalf("the hovered chip uses %d foreground colours; a spectrum needs several: %q", len(distinct), hovered) + if backgroundSequence(hovered) != "" { + t.Fatalf("the hovered chip painted a background box: %q", hovered) + } + if !strings.Contains(ansi.Strip(hovered), zeromaxingChipLabel) { + t.Fatalf("the hovered chip lost its label: %q", ansi.Strip(hovered)) } } diff --git a/internal/tui/zeromaxing_test.go b/internal/tui/zeromaxing_test.go index 3c473b353..ab6d744c3 100644 --- a/internal/tui/zeromaxing_test.go +++ b/internal/tui/zeromaxing_test.go @@ -443,19 +443,23 @@ func TestZeromaxingFooterChipVisibility(t *testing.T) { if !m.zeromaxingActive() { t.Fatal("Entering: the chip must be shown") } - if !strings.Contains(m.statusLine(120), zeromaxingChipLabel) { - t.Fatalf("the footer must carry %q while on:\n%s", zeromaxingChipLabel, m.statusLine(120)) + // STRIPPED, not raw. The chip paints each letter its own hue, so the label + // is no longer a contiguous run in the styled output — and the production + // hit-testers strip ANSI for exactly this reason. A raw Contains here was + // asserting an accident of how the chip happened to be styled. + if !strings.Contains(ansiStripLine(m.statusLine(120)), zeromaxingChipLabel) { + t.Fatalf("the footer must carry %q while on:\n%s", zeromaxingChipLabel, ansiStripLine(m.statusLine(120))) } m = m.advanceZeromaxing() - if !strings.Contains(m.statusLine(120), zeromaxingChipLabel) { + if !strings.Contains(ansiStripLine(m.statusLine(120)), zeromaxingChipLabel) { t.Fatal("Active: the chip must still be shown") } m, _ = m.handleEffortCommand("auto") if m.zeromaxingActive() { t.Fatal("Exiting: the posture is already off, so the chip must be hidden") } - if strings.Contains(m.statusLine(120), zeromaxingChipLabel) { - t.Fatalf("the footer must drop the chip once off:\n%s", m.statusLine(120)) + if strings.Contains(ansiStripLine(m.statusLine(120)), zeromaxingChipLabel) { + t.Fatalf("the footer must drop the chip once off:\n%s", ansiStripLine(m.statusLine(120))) } } From 2ae99dc0819fb309383727bd37f508c05ad0fe6d Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:09:54 +0530 Subject: [PATCH 59/86] feat(tui): the posture reads lowercase and its hover flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LOWERCASE, like every label beside it — "ask", "high", the model name. Shouting was the badge's job and the badge is gone; the word earns attention by moving, so caps were doing the same work twice and losing to the row around them. THE HOVER FLOWS. A three-letter band travels left to right through the word and wraps, so hovering reads as motion rather than as a second static state — which is what an underline across the whole label had been. Three letters is the width that reads as a band: one blinks, and all ten is a rule. IT NEEDED A TICK, and the bound is the whole justification. The resting word walks its colours on the spinner already running during a turn and holds still otherwise, but a band under a resting cursor has no frames to move on. So the hover starts the existing tick loop and stops it the moment the cursor leaves — ensureSpinnerTick's rule is "an idle plain session schedules no timer", and a cursor held on a control is a direct interaction rather than an idle session. Reduced motion pins it, like every other animation here. Wall clock rather than a frame counter, so the band crosses at the same speed whatever cadence the ticks arrive at. Seven mutations. F7 initially MISSED and it is the sixth instance today of one shape: the test asserted the PREDICATE (zeromaxingChipAnimating) and not that the scheduler consults it, so removing the hover from ensureSpinnerTick's gate passed — the predicate would have stayed right while the band never moved. The test now drives ensureSpinnerTick itself, including that it declines to schedule a second timer over a live one. --- internal/tui/model.go | 4 +- internal/tui/mouse.go | 7 +- internal/tui/session_controls.go | 6 +- internal/tui/zeromaxing_glow.go | 88 ++++++++++++++---- internal/tui/zeromaxing_glow_test.go | 131 +++++++++++++++++++++++++++ 5 files changed, 216 insertions(+), 20 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index 304ea1c66..84d242303 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -2192,7 +2192,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { // makes the glyph spin far too fast (and burns CPU) on screens that sit here // for a while, e.g. the aimlapi checkout wait. The active-run path below // already does this; this keeps idle animation at the same cadence. - if (m.sidebarHasAgents() || m.aimlapiOnboardAnimating()) && !m.reducedMotion { + if (m.sidebarHasAgents() || m.aimlapiOnboardAnimating() || m.zeromaxingChipAnimating()) && !m.reducedMotion { var cmd tea.Cmd m.spinner, cmd = m.spinner.Update(msg) m.spinnerPhase++ @@ -5059,7 +5059,7 @@ func (m *model) ensureSpinnerTick() tea.Cmd { if m.spinnerTicking || m.reducedMotion { return nil } - if !m.sidebarHasAgents() && !m.aimlapiOnboardAnimating() { + if !m.sidebarHasAgents() && !m.aimlapiOnboardAnimating() && !m.zeromaxingChipAnimating() { return nil } m.spinnerTicking = true diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go index cf1db07ba..f7f697f40 100644 --- a/internal/tui/mouse.go +++ b/internal/tui/mouse.go @@ -182,7 +182,12 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { // press/drag/release cases above, so it falls through here — resolve what's // under the cursor so it can render with the hover highlight. if mouseHover(msg) { - return m.updateHoverTarget(msg), nil + hovered := m.updateHoverTarget(msg) + // Starting the tick HERE is what lets the hover flow move while the + // cursor rests. Bounded by zeromaxingChipAnimating: it runs only while + // the chip is actually hovered and stops the moment the cursor leaves, + // so an idle session still schedules nothing. + return hovered, hovered.ensureSpinnerTick() } switch { diff --git a/internal/tui/session_controls.go b/internal/tui/session_controls.go index 66458e3cb..c66b95aa2 100644 --- a/internal/tui/session_controls.go +++ b/internal/tui/session_controls.go @@ -1387,7 +1387,11 @@ func (m model) selfCorrectTransition() execprofile.SelfCorrectTransition { // zeromaxingChipLabel is the footer indicator for the zeromaxing posture. Kept // as a constant so the view and its test assert the same bytes. -const zeromaxingChipLabel = "ZEROMAXING" +// +// LOWERCASE, like every other footer label beside it — "ask", "high", the model +// name. Shouting it was the badge's job, and the badge is gone; the word earns +// attention now by moving rather than by being the one thing in caps. +const zeromaxingChipLabel = "zeromaxing" // advanceZeromaxing retires the one-shot notices once the run that carried them // has finished. diff --git a/internal/tui/zeromaxing_glow.go b/internal/tui/zeromaxing_glow.go index f4d9b4deb..d2af135b6 100644 --- a/internal/tui/zeromaxing_glow.go +++ b/internal/tui/zeromaxing_glow.go @@ -52,12 +52,12 @@ func (m model) zeromaxingGlowChip() string { return "" } marker := m.zeromaxingPulseGlyph() - // HOVER is an UNDERLINE, not a box. The chip is clickable — it opens - // /effort — so it has to say so under the cursor, and a background fill is - // the one thing this deliberately does not have. An underline reads as - // "pressable" without reintroducing the slab. - underline := m.hover.kind == hoverZeromaxingChip - return m.zeromaxingSpectrumLabel(marker, underline) + // HOVER IS A FLOW, not a box and not a static rule. The chip is clickable — + // it opens /effort — so it has to say so under the cursor, and a background + // fill is the one thing this deliberately does not have. A band travelling + // through the letters says "pressable" by moving, which is the same language + // the rest of the chip already speaks. + return m.zeromaxingSpectrumLabel(marker, m.hover.kind == hoverZeromaxingChip) } // zeromaxingSpectrumLabel paints each character its own hue. @@ -72,30 +72,86 @@ func (m model) zeromaxingGlowChip() string { // no timer of its own — an idle session still schedules none, which is the rule // the pulse was built around, and a footer that animated forever would be a // timer nobody asked for. -func (m model) zeromaxingSpectrumLabel(marker string, underline bool) string { +func (m model) zeromaxingSpectrumLabel(marker string, hovered bool) string { ramp := zeroTheme.spectrum - paint := func(style lipgloss.Style, text string) string { + letters := []rune(zeromaxingChipLabel) + // THE FLOW: a short band that travels left to right through the word and + // wraps, so hovering reads as motion rather than as a second static state. + // Only the band is underlined, which is what makes it a wave instead of a + // rule under the whole label. + head := -1 + if hovered { + head = m.zeromaxingFlowHead(len(letters)) + } + inBand := func(index int) bool { + if head < 0 { + return false + } + span := len(letters) + zeromaxingFlowBand + distance := (index - head + span) % span + return distance < zeromaxingFlowBand + } + paint := func(style lipgloss.Style, text string, lit bool) string { style = style.Bold(true) - if underline { + if lit { style = style.Underline(true) } return style.Render(text) } if len(ramp) == 0 { - return paint(zeroTheme.accent, " "+marker+" "+zeromaxingChipLabel+" ") + return paint(zeroTheme.accent, " "+marker+" "+zeromaxingChipLabel+" ", hovered) } offset := m.zeromaxingSpectrumOffset() var out strings.Builder - out.WriteString(paint(zeroTheme.faint, " ")) - out.WriteString(paint(ramp[offset%len(ramp)], marker)) - out.WriteString(paint(zeroTheme.faint, " ")) - for index, letter := range zeromaxingChipLabel { - out.WriteString(paint(ramp[(index+offset)%len(ramp)], string(letter))) + out.WriteString(paint(zeroTheme.faint, " ", false)) + out.WriteString(paint(ramp[offset%len(ramp)], marker, false)) + out.WriteString(paint(zeroTheme.faint, " ", false)) + for index, letter := range letters { + out.WriteString(paint(ramp[(index+offset)%len(ramp)], string(letter), inBand(index))) } - out.WriteString(paint(zeroTheme.faint, " ")) + out.WriteString(paint(zeroTheme.faint, " ", false)) return out.String() } +// zeromaxingChipAnimating reports that the chip needs frames of its own. +// +// ONLY WHILE HOVERED, and that bound is the whole justification: an animation +// that ran on an idle session would be a timer nobody asked for, which is the +// rule ensureSpinnerTick exists to keep. A cursor resting on a control is a +// direct interaction, and it stops the moment the cursor leaves. +// +// The resting word needs no tick: it walks its colours on the spinner that is +// already running during a turn, and holds still otherwise. +func (m model) zeromaxingChipAnimating() bool { + return m.zeromaxingActive() && m.hover.kind == hoverZeromaxingChip && !m.reducedMotion +} + +// zeromaxingFlowBand is how many letters the travelling highlight covers. Three +// reads as a moving band; one reads as a blinking character, and the whole word +// reads as a static underline. +const zeromaxingFlowBand = 3 + +// zeromaxingFlowPeriod is how long the band takes to cross the word once. +const zeromaxingFlowPeriod = 900 * time.Millisecond + +// zeromaxingFlowHead is the band's leading letter. +// +// Wall clock, not a frame counter, so the band moves at the same speed whatever +// cadence the ticks arrive at — and it is pinned under reduced motion, like +// every other animation here. +func (m model) zeromaxingFlowHead(letters int) int { + if m.reducedMotion || letters <= 0 { + return 0 + } + span := int64(letters + zeromaxingFlowBand) + period := zeromaxingFlowPeriod.Milliseconds() + if period <= 0 { + return 0 + } + step := m.now().UnixMilli() % period + return int(step * span / period) +} + // zeromaxingSpectrumOffset rotates the ramp with the pulse. Pinned to 0 under // reduced motion and when nothing is running, like every other animation here. func (m model) zeromaxingSpectrumOffset() int { diff --git a/internal/tui/zeromaxing_glow_test.go b/internal/tui/zeromaxing_glow_test.go index 9f28c043a..fe9a21634 100644 --- a/internal/tui/zeromaxing_glow_test.go +++ b/internal/tui/zeromaxing_glow_test.go @@ -380,3 +380,134 @@ func TestTheSpectrumHoldsStillWhenNothingIsRunning(t *testing.T) { t.Fatalf("the shimmer never advanced across a full period: %v", seen) } } + +// LOWERCASE, like every other footer label beside it. Shouting was the badge's +// job and the badge is gone; the word earns attention by moving now. +func TestThePostureLabelIsLowercase(t *testing.T) { + if zeromaxingChipLabel != strings.ToLower(zeromaxingChipLabel) { + t.Fatalf("the footer label is %q; it must be lowercase like the labels beside it", zeromaxingChipLabel) + } + m := glowModel(t) + m.width, m.height = 100, 30 + if !strings.Contains(ansiStripLine(m.footerView(m.width)), zeromaxingChipLabel) { + t.Fatalf("the footer lost the label:\n%s", ansiStripLine(m.footerView(m.width))) + } +} + +// bandPositions reports which letters carry the travelling underline. +func bandPositions(t *testing.T, m model) string { + t.Helper() + rendered := m.zeromaxingGlowChip() + out := "" + for _, letter := range zeromaxingChipLabel { + marker := "." + for _, run := range strings.Split(rendered, "\x1b[") { + if strings.HasSuffix(run, string(letter)) && strings.Contains(run, ";4;") { + marker = "^" + } + } + out += marker + } + return out +} + +// THE HOVER FLOWS. A band travels through the letters and wraps, so hovering +// reads as motion rather than as a second static state — which is what an +// underline across the whole word would have been. +func TestTheHoverBandTravelsThroughTheWord(t *testing.T) { + m := glowModel(t) + m.width, m.height = 100, 30 + m.hover = hoverTarget{kind: hoverZeromaxingChip} + + seen := map[string]bool{} + lit := 0 + for step := 0; step < int(zeromaxingFlowPeriod.Milliseconds()); step += 60 { + at := time.Unix(0, 0).Add(time.Duration(step) * time.Millisecond) + frame := m + frame.now = func() time.Time { return at } + positions := bandPositions(t, frame) + seen[positions] = true + if count := strings.Count(positions, "^"); count > zeromaxingFlowBand { + t.Fatalf("the band covers %d letters, want at most %d: %s", count, zeromaxingFlowBand, positions) + } + if strings.Count(positions, "^") > 0 { + lit++ + } + } + if len(seen) < 3 { + t.Fatalf("the band never moved across a full period: %v", seen) + } + if lit == 0 { + t.Fatal("no letter was ever lit; the hover produces no band at all") + } +} + +// RESTING carries no band: the flow is what hovering ADDS, and a resting chip +// that already flowed would make the hover say nothing. +func TestTheRestingWordCarriesNoBand(t *testing.T) { + m := glowModel(t) + m.width, m.height = 100, 30 + if positions := bandPositions(t, m); strings.Contains(positions, "^") { + t.Fatalf("the resting word is banded: %s", positions) + } +} + +// THE TICK IS BOUNDED BY THE HOVER. An animation that ran on an idle session +// would be a timer nobody asked for, which is the rule ensureSpinnerTick exists +// to keep — so it starts when the cursor arrives and stops when it leaves. +func TestTheChipOnlyAsksForFramesWhileHovered(t *testing.T) { + m := glowModel(t) + if m.zeromaxingChipAnimating() { + t.Fatal("the chip asks for frames while nothing hovers it") + } + m.hover = hoverTarget{kind: hoverZeromaxingChip} + if !m.zeromaxingChipAnimating() { + t.Fatal("the hovered chip asks for no frames, so its band cannot move") + } + m.reducedMotion = true + if m.zeromaxingChipAnimating() { + t.Fatal("reduced motion must stop the chip asking for frames") + } + m.reducedMotion = false + m.zeromaxing = 0 + if m.zeromaxingChipAnimating() { + t.Fatal("a chip that is not shown must not ask for frames") + } +} + +// Reduced motion pins the band, like every other animation here. +func TestReducedMotionStopsTheFlow(t *testing.T) { + m := glowModel(t) + m.hover = hoverTarget{kind: hoverZeromaxingChip} + m.reducedMotion = true + first := bandPositions(t, m) + later := m + later.now = func() time.Time { return time.Unix(0, 0).Add(500 * time.Millisecond) } + if second := bandPositions(t, later); first != second { + t.Fatalf("the band moved under reduced motion: %s then %s", first, second) + } +} + +// THE SCHEDULER MUST CONSULT IT. Asserting zeromaxingChipAnimating alone passes +// against an ensureSpinnerTick that ignores it entirely — the predicate would be +// right and the band would still never move. Sixth instance of that shape today, +// so this drives the scheduler. +func TestTheHoverActuallyStartsTheTickLoop(t *testing.T) { + idle := glowModel(t) + if cmd := idle.ensureSpinnerTick(); cmd != nil { + t.Fatal("an idle session with nothing hovered scheduled a timer") + } + + hovered := glowModel(t) + hovered.hover = hoverTarget{kind: hoverZeromaxingChip} + if cmd := hovered.ensureSpinnerTick(); cmd == nil { + t.Fatal("hovering the chip scheduled no tick, so its band can never move") + } + // ...and it does not schedule a SECOND one while the loop already runs. + running := glowModel(t) + running.hover = hoverTarget{kind: hoverZeromaxingChip} + running.spinnerTicking = true + if cmd := running.ensureSpinnerTick(); cmd != nil { + t.Fatal("a second timer was scheduled while the loop was already alive") + } +} From 4d163ac8d2d0d663f70583130e014c5d189ee453 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:28:02 +0530 Subject: [PATCH 60/86] fix(tui): a finished plan task must close its OWN card, not the last dispatched one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The screenshot: five agents in the sidebar, every one of them spinning, on a plan whose tasks had long since finished. The footer said 6 running and it stayed 6 running. finish() resolved the card with planTaskKey(bridge.dispatched) — the LAST dispatched task's key. With one worker that was indistinguishable from a lookup: a task could only finish after it was dispatched and before the next one was, so the last card was always the finishing task's card. Fan-out opened six at once, completions started arriving in whatever order the tasks finished, and the counter then marked a task that was still running as done while the one that actually finished spun for the rest of the session. cardByTask has existed since fan-out landed and was written on every dispatch. Nothing read it here. Seventh instance this branch of a field populated at one site and consumed at none. The map is also the authority on whether a task was DISPATCHED, which the outcome cannot answer. TaskCancelled is emitted both for a task stopped mid-flight (it has a card) and for one cancelled before it ever ran (it does not); reading the first as undispatched opened a second card and closed that, leaving the real one running forever. That is the "even after cancelling it keeps spinning" half of the same report. Read-and-delete, so an entry exists exactly while its task is in flight and a later plan reusing a task id cannot resolve against a dead card. Three tests, three mutations, all caught. The existing tests only ever completed tasks in dispatch order, which is precisely why this survived. --- internal/tui/plan_progress.go | 27 +++++-- internal/tui/plan_progress_test.go | 112 +++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 5 deletions(-) diff --git a/internal/tui/plan_progress.go b/internal/tui/plan_progress.go index cb5e430e8..c11d6e2fb 100644 --- a/internal/tui/plan_progress.go +++ b/internal/tui/plan_progress.go @@ -441,15 +441,32 @@ func (bridge *PlanProgressBridge) finish(result specialist.TaskResult, status sp if bridge == nil { return } + // BY TASK ID, never by the dispatch counter. With one worker the last + // dispatched card was always the finishing task's card, so a counter was + // indistinguishable from a lookup — and wrong the moment max_workers > 1 + // opened six cards at once. Under fan-out a completion arrives in whatever + // order the task finished, so closing planTaskKey(dispatched) marked a task + // that was still running as done and left the finished one spinning for the + // rest of the session. + // + // The map is also the AUTHORITY on whether the task was dispatched. The + // outcome cannot answer that: TaskCancelled is emitted both for a task + // stopped mid-flight (it has a card) and for one cancelled before it ever + // ran (it does not), and treating the first as undispatched opened a second + // card and left the real one running forever — the same bug wearing the + // cancel path's clothes. + // + // Read-and-delete: an entry exists exactly while its task is in flight, so a + // later plan reusing a task id can never resolve against a stale card. bridge.mu.Lock() - key := planTaskKey(bridge.dispatched) + key, dispatched := bridge.cardByTask[result.ID] + delete(bridge.cardByTask, result.ID) bridge.mu.Unlock() // A task that was never dispatched (dependency-skipped, budget-skipped, - // cancelled before it ran) has no card. Reporting it against the LAST - // dispatched task's key would close the wrong card, so those carry their - // own key and the handler creates the card on demand. - dispatched := result.Outcome == specialist.TaskSucceeded || result.Outcome == specialist.TaskFailed + // cancelled before it ran) has no card. Reporting it against another task's + // key would close the wrong card, so those carry their own key and the + // handler creates the card on demand. taskID, sessionID, reason := result.ID, result.SessionID, result.Err outcome, tokens, attempts := result.Outcome, result.Tokens, result.Attempts bridge.send(func(runID int) tea.Msg { diff --git a/internal/tui/plan_progress_test.go b/internal/tui/plan_progress_test.go index 670a96d59..3b24d03e5 100644 --- a/internal/tui/plan_progress_test.go +++ b/internal/tui/plan_progress_test.go @@ -228,3 +228,115 @@ func TestASingleAttemptAddsNoAttemptCount(t *testing.T) { t.Fatalf("attempts = %d; want 1", got) } } + +// FAN-OUT ORDER. With one worker a task could only finish after it was +// dispatched and before the next one was, so "the last dispatched card" was +// always the finishing task's card. With max_workers > 1 six tasks are open at +// once and they finish in whatever order they finish — so a completion must be +// matched to the card its OWN task opened, by id. +// +// Closing the last dispatched card instead leaves the earlier tasks' cards open +// forever: the sidebar spins on tasks that finished minutes ago, and a task +// still running is shown as done. +func TestACompletionClosesItsOwnCardNotTheLastDispatchedOne(t *testing.T) { + var got []tea.Msg + bridge := NewPlanProgressBridge() + bridge.Attach(func(msg tea.Msg) { got = append(got, msg) }, 1, nil, "") + + // Three tasks in flight together, finishing in reverse order. + bridge.TaskDispatched(specialist.Task{ID: "a", Prompt: "first"}) + bridge.TaskDispatched(specialist.Task{ID: "b", Prompt: "second"}) + bridge.TaskDispatched(specialist.Task{ID: "c", Prompt: "third"}) + + cards := map[string]string{} + for _, msg := range got { + if start, ok := msg.(planTaskStartMsg); ok { + cards[start.taskID] = start.cardKey + } + } + if len(cards) != 3 { + t.Fatalf("expected three open cards, got %v", cards) + } + + got = nil + bridge.TaskCompleted(specialist.TaskResult{ID: "c", Outcome: specialist.TaskSucceeded}) + bridge.TaskFailed(specialist.TaskResult{ID: "a", Outcome: specialist.TaskCancelled}) + bridge.TaskCompleted(specialist.TaskResult{ID: "b", Outcome: specialist.TaskSucceeded}) + + if len(got) != 3 { + t.Fatalf("expected three completions, got %d", len(got)) + } + for _, msg := range got { + done, ok := msg.(planTaskDoneMsg) + if !ok { + t.Fatalf("expected a done message, got %#v", msg) + } + if want := cards[done.taskID]; done.cardKey != want { + t.Errorf("task %s closed card %q, but it opened card %q — the wrong card is marked done and its own spins forever", + done.taskID, done.cardKey, want) + } + } +} + +// STOPPING A PLAN. The executor emits TaskCancelled for two different things: a +// task stopped while it was running, and a task cancelled before it ever +// started. Only the second has no card. Reading "cancelled" as "never +// dispatched" made the handler open a SECOND card for a task that already had +// one and close that, leaving the real card spinning after the plan was stopped. +func TestCancellingARunningTaskClosesTheCardItAlreadyHas(t *testing.T) { + var got []tea.Msg + bridge := NewPlanProgressBridge() + bridge.Attach(func(msg tea.Msg) { got = append(got, msg) }, 1, nil, "") + + bridge.TaskDispatched(specialist.Task{ID: "running", Prompt: "in flight when the user stopped the plan"}) + start := got[0].(planTaskStartMsg) + + got = nil + bridge.TaskFailed(specialist.TaskResult{ID: "running", Outcome: specialist.TaskCancelled, Err: "cancelled"}) + bridge.TaskFailed(specialist.TaskResult{ID: "queued", Outcome: specialist.TaskCancelled, Err: "cancelled"}) + + stopped, ok := got[0].(planTaskDoneMsg) + if !ok { + t.Fatalf("expected a done message, got %#v", got[0]) + } + if !stopped.dispatched { + t.Error("a task cancelled MID-FLIGHT was dispatched; reporting otherwise opens a second card and leaves the first spinning") + } + if stopped.cardKey != start.cardKey { + t.Errorf("cancelled card %q, opened card %q", stopped.cardKey, start.cardKey) + } + + queued, ok := got[1].(planTaskDoneMsg) + if !ok { + t.Fatalf("expected a done message, got %#v", got[1]) + } + if queued.dispatched { + t.Error("a task cancelled BEFORE it ran has no card; claiming it was dispatched closes a card that does not exist") + } +} + +// The map entry is deleted as the task finishes, so it exists exactly while the +// task is in flight. Without that, a task id reused by a LATER plan resolves +// against the earlier plan's card: a skipped task is reported as dispatched and +// closes a card that belongs to a plan that ended long ago. +func TestAFinishedTasksCardIsNotResolvableByALaterPlan(t *testing.T) { + var got []tea.Msg + bridge := NewPlanProgressBridge() + bridge.Attach(func(msg tea.Msg) { got = append(got, msg) }, 1, nil, "") + + // Plan one runs a task called "cfg" to completion. + bridge.TaskDispatched(specialist.Task{ID: "cfg", Prompt: "first plan"}) + bridge.TaskCompleted(specialist.TaskResult{ID: "cfg", Outcome: specialist.TaskSucceeded}) + + // Plan two has a task with the same id, skipped because a dependency failed. + got = nil + bridge.TaskFailed(specialist.TaskResult{ID: "cfg", Outcome: specialist.TaskSkippedDependency, Err: "skipped"}) + + done, ok := got[0].(planTaskDoneMsg) + if !ok { + t.Fatalf("expected a done message, got %#v", got[0]) + } + if done.dispatched { + t.Error("a skipped task resolved against the previous plan's card for the same id") + } +} From 2ea0ce6f68d41fc5d6dc205274b6fa91c604d282 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:32:47 +0530 Subject: [PATCH 61/86] =?UTF-8?q?feat(tui):=20one=20plan,=20one=20surface?= =?UTF-8?q?=20=E2=80=94=20the=20inline=20panel=20stands=20down=20for=20the?= =?UTF-8?q?=20sidebar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The right column already carries the plan: progress bar, task list, live detail, and an agent row per task. The inline panel above the composer predates all of that, and keeping both meant the same plan announced itself three times on one screen while taking footer height from the conversation to do it. It now draws only when the sidebar is not the plan's surface. That is a real set of states, not an empty one: the sidebar needs a wide enough terminal, is suppressed under every full-screen overlay, and hands its PLAN section to update_plan whenever that has steps. In all of them the panel is still the only place a running plan appears, so it still draws — a fallback, not dead code. Two supporting changes: An admitted plan now counts as sidebar content on its own. It did not, so the column waited for the first task to spawn an agent row, and the inline panel flashed on screen for exactly that gap before standing down. visible(), not isEmpty(): a finished plan's tasks are kept for the record, and holding the column open for those would mean the sidebar never auto-hides again once a session has run a plan. Ctrl+G moves off sidebarActive() onto the same predicate, so it acts on whichever surface is on screen. The two disagree precisely when the sidebar is up but update_plan owns its PLAN section — where the old key cycled a selection in a list that was not visible while the panel the user was looking at ignored it. Mutation M7 missed until that case was in the test. --- internal/tui/model.go | 11 +-- internal/tui/orchestrate_panel.go | 28 ++++++ internal/tui/orchestrate_panel_test.go | 116 +++++++++++++++++++++++++ internal/tui/sidebar.go | 10 +++ 4 files changed, 160 insertions(+), 5 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index 84d242303..4b45e064c 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1737,11 +1737,12 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { // /plans are already two different things, and one key toggling // whichever happened to be present would make that worse. if m.noBlockingModal() && !m.orchestrate.isEmpty() { - // With the sidebar open the plan lives there, so ctrl+g walks - // the task selection — the keyboard route to clicking a row. - // Without it, the inline panel is the only surface, so ctrl+g - // keeps expanding that. - if m.sidebarActive() { + // When the sidebar owns the plan, ctrl+g walks the task + // selection — the keyboard route to clicking a row. When the + // inline panel is the surface instead, ctrl+g keeps expanding + // that. Same predicate the panel itself uses, so the key always + // acts on whichever one is actually on screen. + if m.sidebarOwnsOrchestrate() { return m.cycleOrchestrateSelection(), nil } m.orchestrate.expanded = !m.orchestrate.expanded diff --git a/internal/tui/orchestrate_panel.go b/internal/tui/orchestrate_panel.go index 824abb700..24900a981 100644 --- a/internal/tui/orchestrate_panel.go +++ b/internal/tui/orchestrate_panel.go @@ -301,6 +301,31 @@ func (s orchestratePanelState) liveTasks(now time.Time) []orchestrateTask { return live } +// sidebarOwnsOrchestrate reports whether the right-hand column is currently the +// running plan's surface — in which case the inline panel is a second copy of it +// two rows above the composer, and does not draw. +// +// The right column already carries the progress bar, the task list, the live +// detail and every task's agent row. The inline panel stayed because it predates +// all of that; keeping both meant the plan announced itself three times on one +// screen (the admission row, the footer summary, the sidebar) and stole footer +// height from the conversation to do it. +// +// It is a FALLBACK, not dead code. The sidebar needs a wide enough terminal, is +// suppressed under every full-screen overlay, and hands its PLAN section to +// update_plan whenever that has steps — so there are real states where the +// inline panel is the only place a running plan appears, and it still draws in +// all of them. +func (m model) sidebarOwnsOrchestrate() bool { + if m.orchestrate.isEmpty() || !m.sidebarActive() { + return false + } + // sidebarPlanLines renders update_plan's steps whenever it has any and falls + // through to the orchestrate task list only when it does not. With both live + // the plan has no sidebar surface, so the inline panel is still the one. + return m.plan.isEmpty() +} + // renderOrchestratePanel draws the plan: one row per task, indented by // dependency depth so the shape is legible. func (m model) renderOrchestratePanel(width int) string { @@ -309,6 +334,9 @@ func (m model) renderOrchestratePanel(width int) string { if !state.visible(now) { return "" } + if m.sidebarOwnsOrchestrate() { + return "" + } if width < orchestrateMinWidth { width = orchestrateMinWidth } diff --git a/internal/tui/orchestrate_panel_test.go b/internal/tui/orchestrate_panel_test.go index d8292df0c..7ff5fba00 100644 --- a/internal/tui/orchestrate_panel_test.go +++ b/internal/tui/orchestrate_panel_test.go @@ -669,3 +669,119 @@ func TestAMissingFinalTotalDoesNotZeroTheCount(t *testing.T) { t.Fatalf("tokensUsed = %d, want the accumulated count kept when no total arrives", m.orchestrate.tokensUsed) } } + +// planOwnedByTheSidebar is a model whose RIGHT COLUMN is the running plan's surface: +// a real terminal, real conversation, an admitted plan, and no update_plan steps +// (which would otherwise claim the sidebar's PLAN section for themselves). +func planOwnedByTheSidebar(t *testing.T) model { + t.Helper() + m := sidebarTestModel() + m.plan = planPanelState{} // the orchestrate plan, not update_plan, owns the section + m.now = func() time.Time { return time.Unix(1000, 0) } + m.orchestrate.admit(diamondAdmitted(), m.now()) + if !m.sidebarActive() { + t.Fatal("sanity check failed: an admitted plan on a 100-col terminal must open the sidebar") + } + return m +} + +// ONE PLAN, ONE SURFACE. The right column carries the progress bar, the task +// list, the live detail and every task's agent row. The inline panel drawing the +// same plan two rows above the composer is a second copy of it that costs footer +// height the conversation wanted. +func TestTheInlinePanelStandsDownWhenTheSidebarHasThePlan(t *testing.T) { + m := planOwnedByTheSidebar(t) + if got := m.renderOrchestratePanel(100); got != "" { + t.Errorf("the sidebar is showing this plan; the inline panel must not repeat it:\n%s", got) + } + // Expanding it changes nothing: the duplication is the problem, not the size. + m.orchestrate.expanded = true + if got := m.renderOrchestratePanel(100); got != "" { + t.Errorf("expanded inline panel drew over the sidebar's copy:\n%s", got) + } +} + +// A FALLBACK, NOT DEAD CODE. There are real states with no sidebar to carry the +// plan, and the panel has to still be there in every one of them — otherwise +// hiding the column loses the running plan entirely. +func TestTheInlinePanelIsStillTheSurfaceWithoutASidebar(t *testing.T) { + for name, arrange := range map[string]func(model) model{ + "sidebar hidden with ctrl+b": func(m model) model { + m.sidebarHidden = true + return m + }, + "terminal too narrow for a second column": func(m model) model { + m.width = 60 + return m + }, + "update_plan owns the sidebar's PLAN section": func(m model) model { + m.plan.steps = []planStep{{content: "wire it up", status: "in_progress"}} + return m + }, + } { + t.Run(name, func(t *testing.T) { + m := arrange(planOwnedByTheSidebar(t)) + if m.sidebarOwnsOrchestrate() { + t.Fatalf("the sidebar must not claim the plan in this state") + } + rendered := plainRender(t, m.renderOrchestratePanel(100)) + if !strings.Contains(rendered, "PLAN") { + t.Errorf("the plan has no other surface here and must still draw inline, got %q", rendered) + } + }) + } +} + +// The column has to arrive WITH the plan, not one task later. Without this the +// inline panel — which stands down the moment the sidebar takes over — flashes +// on screen for the gap between admission and the first agent row. +func TestAnAdmittedPlanAloneOpensTheSidebar(t *testing.T) { + m := sidebarTestModel() + m.plan = planPanelState{} + m.now = func() time.Time { return time.Unix(1000, 0) } + if m.sidebarHasContent() { + t.Fatal("sanity check failed: no plan, no agents, no files — nothing to show") + } + m.orchestrate.admit(diamondAdmitted(), m.now()) + if !m.sidebarHasContent() { + t.Error("an admitted plan is the sidebar's content before any task has dispatched") + } +} + +// Ctrl+G must act on whichever surface is actually on screen. With the sidebar +// carrying the plan it walks the task selection; with the inline panel as the +// surface it expands that. Driven through the real key handler, because the +// question is what the KEY does, not what the predicate returns. +func TestCtrlGActsOnWhicheverPlanSurfaceIsOnScreen(t *testing.T) { + press := func(m model) model { + t.Helper() + updated, _ := m.Update(tea.KeyPressMsg{Code: 'g', Mod: tea.ModCtrl}) + return updated.(model) + } + + sidebar := press(planOwnedByTheSidebar(t)) + if sidebar.orchestrate.expanded { + t.Error("the sidebar is the surface: ctrl+g must not expand an inline panel that does not draw") + } + + inline := planOwnedByTheSidebar(t) + inline.sidebarHidden = true + if got := press(inline); !got.orchestrate.expanded { + t.Error("the inline panel is the only surface: ctrl+g must expand it") + } + + // THE CASE THAT SEPARATES THE TWO PREDICATES, and the reason the key was + // moved off sidebarActive. The sidebar is up, so sidebarActive() is true — + // but update_plan has claimed its PLAN section, so the orchestrate plan is + // drawing inline. Keying off "is the sidebar up" cycles a selection in a + // list that is not on screen while the panel the user is looking at ignores + // the key. + contended := planOwnedByTheSidebar(t) + contended.plan.steps = []planStep{{content: "wire it up", status: "in_progress"}} + if !contended.sidebarActive() { + t.Fatal("sanity check failed: this case needs the sidebar UP and not owning the plan") + } + if got := press(contended); !got.orchestrate.expanded { + t.Error("the sidebar is up but update_plan has its PLAN section: ctrl+g must expand the panel that is actually drawing") + } +} diff --git a/internal/tui/sidebar.go b/internal/tui/sidebar.go index 48c437cf0..5a99e7819 100644 --- a/internal/tui/sidebar.go +++ b/internal/tui/sidebar.go @@ -144,6 +144,16 @@ func (m model) sidebarHasContent() bool { // FILES pulse for the session's first mutation isn't hidden. return true } + if m.orchestrate.visible(m.orchestrateNow()) { + // An admitted plan counts from the moment it is admitted, before its + // first task has spawned an agent row. Without this the column arrives a + // beat late, and the inline panel — which stands down as soon as the + // sidebar takes the plan — flashes on screen for exactly that beat. + // visible(), not isEmpty(): a finished plan's tasks are kept for the + // record, and holding the column open for them would mean the sidebar + // never auto-hides again once a session has run one. + return true + } return !m.plan.isEmpty() } From 3e292c3ffe87e89fea05915916d3a46c9fd56d57 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:43:25 +0530 Subject: [PATCH 62/86] feat(tui): click an agent in AGENTS to open its brief, spend and reason MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The collapsed row says which agent and which tool it is in. The three things it cannot say are what the agent was actually asked to do, what it has cost, and — when it did not simply finish — what happened to it. A click now opens those under the row, and closes them again. In place, not a drill-in. A specialist row is keyed by its CARD, and a plan task's card key is not a session id until the task finishes and reconciles one, so there was nothing to drill into during the exact stretch where the detail is most wanted. Swarm rows keep their drill-in; only specialist rows toggle. The expansion follows the card→session rename at completion, or a row the user had open would collapse at the moment it gained a result to show. Four lines is the cap. This section shares its column with PLAN, FILES and ACTIVITY, and an expansion that pushes those off has traded three sections for one. The spend line drops segments that do not fit rather than truncating the joined result. These are figures: prose ending in an ellipsis still reads as prose, but "3,4…" is three thousand or three million with equal authority. Elapsed uses the footer's own 1m10s and tokens the column floor's own 3.4K, so a 30-cell sidebar shows "1m10s · 3.4K tok" whole instead of all three cut off. Six mutations, all caught — including the offset hazard sidebarPlanLines warns about, since PLAN and FILES derive their click targets from the agent section's rendered height and every added line moves them. --- internal/tui/hover_test.go | 9 +- internal/tui/model.go | 12 ++ internal/tui/sidebar.go | 136 ++++++++++++++++++--- internal/tui/sidebar_test.go | 169 +++++++++++++++++++++++++++ internal/tui/transcript_selection.go | 12 ++ 5 files changed, 319 insertions(+), 19 deletions(-) diff --git a/internal/tui/hover_test.go b/internal/tui/hover_test.go index 3454300ef..96459cc12 100644 --- a/internal/tui/hover_test.go +++ b/internal/tui/hover_test.go @@ -79,10 +79,11 @@ func TestUpdateHoverTargetOnPlainTextIsNone(t *testing.T) { } func TestUpdateHoverTargetOnSidebarAgentRow(t *testing.T) { - // Only a SWARM member row (a session mapped via swarmSessionMap) is clickable - // in the sidebar — a Task-delegation specialist row is not (sidebarAgentRows - // only records hits from the swarm loop). swarmSidebarTestModel builds exactly - // that: real conversation + a mapped swarm member session. + // A SWARM member row (a session mapped via swarmSessionMap) is clickable and + // drills into that member's session; a specialist row is clickable too but + // expands in place (see TestClickingARunningAgentRowExpandsItInPlace). Both + // hover the same way. swarmSidebarTestModel builds the swarm case: real + // conversation + a mapped member session. m := swarmSidebarTestModel(t, map[string]string{"subagent-1": "sess-1"}) if !m.sidebarActive() { t.Fatal("sanity check failed: sidebar should be active with a swarm member present on a 100-col terminal") diff --git a/internal/tui/model.go b/internal/tui/model.go index 4b45e064c..c5ade8b60 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -214,6 +214,11 @@ type model struct { // orchestrateSelected is the plan task the sidebar's TASK section details. // Clicking a task row in the sidebar sets it; ctrl+g cycles it. orchestrateSelected int + // expandedAgent is the AGENTS row showing its brief and spend, keyed by the + // specialist's card id. One at a time: the section shares its column with + // PLAN, FILES and ACTIVITY, and every row expanded at once would be a + // different panel rather than a detail on this one. + expandedAgent string // leaderHelpOverlay is the Ctrl+X ? modal listing every leader slash chord. leaderHelpOverlay bool // leaderPending is true after Ctrl+X until a second key, Esc, or timeout @@ -2725,6 +2730,13 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m.specialists.complete(cardKey, msg.status, 0, msg.reason, m.now()) m.specialists.setTokens(cardKey, msg.tokens) if msg.sessionID != "" && msg.sessionID != cardKey { + // The expansion follows the rename. It is keyed by the card id, and + // finishing swaps that for the child's real session id — so a row + // the user had open would collapse at the exact moment it gained a + // result to show. + if m.expandedAgent == cardKey { + m.expandedAgent = msg.sessionID + } m.specialists.reconcileSessionID(cardKey, msg.sessionID) cardKey = msg.sessionID } diff --git a/internal/tui/sidebar.go b/internal/tui/sidebar.go index 5a99e7819..2c09ee951 100644 --- a/internal/tui/sidebar.go +++ b/internal/tui/sidebar.go @@ -399,6 +399,12 @@ type sidebarAgentHit struct { lineOffset int sessionID string title string + // expands marks a row whose click TOGGLES ITS DETAIL in place rather than + // drilling into a child session. Specialist rows are keyed by their card, + // and a plan task's card key is not a session id until the task finishes and + // reconciles one — so the drill-in has nothing to open while the task is + // running, which is exactly when its detail is worth reading. + expands bool } // sidebarAgentLines renders one line per active agent. Specialist delegations @@ -454,25 +460,31 @@ func (m model) sidebarAgentRows(width int) ([]string, []sidebarAgentHit) { icon = zeroTheme.faint.Render(glyph) nameStyle = zeroTheme.faint } - lines = append(lines, " "+icon+" "+nameStyle.Render(truncateStep(name, room))) - if a.status != specialistRunning { - continue + // Recorded before the line is appended, so the offset is the row's own. + if a.childSessionID != "" { + hits = append(hits, sidebarAgentHit{lineOffset: len(lines), sessionID: a.childSessionID, title: name, expands: true}) } - // Live working detail for a running subagent: current tool + arg hint, - // falling back to the running tool count. - detail := strings.TrimSpace(a.currentTool) - if d := strings.TrimSpace(a.currentDetail); d != "" { + lines = append(lines, " "+icon+" "+nameStyle.Render(truncateStep(name, room))) + if a.status == specialistRunning { + // Live working detail for a running subagent: current tool + arg hint, + // falling back to the running tool count. + detail := strings.TrimSpace(a.currentTool) + if d := strings.TrimSpace(a.currentDetail); d != "" { + if detail != "" { + detail += " " + d + } else { + detail = d + } + } + if detail == "" && a.toolCount > 0 { + detail = fmt.Sprintf("%d tools", a.toolCount) + } if detail != "" { - detail += " " + d - } else { - detail = d + lines = append(lines, " "+zeroTheme.faint.Render("↳ "+truncateStep(detail, maxInt(2, room-2)))) } } - if detail == "" && a.toolCount > 0 { - detail = fmt.Sprintf("%d tools", a.toolCount) - } - if detail != "" { - lines = append(lines, " "+zeroTheme.faint.Render("↳ "+truncateStep(detail, maxInt(2, room-2)))) + if a.childSessionID == m.expandedAgent { + lines = append(lines, m.sidebarAgentExpansion(a, room)...) } } // Swarm/team members: a live member's whole task-name carries a mild, slow cool @@ -509,6 +521,100 @@ func (m model) sidebarAgentRows(width int) ([]string, []sidebarAgentHit) { return lines, hits } +// sidebarAgentExpansionBriefLines caps how much of the brief the expansion +// shows. Two lines is enough to say what a task was asked to do; the whole +// prompt belongs in the task's card, not in a 26-cell column. +const sidebarAgentExpansionBriefLines = 2 + +// fitSegments joins as many " · "-separated segments as fit in width and DROPS +// the rest, rather than joining them all and truncating the result. +// +// The difference matters because these segments are figures. Prose that runs +// out of room ends in an ellipsis and is still read as prose; a number that does +// reads as a different number — "3,4…" is three thousand or three million with +// equal authority, and the whole point of the line is to report a spend. Losing +// the last segment says less; truncating it says something false. +func fitSegments(segments []string, width int) string { + line := "" + for _, segment := range segments { + candidate := segment + if line != "" { + candidate = line + " · " + segment + } + if lipgloss.Width(candidate) > width { + break + } + line = candidate + } + return line +} + +// sidebarAgentExpansion is what a clicked agent row opens: the brief it was +// given, what it has spent, and — when it did not simply finish — why. +// +// A LITTLE MORE, not a drawer. The collapsed row says which agent and which +// tool; the three questions it cannot answer are what the agent was actually +// asked to do, how much it has cost, and what happened to it. Those fit in four +// lines, and four lines is the cap: this section shares its height with PLAN, +// FILES and ACTIVITY, and an expansion that pushes them off the column has +// traded three sections for one. +func (m model) sidebarAgentExpansion(info specialistInfo, room int) []string { + body := maxInt(4, room-4) + indent := " " + var out []string + + if brief := strings.TrimSpace(info.description); brief != "" { + for i, line := range wrapPlainText(brief, body) { + if i >= sidebarAgentExpansionBriefLines { + break + } + out = append(out, indent+zeroTheme.muted.Render(line)) + } + } + + // Spend, most-wanted first: how long, what it cost, how much it did. Each + // segment is omitted when it has nothing to report rather than shown as a + // zero — "0 tools" on a task that has not called one yet reads as a stuck + // agent, which is the thing this panel is meant to disambiguate. + var spent []string + if !info.startedAt.IsZero() { + until := m.now() + if !info.completedAt.IsZero() { + until = info.completedAt + } + if elapsed := until.Sub(info.startedAt); elapsed > 0 { + // The footer's format (1m10s), not 70.0s: same clock, same reading, + // and a character shorter in a column that has 19 of them at its + // minimum width. + spent = append(spent, formatWorkingElapsed(elapsed)) + } + } + if info.tokenCount > 0 { + // humanCount, the column floor's own format (3.4K), not the card's + // grouped digits — "3,400" does not fit beside the other segments here. + spent = append(spent, humanCount(info.tokenCount)+" tok") + } + if info.toolCount > 0 { + spent = append(spent, fmt.Sprintf("%d tools", info.toolCount)) + } + if line := fitSegments(spent, body); line != "" { + out = append(out, indent+zeroTheme.faint.Render(line)) + } + + // The reason, for the two statuses that have one. A cancelled task is NOT + // red: the user stopped it, and colouring their own decision as a fault is + // the same mistake the ⊘ glyph exists to avoid. + if reason := strings.TrimSpace(info.errorMsg); reason != "" { + switch info.status { + case specialistError: + out = append(out, indent+zeroTheme.red.Render(truncateStep(reason, body))) + case specialistCancelled: + out = append(out, indent+zeroTheme.faint.Render(truncateStep(reason, body))) + } + } + return out +} + // sidebarAgentSelectables returns the clickable swarm-member lines with their // ABSOLUTE index inside the rendered sidebar (the AGENTS header occupies index 0, // so agent rows start at index 1). Recomputed on demand by the mouse hit-test — diff --git a/internal/tui/sidebar_test.go b/internal/tui/sidebar_test.go index 2c0d15cc7..6aad3e619 100644 --- a/internal/tui/sidebar_test.go +++ b/internal/tui/sidebar_test.go @@ -2,6 +2,7 @@ package tui import ( "context" + "path" "strings" "testing" "time" @@ -686,3 +687,171 @@ func TestSidebarSurvivesCommandPalette(t *testing.T) { t.Error("a full-screen picker must still collapse the sidebar") } } + +// specialistSidebarModel is a sidebar-active model with one running plan task in +// AGENTS, an update_plan step list in PLAN, and a touched file in FILES — so the +// sections BELOW the agent rows are present and their click offsets are real. +func specialistSidebarModel(t *testing.T, now time.Time) model { + t.Helper() + m := sidebarTestModel() + m.now = func() time.Time { return now } + m.specialists.start("cfg", "read the config resolver and report how the profile tier merges", "plantask_1", now.Add(-70*time.Second)) + m.specialists.incrementToolCount("plantask_1") + m.specialists.setTokens("plantask_1", 3400) + m.transcript = append(m.transcript, + transcriptRow{kind: rowToolResult, tool: "write_file", detail: "internal/tui/sidebar.go"}) + if !m.sidebarActive() { + t.Fatal("sanity check failed: the sidebar must be up for this test") + } + return m +} + +// A RUNNING agent row is clickable. It is keyed by its card, and a plan task's +// card key is not a session id until the task finishes — so the drill-in has +// nothing to open at the one moment the detail is most wanted. +func TestClickingARunningAgentRowExpandsItInPlace(t *testing.T) { + now := time.Unix(20000, 0) + m := specialistSidebarModel(t, now) + + hits := m.sidebarAgentSelectables(sidebarWidth(m.width)) + if len(hits) != 1 { + t.Fatalf("expected the specialist row to be clickable, got %d hits", len(hits)) + } + if !hits[0].expands || hits[0].sessionID != "plantask_1" { + t.Fatalf("specialist hit = %+v, want an in-place expansion keyed by its card", hits[0]) + } + + collapsed := plainRender(t, strings.Join(m.sidebarAgentLines(sidebarWidth(m.width)), "\n")) + if strings.Contains(collapsed, "config resolver") { + t.Errorf("the collapsed row must not already show the brief:\n%s", collapsed) + } + + x0 := m.chatColumnWidth() + 3 + click := tea.MouseClickMsg{Button: tea.MouseLeft, X: x0, Y: hits[0].lineOffset} + updated, _, handled := m.handleTranscriptSelectionMouse(click) + if !handled { + t.Fatal("the click was not handled") + } + if updated.expandedAgent != "plantask_1" { + t.Fatalf("expandedAgent = %q, want the clicked row's card", updated.expandedAgent) + } + // IN PLACE, NOT A DRILL-IN. The swarm path on the other side of this branch + // swaps the whole view for the member's subchat; a card key that is not yet + // a session would take it there with nothing to open. + if updated.subchat.active { + t.Error("expanding a specialist row must not enter the swarm subchat") + } + + expanded := plainRender(t, strings.Join(updated.sidebarAgentLines(sidebarWidth(m.width)), "\n")) + // The column is 26 cells at its minimum, so the brief wraps and the spend + // line truncates — checked against what actually renders, not against a + // wider terminal's version of it. + for _, want := range []string{"read the config", "1m10s", "3.4K tok"} { + if !strings.Contains(expanded, want) { + t.Errorf("expansion is missing %q:\n%s", want, expanded) + } + } + + // Clicking the open row closes it again. + reclicked, _, _ := updated.handleTranscriptSelectionMouse(click) + if reclicked.expandedAgent != "" { + t.Errorf("a second click must close the row, got %q", reclicked.expandedAgent) + } +} + +// THE OFFSET CONSTRAINT. sidebarPlanSelectables and sidebarFileSelectables both +// derive their base from len(sidebarAgentLines), so an expansion that adds rows +// must move every click target below it. Getting this wrong sends a click to a +// different file than the one under the cursor, with nothing to indicate it. +func TestExpandingAnAgentMovesTheClickTargetsBelowIt(t *testing.T) { + now := time.Unix(20000, 0) + m := specialistSidebarModel(t, now) + width := sidebarWidth(m.width) + + // The rendered sidebar is the oracle: whatever a hit points at must be the + // row it claims, in both states. + check := func(t *testing.T, m model, label string) { + t.Helper() + lines := m.renderContextSidebar(width, m.height) + for _, hit := range m.sidebarPlanSelectables(width) { + line := plainRender(t, lines[hit.lineOffset]) + if !strings.Contains(line, m.plan.steps[hit.stepIndex].content) { + t.Errorf("%s: plan step %d points at %q", label, hit.stepIndex, line) + } + } + for _, hit := range m.sidebarFileSelectables(width) { + line := plainRender(t, lines[hit.lineOffset]) + if !strings.Contains(line, path.Base(hit.path)) { + t.Errorf("%s: file hit %q points at %q", label, hit.path, line) + } + } + } + + check(t, m, "collapsed") + m.expandedAgent = "plantask_1" + check(t, m, "expanded") +} + +// Finishing a task swaps its card id for the child's real session id. A row the +// user had open must not collapse at the exact moment it gains a result. +func TestAnOpenAgentRowSurvivesTheSessionRename(t *testing.T) { + now := time.Unix(20000, 0) + m := specialistSidebarModel(t, now) + m.activeRunID = 1 + m.expandedAgent = "plantask_1" + + updated, _ := m.Update(planTaskDoneMsg{ + runID: 1, taskID: "cfg", cardKey: "plantask_1", dispatched: true, + sessionID: "specialist_real", status: specialistCompleted, outcome: "succeeded", + }) + got := updated.(model) + if got.expandedAgent != "specialist_real" { + t.Errorf("expandedAgent = %q, want it to follow the rename to the real session", got.expandedAgent) + } +} + +// A cancelled task explains itself, and NOT in red: the user stopped it, and +// colouring their own decision as a fault is what the ⊘ glyph already avoids. +func TestACancelledAgentShowsItsReason(t *testing.T) { + now := time.Unix(20000, 0) + m := specialistSidebarModel(t, now) + m.specialists.complete("plantask_1", specialistCancelled, 0, "cancelled: the run was stopped while this task was running", now) + m.expandedAgent = "plantask_1" + + rendered := strings.Join(m.sidebarAgentExpansion(m.sidebarSpecialists()[0], 30), "\n") + if !strings.Contains(plainRender(t, rendered), "cancelled") { + t.Errorf("a cancelled task must say why:\n%s", rendered) + } + if strings.Contains(rendered, zeroTheme.red.Render("cancelled")) { + t.Error("a cancelled task is not an error and must not be red") + } +} + +// A FIGURE THAT DOES NOT FIT IS DROPPED, NOT TRUNCATED. Prose ending in an +// ellipsis still reads as prose; a number ending in one reads as a different +// number, and this line exists to report a spend. +func TestSpendSegmentsDropRatherThanTruncate(t *testing.T) { + segments := []string{"1m10s", "3.4K tok", "12 tools"} + for name, tc := range map[string]struct { + width int + want string + }{ + "everything fits": {40, "1m10s · 3.4K tok · 12 tools"}, + "the last one does not": {23, "1m10s · 3.4K tok"}, + "only the first does": {10, "1m10s"}, + "not even the first": {3, ""}, + } { + t.Run(name, func(t *testing.T) { + got := fitSegments(segments, tc.width) + if got != tc.want { + t.Errorf("fitSegments(%d) = %q, want %q", tc.width, got, tc.want) + } + if lipgloss.Width(got) > tc.width { + t.Errorf("%q overflows %d cells", got, tc.width) + } + if strings.Contains(got, "…") { + t.Errorf("%q truncated a figure instead of dropping it", got) + } + }) + } +} diff --git a/internal/tui/transcript_selection.go b/internal/tui/transcript_selection.go index 8a70bf08e..adc5528a4 100644 --- a/internal/tui/transcript_selection.go +++ b/internal/tui/transcript_selection.go @@ -1321,6 +1321,18 @@ func (m model) handleTranscriptSelectionMouse(msg tea.MouseMsg) (model, tea.Cmd, // session, reusing the specialist-card subchat path. Checked before the // transcript hit-test since the sidebar is outside the chat column. if hit, ok := m.sidebarLineAtMouse(msg); ok { + if hit.expands { + // Toggles in place. A specialist row is keyed by its CARD, and a + // running plan task's card key is not a session id yet — there + // is nothing to drill into until it finishes, and the detail is + // most wanted before then. Clicking the open row closes it. + if m.expandedAgent == hit.sessionID { + m.expandedAgent = "" + } else { + m.expandedAgent = hit.sessionID + } + return m, nil, true + } // The subchat drill-in owns the whole (single-column) view; a file // drill-in can't meaningfully stay open behind it. m = m.exitFileView() From d70a4bb260fa3fa3bc249f893f92706dc3a6d39e Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:18:00 +0530 Subject: [PATCH 63/86] fix(tui): an agent with no card key must not expand itself and shove PLAN off the column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "" is the zero value on BOTH sides of the expansion test: expandedAgent when nothing is open, and childSessionID for a specialist keyed by a tool-call id the provider left blank. Compared bare, such a row is permanently expanded — nobody opened it and nobody can close it, because the hit that would toggle it is already guarded on a non-empty key and never records one. The consequence is not local. The sidebar renders AGENTS first and clips the whole column to its height, so four uninvited lines at the top push PLAN, FILES and ACTIVITY down and off the bottom. The mutation makes it plain: PLAN moves from line 2 to line 6. The guard now matches the hit's. A row that cannot be clicked open must not be able to open itself. --- internal/tui/sidebar.go | 9 ++++++++- internal/tui/sidebar_test.go | 37 ++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/internal/tui/sidebar.go b/internal/tui/sidebar.go index 2c09ee951..5830b9ab5 100644 --- a/internal/tui/sidebar.go +++ b/internal/tui/sidebar.go @@ -483,7 +483,14 @@ func (m model) sidebarAgentRows(width int) ([]string, []sidebarAgentHit) { lines = append(lines, " "+zeroTheme.faint.Render("↳ "+truncateStep(detail, maxInt(2, room-2)))) } } - if a.childSessionID == m.expandedAgent { + // NON-EMPTY, matching the hit above. "" is the zero value of BOTH sides: + // expandedAgent when nothing is open, and childSessionID for a specialist + // keyed by a tool-call id the provider left blank. Comparing them bare + // makes such a row permanently expanded — and since the sidebar clips to + // its height, four uninvited lines at the top push PLAN, FILES and + // ACTIVITY off the bottom. A row that cannot be clicked open must not be + // able to open itself. + if a.childSessionID != "" && a.childSessionID == m.expandedAgent { lines = append(lines, m.sidebarAgentExpansion(a, room)...) } } diff --git a/internal/tui/sidebar_test.go b/internal/tui/sidebar_test.go index 6aad3e619..42e2c133e 100644 --- a/internal/tui/sidebar_test.go +++ b/internal/tui/sidebar_test.go @@ -855,3 +855,40 @@ func TestSpendSegmentsDropRatherThanTruncate(t *testing.T) { }) } } + +// "" is the zero value on BOTH sides of the expansion test: expandedAgent when +// nothing is open, and childSessionID for a specialist keyed by a tool-call id +// the provider left blank. Compared bare, such a row is permanently expanded — +// and because the sidebar clips to its height, the uninvited lines push PLAN, +// FILES and ACTIVITY off the bottom of the column. +func TestAnAgentWithNoCardKeyNeverExpandsItself(t *testing.T) { + now := time.Unix(20000, 0) + m := sidebarTestModel() + m.now = func() time.Time { return now } + m.specialists.start("mystery", "a brief nobody asked to see", "", now.Add(-30*time.Second)) + if m.expandedAgent != "" { + t.Fatal("sanity check failed: nothing should be expanded") + } + + width := sidebarWidth(m.width) + rendered := plainRender(t, strings.Join(m.sidebarAgentLines(width), "\n")) + if strings.Contains(rendered, "nobody asked to see") { + t.Errorf("a row with no card key expanded itself against the zero value:\n%s", rendered) + } + if hits := m.sidebarAgentSelectables(width); len(hits) != 0 { + t.Errorf("a row with no card key is not clickable, so it has no way to be opened: %+v", hits) + } + + // And the sections below it keep their place. + lines := m.renderContextSidebar(width, m.height) + var planHeader int + for i, line := range lines { + if strings.HasPrefix(strings.TrimSpace(plainRender(t, line)), "PLAN") { + planHeader = i + break + } + } + if planHeader == 0 || planHeader > 4 { + t.Errorf("PLAN should sit just below a single-row AGENTS section, found it at line %d", planHeader) + } +} From 77127514bbe0011b2f9b06f995779cf368f80256 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:30:13 +0530 Subject: [PATCH 64/86] fix(tui): the PLAN section holds both plans, so the footer line can finally go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The screenshot showed the footer line still there, on a session that had been running for five minutes. The gate I added stands the inline panel down only when the sidebar's PLAN section is showing the orchestrate plan — and it never was, so the gate never fired and nothing had changed. update_plan and orchestrate are not alternatives. A zeromaxing turn routinely runs an update_plan checklist whose middle step IS "run this orchestrate plan", so both are live for the whole run. Every piece of orchestrate rendering in the sidebar — the task list AND the progress bar — was gated on update_plan being empty, so the running plan had no sidebar surface in the one configuration it actually appears in, and got pushed back into the footer panel it was supposed to have replaced. The section now draws update_plan's checklist and, beneath it, the running plan's own tally, bar and tasks. The outer plan is above because it is the outer plan. The running plan names itself, because the section header is already spent on update_plan's count and two stacked tallies otherwise read as one list. The bar moves inside sidebarPlanLines with everything else. It was drawn beside it and carried a correction term in sidebarFileSelectables to compensate — a standing invitation to the exact offset drift that function's comment warns about. One function now owns every line the section draws. sidebarOwnsOrchestrate keys on the section being COLLAPSED rather than on update_plan's presence: a section collapsed by a click shows a count and a hint, which is not a surface, so the plan goes back to the panel until it is reopened. Four mutations. M18 and M19 missed first: the file-offset loop iterated over a model with no touched files, and nothing asserted the bar at all. Both were passing for want of a fixture rather than for correctness. --- internal/tui/files_panel.go | 3 - internal/tui/orchestrate_panel.go | 8 +-- internal/tui/orchestrate_panel_test.go | 77 +++++++++++++++++++++++--- internal/tui/sidebar.go | 67 +++++++++++++++++++--- internal/tui/sidebar_plan_detail.go | 2 +- internal/tui/sidebar_plan_test.go | 32 ++++++++--- 6 files changed, 156 insertions(+), 33 deletions(-) diff --git a/internal/tui/files_panel.go b/internal/tui/files_panel.go index 7cfe5134a..624b72ffa 100644 --- a/internal/tui/files_panel.go +++ b/internal/tui/files_panel.go @@ -305,9 +305,6 @@ func (m model) sidebarFileSelectables(width int) []fileHit { if planBody == 0 { planBody = 1 // the "no active plan" placeholder occupies one line } - if m.orchestratePlanBar(width) != "" { - planBody++ // the orchestrate progress bar sits inside the PLAN section - } base := 1 + agentBody + 2 + planBody + 2 // sections above + (blank + FILES header) for i := range hits { hits[i].lineOffset += base diff --git a/internal/tui/orchestrate_panel.go b/internal/tui/orchestrate_panel.go index 24900a981..7c80804eb 100644 --- a/internal/tui/orchestrate_panel.go +++ b/internal/tui/orchestrate_panel.go @@ -320,10 +320,10 @@ func (m model) sidebarOwnsOrchestrate() bool { if m.orchestrate.isEmpty() || !m.sidebarActive() { return false } - // sidebarPlanLines renders update_plan's steps whenever it has any and falls - // through to the orchestrate task list only when it does not. With both live - // the plan has no sidebar surface, so the inline panel is still the one. - return m.plan.isEmpty() + // A section collapsed by a click on its header is not a surface: it shows a + // count and a hint to reopen. The plan goes back to the inline panel until + // it is opened again, rather than existing nowhere. + return !m.orchestrate.sidebarCollapsed } // renderOrchestratePanel draws the plan: one row per task, indented by diff --git a/internal/tui/orchestrate_panel_test.go b/internal/tui/orchestrate_panel_test.go index 7ff5fba00..4ee037e09 100644 --- a/internal/tui/orchestrate_panel_test.go +++ b/internal/tui/orchestrate_panel_test.go @@ -2,6 +2,7 @@ package tui import ( "context" + "path" "strings" "testing" "time" @@ -714,8 +715,8 @@ func TestTheInlinePanelIsStillTheSurfaceWithoutASidebar(t *testing.T) { m.width = 60 return m }, - "update_plan owns the sidebar's PLAN section": func(m model) model { - m.plan.steps = []planStep{{content: "wire it up", status: "in_progress"}} + "the sidebar's PLAN section was collapsed by a click": func(m model) model { + m.orchestrate.sidebarCollapsed = true return m }, } { @@ -772,16 +773,76 @@ func TestCtrlGActsOnWhicheverPlanSurfaceIsOnScreen(t *testing.T) { // THE CASE THAT SEPARATES THE TWO PREDICATES, and the reason the key was // moved off sidebarActive. The sidebar is up, so sidebarActive() is true — - // but update_plan has claimed its PLAN section, so the orchestrate plan is - // drawing inline. Keying off "is the sidebar up" cycles a selection in a - // list that is not on screen while the panel the user is looking at ignores - // the key. + // but its PLAN section is collapsed, so the plan is drawing inline instead. + // Keying off "is the sidebar up" cycles a selection in a list that is not on + // screen while the panel the user is looking at ignores the key. contended := planOwnedByTheSidebar(t) - contended.plan.steps = []planStep{{content: "wire it up", status: "in_progress"}} + contended.orchestrate.sidebarCollapsed = true if !contended.sidebarActive() { t.Fatal("sanity check failed: this case needs the sidebar UP and not owning the plan") } if got := press(contended); !got.orchestrate.expanded { - t.Error("the sidebar is up but update_plan has its PLAN section: ctrl+g must expand the panel that is actually drawing") + t.Error("the sidebar is up but its PLAN section is collapsed: ctrl+g must expand the panel that is actually drawing") + } +} + +// THE REPORTED CASE. A zeromaxing turn runs an update_plan checklist whose +// middle step is "run this orchestrate plan", so both are live at once. The +// section used to hand itself entirely to update_plan, so the running plan had +// no sidebar surface, sidebarOwnsOrchestrate() was false, and the footer line +// the user asked to be rid of stayed on screen for the whole run. +func TestTheFooterStandsDownWithBothPlansLive(t *testing.T) { + m := planOwnedByTheSidebar(t) + m.plan.steps = []planStep{ + {content: "Create the lab and copy packages in", status: "completed"}, + {content: "Run the orchestrate plan", status: "in_progress"}, + {content: "Write the docs", status: "pending"}, + } + // A touched file, so the FILES section below the plan block actually has + // click targets to check. Without one the offset assertions below iterate + // over an empty list and prove nothing. + m.transcript = append(m.transcript, transcriptRow{ + kind: rowToolResult, tool: "write_file", + changedFiles: []string{"internal/tui/sidebar.go"}, + detail: "Created internal/tui/sidebar.go", + }) + + if !m.sidebarOwnsOrchestrate() { + t.Fatal("the sidebar shows both plans, so it owns the running one") + } + if got := m.renderOrchestratePanel(100); got != "" { + t.Errorf("the sidebar is showing this plan; the footer must not repeat it:\n%s", got) + } + + // And the sidebar really is showing it — not merely claiming to. + width := sidebarWidth(m.width) + rendered := plainRender(t, strings.Join(m.renderContextSidebar(width, m.height), "\n")) + if !strings.Contains(rendered, "Run the orchestrate plan") { + t.Errorf("update_plan's checklist is missing:\n%s", rendered) + } + if !strings.Contains(rendered, "diamond") { + t.Errorf("the running plan is missing from the column that claims to own it:\n%s", rendered) + } + bar := m.orchestratePlanBar(width) + if bar == "" { + t.Error("the running plan's progress bar was suppressed by update_plan's presence") + } else if !strings.Contains(rendered, plainRender(t, bar)) { + t.Errorf("the bar exists but the column does not draw it:\n%s", rendered) + } + + // The click targets below the PLAN section still land where they point — + // the block added rows, and FILES derives its base from len(sidebarPlanLines). + lines := m.renderContextSidebar(width, m.height) + hits := m.sidebarFileSelectables(width) + if len(hits) == 0 { + t.Fatal("sanity check failed: no file hits to verify, so the offset check proves nothing") + } + for _, hit := range hits { + if hit.lineOffset >= len(lines) { + t.Fatalf("file hit %q points past the end of the column", hit.path) + } + if line := plainRender(t, lines[hit.lineOffset]); !strings.Contains(line, path.Base(hit.path)) { + t.Errorf("file hit %q points at %q", hit.path, line) + } } } diff --git a/internal/tui/sidebar.go b/internal/tui/sidebar.go index 5830b9ab5..5c80c1b85 100644 --- a/internal/tui/sidebar.go +++ b/internal/tui/sidebar.go @@ -759,9 +759,6 @@ func (m model) renderContextSidebar(width, height int) []string { // Collapsed by a click on the header: the count stays, the body goes. add(sidebarPlaceholder("collapsed — click PLAN to open", width)) case len(planLines) > 0: - if bar := m.orchestratePlanBar(width); bar != "" { - add(bar) - } lines = append(lines, planLines...) default: add(sidebarPlaceholder("no active plan", width)) @@ -933,15 +930,27 @@ func (m model) sidebarPlanHeader(width int) string { // status glyphs as the pinned panel (✓ done, • in-progress, ○ pending, ✗ // failed), reading m.plan directly so it stays in sync. Returns nil for an // empty plan (the caller then shows a placeholder). +// BOTH PLANS, NOT WHICHEVER CAME FIRST. update_plan and orchestrate are not +// alternatives — a zeromaxing turn routinely runs an update_plan checklist whose +// middle step IS "run this orchestrate plan", so both are live at once. The +// section used to hand itself entirely to update_plan whenever it had steps, +// which left the running plan with no sidebar surface at all and pushed it back +// into the footer panel it was supposed to have replaced. +// +// Everything the PLAN section draws is assembled HERE, including the progress +// bar. sidebarFileSelectables derives the FILES section's click offsets from +// len(sidebarPlanLines); anything drawn beside it needs its own correction term, +// and the one that used to exist for the bar was a standing invitation to drift. func (m model) sidebarPlanLines(width int) []string { + return append(m.updatePlanStepLines(width), m.sidebarOrchestrateBlock(width)...) +} + +// updatePlanStepLines renders the update_plan checklist, or nil when it has no +// steps. +func (m model) updatePlanStepLines(width int) []string { state := m.plan if state.isEmpty() { - // No update_plan steps, but an ORCHESTRATE plan may be running. Its - // lines belong in THIS function rather than beside the placeholder, - // because sidebarFileSelectables derives the FILES section's click - // offsets from len(sidebarPlanLines) — anything rendered outside it - // would silently misdirect every file hit by the number of lines added. - return m.sidebarOrchestrateLines(width) + return nil } room := maxInt(4, width-3) lines := make([]string, 0, len(state.steps)) @@ -966,6 +975,46 @@ func (m model) sidebarPlanLines(width int) []string { return lines } +// sidebarOrchestrateBlock is the running plan's place in the PLAN section: its +// progress bar and task list, and — only when update_plan steps sit above it — +// a naming line, so two stacked lists never read as one. +func (m model) sidebarOrchestrateBlock(width int) []string { + tasks := m.sidebarOrchestrateLines(width) + if len(tasks) == 0 { + return nil + } + var lines []string + if !m.plan.isEmpty() { + // The section header is already spent on update_plan's count, so the + // running plan carries its own name and tally here. Without it the two + // lists abut and "1/3" appears to describe nine tasks. + done, failed, _, _, _ := m.orchestrate.counts() + style := zeroTheme.accent + if failed > 0 { + style = zeroTheme.red + } else if done == len(m.orchestrate.tasks) { + style = zeroTheme.green + } + name := strings.TrimSpace(m.orchestrate.name) + if name == "" { + name = "plan" + } + count := style.Render(fmt.Sprintf("%d/%d", done, len(m.orchestrate.tasks))) + // Count flush right, the way the section headers above it sit, so the two + // tallies line up in a column instead of floating mid-row. + label := zeroTheme.faint.Render(truncateStep(name, maxInt(4, width-2-lipgloss.Width(count)))) + gap := width - 1 - lipgloss.Width(label) - lipgloss.Width(count) + if gap < 1 { + gap = 1 + } + lines = append(lines, " "+label+strings.Repeat(" ", gap)+count) + } + if bar := m.orchestratePlanBar(width); bar != "" { + lines = append(lines, bar) + } + return append(lines, tasks...) +} + // maxSidebarActivityLines caps the ACTIVITY feed so it stays a glanceable tail, // not a scrolling log. const maxSidebarActivityLines = 5 diff --git a/internal/tui/sidebar_plan_detail.go b/internal/tui/sidebar_plan_detail.go index eb78f146f..99f774e44 100644 --- a/internal/tui/sidebar_plan_detail.go +++ b/internal/tui/sidebar_plan_detail.go @@ -271,7 +271,7 @@ func (m model) cycleOrchestrateSelection() model { // It occupies ONE line, and sidebarOrchestrateSelectables accounts for it, so // the click offsets below stay correct. func (m model) orchestratePlanBar(width int) string { - if !m.plan.isEmpty() || m.orchestrate.isEmpty() || m.orchestrate.sidebarCollapsed { + if m.orchestrate.isEmpty() || m.orchestrate.sidebarCollapsed { return "" } return sidebarProgressBar(m.orchestrate, width) diff --git a/internal/tui/sidebar_plan_test.go b/internal/tui/sidebar_plan_test.go index 5507d3d95..1e2f7126a 100644 --- a/internal/tui/sidebar_plan_test.go +++ b/internal/tui/sidebar_plan_test.go @@ -93,18 +93,32 @@ func TestTheSidebarPlanHeaderShowsProgress(t *testing.T) { } } -// update_plan's own steps still win the section when it has any: the two are -// different things, and the TODO list is the one the model is actively editing. -func TestUpdatePlanStepsStillWinTheSection(t *testing.T) { +// BOTH PLANS SHARE THE SECTION, in that order. They are not alternatives: a +// zeromaxing turn routinely runs an update_plan checklist whose middle step is +// "run this orchestrate plan", so both are live at once. The section used to +// hand itself entirely to update_plan, which left the running plan with no +// sidebar surface and pushed it back into the footer panel it was meant to +// replace. +func TestTheSectionHoldsBothPlansAtOnce(t *testing.T) { m := sidebarPlanModel(t) m.plan.steps = []planStep{{content: "a step of the model's own plan", status: "in_progress"}} - rendered := strings.Join(m.sidebarPlanLines(34), "\n") - if !strings.Contains(ansi.Strip(rendered), "a step of the model") { + rendered := ansi.Strip(strings.Join(m.sidebarPlanLines(34), "\n")) + stepAt := strings.Index(rendered, "a step of the model") + taskAt := strings.Index(rendered, "root") + if stepAt < 0 { t.Fatalf("update_plan's steps must still render:\n%s", rendered) } - if strings.Contains(ansi.Strip(rendered), "root") { - t.Fatalf("the orchestrate plan must not displace them:\n%s", rendered) + if taskAt < 0 { + t.Fatalf("the running plan must have a surface here too:\n%s", rendered) + } + if stepAt > taskAt { + t.Errorf("update_plan's checklist is the outer plan and belongs above:\n%s", rendered) + } + // Two tallies stacked without a name reads as one list; the running plan + // carries its own, because the section header is spent on update_plan's. + if !strings.Contains(rendered, "diamond") { + t.Errorf("the running plan must name itself under a borrowed header:\n%s", rendered) } } @@ -119,8 +133,10 @@ func TestALongPlanIsBoundedInTheSidebar(t *testing.T) { m := model{now: func() time.Time { return time.Unix(1000, 0) }} m.orchestrate.admit(msg, m.now()) + // The block is the progress bar, at most maxSidebarOrchestrateLines tasks, + // and the note saying what it dropped. lines := m.sidebarPlanLines(34) - if len(lines) > maxSidebarOrchestrateLines+1 { + if len(lines) > maxSidebarOrchestrateLines+2 { t.Fatalf("the sidebar drew %d lines for a 20-task plan", len(lines)) } if !strings.Contains(ansi.Strip(strings.Join(lines, "\n")), "more") { From f08ee30643ebbb22c9311a9862bb52e9e9411ee8 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:37:38 +0530 Subject: [PATCH 65/86] =?UTF-8?q?fix(tui):=20a=20running=20task=20is=20wor?= =?UTF-8?q?k=20underway,=20not=20progress=20=E2=80=94=20the=20bar=20must?= =?UTF-8?q?=20not=20fill=20for=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four of nine tasks dispatched, none finished, and the bar was 44% filled with the same solid block a completed task gets — sitting directly beside its own "0/9". The bar contradicted its own number, and the number was the one telling the truth. done, failed, skipped and cancelled are TERMINAL: the task is not coming back and the bar can spend a full block on it. Running is not a fraction of done. It now draws as a half-shade, which ranks correctly between the settled blocks and the pending track and cannot be misread as either. nothing started ░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0/9 4 running, 0 done ▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░░░░░░ 0/9 3 done, 2 running █████████▓▓▓▓▓▓░░░░░░░░░░░░░ 3/9 all 9 done ████████████████████████████ 9/9 --- internal/tui/orchestrate_panel.go | 17 ++++++--- internal/tui/orchestrate_panel_test.go | 51 ++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/internal/tui/orchestrate_panel.go b/internal/tui/orchestrate_panel.go index 7c80804eb..eba13146b 100644 --- a/internal/tui/orchestrate_panel.go +++ b/internal/tui/orchestrate_panel.go @@ -652,14 +652,21 @@ func sidebarProgressBar(state orchestratePanelState, width int) string { // zero cells is a failure the bar does not show. return maxInt(1, count*cells/total) } + // SOLID MEANS SETTLED. done, failed, skipped and cancelled are terminal — the + // task is not coming back, and the bar can spend a full block on it. Running + // is not progress, it is work underway, and drawing it with the same solid + // block made a plan with four of nine dispatched and NOTHING finished look + // 44% complete beside its own "0/9". The half-shade ranks correctly between + // the settled blocks and the pending track, and cannot be misread as either. segments := []struct { count int + mark string style lipgloss.Style }{ - {done, zeroTheme.green}, - {failed, zeroTheme.red}, - {skipped + cancelled, zeroTheme.muted}, - {running, zeroTheme.accent}, + {done, "█", zeroTheme.green}, + {failed, "█", zeroTheme.red}, + {skipped + cancelled, "█", zeroTheme.muted}, + {running, "▓", zeroTheme.accent}, } var b strings.Builder @@ -672,7 +679,7 @@ func sidebarProgressBar(state orchestratePanelState, width int) string { if n <= 0 { continue } - b.WriteString(segment.style.Render(strings.Repeat("█", n))) + b.WriteString(segment.style.Render(strings.Repeat(segment.mark, n))) used += n } if used < cells { diff --git a/internal/tui/orchestrate_panel_test.go b/internal/tui/orchestrate_panel_test.go index 4ee037e09..be2eda61b 100644 --- a/internal/tui/orchestrate_panel_test.go +++ b/internal/tui/orchestrate_panel_test.go @@ -2,6 +2,7 @@ package tui import ( "context" + "fmt" "path" "strings" "testing" @@ -846,3 +847,53 @@ func TestTheFooterStandsDownWithBothPlansLive(t *testing.T) { } } } + +// SOLID MEANS SETTLED. A plan with four of nine tasks dispatched and nothing +// finished drew a bar 44% filled with the same solid block a completed task +// gets, directly beside its own "0/9". The bar contradicted its own number, and +// the number was the one telling the truth. +func TestTheBarDoesNotCountRunningTasksAsProgress(t *testing.T) { + now := time.Unix(1000, 0) + admit := func() model { + m := model{now: func() time.Time { return now }} + var tasks []planGraphTask + for i := 0; i < 9; i++ { + tasks = append(tasks, planGraphTask{id: fmt.Sprintf("t%d", i)}) + } + m.orchestrate.admit(planAdmittedMsg{runID: 1, name: "pkg-audit", taskCount: 9, tasks: tasks}, now) + return m + } + + running := admit() + for i := 0; i < 4; i++ { + running.orchestrate.markStarted(fmt.Sprintf("t%d", i), "s", "", now) + } + bar := plainRender(t, sidebarProgressBar(running.orchestrate, 36)) + if strings.Contains(bar, "█") { + t.Errorf("nothing has finished, so no cell may be solid: %q", bar) + } + if !strings.Contains(bar, "▓") { + t.Errorf("four tasks are underway and the bar should say so: %q", bar) + } + if !strings.Contains(bar, "0/9") { + t.Errorf("the count must still read 0/9: %q", bar) + } + + // A finished task IS solid, so the two are told apart at a glance. + mixed := admit() + for i := 0; i < 3; i++ { + mixed.orchestrate.markStarted(fmt.Sprintf("t%d", i), "s", "", now) + mixed.orchestrate.markDone(fmt.Sprintf("t%d", i), "succeeded", 0, 1, now) + } + mixed.orchestrate.markStarted("t3", "s", "", now) + bar = plainRender(t, sidebarProgressBar(mixed.orchestrate, 36)) + if !strings.Contains(bar, "█") || !strings.Contains(bar, "▓") { + t.Errorf("three done and one running must show both marks: %q", bar) + } + if solid, shade := strings.Index(bar, "█"), strings.Index(bar, "▓"); solid > shade { + t.Errorf("settled work comes first in the bar: %q", bar) + } + if !strings.Contains(bar, "3/9") { + t.Errorf("the count must read 3/9: %q", bar) + } +} From 197cdcc56bf60e105187628febcdd0ba5047cdb6 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:53:44 +0530 Subject: [PATCH 66/86] feat(tui): a toggle for finished agents, and each one opens on what it produced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plan's finished tasks ARE its result, and they vanished a second and a half after each one landed. A nine-task run that succeeded showed an empty AGENTS section, with no way left to ask what any of them did. The header carries the control: "AGENTS 1 · ▸2 done". Clicking it keeps the finished agents for the session; clicking a finished agent opens it. AGENTS 1 · ▸2 done AGENTS 3 · ▾2 done ⠋ d-report ✓ a-reltime You are auditing package 10s · 22.8K tok pkg/reltime: 3 findings. reltime.go:41 — Parse ign… … ✓ a-fsutil ⠋ d-report WHAT IT PRODUCED is new data, not a new rendering. TaskResult.Output has always held the child's full answer verbatim and the TUI never saw it: planTaskDoneMsg carried the outcome, the spend and the error, so a finished row could report everything except the thing the user ran the agent for. It is bounded to 2000 bytes at the bridge — the report task in a nine-task plan returned a hundred thousand tokens of output, and carrying that through the event loop to show four lines would be paying megabytes for text nothing reads. The whole answer is still in the child's session, which the row's drill-in opens. The toggle is registered independently of the agent rows. Hung off them it would have been unclickable in exactly the state it exists for: when every agent has finished, there are no rows. Six mutations, all caught. The output is tested bridge → message → Update rather than by calling setResult, because testing the setter would pass with the bridge sending nothing at all. --- internal/tui/model.go | 5 + internal/tui/mouse.go | 2 +- internal/tui/plan_messages.go | 5 + internal/tui/plan_progress.go | 27 ++++++ internal/tui/plan_progress_test.go | 70 ++++++++++++++ internal/tui/sidebar.go | 95 ++++++++++++++++++- internal/tui/sidebar_test.go | 134 +++++++++++++++++++++++++++ internal/tui/specialist_card.go | 17 ++++ internal/tui/transcript_selection.go | 4 + 9 files changed, 354 insertions(+), 5 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index c5ade8b60..26fb5e1cd 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -214,6 +214,10 @@ type model struct { // orchestrateSelected is the plan task the sidebar's TASK section details. // Clicking a task row in the sidebar sets it; ctrl+g cycles it. orchestrateSelected int + // showDoneAgents keeps finished agents in the AGENTS section instead of + // dropping them after their linger. Off by default: during a run the live + // agents are the news. Toggled by clicking the header's "N done". + showDoneAgents bool // expandedAgent is the AGENTS row showing its brief and spend, keyed by the // specialist's card id. One at a time: the section shares its column with // PLAN, FILES and ACTIVITY, and every row expanded at once would be a @@ -2729,6 +2733,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { } m.specialists.complete(cardKey, msg.status, 0, msg.reason, m.now()) m.specialists.setTokens(cardKey, msg.tokens) + m.specialists.setResult(cardKey, msg.output) if msg.sessionID != "" && msg.sessionID != cardKey { // The expansion follows the rename. It is keyed by the card id, and // finishing swaps that for the child's real session id — so a row diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go index f7f697f40..e962aca90 100644 --- a/internal/tui/mouse.go +++ b/internal/tui/mouse.go @@ -285,7 +285,7 @@ func (m model) sidebarLineAtMouse(msg tea.MouseMsg) (sidebarAgentHit, bool) { return sidebarAgentHit{}, false } for _, hit := range m.sidebarAgentSelectables(sidebarW) { - if hit.lineOffset == y && hit.sessionID != "" { + if hit.lineOffset == y && (hit.sessionID != "" || hit.toggleDone) { return hit, true } } diff --git a/internal/tui/plan_messages.go b/internal/tui/plan_messages.go index e7fd8fabf..3bf4d777b 100644 --- a/internal/tui/plan_messages.go +++ b/internal/tui/plan_messages.go @@ -67,6 +67,11 @@ type planTaskDoneMsg struct { status specialistStatus outcome string reason string + // output is what the task produced, bounded at the bridge. Until this + // existed the TUI knew a task had finished and what it cost, but never what + // it actually returned — so a finished agent row could report everything + // except the thing the user ran it for. + output string // tokens is what the task actually spent. The card omits the segment when // it is zero rather than reporting a total nobody measured. tokens int diff --git a/internal/tui/plan_progress.go b/internal/tui/plan_progress.go index c11d6e2fb..7093c8d5e 100644 --- a/internal/tui/plan_progress.go +++ b/internal/tui/plan_progress.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" "sync" + "unicode/utf8" tea "charm.land/bubbletea/v2" @@ -360,6 +361,30 @@ func (bridge *PlanProgressBridge) send(build func(runID int) tea.Msg) { // so it can never collide with a tool call id. func planTaskKey(n int) string { return fmt.Sprintf("plantask_%d", n) } +// planTaskOutputLimit bounds what a task's result contributes to the model. +// +// TaskResult.Output is the child's FULL answer and is deliberately not +// truncated at the tool boundary — the report task in a nine-task plan returned +// a hundred thousand tokens of it. The sidebar shows a few lines; carrying the +// rest through the event loop and holding it per task for the life of the +// session would be paying megabytes for text nothing reads. The whole answer is +// still in the child's session, which the row's drill-in opens. +const planTaskOutputLimit = 2000 + +func boundTaskOutput(output string) string { + output = strings.TrimSpace(output) + if len(output) <= planTaskOutputLimit { + return output + } + // Cut on a rune boundary: a half-written multibyte character renders as a + // replacement glyph, which reads as corruption rather than truncation. + cut := planTaskOutputLimit + for cut > 0 && !utf8.RuneStart(output[cut]) { + cut-- + } + return strings.TrimSpace(output[:cut]) + "…" +} + // PlanAdmitted announces the plan so the transcript can show that N tasks are // about to run rather than going silent until the first one finishes. func (bridge *PlanProgressBridge) PlanAdmitted(plan specialist.Plan) { @@ -469,6 +494,7 @@ func (bridge *PlanProgressBridge) finish(result specialist.TaskResult, status sp // handler creates the card on demand. taskID, sessionID, reason := result.ID, result.SessionID, result.Err outcome, tokens, attempts := result.Outcome, result.Tokens, result.Attempts + output := boundTaskOutput(result.Output) bridge.send(func(runID int) tea.Msg { return planTaskDoneMsg{ runID: runID, @@ -481,6 +507,7 @@ func (bridge *PlanProgressBridge) finish(result specialist.TaskResult, status sp reason: reason, tokens: tokens, attempts: attempts, + output: output, background: bridge.isBackground(), } }) diff --git a/internal/tui/plan_progress_test.go b/internal/tui/plan_progress_test.go index 3b24d03e5..d8579f5c8 100644 --- a/internal/tui/plan_progress_test.go +++ b/internal/tui/plan_progress_test.go @@ -1,9 +1,11 @@ package tui import ( + "context" "strings" "testing" "time" + "unicode/utf8" tea "charm.land/bubbletea/v2" @@ -340,3 +342,71 @@ func TestAFinishedTasksCardIsNotResolvableByALaterPlan(t *testing.T) { t.Error("a skipped task resolved against the previous plan's card for the same id") } } + +// THE WIRING, not the pieces. TaskResult.Output has always held the child's full +// answer and the TUI never saw it: the model knew a task had finished and what +// it cost, but not what it returned — so a finished agent row could report +// everything except the thing the user ran it for. +// +// Driven bridge → message → Update, because testing setResult directly would +// pass with the bridge sending nothing at all. +func TestATasksOutputReachesTheAgentRow(t *testing.T) { + var got []tea.Msg + bridge := NewPlanProgressBridge() + bridge.Attach(func(msg tea.Msg) { got = append(got, msg) }, 7, nil, "") + + bridge.TaskDispatched(specialist.Task{ID: "a-reltime", Prompt: "audit pkg/reltime"}) + bridge.TaskCompleted(specialist.TaskResult{ + ID: "a-reltime", + Outcome: specialist.TaskSucceeded, + Output: "pkg/reltime: 3 findings.\nreltime.go:41 — Parse ignores the tz suffix", + Tokens: 22781, + }) + + m := newModel(context.Background(), Options{}) + m.activeRunID = 7 + for _, msg := range got { + updated, _ := m.Update(msg) + m = updated.(model) + } + + info, ok := m.specialists.getBySessionID("plantask_1") + if !ok { + t.Fatalf("no card for the finished task: %+v", m.specialists.all()) + } + if !strings.Contains(info.result, "Parse ignores the tz suffix") { + t.Errorf("the task's output never reached its card, got %q", info.result) + } +} + +// The child's answer is not budgeted for the event loop. The report task in a +// nine-task plan returned a hundred thousand tokens of it; carrying that through +// the loop and holding it per task for the session would be paying megabytes for +// text nothing reads. The whole answer stays in the child's session. +func TestATasksOutputIsBoundedBeforeItLeavesTheBridge(t *testing.T) { + long := strings.Repeat("finding line that goes on and on. ", 500) + if len(long) <= planTaskOutputLimit { + t.Fatalf("sanity check failed: the fixture must exceed the limit") + } + bounded := boundTaskOutput(long) + if len(bounded) > planTaskOutputLimit+len("…") { + t.Errorf("bounded output is %d bytes, limit is %d", len(bounded), planTaskOutputLimit) + } + if !strings.HasSuffix(bounded, "…") { + t.Errorf("a truncated answer must say it was truncated: %q", bounded[maxInt(0, len(bounded)-40):]) + } + if !utf8.ValidString(bounded) { + t.Error("the cut must land on a rune boundary") + } + + // A multibyte character straddling the limit must not be split in half. + multi := strings.Repeat("é", planTaskOutputLimit) + if b := boundTaskOutput(multi); !utf8.ValidString(b) { + t.Error("multibyte output was cut mid-rune") + } + + // Short output passes through whole, with no ellipsis implying loss. + if got := boundTaskOutput(" brief answer "); got != "brief answer" { + t.Errorf("short output = %q, want it trimmed and intact", got) + } +} diff --git a/internal/tui/sidebar.go b/internal/tui/sidebar.go index 5c80c1b85..c0e6d1615 100644 --- a/internal/tui/sidebar.go +++ b/internal/tui/sidebar.go @@ -210,8 +210,11 @@ func (m model) sidebarSpecialists() []specialistInfo { continue } // Linger a finished specialist for sidebarAgentLinger (a fading ✓), then - // drop it — a smooth exit rather than an abrupt pop. - if a.status != specialistRunning && !a.completedAt.IsZero() && + // drop it — a smooth exit rather than an abrupt pop. Unless the user has + // asked to keep them, in which case they stay for the session: a plan's + // finished tasks are its RESULT, and dropping them left a nine-task run + // showing an empty AGENTS section the moment it succeeded. + if !m.showDoneAgents && a.status != specialistRunning && !a.completedAt.IsZero() && m.now().Sub(a.completedAt) >= sidebarAgentLinger { continue } @@ -220,6 +223,25 @@ func (m model) sidebarSpecialists() []specialistInfo { return out } +// doneAgentCount is how many finished specialists the toggle would reveal — +// those past their linger, which the section would otherwise have dropped. +// +// Counted from the tracker rather than from sidebarSpecialists, because that +// already applies the toggle and would report zero whenever the toggle is on. +func (m model) doneAgentCount() int { + var n int + for _, a := range m.specialists.all() { + if a.status == specialistError && strings.Contains(strings.ToLower(a.errorMsg), "not found") { + continue + } + if a.status != specialistRunning && !a.completedAt.IsZero() && + m.now().Sub(a.completedAt) >= sidebarAgentLinger { + n++ + } + } + return n +} + // sidebarHasAgents reports whether the two-column sidebar is active AND has at // least one agent line to animate (a specialist delegation or a swarm member). // The spinner tick keeps firing while this holds so the cool swarm ripple on @@ -236,10 +258,29 @@ func (m model) sidebarHasAgents() bool { // active agents — specialist delegations plus swarm/team members. func (m model) sidebarAgentHeader(width int) string { n := len(m.sidebarSpecialists()) + len(m.swarmSpawnedAgents()) - if n == 0 { + count := "" + if n > 0 { + count = fmt.Sprintf("%d", n) + } + // The finished-agents toggle rides the header's count. A plan's finished + // tasks are its RESULT, and they used to vanish a second and a half after + // each one landed — so a nine-task run that succeeded showed an empty + // AGENTS section and no way to ask what any of them did. + if done := m.doneAgentCount(); done > 0 || m.showDoneAgents { + mark := fmt.Sprintf("▸%d done", done) + if m.showDoneAgents { + mark = fmt.Sprintf("▾%d done", done) + } + if count != "" { + count += " · " + mark + } else { + count = mark + } + } + if count == "" { return sidebarHeader("AGENTS", width) } - return sidebarHeaderWithCount("AGENTS", fmt.Sprintf("%d", n), zeroTheme.muted, width) + return sidebarHeaderWithCount("AGENTS", count, zeroTheme.muted, width) } // swarmSpawnRe extracts a member id from a swarm_spawn tool result, whose text @@ -399,6 +440,8 @@ type sidebarAgentHit struct { lineOffset int sessionID string title string + // toggleDone marks the header's finished-agents control rather than an agent. + toggleDone bool // expands marks a row whose click TOGGLES ITS DETAIL in place rather than // drilling into a child session. Specialist rows are keyed by their card, // and a plan task's card key is not a session id until the task finishes and @@ -533,6 +576,11 @@ func (m model) sidebarAgentRows(width int) ([]string, []sidebarAgentHit) { // prompt belongs in the task's card, not in a 26-cell column. const sidebarAgentExpansionBriefLines = 2 +// sidebarAgentExpansionResultLines caps the head of the agent's own output. Four +// lines is a look at what it produced, not a reader for it — the whole answer is +// in the child's session, which the card's drill-in opens. +const sidebarAgentExpansionResultLines = 4 + // fitSegments joins as many " · "-separated segments as fit in width and DROPS // the rest, rather than joining them all and truncating the result. // @@ -619,6 +667,26 @@ func (m model) sidebarAgentExpansion(info specialistInfo, room int) []string { out = append(out, indent+zeroTheme.faint.Render(truncateStep(reason, body))) } } + + // WHAT IT PRODUCED, which is the thing the agent was run for. Everything + // above says how the work went; this is the work. Sanitised per line, since + // a child's answer is untrusted text and an ANSI escape in it would repaint + // the column. + if result := strings.TrimSpace(info.result); result != "" { + shown := 0 + for _, raw := range strings.Split(result, "\n") { + line := strings.TrimSpace(sanitizeCardText(raw)) + if line == "" { + continue + } + if shown >= sidebarAgentExpansionResultLines { + out = append(out, indent+zeroTheme.faint.Render("…")) + break + } + out = append(out, indent+zeroTheme.ink.Render(truncateStep(line, body))) + shown++ + } + } return out } @@ -632,9 +700,28 @@ func (m model) sidebarAgentSelectables(width int) []sidebarAgentHit { for i := range hits { hits[i].lineOffset++ // shift past the AGENTS header at sidebar index 0 } + // The header's toggle is registered INDEPENDENTLY of the rows. When every + // agent has finished and the toggle is off there are no rows at all, and + // hanging the control off them would make it unclickable in precisely the + // state it exists for. + if m.doneAgentCount() > 0 || m.showDoneAgents { + hits = append(hits, sidebarAgentHit{ + lineOffset: 0, + sessionID: agentsToggleHitID, + title: "completed agents", + toggleDone: true, + }) + } return hits } +// agentsToggleHitID keys the header control for hover resolution, which matches +// hits by session id. The NUL prefix is not decoration: it makes collision with +// a real session id — a uuid, a plantask key, a provider tool-call id — +// impossible rather than merely unlikely, and an id that collides would light +// the wrong row on hover. +const agentsToggleHitID = "\x00agents-done-toggle" + // agentExitFading reports whether a finished agent is in the later half of its // linger window (sidebarAgentLinger), so its row dims toward faint just before // it's removed. A zero finishedAt (not yet stamped) is not fading. diff --git a/internal/tui/sidebar_test.go b/internal/tui/sidebar_test.go index 42e2c133e..cf7887436 100644 --- a/internal/tui/sidebar_test.go +++ b/internal/tui/sidebar_test.go @@ -2,6 +2,7 @@ package tui import ( "context" + "fmt" "path" "strings" "testing" @@ -892,3 +893,136 @@ func TestAnAgentWithNoCardKeyNeverExpandsItself(t *testing.T) { t.Errorf("PLAN should sit just below a single-row AGENTS section, found it at line %d", planHeader) } } + +// doneAgentsModel: two finished specialists past their linger, one still +// running — the state a plan is in for most of its life. +func doneAgentsModel(t *testing.T, now time.Time) model { + t.Helper() + m := sidebarTestModel() + m.now = func() time.Time { return now } + for i, name := range []string{"a-reltime", "a-fsutil"} { + key := fmt.Sprintf("plantask_%d", i+1) + m.specialists.start(name, "You are auditing package "+name, key, now.Add(-40*time.Second)) + m.specialists.complete(key, specialistCompleted, 0, "", now.Add(-30*time.Second)) + m.specialists.setResult(key, "pkg: 3 findings.\nreltime.go:41 — Parse ignores the tz suffix") + } + m.specialists.start("d-report", "producing the report", "plantask_3", now.Add(-9*time.Second)) + return m +} + +// A plan's finished tasks ARE its result. They used to vanish a second and a +// half after each one landed, so a nine-task run that succeeded showed an empty +// AGENTS section and no way to ask what any of them did. +func TestTheDoneToggleRevealsFinishedAgents(t *testing.T) { + now := time.Unix(50000, 0) + m := doneAgentsModel(t, now) + width := sidebarWidth(m.width) + + if got := m.doneAgentCount(); got != 2 { + t.Fatalf("doneAgentCount = %d, want 2", got) + } + collapsed := plainRender(t, strings.Join(m.sidebarAgentLines(width), "\n")) + if strings.Contains(collapsed, "a-reltime") { + t.Errorf("finished agents stay hidden until asked for:\n%s", collapsed) + } + if header := plainRender(t, m.sidebarAgentHeader(width)); !strings.Contains(header, "2 done") { + t.Errorf("the header must advertise what the toggle would reveal: %q", header) + } + + // The toggle is clickable at the header row. + hits := m.sidebarAgentSelectables(width) + var toggle *sidebarAgentHit + for i := range hits { + if hits[i].toggleDone { + toggle = &hits[i] + } + } + if toggle == nil { + t.Fatal("the header's toggle must be clickable") + } + if toggle.lineOffset != 0 { + t.Fatalf("the toggle sits on the AGENTS header at offset 0, got %d", toggle.lineOffset) + } + + x0 := m.chatColumnWidth() + 3 + opened, _, handled := m.handleTranscriptSelectionMouse( + tea.MouseClickMsg{Button: tea.MouseLeft, X: x0, Y: 0}) + if !handled || !opened.showDoneAgents { + t.Fatalf("the click must open the finished list: handled=%v shown=%v", handled, opened.showDoneAgents) + } + shown := plainRender(t, strings.Join(opened.sidebarAgentLines(width), "\n")) + for _, want := range []string{"a-reltime", "a-fsutil", "d-report"} { + if !strings.Contains(shown, want) { + t.Errorf("expected %q in the opened list:\n%s", want, shown) + } + } + + // And it closes again. + closed, _, _ := opened.handleTranscriptSelectionMouse( + tea.MouseClickMsg{Button: tea.MouseLeft, X: x0, Y: 0}) + if closed.showDoneAgents { + t.Error("a second click must close the finished list") + } +} + +// THE STATE THE TOGGLE EXISTS FOR. When every agent has finished there are no +// rows left, so a control hung off the rows would be unclickable in precisely +// the case it is needed. +func TestTheDoneToggleIsClickableWithNoLiveAgents(t *testing.T) { + now := time.Unix(50000, 0) + m := doneAgentsModel(t, now) + m.specialists.complete("plantask_3", specialistCompleted, 0, "", now.Add(-30*time.Second)) + width := sidebarWidth(m.width) + + if len(m.sidebarAgentLines(width)) != 0 { + t.Fatal("sanity check failed: every agent has finished, so no rows remain") + } + hits := m.sidebarAgentSelectables(width) + if len(hits) != 1 || !hits[0].toggleDone { + t.Fatalf("the toggle must survive an empty list, got %+v", hits) + } + x0 := m.chatColumnWidth() + 3 + opened, _, handled := m.handleTranscriptSelectionMouse( + tea.MouseClickMsg{Button: tea.MouseLeft, X: x0, Y: 0}) + if !handled || !opened.showDoneAgents { + t.Fatal("clicking the toggle with no live agents must still open the list") + } + if got := len(opened.sidebarAgentLines(width)); got != 3 { + t.Errorf("expected all three finished agents, got %d rows", got) + } +} + +// WHAT IT PRODUCED, which is the thing the agent was run for. Every other line +// of the expansion says how the work went; this is the work. +func TestAFinishedAgentExpandsToShowWhatItProduced(t *testing.T) { + now := time.Unix(50000, 0) + m := doneAgentsModel(t, now) + m.showDoneAgents = true + width := sidebarWidth(m.width) + + hits := m.sidebarAgentSelectables(width) + var row *sidebarAgentHit + for i := range hits { + if hits[i].sessionID == "plantask_1" { + row = &hits[i] + } + } + if row == nil { + t.Fatalf("the finished agent's row must be clickable, got %+v", hits) + } + + x0 := m.chatColumnWidth() + 3 + opened, _, handled := m.handleTranscriptSelectionMouse( + tea.MouseClickMsg{Button: tea.MouseLeft, X: x0, Y: row.lineOffset}) + if !handled || opened.expandedAgent != "plantask_1" { + t.Fatalf("clicking a finished agent must expand it, got %q", opened.expandedAgent) + } + shown := plainRender(t, strings.Join(opened.sidebarAgentLines(width), "\n")) + // Checked against what a 30-cell column actually renders: the result wraps + // nothing and truncates per line, so assert on the head of each. + for _, want := range []string{"3 findings", "reltime.go:41"} { + if !strings.Contains(shown, want) { + t.Errorf("the expansion must show what the agent produced (missing %q):\n%s", want, shown) + } + } +} diff --git a/internal/tui/specialist_card.go b/internal/tui/specialist_card.go index 27dd4eed3..ad3e26a1a 100644 --- a/internal/tui/specialist_card.go +++ b/internal/tui/specialist_card.go @@ -47,6 +47,10 @@ type specialistInfo struct { tokenCount int // total tokens consumed currentTool string currentDetail string + // result is what the agent PRODUCED, bounded at the bridge. The sidebar + // shows the head of it when its row is expanded; the whole thing lives in + // the child's own session, which the card's drill-in opens. + result string } // specialistTracker holds the live state for every specialist the parent agent @@ -111,6 +115,19 @@ func (t *specialistTracker) setTokens(childSessionID string, tokens int) { } } +// setResult records what a finished agent produced. +func (t *specialistTracker) setResult(childSessionID, result string) { + if strings.TrimSpace(result) == "" { + return + } + for index := range t.specialists { + if t.specialists[index].childSessionID == childSessionID { + t.specialists[index].result = result + return + } + } +} + func (t *specialistTracker) incrementToolCount(childSessionID string) { for index := range t.specialists { if t.specialists[index].childSessionID == childSessionID { diff --git a/internal/tui/transcript_selection.go b/internal/tui/transcript_selection.go index adc5528a4..e52ac740d 100644 --- a/internal/tui/transcript_selection.go +++ b/internal/tui/transcript_selection.go @@ -1321,6 +1321,10 @@ func (m model) handleTranscriptSelectionMouse(msg tea.MouseMsg) (model, tea.Cmd, // session, reusing the specialist-card subchat path. Checked before the // transcript hit-test since the sidebar is outside the chat column. if hit, ok := m.sidebarLineAtMouse(msg); ok { + if hit.toggleDone { + m.showDoneAgents = !m.showDoneAgents + return m, nil, true + } if hit.expands { // Toggles in place. A specialist row is keyed by its CARD, and a // running plan task's card key is not a session id yet — there From 648c0d0ee59f58a78d01079baeb7a5699df73eac Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:00:16 +0530 Subject: [PATCH 67/86] fix(tui): a sidebar row that was never drawn must not be clickable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The column clips to height-1 and pins the token readout at that last row, but the selectable tables are built from the full section heights. Only fileRowAtMouse checked for this, inline; the plan, orchestrate and agent tables did not — so a click on the token readout opened whichever row had been pushed underneath it, and hovering it highlighted the readout as though it were one. Expanding an agent is what makes it reachable in a single click. On an 11-row terminal with a four-step plan: before after one click on the agent row 6 |PLAN 2/4| 6 |PLAN 2/4| 7 | ✓ alpha | 7 | ✓ alpha | 8 | ✓ bravo | 8 | ✓ bravo | 9 | • charlie | 9 | • charlie | 10 | ○ delta | 10 | 0 tokens | <- clicking here opened "delta" The rule now lives in one predicate and is applied to the TABLES rather than to each hit-test, so hover and every future consumer inherit it instead of each having to remember. fileRowAtMouse's inline copy is gone with it: two statements of one rule is how they drift apart. It fails OPEN on an unmeasured terminal. Filtering the whole table before the first WindowSizeMsg changes nothing in production — every consumer already requires a live sidebar — but it made the offset tables untestable in isolation, which is how the existing TestSidebarPlanSelectablesOffsets caught me. Also repairs a gap the shared PLAN section opened: sidebarOrchestrateSelectables returned nil whenever update_plan had steps, so the running plan's rows rendered but could not be clicked, and its base ignored the checklist, the naming line and the bar above them. The prefix is now MEASURED off the renderers rather than re-derived from the conditions that produce them — the bar already had a hand-written correction term here, and a second one for the naming line would have been a second chance to disagree with what was drawn. Found by an adversarial review pass, with a reproduction. Four mutations. --- internal/tui/files_panel.go | 12 +++-- internal/tui/plan_step_detail.go | 4 +- internal/tui/sidebar.go | 35 ++++++++++++- internal/tui/sidebar_plan_detail.go | 26 +++++----- internal/tui/sidebar_test.go | 78 +++++++++++++++++++++++++++++ 5 files changed, 137 insertions(+), 18 deletions(-) diff --git a/internal/tui/files_panel.go b/internal/tui/files_panel.go index 624b72ffa..69cafba56 100644 --- a/internal/tui/files_panel.go +++ b/internal/tui/files_panel.go @@ -306,10 +306,14 @@ func (m model) sidebarFileSelectables(width int) []fileHit { planBody = 1 // the "no active plan" placeholder occupies one line } base := 1 + agentBody + 2 + planBody + 2 // sections above + (blank + FILES header) - for i := range hits { - hits[i].lineOffset += base + kept := hits[:0] + for _, hit := range hits { + hit.lineOffset += base + if m.sidebarRowOnScreen(hit.lineOffset) { + kept = append(kept, hit) + } } - return hits + return kept } // fileRowAtMouse maps a left-click in the context sidebar to a touched file, @@ -332,7 +336,7 @@ func (m model) fileRowAtMouse(msg tea.MouseMsg) (string, bool) { return "", false } for _, hit := range m.sidebarFileSelectables(sidebarW) { - if hit.lineOffset == y && hit.lineOffset < m.height-1 && hit.path != "" { + if hit.lineOffset == y && hit.path != "" { return hit.path, true } } diff --git a/internal/tui/plan_step_detail.go b/internal/tui/plan_step_detail.go index 9b8c19034..04c3dafd7 100644 --- a/internal/tui/plan_step_detail.go +++ b/internal/tui/plan_step_detail.go @@ -115,7 +115,9 @@ func (m model) sidebarPlanSelectables(width int) []planStepHit { base := 1 + agentBody + 2 // AGENTS header + body + (blank line + PLAN header) hits := make([]planStepHit, 0, len(m.plan.steps)) for i := range m.plan.steps { - hits = append(hits, planStepHit{lineOffset: base + i, stepIndex: i}) + if offset := base + i; m.sidebarRowOnScreen(offset) { + hits = append(hits, planStepHit{lineOffset: offset, stepIndex: i}) + } } return hits } diff --git a/internal/tui/sidebar.go b/internal/tui/sidebar.go index c0e6d1615..0bbf772a5 100644 --- a/internal/tui/sidebar.go +++ b/internal/tui/sidebar.go @@ -712,7 +712,40 @@ func (m model) sidebarAgentSelectables(width int) []sidebarAgentHit { toggleDone: true, }) } - return hits + kept := hits[:0] + for _, hit := range hits { + if m.sidebarRowOnScreen(hit.lineOffset) { + kept = append(kept, hit) + } + } + return kept +} + +// sidebarRowOnScreen reports whether a sidebar offset names a row the user can +// actually see. +// +// renderContextSidebar clips the column to height-1 and pins the token readout +// at that last row, but the selectable lists are computed from the FULL section +// heights — so any offset at or past the clip names a row that was never drawn. +// Only fileRowAtMouse checked this, inline; the plan, orchestrate and agent +// hit-testers did not, and a click on the token readout opened whichever row had +// been pushed underneath it. +// +// Applied to the LISTS rather than to each hit-test, so hover resolution and +// every future consumer inherit it instead of each having to remember. +func (m model) sidebarRowOnScreen(lineOffset int) bool { + if lineOffset < 0 { + return false + } + if m.height <= 0 { + // No measured terminal yet, so nothing is known to be off screen. Fail + // OPEN here rather than closed: every hit-test that consumes these + // tables already requires a live sidebar, and a filter that swallowed + // the whole table before the first WindowSizeMsg would make the offsets + // untestable in isolation while changing nothing in production. + return true + } + return lineOffset < m.height-1 } // agentsToggleHitID keys the header control for hover resolution, which matches diff --git a/internal/tui/sidebar_plan_detail.go b/internal/tui/sidebar_plan_detail.go index 99f774e44..8fe22394d 100644 --- a/internal/tui/sidebar_plan_detail.go +++ b/internal/tui/sidebar_plan_detail.go @@ -35,27 +35,29 @@ type orchestrateTaskHit struct { // (the live-task filter, the six-line cap) is never clickable — an offset table // built from the full task list would map clicks onto rows that are not there. func (m model) sidebarOrchestrateSelectables(width int) []orchestrateTaskHit { - if m.orchestrate.isEmpty() || !m.plan.isEmpty() { - // update_plan's steps own the section when it has any, and they have - // their own hit table. + if m.orchestrate.isEmpty() { return nil } agentBody := len(m.sidebarAgentLines(width)) if agentBody == 0 { agentBody = 1 } - base := 1 + agentBody + 2 - if m.orchestratePlanBar(width) != "" { - // The progress bar sits between the header and the first task, so every - // row below it moves down one. An offset table that ignored it would - // select the task ABOVE the one clicked. - base++ - } + // Everything the PLAN section draws ABOVE the first task row: update_plan's + // checklist when it has one, then the running plan's naming line and its + // progress bar. MEASURED off the renderers rather than re-derived from the + // conditions that produce them — the bar already had its own hand-written + // correction term here, and a second one for the naming line would be a + // second chance to disagree with what was actually drawn. + steps := len(m.updatePlanStepLines(width)) + prefix := len(m.sidebarOrchestrateBlock(width)) - len(m.sidebarOrchestrateLines(width)) + base := 1 + agentBody + 2 + steps + prefix rows := m.sidebarOrchestrateRows() hits := make([]orchestrateTaskHit, 0, len(rows)) for offset, index := range rows { - hits = append(hits, orchestrateTaskHit{lineOffset: base + offset, taskIndex: index}) + if lineOffset := base + offset; m.sidebarRowOnScreen(lineOffset) { + hits = append(hits, orchestrateTaskHit{lineOffset: lineOffset, taskIndex: index}) + } } return hits } @@ -112,7 +114,7 @@ func (m model) orchestrateTaskAtMouse(msg tea.MouseMsg) (int, bool) { // orchestrateHeaderAtMouse reports a click on the sidebar's PLAN header, which // collapses and expands the whole section. func (m model) orchestrateHeaderAtMouse(msg tea.MouseMsg) bool { - if !m.sidebarActive() || m.orchestrate.isEmpty() || !m.plan.isEmpty() { + if !m.sidebarActive() || m.orchestrate.isEmpty() { return false } sidebarW := sidebarWidth(m.width) diff --git a/internal/tui/sidebar_test.go b/internal/tui/sidebar_test.go index cf7887436..fb8d06511 100644 --- a/internal/tui/sidebar_test.go +++ b/internal/tui/sidebar_test.go @@ -1026,3 +1026,81 @@ func TestAFinishedAgentExpandsToShowWhatItProduced(t *testing.T) { } } } + +// A ROW THAT WAS NEVER DRAWN CANNOT BE CLICKED. renderContextSidebar clips the +// column to height-1 and pins the token readout at that last row, but the +// selectable tables are built from the full section heights. Only fileRowAtMouse +// checked this, inline; the plan, orchestrate and agent tables did not — so a +// click on the token readout opened whichever row had been pushed under it. +// +// Expanding an agent is what makes it reachable in one click: the AGENTS body +// grows by up to four rows and shoves the bottom of PLAN off the column. +func TestAClickOnTheTokenReadoutSelectsNothing(t *testing.T) { + now := time.Unix(50000, 0) + m := sidebarTestModel() + m.height = 11 + m.now = func() time.Time { return now } + m.plan.steps = []planStep{ + {content: "alpha", status: "completed"}, + {content: "bravo", status: "completed"}, + {content: "charlie", status: "in_progress"}, + {content: "delta", status: "pending"}, + } + m.specialists.start("worker", "a brief long enough to wrap over two lines in the column", "plantask_1", now.Add(-30*time.Second)) + m.expandedAgent = "plantask_1" // one click on the agent row + + width := sidebarWidth(m.width) + lines := m.renderContextSidebar(width, m.height) + last := m.height - 1 + if got := plainRender(t, lines[last]); !strings.Contains(got, "tokens") { + t.Fatalf("sanity check failed: the last row should be the token readout, got %q", got) + } + // The expansion must actually have pushed a step off, or this proves nothing. + if len(m.plan.steps) <= len(m.sidebarPlanSelectables(width)) { + t.Fatalf("sanity check failed: no step was pushed off the column") + } + + x := m.chatColumnWidth() + 3 + 2 + if index, ok := m.planStepAtMouse(testMouseClick(tea.MouseLeft, x, last)); ok { + t.Errorf("clicking the token readout selected plan step %d, which is not drawn anywhere", index) + } + for _, hit := range m.sidebarPlanSelectables(width) { + if hit.lineOffset >= last { + t.Errorf("step %d is offered at offset %d, at or past the clip", hit.stepIndex, hit.lineOffset) + } + } + for _, hit := range m.sidebarAgentSelectables(width) { + if hit.lineOffset >= last { + t.Errorf("agent hit %q is offered at offset %d, at or past the clip", hit.title, hit.lineOffset) + } + } +} + +// The running plan's task rows are clickable when update_plan shares the +// section — and land on the task under the cursor. The offset table used to +// give up entirely whenever update_plan had steps, and its base did not account +// for the checklist, the naming line or the bar sitting above the first task. +func TestOrchestrateTaskClicksLandWithBothPlansInTheSection(t *testing.T) { + now := time.Unix(50000, 0) + m := sidebarTestModel() + m.now = func() time.Time { return now } + m.plan.steps = []planStep{ + {content: "set up the lab", status: "completed"}, + {content: "run the plan", status: "in_progress"}, + } + m.orchestrate.admit(diamondAdmitted(), now) + + width := sidebarWidth(m.width) + hits := m.sidebarOrchestrateSelectables(width) + if len(hits) == 0 { + t.Fatal("the running plan's rows must stay clickable when update_plan shares the section") + } + lines := m.renderContextSidebar(width, m.height) + for _, hit := range hits { + row := plainRender(t, lines[hit.lineOffset]) + want := m.orchestrate.tasks[hit.taskIndex].id + if !strings.Contains(row, want) { + t.Errorf("task %q is offered at offset %d, where the column reads %q", want, hit.lineOffset, row) + } + } +} From ac13ba3c291e752b6c3bc93f8fec4d4e4a624971 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:13:44 +0530 Subject: [PATCH 68/86] fix(specialist): one plan per surface, on the path the model drives too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from the review pass, and one root cause: the surface that shows a plan can hold exactly one, and only the path a USER drives enforced that. /plans restart already refused to start under a running plan, with a comment saying precisely why — "the panel is keyed by task id, the second would overwrite the first's rows". The orchestrate TOOL had no such gate, and the tool is the reachable path: a background plan returns immediately by design, so the model's very next call lands while it is still running. What that cost, both confirmed against the code: A foreground plan finishing cleared the shared background flag and cancel. The foreground plan was announced to the model as "the background plan finished" — for a plan that had already returned its summary inline — /plans stop then reported no plan running while the real one kept spending, and when the background plan genuinely finished its own completion saw the flag already false and told nobody. Spend nobody sees and no result, which is the failure mode that code's own comment names. And cardByTask is keyed by task id, unique WITHIN a plan and not between two. Two plans sharing an id as ordinary as "tests" would have one plan's dispatch overwrite the other's entry; the first completion closes the wrong row, and the card left behind can be closed by nothing at all — the forever-spinning agent from this morning's screenshot, back by another door. PlanSurfaceBusy is the optional CONCURRENCY half of a recorder, type-asserted like PlanController beside it, so a recorder that cannot answer — the headless path, which runs one plan per process and has no surface to contend for — is treated as free rather than blocked. The gate is checked AFTER parsing, so an invalid plan is still called invalid rather than blamed on the plan already running, and BEFORE admission, so a refused plan leaves no record of having started. The bridge reports busy on `cancelPlan != nil || background`, not on the cancel alone. The launcher sets background synchronously before starting the goroutine; the cancel arrives only when the executor reaches its first task. A gate reading the cancel alone would wave the next plan through the gap between them — which is exactly where the model's next call arrives. Both preconditions are now written where the code rests on them, so relaxing the gate cannot silently reintroduce either. Five compile-time assertions added for the optional interfaces: each is reached by a type assertion, so a signature that drifts does not fail the build — it stops being found, and the behaviour disappears with no error anywhere. This branch has shipped that defect seven times. Four mutations, all caught. --- internal/specialist/plan_concurrent_test.go | 119 ++++++++++++++++++++ internal/specialist/plan_exec.go | 32 ++++++ internal/specialist/plan_tool.go | 21 ++++ internal/tui/plan_progress.go | 56 +++++++++ internal/tui/plan_progress_test.go | 58 ++++++++++ 5 files changed, 286 insertions(+) diff --git a/internal/specialist/plan_concurrent_test.go b/internal/specialist/plan_concurrent_test.go index 56ae46a43..8818834b8 100644 --- a/internal/specialist/plan_concurrent_test.go +++ b/internal/specialist/plan_concurrent_test.go @@ -3,12 +3,14 @@ package specialist import ( "context" "runtime" + "strings" "sync" "sync/atomic" "testing" "time" "github.com/Gitlawb/zero/internal/sessions" + "github.com/Gitlawb/zero/internal/tools" ) // concurrencyProbe records the maximum number of tasks in flight at once, and @@ -419,3 +421,120 @@ func TestResumeAfterAPartialConcurrentRun(t *testing.T) { t.Fatalf("the remainder's max_workers = %d, want 4", remaining.Budget().MaxWorkers) } } + +// busySurface is a recorder that reports a plan already on screen — the state +// the TUI bridge is in from the moment a background plan is launched until it +// completes. +type busySurface struct { + name string + running bool + admitted []string +} + +func (s *busySurface) TaskDispatched(Task) {} +func (s *busySurface) TaskCompleted(TaskResult) {} +func (s *busySurface) TaskFailed(TaskResult) {} +func (s *busySurface) PlanAdmitted(plan Plan) { s.admitted = append(s.admitted, plan.Name()) } +func (s *busySurface) PlanCompleted(Plan, PlanReport) {} +func (s *busySurface) RunningPlanName() (string, bool) { return s.name, s.running } + +// ONE PLAN AT A TIME, on the path the MODEL drives. +// +// The surface holds one plan and the card table is keyed by task id — unique +// within a plan, not between two. The TUI refused a second plan on the path a +// USER drives (/plans restart) and not on this one, and this one is the +// reachable one: a background plan returns immediately by design, so the very +// next tool call lands while it is still running. +func TestASecondPlanIsRefusedWhileOneIsRunning(t *testing.T) { + gate := &PostureGate{} + gate.Set(true) + surface := &busySurface{name: "sweep", running: true} + + tool := &OrchestrateTool{ + PostureActive: gate.Active, + ParentTools: []string{"read_file"}, + Recorder: surface, + RunTask: NewPlanRunner(PlanTaskContext{ + Executor: progressExecutor(t), + Cwd: t.TempDir(), + SpecialistName: "explorer", + }), + } + args := map[string]any{ + "name": "fix", + "tasks": []any{map[string]any{"id": "a", "prompt": "one"}}, + "budget": map[string]any{"max_workers": float64(1), "max_tokens": float64(100000)}, + } + + result := tool.RunWithOptions(context.Background(), args, tools.RunOptions{}) + if result.Status != tools.StatusError { + t.Fatalf("a second plan must be refused while one runs, got %s: %s", result.Status, result.Output) + } + if !strings.Contains(result.Output, "sweep") { + t.Errorf("the refusal must name the plan that is running: %q", result.Output) + } + if len(surface.admitted) != 0 { + t.Errorf("a refused plan must leave no record of having started, got %v", surface.admitted) + } + + // And it runs the moment the surface is free again. + surface.running = false + if result := tool.RunWithOptions(context.Background(), args, tools.RunOptions{}); result.Status == tools.StatusError { + t.Fatalf("the plan must run once the surface is free: %s", result.Output) + } + if len(surface.admitted) != 1 { + t.Errorf("expected exactly one admission, got %v", surface.admitted) + } +} + +// An INVALID plan is still reported as invalid. Blaming a bad plan on the plan +// already running would send the model off to wait for something that was never +// the problem. +func TestABadPlanIsStillCalledBadWhileAnotherRuns(t *testing.T) { + gate := &PostureGate{} + gate.Set(true) + tool := &OrchestrateTool{ + PostureActive: gate.Active, + ParentTools: []string{"read_file"}, + Recorder: &busySurface{name: "sweep", running: true}, + RunTask: NewPlanRunner(PlanTaskContext{ + Executor: progressExecutor(t), Cwd: t.TempDir(), SpecialistName: "explorer", + }), + } + result := tool.RunWithOptions(context.Background(), map[string]any{ + "name": "fix", + "tasks": []any{map[string]any{"id": "a", "prompt": "one", "depends_on": []any{"nope"}}}, + "budget": map[string]any{"max_workers": float64(1), "max_tokens": float64(100000)}, + }, tools.RunOptions{}) + if result.Status != tools.StatusError { + t.Fatal("a plan with a dangling dependency must be refused") + } + if strings.Contains(result.Output, "still running") { + t.Errorf("the dependency error was replaced by the concurrency refusal: %q", result.Output) + } +} + +// A recorder that cannot answer is treated as FREE. The headless path runs one +// plan per process and has no surface to contend for; gating on a question it +// cannot be asked would refuse every plan `zero exec` ever runs. +func TestARecorderThatCannotAnswerDoesNotBlockThePlan(t *testing.T) { + gate := &PostureGate{} + gate.Set(true) + tool := &OrchestrateTool{ + PostureActive: gate.Active, + ParentTools: []string{"read_file"}, + RunTask: NewPlanRunner(PlanTaskContext{ + Executor: progressExecutor(t), Cwd: t.TempDir(), SpecialistName: "explorer", + }), + } + if _, busy := runningPlanOn(nil); busy { + t.Error("a nil recorder must read as free") + } + result := tool.RunWithOptions(context.Background(), map[string]any{ + "name": "p", "tasks": []any{map[string]any{"id": "a", "prompt": "one"}}, + "budget": map[string]any{"max_workers": float64(1), "max_tokens": float64(100000)}, + }, tools.RunOptions{}) + if result.Status == tools.StatusError { + t.Fatalf("a plan with no surface to contend for must run: %s", result.Output) + } +} diff --git a/internal/specialist/plan_exec.go b/internal/specialist/plan_exec.go index eefb3119b..760cad1e2 100644 --- a/internal/specialist/plan_exec.go +++ b/internal/specialist/plan_exec.go @@ -715,6 +715,38 @@ type PlanController interface { WaitWhilePaused(ctx context.Context) } +// PlanSurfaceBusy is the optional CONCURRENCY half of a recorder: the surface +// says whether it is already carrying a plan. +// +// ONE PLAN PER SURFACE, and it is not a policy choice — it is what the display +// can actually represent. The panel holds one plan, the sidebar's PLAN section +// draws one plan, and the card table that pairs a task with its row is keyed by +// TASK ID, which is unique within a plan and not between two. Two plans sharing +// an id as ordinary as "tests" makes one plan's completion close the other +// plan's row, and leaves a card that nothing can ever close spinning in AGENTS +// for the rest of the session. +// +// The TUI already refused this on the path a USER drives (/plans restart), with +// a comment saying exactly why. It did not refuse it on the path the MODEL +// drives, and the model reaches it by launching a background plan — which +// returns immediately, by design — and then calling orchestrate again. +type PlanSurfaceBusy interface { + // RunningPlanName reports the plan this surface is already carrying. The + // name is for the refusal message; the bool is the answer. + RunningPlanName() (string, bool) +} + +// runningPlanOn asks the surface whether it is free, best-effort and nil-safe +// like every other optional half. A recorder that cannot answer is treated as +// free: the headless path runs one plan per process and has no surface to +// contend for. +func runningPlanOn(recorder PlanRecorder) (string, bool) { + if busy, ok := recorder.(PlanSurfaceBusy); ok && busy != nil { + return busy.RunningPlanName() + } + return "", false +} + // planRunning and waitWhilePaused are best-effort and nil-safe, mirroring // recordPlanAdmitted: a recorder that does not implement control simply cannot // be asked to control anything. diff --git a/internal/specialist/plan_tool.go b/internal/specialist/plan_tool.go index 2886c8c8d..161bea27d 100644 --- a/internal/specialist/plan_tool.go +++ b/internal/specialist/plan_tool.go @@ -439,6 +439,27 @@ func (tool *OrchestrateTool) RunWithOptions(ctx context.Context, args map[string if tool.RunTask == nil { return tools.Result{Status: tools.StatusError, Output: "Error: orchestrate has no task runner wired."} } + // ONE PLAN AT A TIME, on this path too. + // + // The surface that displays a plan can hold exactly one, and the card table + // pairing tasks with rows is keyed by task id — unique within a plan, not + // between two. The TUI already refused a second plan on the path a USER + // drives; this is the path the MODEL drives, and it is the reachable one: + // a background plan returns immediately by design, so the very next tool + // call lands while it is still running. + // + // Checked AFTER parsing, so an invalid plan is still reported as invalid + // rather than blamed on the plan already running — and BEFORE admission, so + // a refused plan leaves no record of having started. + if running, busy := runningPlanOn(tool.Recorder); busy { + return tools.Result{ + Status: tools.StatusError, + Output: fmt.Sprintf( + "Error: plan %q is still running, and a session shows one plan at a time. "+ + "Wait for it to finish — its result arrives on a later turn if it is a background plan — "+ + "or stop it with /plans stop, then run this one.", running), + } + } // BACKGROUND, when asked for and when this surface can carry one. // diff --git a/internal/tui/plan_progress.go b/internal/tui/plan_progress.go index 7093c8d5e..c38a7a3db 100644 --- a/internal/tui/plan_progress.go +++ b/internal/tui/plan_progress.go @@ -77,6 +77,15 @@ type PlanProgressBridge struct { // can be routed to the right row. The recorder is the only thing that knows // this pairing — it created it — which is why per-task progress travels // through here rather than being guessed at the display end. + // + // KEYED BY TASK ID, WHICH IS UNIQUE WITHIN A PLAN AND NOT BETWEEN TWO. That + // is sound only because a surface carries one plan at a time, which + // specialist.PlanSurfaceBusy enforces at the tool. Were two plans ever to + // report here at once, an id as ordinary as "tests" would have one plan's + // dispatch overwrite the other's entry: the first completion would close the + // wrong row, and the card left behind could never be closed by anything — + // it would spin in AGENTS for the rest of the session. If that gate is ever + // relaxed, this map needs a plan discriminator BEFORE it is. cardByTask map[string]string // dispatched counts tasks so each gets a unique temporary card key. The // child's real session id is not known until the child process creates it, @@ -85,6 +94,19 @@ type PlanProgressBridge struct { dispatched int } +// The optional halves the bridge implements, asserted at COMPILE TIME. Each is +// consulted through a type assertion, so a signature that drifts out of shape +// does not fail to build — it silently stops being found, and the behaviour it +// carries disappears with no error anywhere. This branch has shipped that exact +// defect often enough to pay for four lines. +var ( + _ specialist.PlanRecorder = (*PlanProgressBridge)(nil) + _ specialist.PlanLifecycleRecorder = (*PlanProgressBridge)(nil) + _ specialist.PlanController = (*PlanProgressBridge)(nil) + _ specialist.PlanSurfaceBusy = (*PlanProgressBridge)(nil) + _ specialist.PlanTaskProgressRecorder = (*PlanProgressBridge)(nil) +) + // NewPlanProgressBridge returns a bridge that is inert until Attach is called. func NewPlanProgressBridge() *PlanProgressBridge { return &PlanProgressBridge{} } @@ -275,6 +297,33 @@ func (bridge *PlanProgressBridge) PlanRunningNow() bool { return bridge.cancelPlan != nil } +// RunningPlanName answers specialist.PlanSurfaceBusy: this surface carries one +// plan, and it says which. +// +// background is consulted ALONGSIDE cancelPlan, not instead of it, because the +// two are set at different moments. The launcher sets background synchronously +// before it starts the goroutine; the goroutine sets cancelPlan when the +// executor reaches its first task. Between those two points the plan is +// unquestionably running, and a gate reading only cancelPlan would wave the +// next one straight through the window. +func (bridge *PlanProgressBridge) RunningPlanName() (string, bool) { + if bridge == nil { + return "", false + } + bridge.mu.Lock() + defer bridge.mu.Unlock() + if bridge.cancelPlan == nil && !bridge.background { + return "", false + } + name := bridge.lastPlanName + if strings.TrimSpace(name) == "" { + // A plan launched but not yet admitted has no name recorded. Refusing + // without one is still the right answer; "a plan" is honest. + name = "a plan" + } + return name, true +} + // clearPauseLocked releases any waiter. Closing the channel rather than sending // on it means every waiter wakes and a late waiter never blocks. func (bridge *PlanProgressBridge) clearPauseLocked() { @@ -531,6 +580,13 @@ func (bridge *PlanProgressBridge) PlanCompleted(plan specialist.Plan, report spe // stale cancel would let a later stop cancel a context that has since // been reused, which is the PostureGate lifetime mistake in another // costume. + // + // These fields belong to THE plan, not to a plan, and clearing them here + // is correct only while one plan runs at a time — the same precondition + // cardByTask rests on, enforced by specialist.PlanSurfaceBusy. Without + // it a foreground plan's completion would strip a still-running + // background plan's flag and cancel, and the background plan's result + // would then reach nobody: spend nobody sees and no result. bridge.cancelPlan = nil bridge.background = false bridge.clearPauseLocked() diff --git a/internal/tui/plan_progress_test.go b/internal/tui/plan_progress_test.go index d8579f5c8..ee8cb5439 100644 --- a/internal/tui/plan_progress_test.go +++ b/internal/tui/plan_progress_test.go @@ -410,3 +410,61 @@ func TestATasksOutputIsBoundedBeforeItLeavesTheBridge(t *testing.T) { t.Errorf("short output = %q, want it trimmed and intact", got) } } + +// THE WINDOW BETWEEN LAUNCH AND FIRST TASK. The launcher sets the background +// flag synchronously before it starts the goroutine; the goroutine sets the +// cancel when the executor reaches its first task. A gate that read only the +// cancel would wave the next plan straight through the gap between them — and +// that gap is exactly where the model's next tool call arrives, because a +// background plan returns immediately by design. +func TestTheSurfaceReadsBusyFromTheMomentAPlanIsLaunched(t *testing.T) { + bridge := NewPlanProgressBridge() + bridge.Attach(func(tea.Msg) {}, 1, nil, "") + + if _, busy := bridge.RunningPlanName(); busy { + t.Fatal("a fresh bridge carries no plan") + } + + // Launched, not yet executing: no cancel has been handed over. + bridge.SetBackground(true) + name, busy := bridge.RunningPlanName() + if !busy { + t.Error("a launched background plan makes the surface busy before its first task runs") + } + if name == "" { + t.Error("the refusal needs something to name, even before admission") + } + + // Admitted: the name becomes the real one. + bridge.PlanAdmitted(mustPlan(t, "sweep")) + if name, _ := bridge.RunningPlanName(); name != "sweep" { + t.Errorf("RunningPlanName = %q, want the admitted plan's name", name) + } + + // A foreground plan makes it busy through the cancel instead. + free := NewPlanProgressBridge() + free.Attach(func(tea.Msg) {}, 1, nil, "") + free.PlanRunning(func() {}) + if _, busy := free.RunningPlanName(); !busy { + t.Error("a foreground plan holding a cancel makes the surface busy") + } + + // And the surface is free again once the plan ends. + bridge.PlanCompleted(mustPlan(t, "sweep"), specialist.PlanReport{Status: specialist.PlanCompleted}) + if _, busy := bridge.RunningPlanName(); busy { + t.Error("a finished plan must release the surface") + } +} + +func mustPlan(t *testing.T, name string) specialist.Plan { + t.Helper() + plan, err := specialist.ParsePlan(map[string]any{ + "name": name, + "tasks": []any{map[string]any{"id": "a", "prompt": "one"}}, + "budget": map[string]any{"max_workers": float64(1), "max_tokens": float64(1000)}, + }, specialist.Limits{ParentTools: []string{"read_file"}}) + if err != nil { + t.Fatalf("building the fixture plan: %v", err) + } + return plan +} From 0dfd1855063e36488d0b4934b93ab10a61c8ec39 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:47:59 +0530 Subject: [PATCH 69/86] test(specialist): measure concurrency against the host, not against the request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestIndependentTasksRunConcurrently asserted that four tasks reach a peak concurrency of four. effectivePlanWorkers deliberately caps a plan at runtime.NumCPU()-2, so on a four-core runner a plan asking for four workers correctly gets two — and the assertion could never hold. Not flaky. Deterministic on any host with fewer than six cores, which is every CI runner: green on a ten-core laptop, red on macos, ubuntu and windows alike. The test was measuring the machine and calling it the scheduler. It now waits for effectivePlanWorkers(requested) — what this host will actually run. The minPlanWorkers floor of 2 is what keeps it honest: want is never below two, so it still fails against a scheduler that runs tasks one at a time, which is the only thing it exists to catch. The failure message now reports the request, the effective count and the host's capacity, so the next person reads the cause instead of rediscovering it. Verified by forcing machinePlanWorkers() to 2 to simulate the runner and checking both directions: the old assertion fails with CI's exact message, the new one passes alongside the five sibling concurrency tests. This was the single cause of all four failing checks — the three Smoke jobs run go test ./... directly, and Zero Review's blocker gate fires on its test step (diffcheck, build and smoke were already green there). --- internal/specialist/plan_concurrent_test.go | 26 +++++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/internal/specialist/plan_concurrent_test.go b/internal/specialist/plan_concurrent_test.go index 8818834b8..1bd67fca7 100644 --- a/internal/specialist/plan_concurrent_test.go +++ b/internal/specialist/plan_concurrent_test.go @@ -84,28 +84,44 @@ func TestOneWorkerDispatchesInPlanOrder(t *testing.T) { // TASKS ACTUALLY RUN AT ONCE. Asserting only that a plan with 4 workers // completes would pass against a scheduler that ignored the setting entirely. +// +// MEASURED AGAINST WHAT THIS MACHINE WILL RUN, not against what the plan asked +// for. effectivePlanWorkers caps the request at runtime.NumCPU()-2, so a plan +// asking for four gets two on a four-core CI runner — and the literal 4 this +// once asserted passed on a ten-core laptop while failing on every CI platform, +// deterministically. It was measuring the host, not the scheduler. +// +// The floor is what keeps it honest: minPlanWorkers is 2, so want is never +// below 2 and this still fails against a scheduler that runs tasks one at a +// time, which is the only thing it exists to catch. func TestIndependentTasksRunConcurrently(t *testing.T) { probe := &concurrencyProbe{release: make(chan struct{})} - plan := fanOutPlan(t, 4, 4) + const requested = 4 + want := effectivePlanWorkers(requested) + if want < 2 { + t.Fatalf("effectivePlanWorkers(%d) = %d; the floor guarantees at least 2", requested, want) + } + plan := fanOutPlan(t, requested, 4) done := make(chan PlanReport, 1) go func() { done <- ExecutePlan(context.Background(), plan, []string{"read_file"}, probe.runner(), nil) }() - // Wait for all four to be in flight together, which can only happen if they - // genuinely overlap. + // Wait until as many are in flight together as this host allows, which can + // only happen if they genuinely overlap. deadline := time.After(5 * time.Second) for { probe.mu.Lock() peak := probe.peak probe.mu.Unlock() - if peak >= 4 { + if peak >= want { break } select { case <-deadline: - t.Fatalf("peak concurrency reached only %d of 4", peak) + t.Fatalf("peak concurrency reached only %d of %d (plan asked for %d, this host allows %d)", + peak, want, requested, machinePlanWorkers()) case <-time.After(5 * time.Millisecond): } } From 72425c5853a70a0254c457012552874784f94b01 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:13:33 +0530 Subject: [PATCH 70/86] test(cli): neutralise XDG_CONFIG_HOME in the sandbox policy golden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test already redirects HOME, USERPROFILE and GOOGLE_APPLICATION_CREDENTIALS so host credential stores cannot leak into the golden. It missed the variable that decides where the config directory actually is: credentialDenyReadPathsForEnvironment reads XDG_CONFIG_HOME FIRST and only falls back to $HOME/.config. Every ubuntu GitHub runner sets it. So redirecting HOME left configHome pointing at the real /home/runner/.config, and the golden gained denyRead: ["/home/runner/.config/zero"] the moment that directory existed. Other packages in the same `go test ./...` create it — zero/config.json from internal/config, zero/trust.json from internal/workspacetrust, zero/specialists from this package's own specialist test — and `go test` runs packages in parallel, so whether this golden saw it was a race nobody was arbitrating. That is why it only ever went red on ubuntu: macOS and Windows runners leave XDG_CONFIG_HOME unset, so those hosts fell back to the redirected HOME and the path could not exist. Verified against the exact CI condition — XDG_CONFIG_HOME set AND $XDG/zero already present — passing with the fix and failing without it. The wider issue is untouched and belongs to its own change: tests write into the user's real config directory, which is what made this reachable at all. --- internal/cli/sandbox_test.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/internal/cli/sandbox_test.go b/internal/cli/sandbox_test.go index 16489f407..2ea8f2357 100644 --- a/internal/cli/sandbox_test.go +++ b/internal/cli/sandbox_test.go @@ -491,6 +491,17 @@ func TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields(t *testing.T) { t.Setenv("HOME", emptyHome) t.Setenv("USERPROFILE", emptyHome) t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "") + // XDG_CONFIG_HOME as well, or HOME is not actually the answer to "where is + // the config directory". credentialDenyReadPathsForEnvironment reads + // XDG_CONFIG_HOME FIRST and only falls back to $HOME/.config, so on a host + // that sets it — every ubuntu GitHub runner does — redirecting HOME alone + // left configHome pointing at the real /home/runner/.config. Other packages + // in the same `go test ./...` write zero/config.json and zero/trust.json + // there, `go test` runs packages in parallel, and this golden then failed or + // passed depending on who won the race. macOS and Windows runners leave + // XDG_CONFIG_HOME unset, which is the whole reason it only ever went red on + // one platform. + t.Setenv("XDG_CONFIG_HOME", filepath.Join(emptyHome, ".config")) store := newSandboxTestStore(t) workspace := t.TempDir() deps := appDeps{ From 5e9a8a79199636b720c3d8bbf17f50e3ac7836fc Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 31 Jul 2026 16:17:40 +0530 Subject: [PATCH 71/86] fix(tui): resume the named plan, sanitize task text, keep background panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three from the review on #829. RESUME NARROWED BY THE WRONG PLAN'S PROGRESS. ReducePlanEvents keeps only the LAST admitted plan's state and records whose it is in PlanProgress.Name, but resumeSavedPlan narrowed whichever plan the user named by that progress without checking the two match. Run plan A, run plan B, then /plans resume A: every A task whose id sits in B's succeeded set is dropped from the remainder and never runs. Ids like "tests" or "lint" collide across plans constantly, so this is the ordinary case rather than a contrived one, and it fails silently — the remainder validates and runs, just without the work. The comparison is against plan.Name(), not stored.Name: plan_admitted records the plan's own name, which is independent of the name it was saved under. A test asserting the saved name would have passed while breaking every legitimate resume, so both directions are pinned. TASK SUMMARIES CARRIED CONTROL BYTES. planTaskSummary cut at the first newline and left every other control byte intact, and these strings are model-authored — the realistic path is indirect prompt injection, poisoned file or web content echoed into orchestrate args and painted into the inline panel, the sidebar rows and the detail pane. It now goes through sanitizeCardText, which permission_detail.go already applies to exactly this class of data. BACKGROUND PLANS LOST THEIR PANEL. beginRun cleared the orchestrate panel unconditionally, but background plans are built to outlive the run that launched them and every message they post carries the background flag precisely to pass the stale-run guard. After the clear those messages passed the guard and no-oped against an empty byID, so the PLAN surface vanished for the rest of the plan's life while it kept running. BackgroundPlanLive gates the clear, consulting background alongside cancelPlan for the same reason RunningPlanName does — the launcher sets one synchronously and the goroutine sets the other later, so reading either alone leaves a window. Co-Authored-By: Claude Opus 5 --- internal/tui/model.go | 9 ++- .../orchestrate_background_survival_test.go | 46 ++++++++++++ .../tui/orchestrate_resume_identity_test.go | 70 +++++++++++++++++++ internal/tui/orchestrate_saved.go | 13 ++++ internal/tui/plan_progress.go | 34 +++++++-- internal/tui/plan_summary_sanitize_test.go | 45 ++++++++++++ 6 files changed, 212 insertions(+), 5 deletions(-) create mode 100644 internal/tui/orchestrate_background_survival_test.go create mode 100644 internal/tui/orchestrate_resume_identity_test.go create mode 100644 internal/tui/plan_summary_sanitize_test.go diff --git a/internal/tui/model.go b/internal/tui/model.go index 26fb5e1cd..ecb3bb0b2 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -5045,7 +5045,14 @@ func (m model) beginRun(cancel context.CancelFunc) model { // previous turn don't bleed into the new one. m.specialists.clear() m.plan.clear() - m.orchestrate.clear() + // The orchestrate panel survives a new run while a BACKGROUND plan is still + // working. Those plans outlive the run that launched them by design, and + // their messages carry the background flag to pass the stale-run guard — + // clearing byID here would let the guard pass them into an empty panel, + // where they no-op and the PLAN surface disappears until the plan ends. + if !m.planProgress.BackgroundPlanLive() { + m.orchestrate.clear() + } // Re-bind the plan recorder to THIS run. The orchestrate tool holds the // bridge for the process's life (the registry is built once per session), // so the run id has to be pushed in per run — the PostureGate problem, same diff --git a/internal/tui/orchestrate_background_survival_test.go b/internal/tui/orchestrate_background_survival_test.go new file mode 100644 index 000000000..a2738a86e --- /dev/null +++ b/internal/tui/orchestrate_background_survival_test.go @@ -0,0 +1,46 @@ +package tui + +import ( + "context" + "testing" + "time" +) + +// A background plan outlives the run that launched it — that is the whole point +// of the flag, and every message it posts carries it so the stale-run guard lets +// it through. beginRun wiping the panel anyway leaves those messages passing the +// guard and then no-oping against an empty byID: the PLAN surface disappears for +// the rest of the plan's life while it keeps running. +func TestBeginRunKeepsTheOrchestratePanelForALiveBackgroundPlan(t *testing.T) { + plan := samplePlan(t) + + for name, testCase := range map[string]struct { + background bool + wantKept bool + }{ + "background plan survives the next run": {background: true, wantKept: true}, + "foreground leftovers are cleared": {background: false, wantKept: false}, + } { + t.Run(name, func(t *testing.T) { + m, _ := savedPlanModel(t) + m.now = time.Now + m.planProgress.SetBackground(testCase.background) + m.planProgress.PlanAdmitted(plan) + + // Whatever the panel held when the next run began. + m.orchestrate.admit(planAdmittedMsg{ + name: plan.Name(), + tasks: []planGraphTask{{id: "a"}, {id: "b"}}, + }, time.Now()) + if m.orchestrate.isEmpty() { + t.Fatal("precondition: the panel should hold the admitted tasks") + } + + m = m.beginRun(context.CancelFunc(func() {})) + + if kept := !m.orchestrate.isEmpty(); kept != testCase.wantKept { + t.Errorf("panel kept = %v, want %v", kept, testCase.wantKept) + } + }) + } +} diff --git a/internal/tui/orchestrate_resume_identity_test.go b/internal/tui/orchestrate_resume_identity_test.go new file mode 100644 index 000000000..63d11c228 --- /dev/null +++ b/internal/tui/orchestrate_resume_identity_test.go @@ -0,0 +1,70 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/specialist" +) + +// ReducePlanEvents keeps only the LAST admitted plan's progress, and records +// whose it is in PlanProgress.Name. Narrowing a plan by progress belonging to a +// different plan silently drops work: task ids like "tests" or "lint" collide +// across plans constantly, so every task of the named plan whose id happens to +// sit in the other plan's succeeded set disappears from the remainder and never +// runs. +func TestResumeRefusesProgressFromADifferentPlan(t *testing.T) { + m, paths, plan := resumeModel(t) + + // Plan A is admitted and nothing is completed. + m.planProgress.PlanAdmitted(plan) + + // A DIFFERENT plan then runs and completes a task whose id collides with + // one of A's. This is what the session log ends up holding. + other, err := specialist.ParsePlan(map[string]any{ + "name": "unrelated", + "tasks": []any{ + map[string]any{"id": "a", "prompt": "something else entirely"}, + }, + "budget": map[string]any{"max_workers": float64(1)}, + }, m.savedPlanLimits()) + if err != nil { + t.Fatalf("ParsePlan(other): %v", err) + } + m.planProgress.PlanAdmitted(other) + m.planProgress.TaskDispatched(specialist.Task{ID: "a"}) + m.planProgress.TaskCompleted(specialist.TaskResult{ID: "a", Attempts: 1}) + if err := m.planProgress.RecordingError(); err != nil { + t.Fatalf("recording: %v", err) + } + + stored, err := specialist.FindSavedPlan(paths, "sweep") + if err != nil { + t.Fatal(err) + } + name, notice, ok := m.resumeSavedPlan(stored) + + if ok { + // If a remainder was staged at all, it must not have dropped task "a" — + // the named plan never ran it. + remaining, findErr := specialist.FindSavedPlan(paths, name) + if findErr != nil { + t.Fatalf("staged remainder not findable: %v", findErr) + } + restored, parseErr := specialist.ParsePlan(remaining.Args, m.savedPlanLimits()) + if parseErr != nil { + t.Fatalf("staged remainder does not validate: %v", parseErr) + } + for _, id := range restored.Order() { + if id == "a" { + return // task survived; acceptable outcome + } + } + t.Fatalf("resume dropped task %q, which the named plan never ran — it was completed by a DIFFERENT plan (notice: %s)", "a", notice) + } + + // Refusing is the other acceptable outcome, as long as it says why. + if !strings.Contains(strings.ToLower(notice), "plan") { + t.Errorf("refusal should explain the progress belongs to another plan, got %q", notice) + } +} diff --git a/internal/tui/orchestrate_saved.go b/internal/tui/orchestrate_saved.go index b23ad82da..419db4465 100644 --- a/internal/tui/orchestrate_saved.go +++ b/internal/tui/orchestrate_saved.go @@ -360,6 +360,19 @@ func (m model) resumeSavedPlan(stored specialist.SavedPlan) (name string, notice if err != nil { return "", planControlNotice("warning", fmt.Sprintf("%s does not validate: %v", stored.Path, err)), false } + // The reduction keeps only the LAST admitted plan's progress, so it may + // belong to a different plan than the one being resumed. Narrowing across + // that boundary silently drops work: ids like "tests" or "lint" collide + // between plans routinely, and every task of this plan whose id sits in the + // other plan's succeeded set would vanish from the remainder and never run. + // + // Compared against plan.Name(), not stored.Name: plan_admitted records the + // plan's own name, which is independent of the name it was saved under. + if progress.Name != plan.Name() { + return "", planControlNotice("warning", fmt.Sprintf( + "The last plan to run in this session was %q, not %q, so there is no record of what %q already did. Use /plans run %s to run it from the start.", + progress.Name, plan.Name(), plan.Name(), stored.Name)), false + } remaining, err := specialist.RemainingPlan(plan, progress, m.savedPlanLimits()) if err != nil { return "", planControlNotice("info", err.Error()), false diff --git a/internal/tui/plan_progress.go b/internal/tui/plan_progress.go index c38a7a3db..45b3a8510 100644 --- a/internal/tui/plan_progress.go +++ b/internal/tui/plan_progress.go @@ -324,6 +324,29 @@ func (bridge *PlanProgressBridge) RunningPlanName() (string, bool) { return name, true } +// BackgroundPlanLive reports whether a BACKGROUND plan is still in flight. +// +// beginRun wipes the orchestrate panel so a previous turn's plan cannot bleed +// into the new one, which is right for a foreground plan and wrong for a +// background one: those are built to outlive the run that launched them, and +// every message they post carries the background flag precisely to survive the +// stale-run guard. Wiping anyway leaves the guard passing messages that then +// no-op against an empty byID, so the PLAN surface vanishes for the rest of the +// plan's life while it keeps running. +// +// background is consulted alongside cancelPlan for the same reason +// RunningPlanName does it: the launcher sets background synchronously before +// starting the goroutine, and cancelPlan only appears once the executor reaches +// its first task. Reading either alone leaves a window. +func (bridge *PlanProgressBridge) BackgroundPlanLive() bool { + if bridge == nil { + return false + } + bridge.mu.Lock() + defer bridge.mu.Unlock() + return bridge.background && (bridge.cancelPlan != nil || bridge.lastPlanName != "") +} + // clearPauseLocked releases any waiter. Closing the channel rather than sending // on it means every waiter wakes and a late waiter never blocks. func (bridge *PlanProgressBridge) clearPauseLocked() { @@ -662,13 +685,16 @@ func planOutcomeStatus(outcome specialist.TaskOutcome) specialistStatus { // planTaskSummary is a SHORT label for the card. The full prompt stays in the // tool output; a display surface never becomes the data path. func planTaskSummary(task specialist.Task) string { - summary := strings.TrimSpace(task.Prompt) + // Sanitized, not just newline-cut. Task prompts are model-authored and can + // carry whatever a poisoned file or web page fed into the orchestrate args, + // and this string is painted into the inline panel, the sidebar rows and the + // detail pane. Cutting at the first newline leaves every other control byte + // intact, so an ESC/OSC sequence survives and can repaint the terminal. + // sanitizeCardText already drops exactly these for the permission cards. + summary := sanitizeCardText(task.Prompt) if summary == "" { return "" } - if index := strings.IndexAny(summary, "\r\n"); index >= 0 { - summary = summary[:index] - } return truncateRunes(summary, planTaskSummaryWidth) } diff --git a/internal/tui/plan_summary_sanitize_test.go b/internal/tui/plan_summary_sanitize_test.go new file mode 100644 index 000000000..18434b5f3 --- /dev/null +++ b/internal/tui/plan_summary_sanitize_test.go @@ -0,0 +1,45 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/specialist" +) + +// Task prompts are model-authored and reach the terminal through the inline +// panel, the sidebar rows and the detail pane. The realistic path is indirect +// prompt injection: poisoned file or web content echoed into orchestrate args. +// Cutting at the first newline is not enough — every other control byte, ESC +// included, would survive into the rendered row. +func TestPlanTaskSummaryStripsControlSequences(t *testing.T) { + for name, prompt := range map[string]string{ + "ANSI colour": "run tests \x1b[31mred\x1b[0m", + "OSC title": "run tests \x1b]0;pwned\x07", + "bare escape": "run \x1btests", + "carriage ret": "run tests\rOVERWRITTEN", + "backspace": "run tests\x08\x08\x08pwn", + "bell": "run tests\x07", + } { + t.Run(name, func(t *testing.T) { + got := planTaskSummary(specialist.Task{ID: "t", Prompt: prompt}) + for _, r := range got { + if r < 0x20 || r == 0x7f { + t.Fatalf("summary %q still carries control byte %q", got, r) + } + } + if strings.Contains(got, "\x1b") { + t.Fatalf("summary %q still carries ESC", got) + } + }) + } +} + +// The ordinary case must survive intact — sanitizing is not an excuse to mangle +// a normal prompt. +func TestPlanTaskSummaryKeepsOrdinaryText(t *testing.T) { + got := planTaskSummary(specialist.Task{ID: "t", Prompt: " run the unit tests for internal/tui "}) + if got != "run the unit tests for internal/tui" { + t.Errorf("summary = %q, want the trimmed prompt", got) + } +} From 9b6ea93242113880774b8f63401e77fe5422e05e Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 31 Jul 2026 16:33:39 +0530 Subject: [PATCH 72/86] fix(sandbox): release a temporary root under one lock hold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit releaseTemporaryRead deleted the refcount, dropped the lock, then stripped the root in a second acquisition. Between those two holds the root was still in readRoots but no longer in tempReads, and AddTemporaryRead reads exactly that pair to decide what a caller is holding: it finds the root covered, finds no refcount, concludes the cover is PERMANENT, and returns a no-op undo. The release then strips the root and that caller has silently lost access it believes it holds. Reproduced by driving the two mutations apart and stepping a second caller through the gap: after add: readRoots=[.../shared] tempReads=map[.../shared:1] in window: second caller tracked=false (a no-op undo) after release: validateRead -> "requires access outside the workspace" Both mutations now happen under one hold, and releaseTemporaryWrite gets the same treatment. removeReadRoot/removeWriteRoot existed only to re-acquire the lock and are gone. ON THE TESTS, because they are weaker than they look: neither reproduces the window on demand. The concurrent one cannot hit a gap that narrow reliably — it survived the unfixed code even with an injected scheduling point — and the sequential one holds two references, so the refcount never reaches zero and the delete never runs. They assert the invariant the bug broke (a live grant stays readable, the root retires only with its last holder) and are worth having, but the evidence for the bug is the reproduction above, not them. Two earlier versions of these tests were worse than weak: they used t.TempDir() with NewScope, which adds the system temp directory as a write root, so AddTemporaryRead returned early and never touched the refcount at all. The scope is built directly here for that reason. Co-Authored-By: Claude Opus 5 --- internal/sandbox/scope.go | 22 ++-- internal/sandbox/scope_temp_refcount_test.go | 102 +++++++++++++++++++ 2 files changed, 110 insertions(+), 14 deletions(-) create mode 100644 internal/sandbox/scope_temp_refcount_test.go diff --git a/internal/sandbox/scope.go b/internal/sandbox/scope.go index c70dd5280..b983a09b0 100644 --- a/internal/sandbox/scope.go +++ b/internal/sandbox/scope.go @@ -214,9 +214,14 @@ func (s *Scope) releaseTemporaryRead(root string) { s.mu.Unlock() return } + // Both mutations under ONE hold. Dropping the lock between them opened a + // window where the root was still in readRoots but no longer in tempReads: + // a concurrent AddTemporaryRead landing there reads it as a PERMANENT root, + // hands its caller a no-op undo, and then this call strips the root — so + // that caller believes it holds access it has already silently lost. delete(s.tempReads, root) + s.readRoots = removeScopeRoot(s.readRoots, root) s.mu.Unlock() - s.removeReadRoot(root) } // releaseTemporaryWrite is releaseTemporaryRead for write roots. Two functions @@ -236,21 +241,10 @@ func (s *Scope) releaseTemporaryWrite(root string) { s.mu.Unlock() return } + // Same single-hold rule as releaseTemporaryRead above. delete(s.tempWrites, root) - s.mu.Unlock() - s.removeWriteRoot(root) -} - -func (s *Scope) removeReadRoot(root string) { - s.mu.Lock() - defer s.mu.Unlock() - s.readRoots = removeScopeRoot(s.readRoots, root) -} - -func (s *Scope) removeWriteRoot(root string) { - s.mu.Lock() - defer s.mu.Unlock() s.extraRoots = removeScopeRoot(s.extraRoots, root) + s.mu.Unlock() } func removeScopeRoot(roots []string, root string) []string { diff --git a/internal/sandbox/scope_temp_refcount_test.go b/internal/sandbox/scope_temp_refcount_test.go new file mode 100644 index 000000000..de0a8c6ad --- /dev/null +++ b/internal/sandbox/scope_temp_refcount_test.go @@ -0,0 +1,102 @@ +package sandbox + +import ( + "os" + "path/filepath" + "sync" + "testing" +) + +// scopeOutsideDefaults builds a Scope directly instead of through NewScope. +// +// NewScope adds the system temp directory as a write root, so a target under +// t.TempDir() is already covered and AddTemporaryRead returns before it ever +// touches the refcount. A test built that way exercises none of this and passes +// against the bug — which is how the first version of this test passed. +func scopeOutsideDefaults(t *testing.T) (*Scope, string) { + t.Helper() + workspace := t.TempDir() + target := filepath.Join(t.TempDir(), "shared") + if err := os.MkdirAll(target, 0o700); err != nil { + t.Fatalf("mkdir target: %v", err) + } + // Built directly, NOT via NewScope: real directories are needed because + // normalizeScopeRoot requires them to exist, but NewScope's default temp + // write root would then cover the target and short-circuit the path. + return &Scope{workspaceRoot: workspace}, target +} + +// The refcount and the root list have to move together. +// +// Release used to delete the refcount, drop the lock, then strip the root in a +// second acquisition. In that window the root was still in readRoots but no +// longer in tempReads, so a concurrent AddTemporaryRead read it as a PERMANENT +// root and handed its caller a no-op undo — then the release stripped it and +// that caller silently lost access it believed it held. +func TestReleasingATemporaryReadCannotStripALiveGrant(t *testing.T) { + scope, target := scopeOutsideDefaults(t) + + // First holder establishes the root. + first, releaseFirst, err := scope.AddTemporaryRead(target) + if err != nil { + t.Fatalf("AddTemporaryRead: %v", err) + } + if len(scope.tempReads) == 0 { + t.Fatal("precondition: the grant should be refcounted, not covered by a default root") + } + + // Second holder takes a reference on the same root. + second, releaseSecond, err := scope.AddTemporaryRead(target) + if err != nil { + t.Fatalf("AddTemporaryRead (second): %v", err) + } + + // The first holder leaving must not revoke the second's access. + releaseFirst() + if block := scope.validateRead(second); block != nil { + t.Fatalf("the second holder lost its grant when the first released: %v", block) + } + + // Only the last release retires the root. + releaseSecond() + if block := scope.validateRead(first); block == nil { + t.Error("the root outlived its last holder") + } +} + +// Under contention the same invariant has to hold: while a grant is live, its +// root is readable. +func TestTemporaryReadGrantsSurviveConcurrentReleases(t *testing.T) { + scope, target := scopeOutsideDefaults(t) + + var wg sync.WaitGroup + var mu sync.Mutex + var failures []string + + for i := 0; i < 24; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 200; j++ { + granted, undo, err := scope.AddTemporaryRead(target) + if err != nil { + mu.Lock() + failures = append(failures, "AddTemporaryRead: "+err.Error()) + mu.Unlock() + return + } + if block := scope.validateRead(granted); block != nil { + mu.Lock() + failures = append(failures, "root not readable while a grant was live") + mu.Unlock() + } + undo() + } + }() + } + wg.Wait() + + if len(failures) > 0 { + t.Fatalf("%d failures, first: %s", len(failures), failures[0]) + } +} From 4c31a7473fc75052e4d1efe92b0cb1ae2f37a89f Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 31 Jul 2026 16:43:26 +0530 Subject: [PATCH 73/86] fix(tui): guard the zeromaxing effort switch and two mouse hit-testers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three guards that were missing beside siblings that have them. /effort zeromaxing delegated to handleProfileCommand with no idle check. That is the same mutation /profile refuses mid-run — turn budget, self-correct, and the shared orchestrate gate — and /profile refuses it because the budget propagates to sub-agents spawned later in the same run, so changing it mid-turn leaves one run straddling two budgets. Reaching the mutation through the effort namespace skipped the guard the profile namespace applies. Idle behaviour is unchanged and tested, so the guard cannot quietly disable the feature. orchestrateHeaderAtMouse had no modal guard while orchestrateTaskAtMouse directly above it does, and sidebar.go states the rule: sidebarAvailable deliberately does not suppress the sidebar for the `/` palette, because a palette must not reflow the layout, so each hit-tester carries its own suggestionsActive() guard. Without it, clicking the PLAN header while the palette is open toggled the section behind the overlay. The posture chip in mouse.go is checked BEFORE the surface switch, so the modal guards in those cases do not cover it. With the /model picker or a provider wizard open, clicking the chip swapped in a fresh effort picker and discarded whatever the open one had loaded or the user had typed. The header test first used a picker and passed against the unguarded code: sidebarAvailable already suppresses the sidebar for pickers, so it proved nothing. It uses the `/` palette now, which is the case the guard exists for, and that mutation fails it. Co-Authored-By: Claude Opus 5 --- internal/tui/mouse.go | 11 ++++ internal/tui/session_controls.go | 11 ++++ internal/tui/sidebar_plan_detail.go | 9 +++ internal/tui/zeromaxing_guards_test.go | 78 ++++++++++++++++++++++++++ 4 files changed, 109 insertions(+) create mode 100644 internal/tui/zeromaxing_guards_test.go diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go index e962aca90..a6bb265b4 100644 --- a/internal/tui/mouse.go +++ b/internal/tui/mouse.go @@ -97,6 +97,17 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { // Not while a turn is in flight: the effort picker refuses mid-run for // the same reason /effort does, and opening one that cannot be acted on // would be a dead dialog. + // + // Nor while another modal owns the screen. This branch runs BEFORE the + // surface switch below, so it is not covered by the guards there: with + // the /model picker or a provider wizard open, a click here would swap + // in a fresh effort picker and discard whatever the open one had loaded + // or the user had typed. The sidebar hit-testers each carry the same + // guard for the same reason. + if m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || + m.mcpManager != nil || m.picker != nil || m.suggestionsActive() { + return m, nil + } if !m.pending { if picker := m.newEffortPicker(); picker != nil { m.picker = picker diff --git a/internal/tui/session_controls.go b/internal/tui/session_controls.go index c66b95aa2..e28dad354 100644 --- a/internal/tui/session_controls.go +++ b/internal/tui/session_controls.go @@ -71,6 +71,17 @@ func (m model) handleEffortCommand(args string) (model, string) { // so "/effort zeromaxing and /profile zeromaxing resolve identically" is // true by construction instead of by two implementations a test hopes agree. if args == execprofile.Name { + // Same idle-session rule /profile enforces, and for the same reason: this + // delegates to handleProfileCommand, which mutates the turn budget, the + // self-correct setting and the shared orchestrate gate. The budget + // propagates to sub-agents spawned later in the same run, so changing it + // mid-run leaves one turn running under two different budgets. Reaching + // the same mutation through the effort namespace must not skip the guard + // the profile namespace applies. + if m.pending { + return m, `Effort +Finish or stop the current run before switching to the zeromaxing posture.` + } return m.handleProfileCommand(execprofile.Name) } if args == "auto" { diff --git a/internal/tui/sidebar_plan_detail.go b/internal/tui/sidebar_plan_detail.go index 8fe22394d..3dcf3a424 100644 --- a/internal/tui/sidebar_plan_detail.go +++ b/internal/tui/sidebar_plan_detail.go @@ -117,6 +117,15 @@ func (m model) orchestrateHeaderAtMouse(msg tea.MouseMsg) bool { if !m.sidebarActive() || m.orchestrate.isEmpty() { return false } + // Same modal guard orchestrateTaskAtMouse carries directly above. sidebar.go + // notes that each hit-tester supplies its own suggestionsActive() guard + // because sidebarActive() deliberately does not exclude the palette; without + // it, clicking the PLAN header while the / palette is open toggles the + // section behind the overlay. + if m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || + m.mcpManager != nil || m.picker != nil || m.suggestionsActive() { + return false + } sidebarW := sidebarWidth(m.width) if sidebarW <= 0 { return false diff --git a/internal/tui/zeromaxing_guards_test.go b/internal/tui/zeromaxing_guards_test.go new file mode 100644 index 000000000..bd6b97259 --- /dev/null +++ b/internal/tui/zeromaxing_guards_test.go @@ -0,0 +1,78 @@ +package tui + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/Gitlawb/zero/internal/execprofile" +) + +// /effort zeromaxing delegates to handleProfileCommand, which mutates the turn +// budget, self-correct and the shared orchestrate gate. /profile refuses that +// mid-run because the budget propagates to sub-agents spawned later in the same +// run — reaching the same mutation through the effort namespace must not skip +// the guard. +func TestEffortZeromaxingRefusesMidRun(t *testing.T) { + m := model{pending: true} + before := m.execProfileName + + got, text := m.handleEffortCommand(execprofile.Name) + + if !strings.Contains(strings.ToLower(text), "finish or stop") { + t.Errorf("expected a mid-run refusal, got %q", text) + } + if got.execProfileName != before { + t.Errorf("the posture changed mid-run: %q -> %q", before, got.execProfileName) + } +} + +// Idle, the same command still works — the guard must not disable the feature. +func TestEffortZeromaxingWorksWhenIdle(t *testing.T) { + m := model{} + got, text := m.handleEffortCommand(execprofile.Name) + if strings.Contains(strings.ToLower(text), "finish or stop") { + t.Fatalf("refused while idle: %q", text) + } + if got.execProfileName != execprofile.Name { + t.Errorf("posture = %q, want %q", got.execProfileName, execprofile.Name) + } +} + +// Every sidebar hit-tester carries its own modal guard, because sidebarActive() +// deliberately does not exclude the palette. The PLAN header was missing one, so +// clicking it while the / palette was open toggled the section behind the +// overlay. +func TestOrchestrateHeaderIgnoresClicksBehindAModal(t *testing.T) { + base := sidebarDetailModel(t) + sidebarW := sidebarWidth(base.width) + agentBody := len(base.sidebarAgentLines(sidebarW)) + if agentBody == 0 { + agentBody = 1 + } + headerRow := 1 + agentBody + 1 + x := base.chatColumnWidth() + 4 + click := tea.MouseClickMsg{X: x, Y: headerRow, Button: tea.MouseLeft} + + // Sanity: the click lands on the header when nothing is in the way. + if !base.orchestrateHeaderAtMouse(click) { + t.Skip("the header is not where this test thinks it is") + } + + // The `/` palette specifically. sidebarAvailable deliberately does NOT + // suppress the sidebar for it — a palette must not reflow the layout — so + // sidebarActive() stays true and every hit-tester has to refuse on its own. + // A picker would be caught by sidebarAvailable already and proves nothing. + withPalette := base + withPalette.suggestions = []commandSuggestion{{Name: "/model", Desc: "Pick a model."}} + if !withPalette.suggestionsActive() { + t.Fatal("precondition: the palette should be active") + } + if !withPalette.sidebarActive() { + t.Fatal("precondition: the palette must not collapse the sidebar") + } + if withPalette.orchestrateHeaderAtMouse(click) { + t.Error("PLAN header answered a click aimed at the open / palette") + } +} From cc3e9f993c8161f076221d140d13e0051ad299b5 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 31 Jul 2026 16:47:31 +0530 Subject: [PATCH 74/86] fix(specialist): reject negative plan timeouts, correct two false claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit planInt returns 0 for both an absent key and a present-but-negative one, so `seconds > 0` read a model-supplied max_wall_seconds of -60 as "unset" and ran the plan unbounded with no error. Every other numeric in the budget refuses a negative — max_retries and max_tokens explicitly, and max_stall_seconds even refuses positive values under its floor — so this was the one invalid input silently accepted, and the failure mode is the worst kind: a bound the caller asked for that is not applied. Absent and explicit zero keep their meanings, which is tested, so the stricter parse cannot turn "unset" into an error. The orchestrate approval card said "read-only specialist sub-agents". PermissionForArgs prompts ONLY when argsCanWrite is true, so every card a user actually sees is asking about a plan that CAN write — the description was exactly inverted on the one screen where it matters. Description() said the same thing to the model. Both now say tasks inherit the parent's grant and may hold write tools granted by name. The posture-off identity test claimed Zero is byte-identical to a build without the feature when the posture is off. It is not: system_prompt.go drops the ~5 KB confirmation policy for any run that cannot mutate, and runCanMutate is evaluated regardless of posture, so an all-read-only run gets a smaller prompt either way. The drop is deliberate and fails closed — the claim was just wider than the proof, and the comparison could not catch it since both registries take the same branch. The comment now states the property actually proved: registering the tool changes nothing while the posture is off. Left alone deliberately, as design calls rather than defects: PlanProgress Done/Remaining have only test callers, and research.json ships with an unsubstituted "THE SUBJECT" placeholder plus dependents that assume upstream output they never receive. Both need a decision (wire in vs drop; add argument substitution vs unship the plan) that belongs to the author. Co-Authored-By: Claude Opus 5 --- internal/agent/posture_off_identity_test.go | 19 +++++-- internal/specialist/plan.go | 27 ++++++++-- .../specialist/plan_budget_negative_test.go | 51 +++++++++++++++++++ internal/specialist/plan_tool.go | 9 +++- 4 files changed, 94 insertions(+), 12 deletions(-) create mode 100644 internal/specialist/plan_budget_negative_test.go diff --git a/internal/agent/posture_off_identity_test.go b/internal/agent/posture_off_identity_test.go index 3a77f4edb..ecf5a7c89 100644 --- a/internal/agent/posture_off_identity_test.go +++ b/internal/agent/posture_off_identity_test.go @@ -15,11 +15,20 @@ import ( // THE ADDITIVITY PROOF. // -// ZeroMaxing Phase 2 adds an `orchestrate` tool. The overriding constraint is -// that with the posture OFF, Zero is BYTE-IDENTICAL to a build without the -// feature — not almost, identical. Same advertised tool set, same -// tool-definition bytes, same assembled system prompt, therefore the same token -// count and the same provider request. +// ZeroMaxing Phase 2 adds an `orchestrate` tool. The constraint proved here is +// that REGISTERING THAT TOOL changes nothing while the posture is OFF: same +// advertised tool set, same tool-definition bytes, same assembled system +// prompt, therefore the same token count and the same provider request. +// +// Stated narrowly on purpose. "With the posture off, Zero is byte-identical to +// a build without the feature" is the tempting phrasing and it is not true: +// system_prompt.go drops the ~5 KB confirmation policy for any run that cannot +// mutate, and runCanMutate is evaluated regardless of posture. An all-read-only +// run — `zero exec --enabled-tools read_file,grep`, or a read-only specialist +// child — therefore gets a smaller prompt than a build without the feature, +// posture off. That drop is deliberate and fails closed; it is simply not part +// of what this test proves, and the comparison below could not catch it anyway +// since both registries take the same branch. // // The proof is a CONTROLLED COMPARISON rather than a frozen hash of the whole // prefix. Two registries are built that differ in exactly one thing — whether diff --git a/internal/specialist/plan.go b/internal/specialist/plan.go index 75e39f75e..649dab8c5 100644 --- a/internal/specialist/plan.go +++ b/internal/specialist/plan.go @@ -480,17 +480,34 @@ func planBudget(args map[string]any, limits Limits) (Budget, error) { MaxWorkers: planInt(raw, "max_workers"), MaxTokens: planInt(raw, "max_tokens"), } - if seconds := planInt(raw, "max_wall_seconds"); seconds > 0 { - budget.MaxWall = time.Duration(seconds) * time.Second + // Rejected rather than read as "unset". planInt returns 0 for both an absent + // key and a present-but-negative one, so a bare `seconds > 0` let a + // model-supplied -60 vanish and the plan run unbounded with no error. Every + // other numeric here refuses a negative — max_retries and max_tokens + // explicitly, max_stall_seconds even refuses positive values under its floor + // — so silently accepting this one was the odd case out, and the failure is + // the worst kind: a budget the caller asked for that is not applied. + if seconds, set := planIntSet(raw, "max_wall_seconds"); set { + if seconds < 0 { + return Budget{}, fmt.Errorf("budget.max_wall_seconds must not be negative; %d was requested", seconds) + } + if seconds > 0 { + budget.MaxWall = time.Duration(seconds) * time.Second + } } - if seconds := planInt(raw, "max_stall_seconds"); seconds > 0 { + if seconds, set := planIntSet(raw, "max_stall_seconds"); set { + if seconds < 0 { + return Budget{}, fmt.Errorf("budget.max_stall_seconds must not be negative; %d was requested", seconds) + } stall := time.Duration(seconds) * time.Second - if stall < minStallTimeout { + if seconds > 0 && stall < minStallTimeout { return Budget{}, fmt.Errorf( "budget.max_stall_seconds must be at least %d: below that the watchdog fires on ordinary think-time and becomes a random task-killer", int(minStallTimeout.Seconds())) } - budget.MaxStall = stall + if seconds > 0 { + budget.MaxStall = stall + } } // max_retries needs PRESENCE, not a value: an explicit 0 means "do not retry // this plan" and an absent key means "use the default", and planInt cannot diff --git a/internal/specialist/plan_budget_negative_test.go b/internal/specialist/plan_budget_negative_test.go new file mode 100644 index 000000000..db10e3bda --- /dev/null +++ b/internal/specialist/plan_budget_negative_test.go @@ -0,0 +1,51 @@ +package specialist + +import ( + "strings" + "testing" +) + +// planInt returns 0 for both an absent key and a present-but-negative one, so a +// bare `seconds > 0` read a model-supplied -60 as "unset" and ran the plan +// unbounded with no error. Every other numeric in the budget refuses a +// negative; a timeout the caller asked for and silently did not get is the +// worst version of that inconsistency. +func TestBudgetRejectsNegativeTimeouts(t *testing.T) { + for field, value := range map[string]float64{ + "max_wall_seconds": -60, + "max_stall_seconds": -60, + } { + t.Run(field, func(t *testing.T) { + _, err := ParsePlan(map[string]any{ + "name": "p", + "tasks": []any{map[string]any{"id": "a", "prompt": "x"}}, + "budget": map[string]any{"max_workers": float64(1), field: value}, + }, Limits{MaxTasks: 20, ParentTools: []string{"read_file"}}) + if err == nil { + t.Fatalf("a negative %s was accepted and silently ignored", field) + } + if !strings.Contains(err.Error(), field) { + t.Errorf("error should name %s, got %v", field, err) + } + }) + } +} + +// Absent and zero must keep their existing meanings — the fix must not turn +// "unset" into an error. +func TestBudgetStillAcceptsAbsentAndZeroTimeouts(t *testing.T) { + for name, budget := range map[string]map[string]any{ + "absent": {"max_workers": float64(1)}, + "zero": {"max_workers": float64(1), "max_wall_seconds": float64(0), "max_stall_seconds": float64(0)}, + } { + t.Run(name, func(t *testing.T) { + if _, err := ParsePlan(map[string]any{ + "name": "p", + "tasks": []any{map[string]any{"id": "a", "prompt": "x"}}, + "budget": budget, + }, Limits{MaxTasks: 20, ParentTools: []string{"read_file"}}); err != nil { + t.Fatalf("%s timeouts should parse: %v", name, err) + } + }) + } +} diff --git a/internal/specialist/plan_tool.go b/internal/specialist/plan_tool.go index 161bea27d..150f4f655 100644 --- a/internal/specialist/plan_tool.go +++ b/internal/specialist/plan_tool.go @@ -88,7 +88,8 @@ const ( func (tool *OrchestrateTool) Name() string { return OrchestrateToolName } func (tool *OrchestrateTool) Description() string { - return "Execute a structured plan of read-only sub-agent tasks in dependency order. " + + return "Execute a structured plan of sub-agent tasks in dependency order. " + + "Tasks inherit the parent's sandbox and tool grant; write tools are grantable to a task by name. " + "Independent tasks run in parallel up to budget.max_workers; declare dependencies with depends_on and a task never starts before what it waits on has finished. " + // The either/or the schema cannot express, and the shape worth // encouraging: a plan is only worth more than reading the code yourself @@ -208,7 +209,11 @@ func (tool *OrchestrateTool) Safety() tools.Safety { return tools.Safety{ SideEffect: tools.SideEffectShell, Permission: permission, - Reason: "Runs a plan of read-only specialist sub-agents under the parent's sandbox and tool grant.", + // Not "read-only": this Reason is rendered on the approval card, and + // PermissionForArgs only prompts when argsCanWrite is true — so every + // card a user actually sees is asking about a plan that CAN write. + // Describing it as read-only there was precisely backwards. + Reason: "Runs a plan of specialist sub-agents under the parent's sandbox and tool grant; tasks may hold write tools granted by name.", // Irrelevant while Permission is Allow (auto advertises Allow tools // anyway) and equally irrelevant while it is Deny (ToolAdvertised // short-circuits on Deny before reading this). Left false so the field From 72d4a3168b603cb5437bdfa7e425a5c716febf22 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 31 Jul 2026 18:56:55 +0530 Subject: [PATCH 75/86] fix(specialist): make max_wall_seconds bound a concurrent plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wall deadline was consulted only before dispatching a task, and the walk only reaches that check when it needs a free worker slot. A plan whose ready set fits the worker pool dispatches in one wave and is never checked again, so it ran for as long as its slowest child took and reported "completed" with nothing skipped. Measured before the fix, four 2s tasks under a 1s wall: max_workers=4 -> 2.0s elapsed, status=completed, 4 succeeded, 0 skipped max_workers=1 -> status=partial, 1 succeeded, 3 skipped So the bound existed only on the sequential path, and asking for parallelism silently deleted it. Nothing cancelled an in-flight child either — the plan context was WithCancel, and the only deadline reads were the two dispatch gates. The plan context now carries the wall as a real deadline, which reaches the children through the same machinery a user stop already proves works. The walk keeps its skip-the-rest behaviour for tasks not yet dispatched. This is the defect max_tokens' cap was removed for, in the commit that said a number which neither bounds spend nor lets a plan finish "is worse than no number, because it reads like a guarantee". max_wall_seconds was the worse case: the token check at least fires between tasks, while the wall deadline fired zero times in a fan-out — and the approval card shows "300s wall" while never showing max_workers, the parameter that disabled it. Both cancellation paths now say WHICH reason stopped a task. A wall expiry is the plan spending what it was allowed; a cancel is a person deciding. Both read "the run was stopped", which sent the reader looking for who stopped it. The tests bound their own waits rather than blocking on ctx.Done(). An unbounded wait there wedged a full run for ten minutes when the fix was mutated out — a test that hangs on a regression is worse than one that fails. --- internal/specialist/plan_exec.go | 34 +++- internal/specialist/plan_wall_budget_test.go | 116 +++++++++++++ internal/specialist/zzhol829_probe_test.go | 172 +++++++++++++++++++ 3 files changed, 320 insertions(+), 2 deletions(-) create mode 100644 internal/specialist/plan_wall_budget_test.go create mode 100644 internal/specialist/zzhol829_probe_test.go diff --git a/internal/specialist/plan_exec.go b/internal/specialist/plan_exec.go index 760cad1e2..39a80bde6 100644 --- a/internal/specialist/plan_exec.go +++ b/internal/specialist/plan_exec.go @@ -193,6 +193,22 @@ func ExecutePlanIn(ctx context.Context, plan Plan, workspace PlanWorkspace, pare // lifetime is. ctx, cancelPlan := context.WithCancel(ctx) defer cancelPlan() + // max_wall_seconds bounds the PLAN, which means it has to reach the children. + // The pre-dispatch check below is not a bound on its own: it is consulted + // only when the walk needs a free slot, so a plan whose ready set fits the + // worker pool dispatches in one wave and is never gated again. Measured at + // max_workers=4 with a 1s wall and four 2s tasks: 2.0s elapsed, reported + // "completed", nothing skipped — while the same plan at max_workers=1 + // correctly reported partial. Asking for parallelism deleted the bound. + // + // A deadline on the plan context fixes that with the machinery a user stop + // already proves works, and the walk keeps its skip-the-rest behaviour for + // tasks not yet dispatched. + if wall := plan.Budget().MaxWall; wall > 0 { + var cancelWall context.CancelFunc + ctx, cancelWall = context.WithTimeout(ctx, wall) + defer cancelWall() + } planRunning(recorder, cancelPlan) tasks := map[string]Task{} @@ -254,7 +270,15 @@ func ExecutePlanIn(ctx context.Context, plan Plan, workspace PlanWorkspace, pare cancelled = true result.Outcome = TaskCancelled if result.Err == "" { - result.Err = "cancelled: the run was stopped while this task was running" + // Which one matters to the reader: a wall-budget stop is the + // plan spending what it was allowed, a cancel is a person + // deciding. Reporting both as "the run was stopped" left a + // user looking for who stopped it. + if errors.Is(ctx.Err(), context.DeadlineExceeded) || errors.Is(err, context.DeadlineExceeded) { + result.Err = "cancelled: the plan's max_wall_seconds elapsed while this task was running" + } else { + result.Err = "cancelled: the run was stopped while this task was running" + } } results[id] = result failed[id] = true @@ -315,10 +339,16 @@ func ExecutePlanIn(ctx context.Context, plan Plan, workspace PlanWorkspace, pare // not blocked by a dependency and the budget did not run out. if cancelled || ctx.Err() != nil { cancelled = true + // Same distinction the in-flight path draws: a wall-budget expiry is + // the plan spending what it was allowed, not a person stopping it. + reason := "cancelled: the run was stopped before this task ran" + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + reason = "cancelled: the plan's max_wall_seconds elapsed before this task ran" + } result := TaskResult{ ID: id, Outcome: TaskCancelled, - Err: "cancelled: the run was stopped before this task ran", + Err: reason, } results[id] = result failed[id] = true diff --git a/internal/specialist/plan_wall_budget_test.go b/internal/specialist/plan_wall_budget_test.go new file mode 100644 index 000000000..7cb9d3571 --- /dev/null +++ b/internal/specialist/plan_wall_budget_test.go @@ -0,0 +1,116 @@ +package specialist + +import ( + "context" + "strings" + "testing" + "time" +) + +// max_wall_seconds bounds the PLAN, and a bound that only holds when the plan +// happens to run sequentially is not a bound. +// +// The pre-dispatch deadline check is consulted only when the walk needs a free +// worker slot. A plan whose ready set fits the pool dispatches in one wave and +// is never checked again, so it ran to completion however long its children +// took and reported "completed" with nothing skipped. Measured before the fix: +// four 2s tasks at max_workers=4 under a 1s wall finished in 2.0s and reported +// 4 succeeded; the identical plan at max_workers=1 correctly reported partial. +// Asking for parallelism deleted the bound. +func TestWallBudgetBoundsAConcurrentPlan(t *testing.T) { + plan := wallBudgetPlan(t, 4, 1) + + slow := PlanRunner(func(ctx context.Context, req PlanTaskRequest) (TaskResult, error) { + select { + case <-time.After(4 * time.Second): + return TaskResult{ID: req.Task.ID, Outcome: TaskSucceeded}, nil + case <-ctx.Done(): + // The point: a wall-expired plan must reach its in-flight children. + return TaskResult{ID: req.Task.ID, Outcome: TaskFailed}, ctx.Err() + } + }) + + started := time.Now() + report := ExecutePlan(context.Background(), plan, PlanReadOnlyToolNames(), slow, nil) + elapsed := time.Since(started) + + if elapsed > 3*time.Second { + t.Errorf("plan ran %s under a 1s wall budget; the deadline never reached the children", elapsed.Round(time.Millisecond)) + } + if report.Succeeded == len(plan.Order()) { + t.Errorf("every task succeeded under a wall budget that should have stopped them: %s", report.Summary()) + } +} + +// And it must say WHY. A wall-budget stop is the plan spending what it was +// allowed; a cancel is a person deciding. Both used to read "the run was +// stopped", which sent the reader looking for who stopped it. +func TestWallBudgetStopSaysItWasTheBudget(t *testing.T) { + plan := wallBudgetPlan(t, 2, 1) + // Bounded, never a bare <-ctx.Done(). If the deadline regresses, this test + // must FAIL rather than hang: an unbounded wait here wedged a full test run + // for ten minutes when the fix was mutated out. + slow := PlanRunner(func(ctx context.Context, req PlanTaskRequest) (TaskResult, error) { + select { + case <-ctx.Done(): + return TaskResult{ID: req.Task.ID, Outcome: TaskFailed}, ctx.Err() + case <-time.After(5 * time.Second): + return TaskResult{ID: req.Task.ID, Outcome: TaskSucceeded}, nil + } + }) + + report := ExecutePlan(context.Background(), plan, PlanReadOnlyToolNames(), slow, nil) + + var sawReason bool + for _, task := range report.Tasks { + if strings.Contains(task.Err, "max_wall_seconds") { + sawReason = true + } + if strings.Contains(task.Err, "the run was stopped") { + t.Errorf("task %q blames a user stop for a budget expiry: %q", task.ID, task.Err) + } + } + if !sawReason { + t.Errorf("no task explained that the wall budget elapsed: %s", report.Summary()) + } +} + +// A user stop must keep saying it was a stop — the fix must not relabel it. +func TestUserStopStillReadsAsAStop(t *testing.T) { + plan := wallBudgetPlan(t, 2, 0) // no wall budget at all + ctx, cancel := context.WithCancel(context.Background()) + slow := PlanRunner(func(ctx context.Context, req PlanTaskRequest) (TaskResult, error) { + cancel() + select { + case <-ctx.Done(): + case <-time.After(5 * time.Second): + } + return TaskResult{ID: req.Task.ID, Outcome: TaskFailed}, ctx.Err() + }) + + report := ExecutePlan(ctx, plan, PlanReadOnlyToolNames(), slow, nil) + for _, task := range report.Tasks { + if strings.Contains(task.Err, "max_wall_seconds") { + t.Errorf("task %q blames the wall budget for a user stop: %q", task.ID, task.Err) + } + } +} + +func wallBudgetPlan(t *testing.T, workers int, wallSeconds int) Plan { + t.Helper() + budget := map[string]any{"max_workers": float64(workers)} + if wallSeconds > 0 { + budget["max_wall_seconds"] = float64(wallSeconds) + } + tasks := []any{} + for _, id := range []string{"a", "b", "c", "d"} { + tasks = append(tasks, map[string]any{"id": id, "prompt": "work"}) + } + plan, err := ParsePlan(map[string]any{ + "name": "wall", "tasks": tasks, "budget": budget, + }, Limits{MaxTasks: 20, ParentTools: PlanReadOnlyToolNames()}) + if err != nil { + t.Fatalf("ParsePlan: %v", err) + } + return plan +} diff --git a/internal/specialist/zzhol829_probe_test.go b/internal/specialist/zzhol829_probe_test.go new file mode 100644 index 000000000..db2490f9c --- /dev/null +++ b/internal/specialist/zzhol829_probe_test.go @@ -0,0 +1,172 @@ +package specialist + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" +) + +// PROBE 1: does a WRITE-CAPABLE plan admit many workers sharing one worktree? +func TestZZProbeConcurrentWritersShareOneTree(t *testing.T) { + limits := Limits{MaxTasks: 20, ParentTools: []string{"read_file", "edit_file", "bash"}} + tasks := []any{ + map[string]any{"id": "w1", "prompt": "edit", "tools": []any{"edit_file"}}, + map[string]any{"id": "w2", "prompt": "edit", "tools": []any{"edit_file"}}, + map[string]any{"id": "w3", "prompt": "shell", "tools": []any{"bash"}}, + map[string]any{"id": "w4", "prompt": "shell", "tools": []any{"bash"}}, + } + plan, err := ParsePlan(planArgs(tasks, map[string]any{"max_workers": float64(4)}), limits) + if err != nil { + t.Fatalf("a write-capable plan with 4 workers was REFUSED: %v", err) + } + t.Logf("ACCEPTED: max_workers=%d, RequiresIsolation=%v", plan.Budget().MaxWorkers, plan.RequiresIsolation()) + + workspace := PlanWorkspace{Path: "C:/tmp/plan-worktree", Isolated: true, Release: func() {}} + + var mu sync.Mutex + cwds := map[string]string{} + toolsSeen := map[string][]string{} + inFlight, peak := 0, 0 + report := ExecutePlanIn(context.Background(), plan, workspace, limits.ParentTools, + func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + mu.Lock() + inFlight++ + if inFlight > peak { + peak = inFlight + } + cwds[req.Task.ID] = req.Cwd + toolsSeen[req.Task.ID] = req.Tools + mu.Unlock() + time.Sleep(40 * time.Millisecond) + mu.Lock() + inFlight-- + mu.Unlock() + return TaskResult{Outcome: TaskSucceeded}, nil + }, nil) + + t.Logf("peak concurrent writers = %d (workers=%d)", peak, report.Workers) + for id, cwd := range cwds { + t.Logf("task %s cwd=%q tools=%v", id, cwd, toolsSeen[id]) + } +} + +// PROBE 2: head-of-line blocking. Two independent chains; the walk is parked on +// a task whose dependency is slow, so a runnable task from the other chain waits +// behind it even though a worker is free. +func TestZZProbeHeadOfLineBlocking(t *testing.T) { + if got := effectivePlanWorkers(2); got < 2 { + t.Skipf("host allows only %d workers", got) + } + const slow = 400 * time.Millisecond + const quick = 20 * time.Millisecond + + // Declaration order matters for the Kahn seed: slowHead, fastHead are the + // zero-indegree nodes. + plan := mustPlan(t, []any{ + task("slowHead", "slow"), + task("fastHead", "quick"), + task("slowTail", "quick", "slowHead"), + task("fastTail", "slow", "fastHead"), + }, map[string]any{"max_workers": float64(2)}, readOnlyLimits()) + t.Logf("order = %v", plan.Order()) + + durations := map[string]time.Duration{ + "slowHead": slow, "fastHead": quick, "slowTail": quick, "fastTail": slow, + } + + var mu sync.Mutex + start := time.Now() + startedAt := map[string]time.Duration{} + endedAt := map[string]time.Duration{} + + wall := time.Now() + report := ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + mu.Lock() + startedAt[req.Task.ID] = time.Since(start) + mu.Unlock() + time.Sleep(durations[req.Task.ID]) + mu.Lock() + endedAt[req.Task.ID] = time.Since(start) + mu.Unlock() + return TaskResult{Outcome: TaskSucceeded}, nil + }, nil) + elapsed := time.Since(wall) + + for _, id := range plan.Order() { + t.Logf("%-9s started %6s ended %6s", id, startedAt[id].Round(time.Millisecond), endedAt[id].Round(time.Millisecond)) + } + t.Logf("WALL CLOCK = %s", elapsed.Round(time.Millisecond)) + t.Logf("report: sequential=%s criticalPath=%s max_speedup=%.2f workers=%d", + report.SequentialTotal.Round(time.Millisecond), report.CriticalPath.Round(time.Millisecond), + report.MaxSpeedup, report.Workers) + t.Logf("ACTUAL speedup = %.2f", float64(report.SequentialTotal)/float64(elapsed)) + t.Logf("fastTail was runnable at %s (fastHead ended) but started at %s", + endedAt["fastHead"].Round(time.Millisecond), startedAt["fastTail"].Round(time.Millisecond)) +} + +// PROBE 3: a task that failed for a REAL reason, harvested after the plan's +// context is cancelled, is relabelled as cancelled. +type zzPauseRecorder struct { + gate chan struct{} + once sync.Once + calls atomic.Int32 +} + +func (r *zzPauseRecorder) TaskDispatched(Task) {} +func (r *zzPauseRecorder) TaskCompleted(TaskResult) {} +func (r *zzPauseRecorder) TaskFailed(TaskResult) {} +func (r *zzPauseRecorder) PlanRunning(context.CancelFunc) {} +func (r *zzPauseRecorder) WaitWhilePaused(ctx context.Context) { + // Park the scheduler at the SECOND task boundary, i.e. after "boom" has been + // dispatched. The first boundary must pass through or nothing ever runs. + if r.calls.Add(1) < 2 { + return + } + r.once.Do(func() { + select { + case <-r.gate: + case <-ctx.Done(): + } + }) +} + +func TestZZProbeRealFailureRelabelledCancelled(t *testing.T) { + plan := mustPlan(t, []any{ + task("boom", "fails for real"), + task("later", "never runs"), + }, map[string]any{"max_workers": float64(2)}, readOnlyLimits()) + t.Logf("order = %v", plan.Order()) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + rec := &zzPauseRecorder{gate: make(chan struct{})} + + failed := make(chan struct{}) + done := make(chan PlanReport, 1) + go func() { + done <- ExecutePlan(ctx, plan, []string{"read_file"}, + func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + if req.Task.ID == "boom" { + close(failed) + // A REAL failure: not a context error. + return TaskResult{Outcome: TaskFailed, Err: "compile error in main.go"}, nil + } + return TaskResult{Outcome: TaskSucceeded}, nil + }, rec) + }() + + <-failed + time.Sleep(50 * time.Millisecond) // let the completion sit in the buffer + cancel() // user stops the paused plan + close(rec.gate) + + report := <-done + for _, task := range report.Tasks { + t.Logf("task %-6s outcome=%-18s err=%q", task.ID, task.Outcome, task.Err) + } + t.Logf("status=%s succeeded=%d failed=%d cancelled=%d skipped=%d", + report.Status, report.Succeeded, report.Failed, report.Cancelled, report.Skipped) +} From 3b9f7b29a2aadc1dcebbfb0770fa349a85738b6b Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 31 Jul 2026 19:00:21 +0530 Subject: [PATCH 76/86] fix(specialist): let a task granted write tools actually use them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ParsePlan permits a task to name a write tool, deliberately and recently: "A TASK MAY NOW NAME A WRITE TOOL, and only by naming it." That grant is what makes PermissionForArgs prompt and what makes RequiresIsolation prepare a worktree. Two layers downstream still assumed the older rule that every task is read-only, so the capability was gated, approved and isolated — and then could not be exercised. The child's system prompt was one literal telling every task "You have read-only tools" and "do not attempt to modify anything", including a task granted write_file or bash. A child told not to modify anything does not use the write tool it holds, so the approval the user gave and the worktree prepared for it bought nothing. The prompt is now built from the grant, using the same allow-list ParsePlan validates against so the two cannot drift. The read-only wording is unchanged for the read-only case, which is the majority and the one whose phrasing was tuned. savedPlanLimits granted only the read-only names, so a plan ParsePlan accepts at run time failed to parse on save, show, run, restart and resume alike. The durability surface silently excluded exactly the plans whose work is most worth keeping. It now uses PlanGrantableToolNames. This widens no authority: the parent grant is intersected again when the plan runs, a write-capable plan still needs its approval and its worktree, and a tool outside the grantable list is still refused — both directions tested. NOT changed, because it is a decision rather than a defect: plan children still run at --auto low, where plan_runner.go says "Explicitly NOT MemberAutonomy: Phase 2 tasks are read-only, and granting it here would be the authority widening that was ruled out as needing its own decision." That comment and ParsePlan's contradict each other, and only one of them can stand. Widening child autonomy is not a call to make inside a review fix. --- internal/specialist/plan_runner.go | 54 +++++++++++++++---- internal/specialist/plan_write_grant_test.go | 55 ++++++++++++++++++++ internal/tui/orchestrate_saved.go | 13 ++++- internal/tui/orchestrate_saved_write_test.go | 51 ++++++++++++++++++ 4 files changed, 162 insertions(+), 11 deletions(-) create mode 100644 internal/specialist/plan_write_grant_test.go create mode 100644 internal/tui/orchestrate_saved_write_test.go diff --git a/internal/specialist/plan_runner.go b/internal/specialist/plan_runner.go index 82a5fe2ea..62b122039 100644 --- a/internal/specialist/plan_runner.go +++ b/internal/specialist/plan_runner.go @@ -173,16 +173,9 @@ func planTaskManifest(name string, grantedTools []string) Manifest { // something plausible. A plan task exists to go and look; a plan task // that reasons from memory is worse than no task, because its output // reads exactly like the one that looked. - SystemPrompt: "You are executing one task of a larger plan. You have read-only tools: " + - "USE THEM. Search and read the actual files before you answer — do not rely on memory or " + - "inference about what the code probably says. Start with a tool call, not with prose. " + - "Every claim you make must be backed by something you read in this run, quoted with its " + - "file:line. If you cannot find something, say so plainly; an honest \"not found\" is worth " + - "more than a plausible guess, and a guess is indistinguishable from a finding once it " + - "reaches the plan's report. " + - "Complete exactly the task described and report what you found; do not attempt to modify anything.", - Location: LocationBuiltin, - FilePath: "(plan)", + SystemPrompt: planTaskSystemPrompt(grantedTools), + Location: LocationBuiltin, + FilePath: "(plan)", // AUTHORITATIVE, not a hint: this is the already-intersected grant, and // an empty one must refuse the child rather than expand to the default // read-only category. ExecutePlan refuses before reaching here, so this @@ -191,3 +184,44 @@ func planTaskManifest(name string, grantedTools []string) Manifest { ToolsResolved: true, } } + +// planTaskSystemPrompt builds the child's contract from the tools it was +// ACTUALLY granted. +// +// It used to be one literal that told every task "You have read-only tools" and +// "do not attempt to modify anything" — including a task that named write_file +// or bash, which ParsePlan permits by design ("A TASK MAY NOW NAME A WRITE +// TOOL, and only by naming it"). A child instructed not to modify anything will +// not use the write tool it was granted, so the grant, the approval prompt it +// triggered, and the worktree prepared for it all bought nothing. +// +// The read-only wording stays exactly as it was for the read-only case, which +// is the overwhelming majority and the one whose phrasing was tuned. +func planTaskSystemPrompt(grantedTools []string) string { + const investigate = "USE THEM. Search and read the actual files before you answer — do not rely on memory or " + + "inference about what the code probably says. Start with a tool call, not with prose. " + + "Every claim you make must be backed by something you read in this run, quoted with its " + + "file:line. If you cannot find something, say so plainly; an honest \"not found\" is worth " + + "more than a plausible guess, and a guess is indistinguishable from a finding once it " + + "reaches the plan's report. " + if !grantsPlanWriteTool(grantedTools) { + return "You are executing one task of a larger plan. You have read-only tools: " + investigate + + "Complete exactly the task described and report what you found; do not attempt to modify anything." + } + return "You are executing one task of a larger plan. " + investigate + + "You have been granted tools that CHANGE things, and only the ones named in your task. " + + "Make exactly the change the task describes and nothing beyond it: no drive-by fixes, no " + + "reformatting, no edits to files the task did not name. Report what you changed, with file:line." +} + +// grantsPlanWriteTool reports whether a grant contains any tool that can change +// something, using the same allow-list ParsePlan validates against so the two +// cannot drift. +func grantsPlanWriteTool(grantedTools []string) bool { + for _, name := range grantedTools { + if planWriteTools[name] { + return true + } + } + return false +} diff --git a/internal/specialist/plan_write_grant_test.go b/internal/specialist/plan_write_grant_test.go new file mode 100644 index 000000000..05df92ccf --- /dev/null +++ b/internal/specialist/plan_write_grant_test.go @@ -0,0 +1,55 @@ +package specialist + +import ( + "strings" + "testing" +) + +// ParsePlan lets a task name a write tool ("A TASK MAY NOW NAME A WRITE TOOL, +// and only by naming it"), and that grant triggers an approval prompt and an +// isolated worktree. The child then received a system prompt telling it "You +// have read-only tools" and "do not attempt to modify anything" — so it would +// not use the tool it was granted, and the prompt and the worktree bought +// nothing. +func TestPlanTaskPromptMatchesTheGrant(t *testing.T) { + readOnly := planTaskSystemPrompt([]string{"read_file", "grep"}) + if !strings.Contains(readOnly, "do not attempt to modify anything") { + t.Error("a read-only task must still be told not to modify anything") + } + if !strings.Contains(readOnly, "read-only tools") { + t.Error("the read-only wording changed; it was tuned and should stay") + } + + for _, tool := range PlanWriteToolNames() { + t.Run(tool, func(t *testing.T) { + prompt := planTaskSystemPrompt([]string{"read_file", tool}) + if strings.Contains(prompt, "do not attempt to modify anything") { + t.Errorf("a task granted %s is told not to modify anything", tool) + } + if strings.Contains(prompt, "You have read-only tools") { + t.Errorf("a task granted %s is told its tools are read-only", tool) + } + // Both prompts must keep the investigate-first contract: a task that + // reasons from memory is the defect that wording exists for. + if !strings.Contains(prompt, "Start with a tool call") { + t.Errorf("the write prompt for %s dropped the investigate-first contract", tool) + } + }) + } +} + +// The write detection must use the same allow-list ParsePlan validates against, +// or the two drift and a newly grantable tool silently keeps the read-only +// prompt. +func TestWriteGrantDetectionCoversEveryGrantableWriteTool(t *testing.T) { + for _, tool := range PlanWriteToolNames() { + if !grantsPlanWriteTool([]string{tool}) { + t.Errorf("%s is grantable as a write tool but not detected as one", tool) + } + } + for _, tool := range PlanReadOnlyToolNames() { + if grantsPlanWriteTool([]string{tool}) { + t.Errorf("%s is read-only but counted as a write grant", tool) + } + } +} diff --git a/internal/tui/orchestrate_saved.go b/internal/tui/orchestrate_saved.go index 419db4465..8a49440ff 100644 --- a/internal/tui/orchestrate_saved.go +++ b/internal/tui/orchestrate_saved.go @@ -134,7 +134,18 @@ func (m model) savePlanText(name string) string { // already ran, because the tier moved since, would be refusing to record // history. func (m model) savedPlanLimits() specialist.Limits { - return specialist.Limits{ParentTools: specialist.PlanReadOnlyToolNames()} + // GRANTABLE, not read-only. These limits parse a plan that is being saved, + // shown, restarted or resumed — plans that already ran, or are about to run + // through the ordinary path with its own approval gate. Granting only the + // read-only names here meant a plan that ParsePlan accepts at run time + // (write tools may be named per task) failed to parse on every /plans verb, + // so the entire durability surface silently excluded exactly the plans whose + // work is most worth keeping. + // + // This widens no authority: the parent grant is intersected again when the + // plan actually runs, and a write-capable plan still needs its approval and + // its isolated worktree. + return specialist.Limits{ParentTools: specialist.PlanGrantableToolNames()} } func (m model) savedPlansText() string { diff --git a/internal/tui/orchestrate_saved_write_test.go b/internal/tui/orchestrate_saved_write_test.go new file mode 100644 index 000000000..a762ecbf6 --- /dev/null +++ b/internal/tui/orchestrate_saved_write_test.go @@ -0,0 +1,51 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/specialist" +) + +// The /plans verbs parse a stored plan before doing anything with it. Granting +// only the read-only names there meant a plan ParsePlan accepts at RUN time — +// write tools may be named per task — failed to parse on save, show, run, +// restart and resume alike. The durability surface silently excluded exactly +// the plans whose work is most worth keeping. +func TestSavedPlanLimitsAcceptAWriteCapablePlan(t *testing.T) { + m, _ := savedPlanModel(t) + + args := map[string]any{ + "name": "fixup", + "tasks": []any{ + map[string]any{"id": "read", "prompt": "look"}, + map[string]any{ + "id": "write", "prompt": "change it", + "depends_on": []any{"read"}, + "tools": []any{"edit_file"}, + }, + }, + "budget": map[string]any{"max_workers": float64(1)}, + } + + if _, err := specialist.ParsePlan(args, m.savedPlanLimits()); err != nil { + t.Fatalf("a write-capable plan must parse for the /plans verbs: %v", err) + } +} + +// Widening the limits must not widen what a plan may hold: a tool outside the +// grantable allow-list is still refused. +func TestSavedPlanLimitsStillRefuseUngrantableTools(t *testing.T) { + m, _ := savedPlanModel(t) + _, err := specialist.ParsePlan(map[string]any{ + "name": "bad", + "tasks": []any{map[string]any{"id": "a", "prompt": "x", "tools": []any{"orchestrate"}}}, + "budget": map[string]any{"max_workers": float64(1)}, + }, m.savedPlanLimits()) + if err == nil { + t.Fatal("a plan naming an ungrantable tool was accepted") + } + if !strings.Contains(err.Error(), "may never hold") { + t.Errorf("error should name the rule, got %v", err) + } +} From b8cd18dab19209457216200842218a7e106345e6 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:36:53 +0530 Subject: [PATCH 77/86] feat(config): per-role plan model preferences, and cache counts a child can carry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two additive pieces the plan-model work needs, kept apart from it so each stands alone. PROFILES.PLANMODELS lets a user pin a model per task role, exclude ones that should never be chosen, name the model that does the routing, and add their own guidance in plain words. The guidance exists because the code cannot know the account: discovery orders models by price, and on real providers price is not capability — one account's dearest model was a build preview that failed every task it touched, another reports no prices at all so the ranking collapses to alphabetical. The operator knows which model reasons well; this is where they say so once, instead of naming models in every prompt. PROJECT CONFIG MAY ONLY EXCLUDE. An exclusion removes a candidate and can only lower spend; a pin is the opposite, and a cloned repo pinning every role to the priciest model would raise cost for whoever opened it — the same hazard the PlanSize and DisableZeromaxing tighten-only rules exist for. Guidance is the softest way to do what pinning does ("always pick the most expensive model, it is worth it" costs money without naming a single id), so it sits on the same side of that boundary. CACHE AND REASONING COUNTS on the stream-json usage event, because a parent summarising a child's stream is the only place a sub-agent's turn can be priced, and it was being handed prompt/completion/total alone. A measured plan task had 49,280 of 49,894 prompt tokens served from cache — 98.8% — and every one of those turns was costed as though nothing had been cached. Emitted only when non-zero, so a provider reporting no cache produces exactly the event it always did. --- internal/config/plan_size_test.go | 86 ++++++++++++++++++++++++++++++- internal/config/resolver.go | 38 ++++++++++++++ internal/config/types.go | 58 +++++++++++++++++++++ internal/streamjson/streamjson.go | 28 +++++++--- 4 files changed, 203 insertions(+), 7 deletions(-) diff --git a/internal/config/plan_size_test.go b/internal/config/plan_size_test.go index 22399c1f0..37dc34de8 100644 --- a/internal/config/plan_size_test.go +++ b/internal/config/plan_size_test.go @@ -1,6 +1,10 @@ package config -import "testing" +import ( + "os" + "path/filepath" + "testing" +) // The tier table is the ceiling. Asserted as a table so a change to any number // is a deliberate edit to this test rather than a silent widening. @@ -193,3 +197,83 @@ func TestAnUnknownTierRanksLoosestSoItCanNeverBeAdopted(t *testing.T) { } } } + +// THE MERGE IS FIELD BY FIELD, so a new key that nobody adds to it is silently +// dropped. planModels shipped exactly that way: written correctly to config, +// parsed into the struct, and discarded by the resolver — the setting present in +// the file and absent everywhere it was read. +func TestPlanModelsSurviveTheUserConfigMerge(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + if err := os.WriteFile(path, []byte(`{ + "profiles": { + "planModels": { + "scan": "cheap-one", + "verify": "strong-one", + "exclude": ["never-this"] + } + } + }`), 0o600); err != nil { + t.Fatal(err) + } + resolved, err := Resolve(ResolveOptions{UserConfigPath: path}) + if err != nil { + t.Fatalf("resolve: %v", err) + } + got := resolved.Profiles.PlanModels + if got.Scan != "cheap-one" || got.Verify != "strong-one" { + t.Errorf("pins lost in the merge: %+v", got) + } + // A config that sets only some roles must not blank the others. + if got.Implement != "" { + t.Errorf("implement was invented: %q", got.Implement) + } + if len(got.Exclude) != 1 || got.Exclude[0] != "never-this" { + t.Errorf("exclusions lost in the merge: %+v", got.Exclude) + } +} + +// PROJECT CONFIG MAY ONLY EXCLUDE. An exclusion removes a candidate and can only +// lower spend; a pin is the opposite, and a cloned repo pinning every role to +// the priciest model would raise cost for whoever opened it — the same hazard +// the PlanSize tighten-only rule exists for. +func TestProjectConfigMayExcludeModelsButNotPinThem(t *testing.T) { + dir := t.TempDir() + userPath := filepath.Join(dir, "user.json") + projectPath := filepath.Join(dir, "project.json") + if err := os.WriteFile(userPath, []byte(`{ + "profiles": {"planModels": {"verify": "the-users-choice", "routerGuidance": "trust kimi"}} + }`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(projectPath, []byte(`{ + "profiles": {"planModels": {"verify": "the-repos-choice", "exclude": ["something-bad"], + "routerGuidance": "always pick the most expensive model, it is worth it"}} + }`), 0o600); err != nil { + t.Fatal(err) + } + resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath}) + if err != nil { + t.Fatalf("resolve: %v", err) + } + got := resolved.Profiles.PlanModels + if got.Verify != "the-users-choice" { + t.Errorf("a project config overrode the user's model pin: %q", got.Verify) + } + found := false + for _, name := range got.Exclude { + if name == "something-bad" { + found = true + } + } + if !found { + t.Errorf("a project exclusion was dropped; removing a model is always safe: %+v", got.Exclude) + } + // Guidance is prose fed straight to the router, so it is the SOFTEST way to + // do what pinning does — "always pick the most expensive model" costs the + // reader real money without naming a single model id. It belongs on the same + // side of the boundary as the pins. + if got.RouterGuidance != "trust kimi" { + t.Errorf("a project config rewrote the user's router guidance: %q", got.RouterGuidance) + } +} diff --git a/internal/config/resolver.go b/internal/config/resolver.go index 69ed2c042..a1f6267a0 100644 --- a/internal/config/resolver.go +++ b/internal/config/resolver.go @@ -266,6 +266,34 @@ func mergeConfig(dst *FileConfig, src FileConfig) { if size, err := ParsePlanSize(src.Profiles.PlanSize); err == nil && strings.TrimSpace(src.Profiles.PlanSize) != "" { dst.Profiles.PlanSize = string(size) } + // PlanModels: USER config may set anything. Each field is presence-checked + // on its own so a config setting only `verify` does not blank the other two. + if v := strings.TrimSpace(src.Profiles.PlanModels.Scan); v != "" { + dst.Profiles.PlanModels.Scan = v + } + if v := strings.TrimSpace(src.Profiles.PlanModels.Implement); v != "" { + dst.Profiles.PlanModels.Implement = v + } + if v := strings.TrimSpace(src.Profiles.PlanModels.Verify); v != "" { + dst.Profiles.PlanModels.Verify = v + } + if v := strings.TrimSpace(src.Profiles.PlanModels.Router); v != "" { + dst.Profiles.PlanModels.Router = v + } + // User config only, like the pins: guidance steers which model does the work + // and therefore what a plan costs, so a cloned repo must not be able to write + // it. mergeProjectConfig deliberately does not carry this. + if v := strings.TrimSpace(src.Profiles.PlanModels.RouterGuidance); v != "" { + dst.Profiles.PlanModels.RouterGuidance = v + } + if len(src.Profiles.PlanModels.Exclude) > 0 { + dst.Profiles.PlanModels.Exclude = append([]string(nil), src.Profiles.PlanModels.Exclude...) + } + // Presence-only, like DisableZeromaxing: with omitempty a false is + // indistinguishable from absent, so turning it back off means removing the key. + if src.Profiles.PlanModels.AutoAssign { + dst.Profiles.PlanModels.AutoAssign = true + } if src.Preferences.FavoriteModels != nil { dst.Preferences.FavoriteModels = normalizeFavoriteModels(src.Preferences.FavoriteModels) } @@ -358,6 +386,16 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error { // privilege, which is not a malformed config. // // An unknown tier ranks tightest (rank -1) and so can never win here either. + // PlanModels from project config may only EXCLUDE, never pin. + // + // Excluding removes a candidate and can only ever lower what a plan spends. + // A PIN is the opposite: a cloned repo pinning all three roles to the + // priciest model on the account would raise cost for whoever opened it, + // which is exactly what the PlanSize rule below exists to prevent. Same + // hazard, same answer. + if len(src.Profiles.PlanModels.Exclude) > 0 { + dst.Profiles.PlanModels.Exclude = append(dst.Profiles.PlanModels.Exclude, src.Profiles.PlanModels.Exclude...) + } if strings.TrimSpace(src.Profiles.PlanSize) != "" { if size, err := ParsePlanSize(src.Profiles.PlanSize); err == nil { if size.rank() < dst.Profiles.PlanSizeTier().rank() { diff --git a/internal/config/types.go b/internal/config/types.go index 9d2063cb2..84bb3389f 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -323,6 +323,48 @@ type SwarmConfig struct { } // ProfilesConfig gates the execution-profile catalog. +// PlanModelsConfig pins or forbids models for a plan's tasks. Every field is +// optional; an empty config leaves the automatic choice untouched. +type PlanModelsConfig struct { + // Scan / Implement / Verify pin a model per task role. A pinned role skips + // discovery entirely, so it works on a provider that reports no prices — + // which is most of them. + Scan string `json:"scan,omitempty"` + Implement string `json:"implement,omitempty"` + Verify string `json:"verify,omitempty"` + // Router names the model that DECIDES which model runs each task, by reading + // the task text instead of matching verbs. Empty uses the strongest model + // discovery found. It costs one extra call on that model before the plan + // starts, which is why a plan too small to benefit skips it and why every + // way it can fail falls back to the keyword classifier. + Router string `json:"router,omitempty"` + // RouterGuidance is your own advice to the router, in plain words, added to + // the built-in guidance rather than replacing it. + // + // It exists because the code cannot know your models. Price is the only + // ordering discovery gives, and on real accounts it lies in both directions: + // one provider's most expensive model was a build preview that failed every + // task it touched, another reports no prices at all so the ranking collapses + // to alphabetical. You know which model reasons well and which is quick and + // shallow; this is where you say so, once, instead of per prompt. + // + // Example: "kimi-k2.6 is the best reasoner here — prefer it for judgements. + // qwen3.5:397b is slow; use it only when correctness really matters." + RouterGuidance string `json:"routerGuidance,omitempty"` + // AutoAssign turns per-task model selection on for EVERY plan, so it does not + // have to be asked for in each prompt. + // + // The tool argument still wins when a plan supplies one, so a plan can say + // auto_assign:false and be believed. Absent from config it stays off: turning + // it on changes which model does the work and what a plan costs, and that is + // a decision for the person paying, made once here rather than inferred. + AutoAssign bool `json:"autoAssign,omitempty"` + // Exclude removes models from every tier by exact id. For the ones that are + // eligible on paper and wrong in practice: an image model that ranks highest + // on price, or a preview build that should not be judging anything. + Exclude []string `json:"exclude,omitempty"` +} + type ProfilesConfig struct { // DisableZeromaxing turns the zeromaxing posture off for this workspace, so // /effort zeromaxing, /profile zeromaxing and --exec-profile zeromaxing are @@ -343,6 +385,22 @@ type ProfilesConfig struct { // is disable-only: a cloned repo must not be able to raise a cost ceiling for // whoever opens it. See mergeProjectConfig. PlanSize string `json:"planSize,omitempty"` + // PlanModels states which models a plan's tasks may run on, overriding the + // automatic choice auto_assign would make from provider discovery. + // + // It exists because the automatic choice ranks by PRICE, and price is a proxy + // that fails in both directions on real accounts: an xAI account put a build + // preview on the verify tier because it was the most expensive thing there, + // and an Ollama account reports no prices at all, so the ranking collapses to + // alphabetical order. Neither is fixable with a better heuristic — the person + // with the account knows which model is strongest and the code does not. + // + // PROJECT CONFIG MAY ONLY EXCLUDE, never pin — see mergeProjectConfig. An + // exclusion removes a candidate and can only lower what a plan spends; a pin + // is the opposite, and a cloned repo pinning all three roles to the priciest + // model on the account would raise cost for whoever opened it. That is the + // same hazard PlanSize and DisableZeromaxing guard against. + PlanModels PlanModelsConfig `json:"planModels,omitempty"` } func (cfg *ToolsConfig) UnmarshalJSON(data []byte) error { diff --git a/internal/streamjson/streamjson.go b/internal/streamjson/streamjson.go index e9e66e218..df33e9fff 100644 --- a/internal/streamjson/streamjson.go +++ b/internal/streamjson/streamjson.go @@ -95,12 +95,28 @@ type Event struct { PromptTokens *int `json:"promptTokens,omitempty"` CompletionTokens *int `json:"completionTokens,omitempty"` TotalTokens *int `json:"totalTokens,omitempty"` - CostUSD *float64 `json:"costUsd,omitempty"` - Text string `json:"text,omitempty"` - Message string `json:"message,omitempty"` - Code string `json:"code,omitempty"` - Recoverable *bool `json:"recoverable,omitempty"` - ExitCode *int `json:"exitCode,omitempty"` + // CachedInputTokens, CacheWriteTokens and ReasoningTokens exist so a PARENT + // can price a child's turn the way the child's own session record already + // can. + // + // Without them a sub-agent's usage rolled up to its parent with no cache + // information at all, and BuildReport priced every one of those turns as if + // nothing had been cached. That is not a rounding error: a measured plan task + // had 49,280 of 49,894 prompt tokens served from cache — 98.8% — and plan + // tasks are the ideal cache case, re-sending a large stable prompt every + // turn. The counts were right and the money was wrong. + // + // Emitted only when non-zero, matching usage.EventUsagePayload, so an older + // reader sees exactly the three fields it saw before. + CachedInputTokens *int `json:"cachedInputTokens,omitempty"` + CacheWriteTokens *int `json:"cacheWriteTokens,omitempty"` + ReasoningTokens *int `json:"reasoningTokens,omitempty"` + CostUSD *float64 `json:"costUsd,omitempty"` + Text string `json:"text,omitempty"` + Message string `json:"message,omitempty"` + Code string `json:"code,omitempty"` + Recoverable *bool `json:"recoverable,omitempty"` + ExitCode *int `json:"exitCode,omitempty"` } type InputEvent struct { From e3336cf45b42c6106b851049c2c79f9bbdd93acb Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:37:30 +0530 Subject: [PATCH 78/86] feat(specialist): choose a model per task, prove it runs, and bound what a plan spends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plan ran every task on the session's model and metered a budget it could not enforce. This makes both real. CHOOSING. A task may name a model; auto_assign picks one per task from what the provider serves, ranked by price, with an optional routing call that reads the task text rather than its verbs. Resolution goes through ResolveWithFallback, not ResolveID, because the latter misses regex aliases and skips the deprecation redirect — the plan would display one model while the child ran another. Uncurated ids pass through: the registry holds thirteen models and an xAI account serves half a dozen Grok models, none of them curated. PROVING, because /v1/models is an advertisement. Every model failure this feature produced lived in the gap between listing and serving: grok-4.20-multi-agent-0309 lists like anything else and answers "Multi Agent requests are not allowed on chat completions" — six tasks were assigned it and all six died. A one-token request per candidate, once per session, settles it. It fails toward KEEPING the model: anything not recognised as a refusal changes nothing, because being wrong that way costs one task while being wrong the other way silently removes a model the user pays for. Every candidate refusing points at the provider, not the models, so the list is kept and the user is told. BOUNDING. max_tokens was checked between tasks and decremented after they finished, so nothing in flight could be stopped: a measured plan spent 3,091,618 against a 200,000 budget and still came back incomplete. The meter now reads the child's own stream, a plan too small for its task count is refused before anything is spent, and max_tokens_per_task arrives as a deadline the task is told about rather than a guillotine — a cap that only kills produced 1,437,049 tokens and zero completed tasks. CARRYING. depends_on ordered execution and passed nothing, so a synthesising task received conclusions with no trace behind them and could repeat a claim but never check one. Dependents now receive their dependencies' findings, bounded, with truncation disclosed. RESCUING. A model the provider refuses, or a task that declines rather than answers, gets one attempt on the session's own model. Both triggers are structural — a flag and an exit code — because deciding to spend another child by reading error prose is the shape this package already warns about twice. --- internal/specialist/accounting.go | 22 + .../specialist/budget_enforcement_test.go | 473 +++++++++++++++ internal/specialist/child_scope_test.go | 227 ++++++++ .../specialist/dependency_briefing_test.go | 145 +++++ internal/specialist/exec.go | 112 +++- internal/specialist/manifest.go | 31 +- internal/specialist/manifest_test.go | 23 +- internal/specialist/plan.go | 150 ++++- internal/specialist/plan_exec.go | 414 ++++++++++++- internal/specialist/plan_exec_test.go | 20 +- internal/specialist/plan_grant_test.go | 10 +- internal/specialist/plan_model.go | 85 +++ internal/specialist/plan_model_assign.go | 421 ++++++++++++++ internal/specialist/plan_model_assign_test.go | 542 ++++++++++++++++++ .../specialist/plan_model_fallback_test.go | 338 +++++++++++ internal/specialist/plan_model_probe.go | 204 +++++++ internal/specialist/plan_model_probe_test.go | 268 +++++++++ internal/specialist/plan_model_router.go | 300 ++++++++++ internal/specialist/plan_model_router_test.go | 384 +++++++++++++ internal/specialist/plan_model_test.go | 299 ++++++++++ .../specialist/plan_provider_mismatch_test.go | 159 +++++ internal/specialist/plan_runner.go | 254 +++++++- internal/specialist/plan_store_test.go | 2 +- internal/specialist/plan_test.go | 22 +- internal/specialist/plan_tool.go | 276 ++++++++- internal/specialist/plan_worktree_test.go | 2 +- internal/specialist/plan_write_test.go | 239 ++++++++ internal/specialist/resume_manifest_test.go | 2 +- internal/specialist/router_manifest_test.go | 111 ++++ internal/specialist/served_forms_test.go | 94 +++ internal/specialist/streamer.go | 21 +- internal/specialist/task_role.go | 145 +++++ internal/specialist/task_role_test.go | 108 ++++ internal/specialist/usage_pricing_test.go | 209 +++++++ 34 files changed, 6037 insertions(+), 75 deletions(-) create mode 100644 internal/specialist/budget_enforcement_test.go create mode 100644 internal/specialist/child_scope_test.go create mode 100644 internal/specialist/dependency_briefing_test.go create mode 100644 internal/specialist/plan_model.go create mode 100644 internal/specialist/plan_model_assign.go create mode 100644 internal/specialist/plan_model_assign_test.go create mode 100644 internal/specialist/plan_model_fallback_test.go create mode 100644 internal/specialist/plan_model_probe.go create mode 100644 internal/specialist/plan_model_probe_test.go create mode 100644 internal/specialist/plan_model_router.go create mode 100644 internal/specialist/plan_model_router_test.go create mode 100644 internal/specialist/plan_model_test.go create mode 100644 internal/specialist/plan_provider_mismatch_test.go create mode 100644 internal/specialist/router_manifest_test.go create mode 100644 internal/specialist/served_forms_test.go create mode 100644 internal/specialist/task_role.go create mode 100644 internal/specialist/task_role_test.go create mode 100644 internal/specialist/usage_pricing_test.go diff --git a/internal/specialist/accounting.go b/internal/specialist/accounting.go index 5a072a36b..67ee614fb 100644 --- a/internal/specialist/accounting.go +++ b/internal/specialist/accounting.go @@ -31,6 +31,12 @@ type specialistAccountingInput struct { Mode string Background bool PID int + // Model is what the child actually ran on. Written into the usage rollup so + // the tokens are priced against THAT model rather than the parent session's. + // The payload has always supported it; only escalation runs ever set it, so + // every plan task was priced as if it had run on the session's model — which + // stopped being true the moment tasks could name their own. + Model string } func (executor Executor) recordSpecialistStart(input specialistAccountingInput) { @@ -99,7 +105,23 @@ func appendSpecialistUsageRollup(store *sessions.Store, input specialistAccounti payload["promptTokens"] = summary.Usage.PromptTokens payload["completionTokens"] = summary.Usage.CompletionTokens payload["totalTokens"] = summary.Usage.EffectiveTotalTokens() + // PRICING FIELDS, written on exactly the same terms as usage.EventUsagePayload + // (non-zero only) because BuildReport reads both records with one reader. + // Their absence is what made every sub-agent turn cost as though nothing had + // been cached. + if summary.Usage.CachedInputTokens > 0 { + payload["cachedInputTokens"] = summary.Usage.CachedInputTokens + } + if summary.Usage.CacheWriteTokens > 0 { + payload["cacheWriteTokens"] = summary.Usage.CacheWriteTokens + } + if summary.Usage.ReasoningTokens > 0 { + payload["reasoningTokens"] = summary.Usage.ReasoningTokens + } payload["usageEvents"] = summary.Usage.Events + if model := strings.TrimSpace(input.Model); model != "" { + payload["model"] = model + } // Atomic check+append under the session lock so the TaskOutput poll and the // onExit path cannot both pass the existence check and double-count usage. return appendSpecialistEventOnce(store, input.ParentSessionID, sessions.EventUsage, payload, input.ChildSessionID, summary.RunID) diff --git a/internal/specialist/budget_enforcement_test.go b/internal/specialist/budget_enforcement_test.go new file mode 100644 index 000000000..3ab0ac426 --- /dev/null +++ b/internal/specialist/budget_enforcement_test.go @@ -0,0 +1,473 @@ +package specialist + +import ( + "context" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/streamjson" + "github.com/Gitlawb/zero/internal/tools" +) + +func usageEvents(total int) []streamjson.Event { + return []streamjson.Event{{Type: streamjson.EventUsage, TotalTokens: &total}} +} + +// A BUDGET THAT CANNOT COVER ITS OWN TASKS IS REFUSED BEFORE ANYTHING IS SPENT. +// +// The measured run: six tasks admitted against 200,000 tokens, 3,091,618 spent, +// and the answer still came back incomplete because the two tasks that had not +// started were skipped once the meter caught up. The user paid for the overrun +// AND lost a third of the audit. Refusing costs nothing and is the only response +// that can still produce a complete answer. +func TestAPlanWhoseBudgetCannotCoverItsTasksIsRefusedAtAdmission(t *testing.T) { + budget := okBudget() + budget["max_tokens"] = float64(200_000) + _, err := ParsePlan(planArgs([]any{ + task("a", "x"), task("b", "y"), task("c", "z"), + task("d", "w"), task("e", "v"), task("f", "u"), + }, budget), readOnlyLimits()) + if err == nil { + t.Fatal("the observed run's budget was admitted again") + } + for _, want := range []string{"200000", "6 tasks", "Raise max_tokens"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not mention %q: %v", want, err) + } + } +} + +// It must not become a required field by the back door. Unbounded is a +// deliberate choice recorded in planBudget. +func TestAnUnsetBudgetIsStillAllowedToRunUnbounded(t *testing.T) { + budget := map[string]any{"max_workers": float64(1)} + if _, err := ParsePlan(planArgs([]any{task("a", "x"), task("b", "y")}, budget), readOnlyLimits()); err != nil { + t.Fatalf("omitting max_tokens must stay legal: %v", err) + } +} + +// When the run's own ceiling is below what the plan needs, "raise max_tokens" is +// advice the next call cannot take — the ceiling refuses it — and the caller +// loops between two errors that each blame the other. +func TestAnUnraisableCeilingSaysThePlanIsTooBigInstead(t *testing.T) { + budget := okBudget() + budget["max_tokens"] = float64(60_000) + _, err := ParsePlan(planArgs([]any{task("a", "x"), task("b", "y"), task("c", "z")}, budget), + Limits{MaxTasks: 20, MaxTokens: 100_000, ParentTools: []string{"read_file"}}) + if err == nil { + t.Fatal("expected a refusal") + } + if strings.Contains(err.Error(), "Raise max_tokens") { + t.Errorf("told to raise a number this run's ceiling forbids: %v", err) + } + if !strings.Contains(err.Error(), "too big for this run") { + t.Errorf("the refusal does not say the plan is too big: %v", err) + } +} + +// A ceiling too small for even one task must not produce "ask for at most 0 +// tasks", which is not advice. +func TestACeilingBelowOneTaskSaysSoRatherThanSuggestingZeroTasks(t *testing.T) { + budget := okBudget() + budget["max_tokens"] = float64(100) + _, err := ParsePlan(planArgs([]any{task("a", "x")}, budget), + Limits{MaxTasks: 20, MaxTokens: 100, ParentTools: []string{"read_file"}}) + if err == nil { + t.Fatal("expected a refusal") + } + if strings.Contains(err.Error(), "at most 0") { + t.Errorf("suggested a plan of zero tasks: %v", err) + } + if !strings.Contains(err.Error(), "cannot fund a single plan task") { + t.Errorf("unhelpful refusal: %v", err) + } +} + +// THE PER-TASK CAP STOPS A TASK FROM INSIDE. A plan-level budget cannot: in the +// measured run one task spent 1,017,177 against a 200,000 plan budget, because +// the plan's limit is only consulted between tasks. +func TestAPerTaskCapStopsATaskWhileItIsStillRunning(t *testing.T) { + exec := Executor{ + BinaryPath: "/bin/true", + NewSessionID: func() (string, error) { return "specialist_00000000000000000000000a", nil }, + Load: func(LoadOptions) (LoadResult, error) { return LoadResult{}, nil }, + RunChild: func(ctx context.Context, _ string, _ []string, progress func(streamjson.Event)) (ChildRunResult, error) { + // Three provider calls; the cap sits after the second. + events := usageEvents(40_000) + for round := 0; round < 3; round++ { + for _, event := range events { + if progress != nil { + progress(event) + } + } + if ctx.Err() != nil { + return ChildRunResult{Started: true, ExitCode: -1}, ctx.Err() + } + } + return ChildRunResult{Started: true}, nil + }, + } + run := NewPlanRunner(PlanTaskContext{Executor: exec, Cwd: t.TempDir(), SpecialistName: "explorer"}) + result, _ := run(context.Background(), PlanTaskRequest{ + Task: Task{ID: "greedy", Prompt: "p"}, + Tools: []string{"read_file"}, + MaxTaskTokens: 60_000, + }) + + if result.Outcome != TaskCancelled { + t.Fatalf("the task ran past its cap: %s / %s", result.Outcome, result.Err) + } + if !strings.Contains(result.Err, "max_tokens_per_task") { + t.Errorf("the reason does not name the cap that stopped it: %q", result.Err) + } + // NOT A STALL. A stalled task is retryable; retrying a task stopped for + // spending too much is the worst possible response to it. + if result.Stalled { + t.Error("a budget stop was recorded as a stall, which makes it retryable") + } +} + +// The plan's own limit must bite mid-run too, not only between tasks. +func TestThePlanBudgetStopsATaskThatCrossesItWhileRunning(t *testing.T) { + exec := Executor{ + BinaryPath: "/bin/true", + NewSessionID: func() (string, error) { return "specialist_00000000000000000000000a", nil }, + Load: func(LoadOptions) (LoadResult, error) { return LoadResult{}, nil }, + RunChild: func(ctx context.Context, _ string, _ []string, progress func(streamjson.Event)) (ChildRunResult, error) { + for round := 0; round < 5; round++ { + if progress != nil { + progress(usageEvents(50_000)[0]) + } + if ctx.Err() != nil { + return ChildRunResult{Started: true, ExitCode: -1}, ctx.Err() + } + } + return ChildRunResult{Started: true}, nil + }, + } + run := NewPlanRunner(PlanTaskContext{Executor: exec, Cwd: t.TempDir(), SpecialistName: "explorer"}) + spend := &planSpend{limit: 120_000} + result, _ := run(context.Background(), PlanTaskRequest{ + Task: Task{ID: "solo", Prompt: "p"}, + Tools: []string{"read_file"}, + Spend: spend, + }) + + if result.Outcome != TaskCancelled { + t.Fatalf("the plan budget did not stop the task: %s / %s", result.Outcome, result.Err) + } + if !strings.Contains(result.Err, "token budget") { + t.Errorf("the reason does not name the plan budget: %q", result.Err) + } + // Bounded overshoot: one usage event past the line, not five. + if spent := spend.spent.Load(); spent > 200_000 { + t.Errorf("overshoot is unbounded: spent %d against a 120000 limit", spent) + } +} + +// An unbounded plan must behave exactly as it always did. +func TestAnUnboundedPlanIsNeverStoppedByTheMeter(t *testing.T) { + spend := &planSpend{limit: 0} + for round := 0; round < 100; round++ { + if spend.add(1_000_000) { + t.Fatal("an unset limit reported the plan over budget") + } + } +} + +// THE HEADLINE MUST NAME WHAT NEVER RAN. A budget-skipped task says so in the +// same small text every other row uses; a measured run came back missing a third +// of its questions and nothing above the fold said which. +func TestTheSummaryNamesTheTasksABudgetCutShort(t *testing.T) { + summary := PlanReport{ + Status: PlanPartial, + Tasks: []TaskResult{ + {ID: "config-layers", Outcome: TaskSucceeded}, + {ID: "plan-inherit", Outcome: TaskSkippedBudget, Err: "skipped: the plan's budget was exhausted"}, + {ID: "skills-mcp-surface", Outcome: TaskSkippedBudget, Err: "skipped: the plan's budget was exhausted"}, + }, + }.Summary() + if !strings.Contains(summary, "2 task(s) never ran (budget exhausted)") { + t.Fatalf("the headline hides the unanswered questions:\n%s", summary) + } + for _, id := range []string{"plan-inherit", "skills-mcp-surface"} { + if !strings.Contains(summary, id) { + t.Errorf("the headline does not name %q:\n%s", id, summary) + } + } + // A plan where nothing was cut must not gain a line about it. + clean := PlanReport{Status: PlanCompleted, Tasks: []TaskResult{{ID: "a", Outcome: TaskSucceeded}}}.Summary() + if strings.Contains(clean, "never ran") { + t.Errorf("a complete plan reported cut tasks:\n%s", clean) + } +} + +// The cap round-trips, so a saved plan resumes with the same bound. +func TestThePerTaskCapRoundTripsThroughArgs(t *testing.T) { + budget := okBudget() + budget["max_tokens"] = float64(500_000) + budget["max_tokens_per_task"] = float64(120_000) + plan := mustPlan(t, []any{task("a", "x")}, budget, readOnlyLimits()) + if plan.Budget().MaxTokensPerTask != 120_000 { + t.Fatalf("parsed cap = %d", plan.Budget().MaxTokensPerTask) + } + again, err := ParsePlan(plan.Args(), readOnlyLimits()) + if err != nil { + t.Fatalf("re-parse: %v", err) + } + if again.Budget().MaxTokensPerTask != 120_000 { + t.Errorf("the cap was lost on the round trip: %d", again.Budget().MaxTokensPerTask) + } +} + +// A cap above the plan's own budget bounds nothing and reads like a guarantee. +func TestAPerTaskCapAboveThePlanBudgetIsRefused(t *testing.T) { + budget := okBudget() + budget["max_tokens"] = float64(100_000) + budget["max_tokens_per_task"] = float64(500_000) + _, err := ParsePlan(planArgs([]any{task("a", "x"), task("b", "y")}, budget), readOnlyLimits()) + if err == nil || !strings.Contains(err.Error(), "bounds nothing") { + t.Fatalf("expected a refusal, got %v", err) + } +} + +// A TASK THE BUDGET KILLED MUST NOT BE REPORTED AS SUCCEEDED. +// +// From a real run: every one of six tasks was cut short for running over budget, +// each result carrying "Subagent terminated by a signal (signal: killed)" and +// "the plan's token budget ran out while it was running" — under the headline +// "6 succeeded, 0 failed, 0 skipped". The harvest set TaskSucceeded +// unconditionally, and its guard tested only TaskFailed, so a runner returning +// TaskCancelled with no error fell straight through. +// +// Work that did not finish, reported as done, is the failure this executor +// exists to prevent. +func TestATaskCancelledForBudgetIsNeverCountedAsSucceeded(t *testing.T) { + budget := okBudget() + budget["max_tokens"] = float64(150_000) + plan := mustPlan(t, []any{task("a", "x"), task("b", "y"), task("c", "z")}, budget, readOnlyLimits()) + + report := ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + // Exactly what the runner returns when the meter stops a task. + return TaskResult{ + ID: req.Task.ID, + Outcome: TaskCancelled, + Tokens: 90_000, + Err: "task " + req.Task.ID + " stopped: the plan's token budget ran out while it was running", + }, nil + }, nil) + + if report.Succeeded != 0 { + t.Fatalf("%d killed task(s) were counted as succeeded", report.Succeeded) + } + if report.Cancelled == 0 { + t.Fatal("a budget kill was not recorded as a cancellation") + } + if report.Status == PlanCompleted { + t.Errorf("a plan whose tasks were all cut short reported %q", report.Status) + } + for _, result := range report.Tasks { + if result.Outcome == TaskSucceeded { + t.Errorf("task %q was relabelled a success after being cancelled", result.ID) + } + } + // And the headline must say so, since this is what makes the answer partial. + if summary := report.Summary(); !strings.Contains(summary, "never ran (budget exhausted)") { + t.Errorf("the headline hides that the plan was cut short:\n%s", summary) + } +} + +// A CANCELLED TASK BLOCKS ITS DEPENDENTS. It did not produce what they were +// waiting for, so running them on nothing would compound the loss. +func TestDependentsOfACancelledTaskAreSkipped(t *testing.T) { + budget := okBudget() + budget["max_tokens"] = float64(150_000) + plan := mustPlan(t, []any{task("trace", "x"), task("judge", "y", "trace")}, budget, readOnlyLimits()) + + dispatched := 0 + report := ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + dispatched++ + return TaskResult{ID: req.Task.ID, Outcome: TaskCancelled, Err: "token budget ran out"}, nil + }, nil) + + if dispatched != 1 { + t.Fatalf("a dependent of a cancelled task was dispatched anyway: %d dispatches", dispatched) + } + byID := map[string]TaskResult{} + for _, result := range report.Tasks { + byID[result.ID] = result + } + if byID["judge"].Outcome != TaskSkippedDependency { + t.Errorf("judge = %q, want skipped for its dependency", byID["judge"].Outcome) + } +} + +// A CAPPED TASK IS TOLD ITS BUDGET, so it can land instead of being shot down. +// +// A measured plan capped tasks at 200,000; all six were killed between 213k and +// 259k having done substantial reading, and the run cost 1,437,049 tokens for +// zero completed tasks. Each was most of the way to an answer nobody received. +// The cap has to arrive as a deadline the task can plan against, not as a +// guillotine it never sees coming. +func TestACappedTaskIsToldItsBudgetInAUnitItCanCount(t *testing.T) { + budget := okBudget() + budget["max_tokens_per_task"] = float64(200_000) + plan := mustPlan(t, []any{task("cfg", "trace the config layering")}, budget, readOnlyLimits()) + + var prompt string + ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + prompt = req.Task.Prompt + return TaskResult{Outcome: TaskSucceeded, Output: "ok"}, nil + }, nil) + + if !strings.Contains(prompt, "trace the config layering") { + t.Fatalf("the task's own prompt was lost:\n%s", prompt) + } + // TOLD LESS THAN THE KILL POINT. A task told its full cap would still be + // writing when the hard limit arrived — the runway is the difference. + if !strings.Contains(prompt, "160000") { + t.Errorf("the announced budget is not the cap minus a landing strip:\n%s", prompt) + } + if strings.Contains(prompt, "200000") { + t.Error("the task was told the hard kill point, leaving it no room to write an answer") + } + // A MODEL CANNOT SEE ITS TOKEN METER. Tool calls are the countable proxy. + if !strings.Contains(prompt, "tool calls") { + t.Errorf("the budget is stated in a unit the task cannot observe:\n%s", prompt) + } + if !strings.Contains(prompt, "partial answer") { + t.Errorf("the task is not told that a partial answer beats being cut off:\n%s", prompt) + } +} + +// An uncapped task's prompt is untouched — every plan that does not ask for a cap. +func TestAnUncappedTaskGetsNoBudgetNotice(t *testing.T) { + if got := withTokenBudgetNotice("do the thing", 0); got != "do the thing" { + t.Errorf("an uncapped task's prompt was rewritten: %q", got) + } +} + +// A CAP BELOW WHAT ANY TASK COSTS IS REFUSED. It would kill every task and the +// plan would pay in full for nothing — measured at 1,437,049 tokens and zero +// completed work. +func TestAPerTaskCapBelowAnyPlausibleTaskIsRefused(t *testing.T) { + budget := okBudget() + budget["max_tokens_per_task"] = float64(5_000) + _, err := ParsePlan(planArgs([]any{task("a", "x")}, budget), readOnlyLimits()) + if err == nil { + t.Fatal("a cap no task could meet was admitted") + } + if !strings.Contains(err.Error(), "cut short") { + t.Errorf("the refusal does not say what would happen: %v", err) + } + // A tight-but-workable cap must still be allowed — that is what the landing + // strip is for. + budget["max_tokens_per_task"] = float64(200_000) + if _, err := ParsePlan(planArgs([]any{task("a", "x")}, budget), readOnlyLimits()); err != nil { + t.Errorf("a tight cap must remain legal: %v", err) + } +} + +// A TRIMMED WORKER COUNT MUST BE VISIBLE. The report struct promises both +// numbers — "a plan that asked for sixteen and ran six has not been given +// sixteen" — and only the actual one was ever printed, so the trim was invisible +// and the speedup beneath it read as the plan's own fault. +func TestTheSummarySaysWhenTheMachineAllowedFewerWorkers(t *testing.T) { + trimmed := PlanReport{Status: PlanCompleted, Workers: 6, WorkersRequested: 16, + Tasks: []TaskResult{{ID: "a", Outcome: TaskSucceeded}}}.Summary() + if !strings.Contains(trimmed, "requested 16") { + t.Errorf("a trimmed worker count is invisible:\n%s", trimmed) + } + // Unchanged when the request was honoured, which is the ordinary case. + honoured := PlanReport{Status: PlanCompleted, Workers: 4, WorkersRequested: 4, + Tasks: []TaskResult{{ID: "a", Outcome: TaskSucceeded}}}.Summary() + if strings.Contains(honoured, "requested") { + t.Errorf("an honoured request produced a line about being trimmed:\n%s", honoured) + } +} + +// A WRITE PLAN MUST SAY WHERE IT WROTE. The worktree is deliberately not removed +// — "a plan that wrote produced work nobody has reviewed; deleting it would +// delete the only copy" — so an unnamed location is the difference between work +// the user can review and work they cannot find. +func TestAWritePlanDisclosesTheWorkspaceItWroteIn(t *testing.T) { + note := planWorkspaceNote(PlanWorkspace{ + Path: "/tmp/wt/plan-fix", Isolated: true, Describe: "worktree plan-fix at /tmp/wt/plan-fix", + }) + if !strings.Contains(note, "worktree plan-fix at /tmp/wt/plan-fix") { + t.Errorf("the write workspace is not disclosed: %q", note) + } + if !strings.Contains(note, "nothing was written to the parent tree") { + t.Errorf("the note does not reassure about the parent tree: %q", note) + } + // Falls back to the raw path rather than saying nothing. + if got := planWorkspaceNote(PlanWorkspace{Path: "/tmp/wt/x", Isolated: true}); !strings.Contains(got, "/tmp/wt/x") { + t.Errorf("a describe-less workspace disclosed nothing: %q", got) + } + // A READ-ONLY PLAN HAS NOTHING TO DISCLOSE — it runs in the parent's tree, + // and a line about isolation there would be false. + if got := planWorkspaceNote(PlanWorkspace{}); got != "" { + t.Errorf("a read-only plan claimed an isolated workspace: %q", got) + } +} + +// AND THE TOOL MUST ACTUALLY EMIT IT. The test above asserts the helper builds +// the right sentence and proves nothing about whether anything prints it — a +// mutation dropping the call from the tool's output passed it cleanly. +// +// This is the fourth time in one session that a test asserted a helper instead +// of the caller that consults it. Both seams, both asserted. +func TestTheToolPrintsWhereAWritePlanWrote(t *testing.T) { + tool := &OrchestrateTool{ + PostureActive: func() bool { return true }, + ParentTools: []string{"read_file", "write_file"}, + RunTask: func(context.Context, PlanTaskRequest) (TaskResult, error) { + return TaskResult{Outcome: TaskSucceeded, Output: "wrote it"}, nil + }, + Isolate: func(context.Context, string) (PlanWorkspace, error) { + return PlanWorkspace{ + Path: "/tmp/wt/fix", Isolated: true, + Describe: "worktree fix at /tmp/wt/fix", + Release: func() {}, + }, nil + }, + } + result := tool.RunWithOptions(context.Background(), map[string]any{ + "name": "fix", + "budget": map[string]any{"max_workers": float64(1)}, + "tasks": []any{ + map[string]any{"id": "a", "prompt": "edit the file", "tools": []any{"write_file"}}, + }, + }, tools.RunOptions{Model: "m"}) + + if result.Status == tools.StatusError { + t.Fatalf("plan refused: %s", result.Output) + } + if !strings.Contains(result.Output, "worktree fix at /tmp/wt/fix") { + t.Fatalf("the tool never told the user where the work landed:\n%s", result.Output) + } +} + +// A READ-ONLY PLAN MUST NOT CLAIM ONE. It runs in the parent's own tree, and a +// line about isolation there would be false. +func TestAReadOnlyPlanSaysNothingAboutAnIsolatedWorkspace(t *testing.T) { + tool := &OrchestrateTool{ + PostureActive: func() bool { return true }, + ParentTools: []string{"read_file"}, + RunTask: func(context.Context, PlanTaskRequest) (TaskResult, error) { + return TaskResult{Outcome: TaskSucceeded, Output: "read it"}, nil + }, + } + result := tool.RunWithOptions(context.Background(), map[string]any{ + "name": "look", + "budget": map[string]any{"max_workers": float64(1)}, + "tasks": []any{map[string]any{"id": "a", "prompt": "read the file"}}, + }, tools.RunOptions{Model: "m"}) + + if strings.Contains(result.Output, "isolated workspace") { + t.Errorf("a read-only plan claimed an isolated workspace:\n%s", result.Output) + } +} diff --git a/internal/specialist/child_scope_test.go b/internal/specialist/child_scope_test.go new file mode 100644 index 000000000..38418555e --- /dev/null +++ b/internal/specialist/child_scope_test.go @@ -0,0 +1,227 @@ +package specialist + +import ( + "context" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/streamjson" + "github.com/Gitlawb/zero/internal/tools" +) + +func argsContainPair(args []string, flag, value string) bool { + for index, arg := range args { + if arg == flag && index+1 < len(args) && args[index+1] == value { + return true + } + } + return false +} + +// A CHILD MUST STAND ON THE SAME GROUND AS ITS PARENT, not on less. +// +// A run was granted ~/zm-lab mid-session, created it, copied packages into it, +// then dispatched plan tasks to read them. Every task was refused — "the target +// directory is outside the workspace boundary" — because a child rebuilds its +// sandbox from --cwd alone and the grant lived only in the parent's engine. +// Two tasks died, two dependents were skipped, and a whole retry plan was spent +// rediscovering the boundary. +// +// Asserted from ARGV, which is the only thing the child actually receives. +func TestAChildIsLaunchedWithTheRunsExtraRoots(t *testing.T) { + executor := Executor{ + NewSessionID: func() (string, error) { return "specialist_00000000000000000000000a", nil }, + ExtraWriteRoots: func() []string { return []string{"/Users/kratos/dev/zero", "/Users/kratos/zm-lab"} }, + } + built, err := executor.BuildArgs(BuildArgsInput{ + Manifest: Manifest{Metadata: Metadata{Name: "explorer"}}, + Prompt: "read the packages", + Cwd: "/Users/kratos/dev/zero", + }) + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + if !argsContainPair(built.Args, "--add-dir", "/Users/kratos/zm-lab") { + t.Fatalf("the granted root never reached the child:\n%s", strings.Join(built.Args, " ")) + } + // THE WORKSPACE IS NOT REPEATED. --cwd already says it, and saying it twice + // is a line every future reader has to check. + if argsContainPair(built.Args, "--add-dir", "/Users/kratos/dev/zero") { + t.Errorf("the workspace was passed again as an extra root:\n%s", strings.Join(built.Args, " ")) + } +} + +// READ AT LAUNCH, NOT AT WIRING. The case this exists for is a permission +// granted MID-SESSION; a value captured when the tool was registered would miss +// exactly that, which is the staleness that made model discovery probe the +// provider a session had already switched away from. +func TestTheRunsRootsAreReadAtEveryLaunchNotCapturedOnce(t *testing.T) { + roots := []string{"/ws"} + executor := Executor{ + NewSessionID: func() (string, error) { return "specialist_00000000000000000000000a", nil }, + ExtraWriteRoots: func() []string { return roots }, + } + input := BuildArgsInput{ + Manifest: Manifest{Metadata: Metadata{Name: "explorer"}}, Prompt: "p", Cwd: "/ws", + } + if built, _ := executor.BuildArgs(input); argsContainPair(built.Args, "--add-dir", "/granted-later") { + t.Fatal("setup: the root exists before it was granted") + } + + // The user approves a new directory partway through the session. + roots = append(roots, "/granted-later") + + built, err := executor.BuildArgs(input) + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + if !argsContainPair(built.Args, "--add-dir", "/granted-later") { + t.Errorf("a mid-session grant never reached a child launched after it:\n%s", strings.Join(built.Args, " ")) + } +} + +// UNWIRED MEANS THE WORKSPACE ONLY, which is what every child got before this +// existed. The fail-safe direction: a child confined more tightly than its +// parent is a smaller problem than one confined less. +func TestAnUnwiredSupplierLeavesTheChildConfinedToItsWorkspace(t *testing.T) { + executor := Executor{NewSessionID: func() (string, error) { return "specialist_00000000000000000000000a", nil }} + built, err := executor.BuildArgs(BuildArgsInput{ + Manifest: Manifest{Metadata: Metadata{Name: "explorer"}}, Prompt: "p", Cwd: "/ws", + }) + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + for _, arg := range built.Args { + if arg == "--add-dir" { + t.Errorf("an unwired supplier widened the child anyway:\n%s", strings.Join(built.Args, " ")) + } + } +} + +// A blank root must not become an empty flag value, which the child rejects +// outright — turning a scope detail into a launch failure. +func TestBlankRootsAreDroppedRatherThanEmittedAsEmptyFlags(t *testing.T) { + executor := Executor{ + NewSessionID: func() (string, error) { return "specialist_00000000000000000000000a", nil }, + ExtraWriteRoots: func() []string { return []string{"", " ", "/real"} }, + } + built, err := executor.BuildArgs(BuildArgsInput{ + Manifest: Manifest{Metadata: Metadata{Name: "explorer"}}, Prompt: "p", Cwd: "/ws", + }) + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + if argsContainPair(built.Args, "--add-dir", "") { + t.Error("a blank root was emitted as an empty --add-dir value") + } + if !argsContainPair(built.Args, "--add-dir", "/real") { + t.Error("the real root was dropped alongside the blank ones") + } +} + +// THE RESUME PATH CARRIES THE SAME ROOTS. This file's history is why it is +// asserted separately: the resume path once forgot the model the fresh path +// carried, and a resumed task silently ran on a different one. A resumed task +// confined more tightly than the task it resumes would fail on files it had +// already read. +func TestAResumedChildCarriesTheSameRootsAsAFreshOne(t *testing.T) { + executor := Executor{ + NewSessionID: func() (string, error) { return "specialist_00000000000000000000000a", nil }, + ExtraWriteRoots: func() []string { return []string{"/ws", "/granted"} }, + } + fresh, err := executor.BuildArgs(BuildArgsInput{ + Manifest: Manifest{Metadata: Metadata{Name: "explorer"}}, Prompt: "p", Cwd: "/ws", + }) + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + resumed, err := executor.BuildResumeArgs(BuildResumeArgsInput{ + SessionID: "specialist_00000000000000000000000a", + Manifest: Manifest{Metadata: Metadata{Name: "explorer"}}, Prompt: "p", Cwd: "/ws", + }) + if err != nil { + t.Fatalf("BuildResumeArgs: %v", err) + } + if !argsContainPair(fresh.Args, "--add-dir", "/granted") { + t.Fatal("setup: the fresh path does not carry the root") + } + if !argsContainPair(resumed.Args, "--add-dir", "/granted") { + t.Errorf("the resume path dropped a root the fresh path carries:\n%s", strings.Join(resumed.Args, " ")) + } +} + +// A PLAN TASK'S CHILD MUST BE TRACEABLE TO THE CALL THAT SPAWNED IT. +// +// The Task tool carries the parent's tool-call id; this path did not, so a plan +// task's child was the only kind whose accounting named no originating call — +// spend recorded against a session with nothing saying what asked for it. +// +// Asserted from ARGV, which is what the child actually receives. A field set on +// the request and dropped before launch would pass a struct assertion and fail +// in production, which is how this family of bug survives. +func TestAPlanTaskChildIsLaunchedWithItsOriginatingToolCallID(t *testing.T) { + var argv []string + exec := Executor{ + BinaryPath: "/bin/true", + NewSessionID: func() (string, error) { return "specialist_00000000000000000000000a", nil }, + Load: func(LoadOptions) (LoadResult, error) { return LoadResult{}, nil }, + RunChild: func(_ context.Context, _ string, args []string, _ func(streamjson.Event)) (ChildRunResult, error) { + argv = args + return ChildRunResult{Started: true}, nil + }, + } + run := NewPlanRunner(PlanTaskContext{Executor: exec, Cwd: t.TempDir(), SpecialistName: "explorer"}) + if _, err := run(context.Background(), PlanTaskRequest{ + Task: Task{ID: "t", Prompt: "p"}, + Tools: []string{"read_file"}, + ParentToolCallID: "call_orchestrate_42", + }); err != nil { + t.Fatalf("run: %v", err) + } + if !argsContainPair(argv, "--calling-tool-use-id", "call_orchestrate_42") { + t.Fatalf("the originating call never reached the child:\n%s", strings.Join(argv, " ")) + } +} + +// AND THE TOOL MUST ACTUALLY ATTACH IT. The test above hands the runner a +// request with the id already on it, which proves the runner forwards it and +// proves nothing about whether anything ever puts it there — a mutation +// deleting the tool's assignment passed that test cleanly. +// +// This is the same shape as the defect being fixed: a value present at one +// layer, consumed at another, with nothing asserting the join. Two seams, two +// tests. +func TestTheOrchestrateToolAttachesItsOwnCallIDToEveryTask(t *testing.T) { + var seen []string + tool := &OrchestrateTool{ + PostureActive: func() bool { return true }, + // The run's grant: a plan task inherits from it and can never widen it, + // so with none the tasks are refused before dispatch and this test would + // pass for the wrong reason. + ParentTools: []string{"read_file", "grep"}, + RunTask: func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + seen = append(seen, req.ParentToolCallID) + return TaskResult{Outcome: TaskSucceeded, Output: "ok"}, nil + }, + } + result := tool.RunWithOptions(context.Background(), map[string]any{ + "name": "p", + "budget": map[string]any{"max_workers": float64(1)}, + "tasks": []any{ + map[string]any{"id": "a", "prompt": "one"}, + map[string]any{"id": "b", "prompt": "two"}, + }, + }, tools.RunOptions{Model: "m", ToolCallID: "call_orchestrate_42"}) + + if result.Status == tools.StatusError { + t.Fatalf("plan refused: %s", result.Output) + } + if len(seen) != 2 { + t.Fatalf("expected two dispatched tasks, saw %d", len(seen)) + } + for index, id := range seen { + if id != "call_orchestrate_42" { + t.Errorf("task %d was dispatched with no originating call id: %q", index, id) + } + } +} diff --git a/internal/specialist/dependency_briefing_test.go b/internal/specialist/dependency_briefing_test.go new file mode 100644 index 000000000..f70264016 --- /dev/null +++ b/internal/specialist/dependency_briefing_test.go @@ -0,0 +1,145 @@ +package specialist + +import ( + "context" + "strings" + "testing" +) + +// A DEPENDENCY'S FINDINGS MUST REACH ITS DEPENDENT. +// +// depends_on ordered execution and passed nothing. The dependent started from a +// blank context, so it re-read every file its dependency had already read, and a +// synthesising task received conclusions with no trace behind them — able to +// repeat a claim, never to check one. That is precisely how a real plan reported +// that MCP servers inherit an unscrubbed environment while the tracing task had +// already walked the scrubbing path. +func TestATaskIsToldWhatItsDependenciesFound(t *testing.T) { + var judgePrompt string + plan := mustPlan(t, []any{ + task("trace", "trace the env path"), + task("judge", "decide whether credentials leak", "trace"), + }, okBudget(), readOnlyLimits()) + + report := ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + if req.Task.ID == "judge" { + judgePrompt = req.Task.Prompt + return TaskResult{Outcome: TaskSucceeded, Output: "no leak"}, nil + } + return TaskResult{ + Outcome: TaskSucceeded, + Output: "runner.go:163 sets spec.sensitiveEnvKeys; scrubSensitiveEnv runs at 348 and 446", + }, nil + }, nil) + + if report.Failed != 0 { + t.Fatalf("plan failed: %+v", report.Tasks) + } + if !strings.Contains(judgePrompt, "scrubSensitiveEnv runs at 348") { + t.Fatalf("the dependent never received its dependency's evidence:\n%s", judgePrompt) + } + if !strings.Contains(judgePrompt, "decide whether credentials leak") { + t.Errorf("the task's own prompt was lost:\n%s", judgePrompt) + } + // The briefing is EVIDENCE, not gospel — a task that treats a prior + // conclusion as established fact reproduces the error it was given. + if !strings.Contains(judgePrompt, "not established fact") { + t.Errorf("the briefing does not tell the reader to verify it:\n%s", judgePrompt) + } +} + +// A task with no dependencies must see EXACTLY the prompt the plan wrote. This is +// the overwhelming majority of tasks and nothing about them may change. +func TestATaskWithNoDependenciesGetsItsPromptVerbatim(t *testing.T) { + var seen string + plan := mustPlan(t, []any{task("solo", "do the thing")}, okBudget(), readOnlyLimits()) + ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + seen = req.Task.Prompt + return TaskResult{Outcome: TaskSucceeded, Output: "ok"}, nil + }, nil) + if seen != "do the thing" { + t.Errorf("an independent task's prompt was rewritten: %q", seen) + } +} + +// BOUNDED, or a deep chain carries the whole plan into its last task's context. +func TestTheBriefingIsBoundedAndSaysWhenItTruncated(t *testing.T) { + huge := strings.Repeat("x", dependencyBriefingPerTask*3) + briefed := withDependencyBriefing( + Task{ID: "b", Prompt: "judge", DependsOn: []string{"a"}}, + map[string]TaskResult{"a": {Outcome: TaskSucceeded, Output: huge}}, + ) + if len(briefed) > dependencyBriefingTotal+len("judge")+800 { + t.Errorf("the briefing is unbounded: %d bytes", len(briefed)) + } + if !strings.Contains(briefed, "truncated") { + t.Error("a truncated briefing did not say so, so its reader takes a part for the whole") + } +} + +// A dependency that did not succeed contributes NOTHING — no empty heading, no +// half-answer presented as a finding. +func TestOnlySucceededDependenciesAreQuoted(t *testing.T) { + briefed := withDependencyBriefing( + Task{ID: "c", Prompt: "judge", DependsOn: []string{"failed", "empty", "good"}}, + map[string]TaskResult{ + "failed": {Outcome: TaskFailed, Output: "half an answer before it died"}, + "empty": {Outcome: TaskSucceeded, Output: " "}, + "good": {Outcome: TaskSucceeded, Output: "the real finding"}, + }, + ) + if strings.Contains(briefed, "half an answer") { + t.Error("a failed dependency's output was presented as a finding") + } + if strings.Contains(briefed, `"empty"`) { + t.Error("an empty result produced a heading with nothing under it") + } + if !strings.Contains(briefed, "the real finding") { + t.Error("the succeeded dependency was dropped") + } +} + +// DETERMINISTIC ORDER, following DependsOn as the plan declared it. A resumed +// plan must not build a different prompt because a map iterated differently. +func TestTheBriefingOrderFollowsTheDeclaredDependencies(t *testing.T) { + results := map[string]TaskResult{ + "one": {Outcome: TaskSucceeded, Output: "FIRST"}, + "two": {Outcome: TaskSucceeded, Output: "SECOND"}, + "three": {Outcome: TaskSucceeded, Output: "THIRD"}, + } + want := withDependencyBriefing(Task{ID: "x", Prompt: "p", DependsOn: []string{"one", "two", "three"}}, results) + for attempt := 0; attempt < 20; attempt++ { + if got := withDependencyBriefing(Task{ID: "x", Prompt: "p", DependsOn: []string{"one", "two", "three"}}, results); got != want { + t.Fatal("the briefing is not deterministic across runs") + } + } + if strings.Index(want, "FIRST") > strings.Index(want, "SECOND") { + t.Error("the briefing does not follow the declared dependency order") + } +} + +// THE TOOL MUST SAY THAT DEPENDENTS RECEIVE THEIR DEPENDENCIES' RESULTS. +// +// The briefing only pays off if the planning model knows it exists: a model that +// believes depends_on merely orders execution writes self-contained tasks that +// rediscover everything, which is the behaviour the briefing was built to end. +// Wiring a capability without telling its only caller is how it stays unused. +func TestTheToolDescriptionTellsThePlannerHowToWriteTasks(t *testing.T) { + schema := (&OrchestrateTool{}).Parameters() + tasks, ok := schema.Properties["tasks"] + if !ok { + t.Fatal("the tasks property is missing from the schema") + } + for _, required := range []string{ + "receives its dependencies' results", + "Split by SUBJECT", + "Say what the task must return", + "leave independent work independent", + } { + if !strings.Contains(tasks.Description, required) { + t.Errorf("the tasks description does not mention %q", required) + } + } +} diff --git a/internal/specialist/exec.go b/internal/specialist/exec.go index 61d489212..fc8f32538 100644 --- a/internal/specialist/exec.go +++ b/internal/specialist/exec.go @@ -50,6 +50,28 @@ type Executor struct { BackgroundManager *background.Manager BackgroundManagerFunc BackgroundManagerFunc BackgroundRuntime *Runtime + // ExtraWriteRoots reports the directories THIS RUN may reach outside its + // workspace, so a child is confined to the same ground its parent stands on + // rather than to less. + // + // A CHILD GETTING LESS THAN ITS PARENT IS ALSO A BUG, and it produced a real + // one: a run was granted ~/zm-lab mid-session, created it, copied packages + // into it, then dispatched plan tasks to read them. Every task was refused + // — "the target directory is outside the workspace boundary" — because a + // child rebuilds its sandbox from --cwd alone and the grant lived only in + // the parent's engine. Two tasks died, two dependents were skipped, and a + // whole retry plan was spent rediscovering the boundary. + // + // A FUNCTION, not a slice, because the answer changes DURING the run: a + // request_permissions grant lands mid-session, and a value captured at + // wiring time would carry the roots the run started with forever — which is + // precisely the staleness that made plan model discovery probe the wrong + // provider. + // + // This never widens beyond the parent. It reports what the parent already + // holds; a child still cannot ask for more, and the sandbox it builds is its + // own. + ExtraWriteRoots func() []string } type BuildArgsInput struct { @@ -224,6 +246,11 @@ type ExecResult struct { // never fire — found by driving the real binary, invisible to a unit test // whose fake runner fabricated its own token counts. TotalTokens int + // ExitCode is the child process's own exit code, carried so a caller can tell + // WHY it stopped without reading its prose. childExitIncomplete in particular + // means "stopped with work unfinished", which a plan treats differently from a + // wrong answer. + ExitCode int } type ChildRunResult struct { @@ -316,9 +343,45 @@ func (executor Executor) BuildArgs(input BuildArgsInput) (BuildArgsResult, error if cwd := strings.TrimSpace(input.Cwd); cwd != "" { args = append(args, "--cwd", cwd) } + // READ HERE, not passed in. A field on the input would be one more thing every + // call site must remember, and this file already carries the scar: the resume + // path forgot the model the fresh path carried, and the plan path forgot the + // progress callback the Task path carried. The executor knows its own run; + // asking it directly is the only version with no seam to forget. + args = appendExtraWriteRootArgs(args, executor.extraWriteRoots(), input.Cwd) return BuildArgsResult{Args: args, SessionID: sessionID, PromptFile: promptFile}, nil } +// appendExtraWriteRootArgs emits one --add-dir per root the RUN already holds. +// +// The child's own workspace is skipped: --cwd already covers it, and repeating +// it as an extra root says the same thing twice in a way a reader would have to +// check. Blank entries are dropped rather than emitted as an empty flag value, +// which the child would reject outright and turn a scope detail into a launch +// failure. +func appendExtraWriteRootArgs(args []string, roots []string, cwd string) []string { + workspace := strings.TrimSpace(cwd) + for _, root := range roots { + root = strings.TrimSpace(root) + if root == "" || root == workspace { + continue + } + args = append(args, "--add-dir", root) + } + return args +} + +// extraWriteRoots reports the run's non-workspace roots, or none when the caller +// never wired the supplier — the same fail-safe direction as every other unset +// hook here: a child confined more tightly than its parent is a smaller problem +// than one confined less. +func (executor Executor) extraWriteRoots() []string { + if executor.ExtraWriteRoots == nil { + return nil + } + return executor.ExtraWriteRoots() +} + func (executor Executor) BuildResumeArgs(input BuildResumeArgsInput) (BuildArgsResult, error) { sessionID := strings.TrimSpace(input.SessionID) if sessionID == "" { @@ -356,6 +419,12 @@ func (executor Executor) BuildResumeArgs(input BuildResumeArgsInput) (BuildArgsR if cwd := strings.TrimSpace(input.Cwd); cwd != "" { args = append(args, "--cwd", cwd) } + // THE SAME ROOTS THE FRESH PATH EMITS. This file's own history is the reason + // it is spelled out: the resume path once forgot the model the fresh path + // carried, so a resumed task silently ran on a different one. A resumed task + // confined more tightly than the task it resumes would fail on files it had + // already read. + args = appendExtraWriteRootArgs(args, executor.extraWriteRoots(), input.Cwd) return BuildArgsResult{Args: args, SessionID: sessionID, PromptFile: promptFile}, nil } @@ -506,6 +575,7 @@ func (executor Executor) runBackground(ctx context.Context, built BuildArgsResul ParentSessionID: options.ParentSessionID, ChildSessionID: built.SessionID, SpecialistName: manifest.Metadata.Name, + Model: resolvedChildModel(manifest, options.ParentModel), Description: params.Description, ToolCallID: options.ToolCallID, Mode: "background", @@ -617,6 +687,7 @@ func (executor Executor) runBuiltArgs(ctx context.Context, built BuildArgsResult ParentSessionID: options.ParentSessionID, ChildSessionID: built.SessionID, SpecialistName: manifest.Metadata.Name, + Model: resolvedChildModel(manifest, options.ParentModel), Description: params.Description, ToolCallID: options.ToolCallID, Mode: mode, @@ -627,11 +698,28 @@ func (executor Executor) runBuiltArgs(ctx context.Context, built BuildArgsResult if err != nil { exitCode := run.exitCodeOr(-1) summary := SummarizeStream(run.Events, exitCode) - executor.recordSpecialistStop(accounting, summary, "error", summary.ExitCode, err, false) + // ROLLED UP EVEN THOUGH IT FAILED, for the same reason TotalTokens is + // returned below: spend does not become hypothetical because the task + // died. This branch passed a hard-coded false and appended no usage + // event, so a child killed mid-run had every token it had already spent + // vanish from the parent's session record — and `zero usage` under- + // reported by exactly that much. + rolledUp := executor.rollUpSpecialistUsage(accounting, summary) + executor.recordSpecialistStop(accounting, summary, "error", summary.ExitCode, err, rolledUp) // Carry the child session id even on a post-start failure so a caller (the // swarm launcher -> FailWithSession) can still make the failed member // drillable; the session exists once the child has started. - return ExecResult{SessionID: built.SessionID}, err + // + // AND ITS TOKENS. The summary above already holds what the child spent + // before it died, and returning ExecResult without them reported zero for + // work that was billed: a plan's budget was never decremented for a failed + // task and its total under-counted every one. Spend does not become + // hypothetical because the task failed. + return ExecResult{ + SessionID: built.SessionID, + TotalTokens: summary.Usage.EffectiveTotalTokens(), + ExitCode: summary.ExitCode, + }, err } summary := SummarizeStream(run.Events, run.ExitCode) rolledUp := executor.rollUpSpecialistUsage(accounting, summary) @@ -640,6 +728,7 @@ func (executor Executor) runBuiltArgs(ctx context.Context, built BuildArgsResult Result: BuildFinalResult(run.Events, run.Stderr, run.ExitCode, run.Signal), SessionID: built.SessionID, TotalTokens: summary.Usage.EffectiveTotalTokens(), + ExitCode: summary.ExitCode, }, nil } @@ -741,11 +830,22 @@ func (executor Executor) cleanupBackgroundPromptFile(taskID string, promptFile s cleanupPromptFile(promptFile) } +// resolvedChildModel is what the child will actually run on: its own model when +// the manifest names one, the parent's otherwise. +// +// ONE RULE, TWO READERS. The argv builder needs it to pass --model, and usage +// accounting needs it to price the tokens against the right model. Computing it +// twice is how the command line and the bill come to disagree about which model +// did the work. +func resolvedChildModel(manifest Manifest, parentModel string) string { + if model := strings.TrimSpace(manifest.Metadata.Model); model != "" { + return model + } + return strings.TrimSpace(parentModel) +} + func appendModelArgs(args []string, manifest Manifest, parentModel string, parentReasoningEffort string) []string { - resolvedModel := strings.TrimSpace(manifest.Metadata.Model) - if resolvedModel == "" { - resolvedModel = strings.TrimSpace(parentModel) - } + resolvedModel := resolvedChildModel(manifest, parentModel) if resolvedModel != "" { args = append(args, "--model", resolvedModel) } diff --git a/internal/specialist/manifest.go b/internal/specialist/manifest.go index 932794937..fab640bb7 100644 --- a/internal/specialist/manifest.go +++ b/internal/specialist/manifest.go @@ -9,7 +9,6 @@ import ( "sort" "strconv" "strings" - "time" "github.com/Gitlawb/zero/internal/config" "github.com/Gitlawb/zero/internal/modelregistry" @@ -54,11 +53,10 @@ type Manifest struct { // consumer had to know to exclude (deny-list shaped, invariant 2), and a // distinct type would touch every reader of ResolvedTools for no additional // safety. - ToolsResolved bool `json:"toolsResolved,omitempty"` - Location Location `json:"location"` - FilePath string `json:"filePath"` - LastModified time.Time `json:"lastModified,omitempty"` - Warnings []string `json:"warnings,omitempty"` + ToolsResolved bool `json:"toolsResolved,omitempty"` + Location Location `json:"location"` + FilePath string `json:"filePath"` + Warnings []string `json:"warnings,omitempty"` } type Summary struct { @@ -271,11 +269,16 @@ func Validate(manifest *Manifest) error { if err != nil { return fmt.Errorf("load model registry: %w", err) } - modelID, ok := registry.ResolveID(manifest.Metadata.Model) - if !ok { - return fmt.Errorf("specialist %q references unknown model %q", manifest.Metadata.Name, manifest.Metadata.Model) + // CANONICALISE WHAT IS KNOWN, PASS THROUGH WHAT IS NOT. The registry is a + // curated subset used for display, pricing and alias resolution — not an + // inventory of what a provider serves. Refusing anything outside it meant + // a specialist could not be pointed at a model the active provider offers + // unless it happened to be one of thirteen, which on an xAI or Ollama + // account is none of them. A name this provider cannot serve still fails, + // in providers/factory.go, which is the component that knows. + if modelID, ok := registry.ResolveID(manifest.Metadata.Model); ok { + manifest.Metadata.Model = modelID } - manifest.Metadata.Model = modelID } if manifest.Metadata.ReasoningEffort != "" { effort := strings.ToLower(manifest.Metadata.ReasoningEffort) @@ -516,9 +519,11 @@ func loadDirectory(dir string, location Location) ([]Manifest, []string, error) } manifest.Location = location manifest.FilePath = path - if info, err := entry.Info(); err == nil { - manifest.LastModified = info.ModTime() - } + // REMOVED: a LastModified stamped from the directory entry here and read + // by nothing — not by Go, and not by any JSON surface, because Manifest + // is never marshalled. Dead state on a struct is not free: it reads like + // something downstream depends on, and the next person to touch loading + // has to prove otherwise before changing it. manifests = append(manifests, manifest) } return manifests, warnings, nil diff --git a/internal/specialist/manifest_test.go b/internal/specialist/manifest_test.go index 8e2100f6d..272e4d34c 100644 --- a/internal/specialist/manifest_test.go +++ b/internal/specialist/manifest_test.go @@ -92,14 +92,31 @@ Review.`) t.Fatalf("ReasoningEffort = %q, want high", manifest.Metadata.ReasoningEffort) } - _, err = ParseMarkdown(`--- + // AN UNCURATED MODEL IS NO LONGER REFUSED HERE, and that is a deliberate + // reversal of what this test used to assert. + // + // The registry is a curated subset — thirteen models across OpenAI, + // Anthropic and Google — used for aliases, pricing and display. It is not an + // inventory of what a provider serves. Refusing everything outside it meant a + // specialist could not name a model the active provider actually offers: an + // xAI account serves half a dozen Grok models and an Ollama one serves its + // own, none curated, all of them listed in the model picker with their + // context window, tool support and price. + // + // A name the provider genuinely cannot serve still fails, in + // providers/factory.go ("zero model X belongs to ..."), which is the + // component that knows. The check moves; it does not disappear. + manifest, err = ParseMarkdown(`--- name: reviewer description: Reviews code model: fake-9000 --- Review.`) - if err == nil || !strings.Contains(err.Error(), "unknown model") { - t.Fatalf("expected unknown model error, got %v", err) + if err != nil { + t.Fatalf("an uncurated model must pass through for the provider to judge: %v", err) + } + if manifest.Metadata.Model != "fake-9000" { + t.Fatalf("Model = %q, want it carried through unchanged", manifest.Metadata.Model) } _, err = ParseMarkdown(`--- diff --git a/internal/specialist/plan.go b/internal/specialist/plan.go index 649dab8c5..8b800b39d 100644 --- a/internal/specialist/plan.go +++ b/internal/specialist/plan.go @@ -51,6 +51,10 @@ type Task struct { // Phase is a display and ordering label only. It carries no execution // semantics in Phase 2 — a barrier is expressible as dependencies. Phase string + // Model is the CANONICAL registry id this task runs on, empty to inherit the + // parent's. Stored canonical rather than as the caller wrote it, because the + // id is what reaches the child's argv — see resolveTaskModel. + Model string } // Budget bounds a plan. Every field is required to be sane at admission and @@ -63,7 +67,21 @@ type Budget struct { // rather than letting the request stand as if it were honoured. MaxWorkers int MaxTokens int - MaxWall time.Duration + // MaxTokensPerTask bounds what ONE task may spend. Optional; 0 is unbounded, + // which is every plan that does not ask for a cap. + // + // A PLAN-LEVEL BUDGET CANNOT STOP ONE TASK FROM EATING EVERYTHING, and that + // is not theoretical: in a measured six-task plan against 200,000 tokens, a + // single task spent 1,017,177 — five times the whole plan's budget — and the + // cheapest one that finished spent 510,017. Nothing bounded them, because the + // plan's own limit is only consulted between tasks. + // + // Kept separate from max_tokens rather than derived from it. A derived cap + // (budget ÷ remaining tasks) changes the economics of every existing plan + // without anyone choosing it, which is the same argument this codebase + // already makes for auto_assign being opt-in. + MaxTokensPerTask int + MaxWall time.Duration // MaxStall bounds how long a single task may emit NOTHING before it is // stopped. Distinct from MaxWall, which bounds the whole plan: a plan can // sit inside its wall budget while one task is wedged and the rest never @@ -199,6 +217,13 @@ func (p Plan) Args() map[string]any { if task.Phase != "" { entry["phase"] = task.Phase } + // EMITTED HERE OR LOST ENTIRELY. Args() is the round trip behind + // /plans save, /plans show, resume and the staged _resume plan; a model + // on the struct but not in this map means the plan reruns on the parent's + // model with nothing anywhere reporting the change. + if task.Model != "" { + entry["model"] = task.Model + } tasks = append(tasks, entry) } budget := map[string]any{ @@ -208,6 +233,9 @@ func (p Plan) Args() map[string]any { if p.budget.MaxTokens > 0 { budget["max_tokens"] = p.budget.MaxTokens } + if p.budget.MaxTokensPerTask > 0 { + budget["max_tokens_per_task"] = p.budget.MaxTokensPerTask + } if p.budget.MaxWall > 0 { budget["max_wall_seconds"] = int(p.budget.MaxWall.Seconds()) } @@ -303,10 +331,78 @@ func ParsePlan(args map[string]any, limits Limits) (Plan, error) { if err != nil { return Plan{}, err } + if err := refuseImplausibleBudget(budget, len(plan.tasks), limits); err != nil { + return Plan{}, err + } plan.budget = budget return plan, nil } +// minimumPlausibleTaskTokens is the floor below which a per-task share of the +// budget cannot buy a task at all. +// +// DELIBERATELY AN ORDER OF MAGNITUDE BELOW WHAT ANYTHING REALLY COSTS. In a +// measured six-task plan the CHEAPEST task that completed spent 510,017 tokens; +// the dearest spent 1,017,177. This is not a typical cost and must never be read +// as one — it is the point below which the arithmetic is impossible, so the only +// plans it can refuse are plans that were never going to finish. +// +// A task pays for its whole prompt on every turn, so even a single lookup +// answering in two turns costs tens of thousands. 50k buys a very small task and +// nothing more. +const minimumPlausibleTaskTokens = 50_000 + +// refuseImplausibleBudget rejects a plan whose budget cannot cover its own tasks, +// BEFORE anything is spent. +// +// Neither of the two things that happen without it is a good outcome. A measured +// run admitted six tasks against 200,000 tokens, spent 3,091,618 — fifteen times +// over — and still returned an incomplete answer, because the two tasks that had +// not started yet were skipped once the meter finally caught up. The user paid +// for the overrun AND lost a third of the audit. +// +// Killing a task mid-flight (the other repair) at least stops the bleeding, but +// bills for everything spent before the knife. Refusing here costs nothing at +// all, and is the only response that can still produce a COMPLETE answer: the +// caller raises the number or asks for fewer tasks and runs a plan that finishes. +// +// Silent on an unset budget. Unbounded is a deliberate choice — see planBudget — +// and this must not turn it back into a required field by the back door. +func refuseImplausibleBudget(budget Budget, taskCount int, limits Limits) error { + if budget.MaxTokens <= 0 || taskCount <= 0 { + return nil + } + needed := taskCount * minimumPlausibleTaskTokens + if budget.MaxTokens >= needed { + return nil + } + // THE ADVICE MUST BE FOLLOWABLE. When this run's own ceiling is below what + // the plan would need, "raise max_tokens" is advice the next call cannot + // take — the ceiling refuses it — and the caller loops between two errors + // that each blame the other. The plan is simply too big for this run, and + // saying so is the only thing that ends the loop. + if limits.MaxTokens > 0 && limits.MaxTokens < needed { + // "Ask for at most 0 tasks" is not advice. When the run's ceiling cannot + // fund even one task, no plan fits and the caller needs to hear that + // rather than a number to aim at. + if affordable := limits.MaxTokens / minimumPlausibleTaskTokens; affordable >= 1 { + return fmt.Errorf( + "%d tasks need about %d tokens and this run is capped at %d — the plan is too big for this run. "+ + "Ask for at most %d task(s), or split the work across runs", + taskCount, needed, limits.MaxTokens, affordable) + } + return fmt.Errorf( + "this run is capped at %d tokens, which cannot fund a single plan task (they rarely cost less than %d). "+ + "Do the work directly instead of planning it, or raise this run's ceiling", + limits.MaxTokens, minimumPlausibleTaskTokens) + } + return fmt.Errorf( + "budget.max_tokens is %d for %d tasks — about %d per task, and a plan task rarely costs less than %d "+ + "(a measured six-task plan spent 3,091,618). Raise max_tokens to at least %d, or ask for fewer tasks. "+ + "Omitting max_tokens runs unbounded within this run's own ceiling", + budget.MaxTokens, taskCount, budget.MaxTokens/taskCount, minimumPlausibleTaskTokens, needed) +} + // topologicalOrder runs Kahn's algorithm. It returns the emitted order, or the // ids still carrying unmet dependencies when the queue drains early — which is // exactly the set involved in a cycle. @@ -477,8 +573,9 @@ func planBudget(args map[string]any, limits Limits) (Budget, error) { return Budget{}, fmt.Errorf("plan requires a budget object with max_workers") } budget := Budget{ - MaxWorkers: planInt(raw, "max_workers"), - MaxTokens: planInt(raw, "max_tokens"), + MaxWorkers: planInt(raw, "max_workers"), + MaxTokens: planInt(raw, "max_tokens"), + MaxTokensPerTask: planInt(raw, "max_tokens_per_task"), } // Rejected rather than read as "unset". planInt returns 0 for both an absent // key and a present-but-negative one, so a bare `seconds > 0` let a @@ -560,6 +657,32 @@ func planBudget(args map[string]any, limits Limits) (Budget, error) { if limits.MaxTokens > 0 && budget.MaxTokens > limits.MaxTokens { return Budget{}, fmt.Errorf("budget.max_tokens %d exceeds the limit of %d for this run", budget.MaxTokens, limits.MaxTokens) } + if budget.MaxTokensPerTask < 0 { + return Budget{}, fmt.Errorf("budget.max_tokens_per_task must not be negative") + } + // A CAP BELOW WHAT ANY TASK COSTS KILLS EVERY TASK, and the plan pays in + // full for nothing. Measured: a plan capping tasks at 200,000 lost all six + // between 213k and 259k and cost 1,437,049 tokens with no completed work. + // + // Checked against the same floor as the plan budget, and for the same reason + // — it is the point below which the arithmetic is impossible, not a typical + // cost. A cap above it can still be tight; that is what the landing-strip + // notice in withTokenBudgetNotice is for. + if budget.MaxTokensPerTask > 0 && budget.MaxTokensPerTask < minimumPlausibleTaskTokens { + return Budget{}, fmt.Errorf( + "budget.max_tokens_per_task is %d, and a plan task rarely costs less than %d — every task would be "+ + "cut short and the plan would pay for all of them and finish nothing. Raise it, or omit it to leave tasks unbounded", + budget.MaxTokensPerTask, minimumPlausibleTaskTokens) + } + // A per-task cap ABOVE the plan's own budget bounds nothing — the plan limit + // would always bite first — and reads like a guarantee that no task will + // exceed it. Refused rather than clamped, for the same reason max_workers is: + // a trimmed number is one nobody can reason about afterwards. + if budget.MaxTokens > 0 && budget.MaxTokensPerTask > budget.MaxTokens { + return Budget{}, fmt.Errorf( + "budget.max_tokens_per_task %d exceeds budget.max_tokens %d — a per-task cap above the plan's own budget bounds nothing", + budget.MaxTokensPerTask, budget.MaxTokens) + } return budget, nil } @@ -575,6 +698,16 @@ func planTask(raw any, index int) (Task, error) { Tools: planStrings(fields, "tools"), Phase: planString(fields, "phase"), } + // REFUSED AT ADMISSION, not degraded at dispatch. The manifest loader already + // treats an unknown model as fatal, so falling back to the parent's here + // would soften an existing rejection — and a plan that asked for a stronger + // model on its verify stage, silently ran on a weaker one, and still reported + // success is indistinguishable from one that worked. + model, err := resolveTaskModel(planString(fields, "model")) + if err != nil { + return Task{}, fmt.Errorf("task at position %d: %w", index, err) + } + task.Model = model if task.ID == "" { return Task{}, fmt.Errorf("task at position %d has no id", index) } @@ -632,6 +765,17 @@ func planInt(args map[string]any, key string) int { // planIntSet also reports whether the key was PRESENT, for the settings where // an explicit 0 and an absent key mean different things. planInt is this // function with the answer discarded, so the two can never decode differently. +// planBoolSet reports a boolean argument AND whether it was supplied at all. +// +// planBool cannot distinguish false from absent, which is fine when absent means +// off — and wrong the moment a default can come from somewhere else. A plan +// saying auto_assign:false must be able to override a config that turned it on, +// and that requires knowing the difference. +func planBoolSet(args map[string]any, key string) (bool, bool) { + value, ok := args[key].(bool) + return value, ok +} + func planIntSet(args map[string]any, key string) (int, bool) { switch value := args[key].(type) { case float64: diff --git a/internal/specialist/plan_exec.go b/internal/specialist/plan_exec.go index 39a80bde6..5990d54e8 100644 --- a/internal/specialist/plan_exec.go +++ b/internal/specialist/plan_exec.go @@ -6,6 +6,7 @@ import ( "fmt" "sort" "strings" + "sync/atomic" "time" "github.com/Gitlawb/zero/internal/streamjson" @@ -72,10 +73,43 @@ type TaskResult struct { // of errors.Is, which is what silently disabled every stall retry in the // prototype. Stalled bool + // Model is what the task actually ran on, empty when it inherited the + // parent's. Reported so a per-task model is OBSERVABLE: the feature changes + // which model does the work and what it costs, and a change nobody can see + // is one nobody can check. + Model string // Attempts is how many times the task ran, always at least 1 for a task that // was dispatched. Duration and Tokens are the TOTALS across those attempts, // because what the plan spent is what the plan spent. Attempts int + // Declined reports that the child stopped with its work clearly unfinished — + // it said it could not do the task rather than doing it wrongly. + // + // A CODE, NOT A MESSAGE. The child exits childExitIncomplete for exactly this, + // so the signal is structural like Stalled; the prose that accompanies it + // ("the final message admits the objective was not met") is for the reader, + // never for the decision. + // + // It matters because a decline is usually TRANSIENT in a way a wrong answer is + // not. A measured plan had one task decline on a directory that existed and + // that its three sibling tasks read without trouble; the decline cost that + // task, its dependent, and the final report — a third of the plan. + Declined bool + // ModelRejected reports that the task died without its assigned model ever + // producing anything — the signature of a provider refusing the MODEL rather + // than the work failing. Set by the runner, read by runTaskWithRetries to + // decide whether one attempt on the parent's model is worth spending. + // + // A FLAG, not a message match, exactly like Stalled and for the same reason. + ModelRejected bool + // RetriedOnParentModel names the assigned model that could not run, on a task + // the plan then re-ran on the session's own model. Empty for everything else. + // + // Reported because a silent fallback is the worst of both worlds: the plan + // says it used the model it chose while something else did the work, and the + // next plan picks the same broken model again. Seeing it is what lets the + // name be added to planModels.exclude. + RetriedOnParentModel string } // PlanReport is the plan's terminal record, carried in plan_completed. @@ -145,6 +179,14 @@ type PlanTaskRequest struct { ParentSessionID string ParentModel string ParentReasoningEffort string + // ParentToolCallID is the orchestrate call this task belongs to. + // + // The Task tool carries it and this path did not, so a plan task's child was + // the only kind of child whose accounting could not be traced back to the + // call that spawned it — spend recorded against a session with nothing + // naming what asked for it. Same shape as every other gap in this file: a + // value present at the tool, consumed by accounting, and dropped in between. + ParentToolCallID string // Cwd overrides where this task runs. Empty means the parent's workspace, // which is every read-only plan. A write-capable plan sets it to its // isolated worktree, and it is carried per REQUEST rather than captured in @@ -155,6 +197,24 @@ type PlanTaskRequest struct { // ExecutePlan from the plan's budget so every task in a plan shares one // answer, rather than each runner re-deriving it. StallTimeout time.Duration + // Spend is the plan's live token meter, shared by every task in flight. A + // task that crosses the plan's limit while running is stopped by it — the + // dispatch-time check cannot, because it only sees numbers that have already + // landed. nil means no live metering. + Spend *planSpend + // MaxTaskTokens bounds what THIS task may spend. 0 is unbounded, which is + // every plan that does not ask for a cap. + MaxTaskTokens int + // SystemPrompt replaces the plan-task system prompt for this request. Empty + // keeps the plan-task prompt, which is every task of every plan. + // + // IT EXISTS FOR THE ROUTER, which is not a plan task at all. The plan-task + // prompt opens "You have read-only tools: USE THEM. Start with a tool call, + // not prose" — sound advice for work, and a direct contradiction of the + // router's own instruction to reply with JSON and nothing else. The single + // highest-leverage prompt in the system, the one choosing every other task's + // model, was being told to do the opposite of what it was asked. + SystemPrompt string } // PlanRunner runs one task. The executor depends on this seam rather than on @@ -229,6 +289,9 @@ func ExecutePlanIn(ctx context.Context, plan Plan, workspace PlanWorkspace, pare budgetLeft := plan.Budget().MaxTokens bounded := budgetLeft > 0 budgetExhausted := false + // The live meter, shared by every task in flight. Same limit, consulted from + // inside a running task rather than between two of them. + spend := &planSpend{limit: int64(plan.Budget().MaxTokens)} deadline := time.Time{} if wall := plan.Budget().MaxWall; wall > 0 { @@ -296,6 +359,27 @@ func ExecutePlanIn(ctx context.Context, plan Plan, workspace PlanWorkspace, pare recordFailed(recorder, result) return } + // A TERMINAL OUTCOME THE RUNNER ALREADY DECIDED IS NOT OVERWRITTEN. + // + // The line below used to be unconditional, and the guard above tests only + // TaskFailed — so a runner returning TaskCancelled with no error fell + // through and was relabelled a success. A real plan killed all six of its + // tasks for running over budget and reported "6 succeeded, 0 failed, 0 + // skipped", with the kill notice sitting in each task's own result text + // where the headline contradicted it. + // + // Work that did not finish, reported as done, is the failure this whole + // executor is built to prevent; the runner is the only thing that watched + // the task run, so when it names an outcome, that outcome stands. + if result.Outcome == TaskCancelled { + results[id] = result + // Its dependents are blocked, exactly as for a failure: a task that + // was cut short did not produce what they were waiting for. + failed[id] = true + report.Cancelled++ + recordFailed(recorder, result) + return + } result.Outcome = TaskSucceeded results[id] = result report.Succeeded++ @@ -402,14 +486,22 @@ func ExecutePlanIn(ctx context.Context, plan Plan, workspace PlanWorkspace, pare continue } + // WHAT ITS DEPENDENCIES FOUND, handed to it rather than left for it to + // rediscover. See dependencyBriefing. + task.Prompt = withDependencyBriefing(task, results) + // AND WHAT IT MAY SPEND, so it can land instead of being shot down. + task.Prompt = withTokenBudgetNotice(task.Prompt, plan.Budget().MaxTokensPerTask) + recordDispatched(recorder, task) policy := retryPolicy{ - task: task, - tools: granted, - cwd: workspace.Path, - stallTimeout: stallTimeout, - maxRetries: plan.Budget().MaxRetries, - deadline: deadline, + task: task, + tools: granted, + cwd: workspace.Path, + stallTimeout: stallTimeout, + spend: spend, + maxTaskTokens: plan.Budget().MaxTokensPerTask, + maxRetries: plan.Budget().MaxRetries, + deadline: deadline, } slots.take() started := time.Now() @@ -449,14 +541,25 @@ func ExecutePlanIn(ctx context.Context, plan Plan, workspace PlanWorkspace, pare // PlanTaskRequest is one: this parameter list is the thing that grows, and a // positional list is where a second call site quietly omits a field. type retryPolicy struct { - task Task - tools []string - cwd string - stallTimeout time.Duration - maxRetries int - deadline time.Time + task Task + tools []string + cwd string + stallTimeout time.Duration + maxRetries int + spend *planSpend + maxTaskTokens int + deadline time.Time } +// childExitIncomplete is the child's exit code for "stopped with work clearly +// unfinished". +// +// DUPLICATED FROM internal/cli's exitIncomplete, deliberately: cli imports this +// package, so the constant cannot travel the other way without inverting the +// dependency. Two definitions of one number is a thing that drifts, so cli owns +// a test asserting they agree — the only defence available. +const childExitIncomplete = 4 + // runTaskWithRetries runs one task, retrying it ONLY when it stalled. // // The retry lives HERE, in the executor, and not in the runner — the executor @@ -475,13 +578,25 @@ func runTaskWithRetries(ctx context.Context, policy retryPolicy, run PlanRunner) var totalDuration time.Duration var totalTokens int + // fellBackFrom is the assigned model that would not run, once the task has + // been re-dispatched on the parent's. Non-empty is also what BOUNDS the + // fallback at exactly one: a provider that refuses the parent's model too + // must not put this loop into a spawn cycle. + var fellBackFrom string + // retriedAfterDecline bounds the decline retry at exactly one. A model that + // declines twice is telling us something about the task, not having a bad + // moment. + retriedAfterDecline := false + for attempt := 1; ; attempt++ { started := time.Now() result, err = run(ctx, PlanTaskRequest{ - Task: policy.task, - Tools: policy.tools, - Cwd: policy.cwd, - StallTimeout: policy.stallTimeout, + Task: policy.task, + Tools: policy.tools, + Cwd: policy.cwd, + StallTimeout: policy.stallTimeout, + Spend: policy.spend, + MaxTaskTokens: policy.maxTaskTokens, }) if result.Duration == 0 { result.Duration = time.Since(started) @@ -491,8 +606,67 @@ func runTaskWithRetries(ctx context.Context, policy retryPolicy, run PlanRunner) result.Attempts = attempt result.Duration = totalDuration result.Tokens = totalTokens + // Carried across the fallback: the task that finally ran on the parent's + // model must still report which model could not run, or the same broken + // model is chosen again by the next plan and nothing says why. + result.RetriedOnParentModel = fellBackFrom switch { + case result.ModelRejected && fellBackFrom == "" && strings.TrimSpace(policy.task.Model) != "": + // AN ASSIGNED MODEL THE PROVIDER WILL NOT RUN IS NOT THE TASK'S FAULT. + // + // Auto-assignment picks from the list the provider itself published, + // so "listed but unusable" is the normal failure of a discovery + // endpoint that describes products rather than endpoints — a model + // belonging to another account, or one whose id lists on /v1/models + // while chat completions answers "Multi Agent requests are not allowed + // on chat completions". Falling back to the model the session is + // demonstrably able to run costs one child and saves the task. + // + // Gated by the SAME stops as a stall retry, because it spends the same + // thing: a cancelled run and an exhausted wall budget both refuse. + // Not gated by maxRetries — that budget bounds stall THRASHING, where + // the same model reruns the same work hoping it answers this time. + // This is a different model, tried once, and fellBackFrom is its bound. + if ctx.Err() != nil { + return result, err + } + if !policy.deadline.IsZero() && time.Now().After(policy.deadline) { + return result, err + } + fellBackFrom = strings.TrimSpace(policy.task.Model) + policy.task.Model = "" + continue + case result.Declined && !retriedAfterDecline: + // A DECLINE IS WORTH ONE MORE ATTEMPT, and a wrong answer is not. + // + // The distinction is the whole justification. "Running it again buys + // the same report" is true of a task that read the code and concluded + // wrongly — it will conclude the same thing. It is not true of a task + // that said it could not proceed: a measured plan had one decline on a + // directory that existed and that its three siblings read without + // trouble, and that single refusal cost the task, its dependent, and + // the final report. + // + // Gated by the SAME stops as every other retry here, because it spends + // the same thing. Not gated by maxRetries: that budget bounds stall + // thrashing, where the same model reruns the same work hoping for a + // different mood. This is one attempt, bounded by its own flag. + if ctx.Err() != nil { + return result, err + } + if !policy.deadline.IsZero() && time.Now().After(policy.deadline) { + return result, err + } + retriedAfterDecline = true + // AWAY FROM THE MODEL THAT DECLINED, when there is somewhere to go. + // Repeating the same model is the weakest version of this; the + // session's own model is one the parent is demonstrably able to run. + if fellBackFrom == "" && strings.TrimSpace(policy.task.Model) != "" { + fellBackFrom = strings.TrimSpace(policy.task.Model) + policy.task.Model = "" + } + continue case !result.Stalled: // Anything other than a stall is an ANSWER, including a failure. The // child ran and reported; running it again buys the same report. @@ -535,6 +709,165 @@ func terminalStatus(report PlanReport) PlanStatus { } } +// planSpend is the plan's token meter as it happens, rather than after the fact. +// +// budgetLeft next to it is the AUTHORITATIVE post-completion number and still +// decides dispatch. This one exists because that one arrives too late: it moves +// only when a task finishes, so nothing between dispatch and completion can be +// stopped by it. A measured run spent 3,091,618 against a 200,000 budget and the +// meter did not move once while it happened — four tasks were in flight, each +// unbounded, and the first decrement landed after all of them had finished. +// +// Both sum the same usage events, so they agree at the end; they differ only in +// WHEN they can be consulted. +type planSpend struct { + spent atomic.Int64 + limit int64 +} + +// add records tokens and reports whether the plan has now crossed its limit. +// An unset limit never reports crossed — unbounded is a deliberate choice. +func (spend *planSpend) add(tokens int) bool { + if spend == nil || tokens <= 0 { + return false + } + total := spend.spent.Add(int64(tokens)) + return spend.limit > 0 && total > spend.limit +} + +// Per-dependency and whole-briefing caps on what a task is told about its +// dependencies. +// +// BOUNDED BECAUSE THE ALTERNATIVE DOES NOT TERMINATE. A twenty-task plan where +// every task inherits every ancestor's full output is a context-overflow machine: +// the last task in a chain would carry the entire plan. The caps are generous +// enough to carry a real answer and small enough that a deep chain stays inside +// one context. +// +// A task that needs MORE than the cap still holds its read tools and the file +// paths its dependency quoted, so nothing is unreachable — it is one tool call +// away instead of free. +const ( + dependencyBriefingPerTask = 4000 + dependencyBriefingTotal = 12000 +) + +// withDependencyBriefing prefixes a task's prompt with what its dependencies +// found, and returns the prompt unchanged when it has none. +// +// depends_on ORDERED EXECUTION AND PASSED NOTHING, which is the defect this +// closes. A task named as a dependency ran first and its output went to the +// report; the dependent started from a blank context and had to rediscover the +// same files. Two costs, both measured on real runs: +// +// - Every judgement task re-read what the tracing tasks had already read. The +// largest avoidable spend in a plan. +// - A synthesising task received CONCLUSIONS with no trace behind them, so it +// could repeat a claim but never check one. That is exactly how a plan +// reported that MCP servers inherit an unscrubbed environment: the tracing +// task had walked the scrubbing path, and the task that wrote the finding +// could not see it. +// +// SUCCEEDED DEPENDENCIES ONLY. A failed dependency skips its dependents +// entirely (firstFailedDependency), so anything reaching here succeeded; a +// cancelled or empty result contributes nothing rather than an empty heading. +// +// Deterministic order, following DependsOn as the plan declared it, so the same +// plan produces the same prompt — a resumed plan must not differ from its first +// run because a map iterated differently. +func withDependencyBriefing(task Task, results map[string]TaskResult) string { + if len(task.DependsOn) == 0 { + return task.Prompt + } + var brief strings.Builder + remaining := dependencyBriefingTotal + for _, id := range task.DependsOn { + result, ok := results[id] + if !ok || result.Outcome != TaskSucceeded { + continue + } + output := strings.TrimSpace(result.Output) + if output == "" || remaining <= 0 { + continue + } + budget := dependencyBriefingPerTask + if budget > remaining { + budget = remaining + } + truncated := false + if len(output) > budget { + output, truncated = output[:budget], true + } + remaining -= len(output) + fmt.Fprintf(&brief, "### Result of task %q\n%s\n", id, output) + if truncated { + // SAID, not silent. A reader that cannot tell it was given part of an + // answer will treat the part as the whole. + brief.WriteString("[truncated — re-read the files named above if you need more]\n") + } + brief.WriteString("\n") + } + if brief.Len() == 0 { + return task.Prompt + } + return "## What the tasks you depend on already found\n\n" + + brief.String() + + "Use this instead of rediscovering it. Verify anything you are about to rely on — " + + "a claim above is a previous task's conclusion, not established fact.\n\n" + + "## Your task\n\n" + task.Prompt +} + +// tokensPerToolCall is what one tool call costs a plan task, measured: a task +// that made 28 calls spent 232,416 tokens, another 24 calls for 259,705. The +// number is a rough proxy and is used as one — a model cannot see its own token +// meter, but it can count its own tool calls, so this converts a budget it +// cannot observe into a quantity it can. +const tokensPerToolCall = 8_000 + +// budgetLandingStrip is the share of a task's cap held back so a task that +// decides to stop still has room to WRITE its answer. +// +// Told to stop at its full cap, a task would still be composing when the hard +// limit arrived and be killed mid-sentence — the very outcome this exists to +// avoid. It is told a smaller number and killed at the real one; the gap is the +// runway. +const budgetLandingStrip = 5 // one fifth held back + +// withTokenBudgetNotice tells a task what it may spend, in a unit it can count. +// +// A CAP THAT ONLY KILLS PRODUCES NOTHING. A measured plan set +// max_tokens_per_task to 200,000; every one of its six tasks was killed between +// 213k and 259k having done substantial reading, and the run cost 1,437,049 +// tokens for zero completed tasks. Each was most of the way to an answer nobody +// received. Overspending and getting answers is a bad trade; overspending and +// getting nothing is a worse one. +// +// So the cap is announced as a DEADLINE rather than sprung as a guillotine. A +// task that knows its bound can stop investigating and write down what it found, +// and a partial answer carrying file:line evidence is worth incomparably more +// than a corpse. The hard kill stays as the backstop for a task that ignores it. +// +// Silent when there is no cap, which is every plan that does not ask for one. +func withTokenBudgetNotice(prompt string, maxTaskTokens int) string { + if maxTaskTokens <= 0 { + return prompt + } + announced := maxTaskTokens - maxTaskTokens/budgetLandingStrip + calls := announced / tokensPerToolCall + if calls < 1 { + calls = 1 + } + return prompt + fmt.Sprintf( + "\n\n## Your budget for this task\n\n"+ + "About %d tokens — roughly %d tool calls at this repo's typical cost. You cannot see your own "+ + "token count, so count tool calls instead.\n\n"+ + "Read what matters most FIRST, and start writing your answer well before you reach that number. "+ + "A partial answer quoting file:line for what you did check is worth far more than being cut off "+ + "mid-investigation, which returns nothing at all. If you run short, say plainly what you did not "+ + "get to rather than guessing at it.", + announced, calls) +} + // firstFailedDependency names the dependency that blocked a task, so the record // says WHICH one rather than just that something upstream broke. func firstFailedDependency(task Task, failed map[string]bool) (string, bool) { @@ -661,15 +994,49 @@ func (report PlanReport) Summary() string { fmt.Fprintf(&b, ", %d cancelled", report.Cancelled) } b.WriteString(".\n") + // NAME WHAT NEVER RAN, at the top. + // + // A budget-skipped task says so in its own row, in the same small text every + // other row uses, and the headline says only "partial". A measured run came + // back missing a third of the questions it was asked and nothing above the + // fold said which — the reader had to diff the plan against the report to + // find out. An incomplete answer that does not announce itself gets used as + // a complete one. + if unrun := tasksCutForBudget(report.Tasks); len(unrun) > 0 { + fmt.Fprintf(&b, "%d task(s) never ran (budget exhausted): %s\n", len(unrun), strings.Join(unrun, ", ")) + } + // SAY WHEN THE REQUEST WAS NOT HONOURED. The struct promises both numbers — + // "a plan that asked for sixteen and ran six has not been given sixteen" — + // and then only Workers was ever printed, so the trim was invisible and the + // speedup below it looked like the plan's own fault. Stated only when they + // differ, so the ordinary line is unchanged. + if report.WorkersRequested > 0 && report.Workers > 0 && report.WorkersRequested != report.Workers { + fmt.Fprintf(&b, "workers: %d (requested %d; this machine allowed fewer)\n", + report.Workers, report.WorkersRequested) + } fmt.Fprintf(&b, "sequential total: %s · critical path: %s · max_speedup: %.2fx\n", report.SequentialTotal.Round(time.Millisecond), report.CriticalPath.Round(time.Millisecond), report.MaxSpeedup) for _, task := range report.Tasks { fmt.Fprintf(&b, "\n - %s [%s] %s", task.ID, task.Outcome, task.Duration.Round(time.Millisecond)) + // Only when the task named one. A line saying "on " against every task is noise that hides the one line + // that matters. + if task.Model != "" { + fmt.Fprintf(&b, " on %s", task.Model) + } // A retried task cost more than its one line suggests. Stated only when // it happened, so the common case stays unchanged. if task.Attempts > 1 { fmt.Fprintf(&b, " (%d attempts)", task.Attempts) } + // NAME THE MODEL THAT COULD NOT RUN. The task succeeded, so nothing else + // on this line would hint that the plan's own choice was unusable — and + // an unusable model stays in the discovered list, so the next plan picks + // it again. This line is what makes it fixable, by exclusion or by + // telling the provider. + if task.RetriedOnParentModel != "" { + fmt.Fprintf(&b, " (fell back from %s, which this provider would not run)", task.RetriedOnParentModel) + } if task.Output != "" { b.WriteString("\n result:\n" + task.Output) } @@ -680,6 +1047,21 @@ func (report PlanReport) Summary() string { return b.String() } +// tasksCutForBudget names the tasks a budget stopped — skipped before they ran, +// or cancelled while running. Both are questions the plan did not answer. +func tasksCutForBudget(tasks []TaskResult) []string { + var out []string + for _, task := range tasks { + switch { + case task.Outcome == TaskSkippedBudget: + out = append(out, task.ID) + case task.Outcome == TaskCancelled && strings.Contains(task.Err, "token budget"): + out = append(out, task.ID) + } + } + return out +} + func recordDispatched(recorder PlanRecorder, task Task) { if recorder != nil { recorder.TaskDispatched(task) diff --git a/internal/specialist/plan_exec_test.go b/internal/specialist/plan_exec_test.go index 6061f9eff..2fe35baa3 100644 --- a/internal/specialist/plan_exec_test.go +++ b/internal/specialist/plan_exec_test.go @@ -139,8 +139,12 @@ func TestAllFailedPlanIsFailed(t *testing.T) { // (o) BUDGET ENFORCED AT DISPATCH. Validation alone is a promise, not a bound. func TestBudgetExhaustionMidPlanIsPartial(t *testing.T) { + // AT REALISTIC SCALE, because refuseImplausibleBudget now rejects a budget + // that cannot cover its own tasks — and a 100-token budget for three tasks is + // exactly the arithmetic it exists to refuse. The property under test is + // unchanged; only the numbers are ones a real plan could carry. budget := okBudget() - budget["max_tokens"] = float64(100) + budget["max_tokens"] = float64(150_000) plan := mustPlan(t, []any{task("a", "x"), task("b", "y"), task("c", "z")}, budget, readOnlyLimits()) dispatched := []string{} @@ -148,10 +152,10 @@ func TestBudgetExhaustionMidPlanIsPartial(t *testing.T) { func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { task := req.Task dispatched = append(dispatched, task.ID) - return TaskResult{Outcome: TaskSucceeded, Tokens: 60, Output: "ok"}, nil + return TaskResult{Outcome: TaskSucceeded, Tokens: 90_000, Output: "ok"}, nil }, nil) - // Two tasks at 60 tokens exhaust a 100-token budget; the third is skipped. + // Two tasks at 90k exhaust a 150k budget; the third is skipped. if len(dispatched) != 2 { t.Fatalf("the budget must stop dispatch after it is spent, dispatched %v", dispatched) } @@ -203,7 +207,7 @@ func TestToolGrantIsIntersectedAtDispatch(t *testing.T) { // An empty Tools inherits the parent's READ-ONLY grant — never the parent's // mutating tools, even when the parent holds them. func TestEmptyToolsInheritsOnlyReadOnlyParentTools(t *testing.T) { - plan := mustPlan(t, []any{task("a", "x")}, okBudget(), Limits{MaxTasks: 5, MaxTokens: 1000}) + plan := mustPlan(t, []any{task("a", "x")}, okBudget(), Limits{MaxTasks: 5}) var granted []string ExecutePlan(context.Background(), plan, []string{"read_file", "grep", "write_file", "bash"}, func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { @@ -368,7 +372,9 @@ func TestRealRunnerFeedsTheBudgetMeter(t *testing.T) { Started: true, Events: []streamjson.Event{ {Type: "assistant", Text: "done"}, - {Type: "usage", TotalTokens: intPtrForTest(500)}, + // More than the whole plan budget: the first task alone blows it, + // which is what proves the meter is fed by the REAL runner. + {Type: "usage", TotalTokens: intPtrForTest(200_000)}, }, }, nil }, @@ -376,7 +382,9 @@ func TestRealRunnerFeedsTheBudgetMeter(t *testing.T) { runner := NewPlanRunner(PlanTaskContext{Executor: executor, Cwd: t.TempDir(), SpecialistName: "explorer"}) budget := okBudget() - budget["max_tokens"] = float64(1) // one token: the first task alone blows it + // The floor of what admission will accept for three tasks; the first task + // alone spends more than all of it, which is the condition under test. + budget["max_tokens"] = float64(150_000) plan := mustPlan(t, []any{task("a", "x"), task("b", "y"), task("c", "z")}, budget, readOnlyLimits()) report := ExecutePlan(context.Background(), plan, []string{"read_file"}, runner, nil) diff --git a/internal/specialist/plan_grant_test.go b/internal/specialist/plan_grant_test.go index e06c68c4a..b32c8ea65 100644 --- a/internal/specialist/plan_grant_test.go +++ b/internal/specialist/plan_grant_test.go @@ -20,7 +20,7 @@ func singleTaskPlanArgs(task map[string]any) map[string]any { return map[string]any{ "name": "p", "tasks": []any{task}, - "budget": map[string]any{"max_workers": 1, "max_tokens": 1000}, + "budget": map[string]any{"max_workers": 1, "max_tokens": 500_000}, } } @@ -81,7 +81,7 @@ func TestToolsResolvedSurvivesAJSONRoundTrip(t *testing.T) { // ever built, but if that check were bypassed the manifest itself must still // not expand. This is the belt to planToolGrant's braces. func TestPlanTaskManifestMarksItsGrantAuthoritative(t *testing.T) { - manifest := planTaskManifest("explorer", []string{}) + manifest := planTaskManifest("explorer", "", "", []string{}) if !manifest.ToolsResolved { t.Fatal("a plan task's manifest carries an already-intersected grant; it must be marked authoritative") } @@ -135,7 +135,7 @@ func TestPlanToolGrantInheritsOnlyWhatTheParentHolds(t *testing.T) { func TestParsePlanRejectsAToolTheParentDoesNotHold(t *testing.T) { _, err := ParsePlan( singleTaskPlanArgs(map[string]any{"id": "a", "prompt": "p", "tools": []any{"read_file"}}), - Limits{MaxTasks: 20, MaxTokens: 200000, ParentTools: []string{"grep"}}, + Limits{MaxTasks: 20, ParentTools: []string{"grep"}}, ) if err == nil { t.Fatal("a task requesting a tool the run does not hold must be rejected") @@ -164,7 +164,7 @@ func TestParsePlanRejectsEveryToolWhenNoGrantWasSupplied(t *testing.T) { func TestExecutePlanFailsAnUngrantableTaskWithoutDispatchingIt(t *testing.T) { plan := mustParsePlan(t, singleTaskPlanArgs(map[string]any{"id": "a", "prompt": "p"}), - Limits{MaxTasks: 20, MaxTokens: 200000, ParentTools: []string{"grep"}}, + Limits{MaxTasks: 20, ParentTools: []string{"grep"}}, ) dispatched := 0 report := ExecutePlan(context.Background(), plan, nil, @@ -323,7 +323,7 @@ func TestReadOnlyToolSetsAgree(t *testing.T) { // the consequence the drift produced, asserted at the behaviour rather than at // the two maps. func TestAFullPlanGrantIsAReadOnlyManifest(t *testing.T) { - manifest := planTaskManifest("explorer", PlanReadOnlyToolNames()) + manifest := planTaskManifest("explorer", "", "", PlanReadOnlyToolNames()) if !manifestIsReadOnly(manifest) { t.Fatalf("a manifest holding exactly the plan grant %v is not considered read-only", PlanReadOnlyToolNames()) } diff --git a/internal/specialist/plan_model.go b/internal/specialist/plan_model.go new file mode 100644 index 000000000..3f62591b1 --- /dev/null +++ b/internal/specialist/plan_model.go @@ -0,0 +1,85 @@ +package specialist + +import ( + "fmt" + "strings" + + "github.com/Gitlawb/zero/internal/modelregistry" +) + +// resolveTaskModel canonicalises a task's requested model. An empty request is +// not an error: it means "inherit the parent's model", which is what every task +// did before this existed. +// +// AN UNKNOWN MODEL PASSES THROUGH rather than being refused, and that is a +// deliberate reversal. The curated registry holds thirteen models — OpenAI, +// Anthropic, Google — while a provider serves whatever it serves: an xAI account +// offers half a dozen Grok models, none of them curated, every one of which the +// picker already lists with its context window, tool support and price. Refusing +// what the registry has not heard of made this feature unusable on exactly the +// providers people run. +// +// What is lost is a typo caught at admission. What replaces it is not nothing: +// the child resolves the model through its own provider config and fails with +// "zero model X belongs to , not " (providers/factory.go), +// so a wrong name is still reported — one layer later, by the component that +// actually knows what this provider can serve. +// +// ResolveWithFallback, NOT ResolveID, and the difference decides whether the +// plan and the run agree: +// +// - ResolveID reaches Get, a lookup in a map of normalized keys. Registered +// aliases resolve; PATTERNS do not, because those live in a separate +// regex table Get never consults. So "sonnet 4.5" would be refused here and +// accepted by the child. +// - Nor does Get apply the deprecation redirect. A deprecated id would be +// admitted, echoed back into Plan.Args(), written to the saved plan and shown +// in the panel — while the child, which resolves through ResolveWithFallback, +// quietly ran the replacement. The plan would say one model and the run would +// use another, with nothing anywhere to reveal it. +// +// Returning entry.ID rather than the caller's string is the other half of that: +// the id is what reaches argv, so storing anything else re-opens the same gap +// one layer up. +func resolveTaskModel(name string) (string, error) { + requested := strings.TrimSpace(name) + if requested == "" { + return "", nil + } + registry, err := modelregistry.DefaultRegistry() + if err != nil { + return "", fmt.Errorf("load the model registry: %w", err) + } + entry, _, ok := registry.ResolveWithFallback(requested) + if !ok { + // Not curated: the provider is the authority on whether it exists. + return requested, nil + } + return entry.ID, nil +} + +// modelTakesExplicitEffort reports whether an explicit reasoning-effort value can +// safely be sent for this model. +// +// The registry is the only thing that can answer it. The child clamps a +// requested effort with EffectiveReasoningEffort ONLY for models it can look up; +// for anything else it forwards the value untouched, and a provider that does +// not accept the parameter rejects the whole request. So a model the registry +// has never seen gets no effort at all, and runs at whatever the provider +// defaults to — which is what it would have done anyway before per-task models +// existed. +func modelTakesExplicitEffort(model string) bool { + trimmed := strings.TrimSpace(model) + if trimmed == "" { + return false + } + registry, err := modelregistry.DefaultRegistry() + if err != nil { + return false + } + entry, _, ok := registry.ResolveWithFallback(trimmed) + if !ok { + return false + } + return modelregistry.EffectiveReasoningEffort(entry, modelregistry.ReasoningEffortHigh) != modelregistry.ReasoningEffortNone +} diff --git a/internal/specialist/plan_model_assign.go b/internal/specialist/plan_model_assign.go new file mode 100644 index 000000000..75bab706c --- /dev/null +++ b/internal/specialist/plan_model_assign.go @@ -0,0 +1,421 @@ +package specialist + +import ( + "context" + "sort" + "strings" +) + +// DiscoveredModel is the minimum a plan needs in order to choose between models. +// +// A LOCAL TYPE, not providermodeldiscovery.Model, because this package must not +// import the provider stack: discovery drags in config and providercatalog, and +// specialist is on the child-execution path where those have no business. The +// caller — which already holds a provider profile — adapts. +type DiscoveredModel struct { + ID string + Description string + ToolCall bool + Reasoning bool + InputCost float64 + OutputCost float64 + // OutputModalities is what the model emits. A plan task needs text; a real + // run assigned grok-imagine-video-1.5 to the verify stage purely because it + // was the most expensive thing on the account. + OutputModalities []string +} + +// ModelDiscoverer reports the models the ACTIVE provider can serve. Supplied by +// the surface that owns the provider profile; nil means auto-assignment is +// simply unavailable, which is the honest default for a headless run. +type ModelDiscoverer func(ctx context.Context) ([]DiscoveredModel, error) + +// ModelPreferences is what the user has said about which models plans may use. +// A LOCAL type again: specialist must not import config, and the four strings it +// needs do not justify the dependency. +type ModelPreferences struct { + Scan string + Implement string + Verify string + Exclude []string + // AutoAssign makes per-task model selection the default for every plan. The + // tool argument still overrides it in both directions. + AutoAssign bool + // Router names the model that DECIDES the per-task assignment by reading the + // tasks, instead of the keyword classifier guessing from verbs. Empty falls + // back to the strongest model discovery found; routing is skipped entirely + // when neither is available. + Router string + // RouterGuidance is the operator's own advice to the router, added to the + // built-in guidance. Empty changes nothing. + RouterGuidance string +} + +func (prefs ModelPreferences) excluded(id string) bool { + for _, name := range prefs.Exclude { + if strings.EqualFold(strings.TrimSpace(name), strings.TrimSpace(id)) { + return true + } + } + return false +} + +// pinned returns the model the user fixed for a role, if any. +func (prefs ModelPreferences) pinned(role TaskRole) string { + switch role { + case TaskRoleScan: + return strings.TrimSpace(prefs.Scan) + case TaskRoleImplement: + return strings.TrimSpace(prefs.Implement) + case TaskRoleVerify: + return strings.TrimSpace(prefs.Verify) + default: + return "" + } +} + +// modelTiers is the three-way split a plan assigns from. Any of them may be +// empty, and an empty tier means "inherit the parent's model" rather than a +// guess at a substitute. +type modelTiers struct { + cheap string + balanced string + strong string +} + +// buildModelTiers ranks the provider's tool-calling models by cost. +// +// COST, not curated adjectives, as the primary signal. A description saying +// "fast" or "flagship" is marketing text that varies per provider and changes +// without notice; the price is a number the provider commits to, and it orders +// the same way capability does in practice. Descriptions are consulted only to +// break the degenerate cases below. +// +// Tool calling is a hard filter, not a preference: a plan task that cannot call +// a tool cannot do a plan task's job — it is precisely the "answer from memory" +// failure the task prompt exists to prevent. +// A discovered model is CANONICALISED where the registry knows it and taken as +// given where it does not. Discovery is the authority here: it asked this +// provider what it serves. Requiring curation as well is what made auto-assign +// do nothing on an xAI account, whose Grok models the picker lists in full and +// the registry has never heard of. +// rankedEligibleModels is the candidate set, cheapest first: every model that +// can do a plan task's job, after exclusions. +// +// SHARED WITH THE ROUTER on purpose. Offering the router a wider set than the +// tiers were built from would let it pick something the tier logic had already +// judged unusable — a video model, an excluded id — and the served-set check +// would then drop it silently, leaving the task on the classifier's choice with +// no explanation. +func rankedEligibleModels(models []DiscoveredModel, prefs ModelPreferences) []DiscoveredModel { + // TOOL CALLING IS A FILTER ONLY WHEN THE PROVIDER SAYS ANYTHING ABOUT IT. + // + // DiscoveredModel.ToolCall is a bool, so "cannot call tools" and "this + // provider's /models says nothing about capabilities" arrive identically — + // and plenty of providers publish a bare id list. Requiring it outright made + // auto_assign silently assign nothing there, which is what a real run showed: + // three models discovered, none marked, every task left on the parent's. + // + // So the flag NARROWS the field when some model claims it, and is ignored + // when none does. The parent is itself calling tools on this provider, which + // is better evidence than an absent field. + anyToolCaller := false + for _, model := range models { + if model.ToolCall { + anyToolCaller = true + break + } + } + eligible := make([]DiscoveredModel, 0, len(models)) + for _, model := range models { + if strings.TrimSpace(model.ID) == "" { + continue + } + if !emitsText(model) || prefs.excluded(model.ID) { + continue + } + if anyToolCaller && !model.ToolCall { + continue + } + canonical, err := resolveTaskModel(model.ID) + if err != nil || canonical == "" { + continue + } + model.ID = canonical + eligible = append(eligible, model) + } + sort.SliceStable(eligible, func(i, j int) bool { + if eligible[i].InputCost != eligible[j].InputCost { + return eligible[i].InputCost < eligible[j].InputCost + } + if eligible[i].OutputCost != eligible[j].OutputCost { + return eligible[i].OutputCost < eligible[j].OutputCost + } + return eligible[i].ID < eligible[j].ID + }) + return eligible +} + +func buildModelTiers(models []DiscoveredModel, prefs ModelPreferences) modelTiers { + eligible := rankedEligibleModels(models, prefs) + if len(eligible) == 0 { + return modelTiers{} + } + + tiers := modelTiers{ + cheap: eligible[0].ID, + balanced: eligible[len(eligible)/2].ID, + strong: eligible[len(eligible)-1].ID, + } + // A provider offering one model gives every task the same one, which is + // exactly today's behaviour and is the right answer rather than a failure. + if len(eligible) == 1 { + return tiers + } + // Prefer a REASONING model for the strong tier when one exists and the most + // expensive model is not already one — the verify stage is what the tier is + // for, and price alone can put a large non-reasoning model on top. + if !mostExpensiveReasons(eligible) { + for i := len(eligible) - 1; i >= 0; i-- { + if eligible[i].Reasoning { + tiers.strong = eligible[i].ID + break + } + } + } + return tiers +} + +func mostExpensiveReasons(sorted []DiscoveredModel) bool { + return sorted[len(sorted)-1].Reasoning +} + +// modelForRole picks the tier a role should run on. An empty result means +// "inherit", which is what every unassignable case degrades to. +// +// A PIN WINS OUTRIGHT, and is not filtered by anything above: it is a statement +// about a model the user has, not a candidate to be ranked. That is what makes +// it work on a provider reporting no prices and no capabilities, where every +// automatic signal is absent — which is most of them. +func (tiers modelTiers) modelForRoleWith(role TaskRole, prefs ModelPreferences, served map[string]bool) string { + // A PIN THE ACTIVE PROVIDER DOES NOT SERVE IS NOT A PIN, it is a leftover. + // + // Pins name models, and models belong to providers. Switching provider — + // which a user does when an account runs out of quota, mid-session — leaves + // every pin naming something the new provider has never heard of. Applied + // anyway, that turned a provider switch into "every plan fails": four tasks + // dead with `model "grok-4.3" not found` before one had run. + // + // Checked only when discovery actually answered. A provider that lists + // nothing is not evidence that a pin is wrong, and pins working WITHOUT + // discovery is the whole reason they exist. + if pin := prefs.pinned(role); pin != "" { + if len(served) == 0 || servedContains(served, pin) { + return pin + } + } + return tiers.modelForRole(role) +} + +// servedModels indexes what discovery reported, for checking pins against it. +// +// EVERY FORM OF EACH ID IS INDEXED, not just the one discovery happened to print, +// because the things compared against this map are written by people and by other +// layers that spell the same model differently. +// +// The map held raw discovery ids alone while three callers probed it with other +// forms: the provider-mismatch guard with the session's model, pin validation and +// the router pin with whatever the user typed in config. A session on "sonnet 4.5" +// against a provider listing "claude-sonnet-4.5", or "glm-5.2" against +// "glm-5.2:latest", produced a MISS — and the mismatch guard reads a miss as "this +// list belongs to a different provider", so auto-assignment silently switched +// itself off, or refused the plan outright when it had been asked for explicitly. +// The user saw single-model routing and no reason for it. +// +// Indexing every form can only ADD matches, never remove one, so a setup that +// works today cannot start failing because of this. +func servedModels(models []DiscoveredModel) map[string]bool { + if len(models) == 0 { + return nil + } + served := make(map[string]bool, len(models)*2) + for _, model := range models { + for _, form := range modelIDForms(model.ID) { + served[form] = true + } + } + return served +} + +// servedContains asks whether a provider serves a model, comparing every spelling +// of the probe against every spelling of what was discovered. +// +// EMPTY MEANS UNKNOWN, NOT ABSENT — a provider that lists nothing is not evidence +// that a pin is wrong, and pins working without discovery is the whole reason they +// exist. Callers that must distinguish "not served" from "nothing discovered" +// check len(served) themselves; this answers only the membership question. +func servedContains(served map[string]bool, id string) bool { + for _, form := range modelIDForms(id) { + if served[form] { + return true + } + } + return false +} + +// modelIDForms enumerates the spellings one model id can legitimately take: +// the string itself, its registry-canonical form, and the same without an +// Ollama-style ":latest" tag. Duplicates are fine — callers only test membership. +func modelIDForms(id string) []string { + trimmed := strings.TrimSpace(id) + if trimmed == "" { + return nil + } + forms := []string{trimmed} + if canonical, err := resolveTaskModel(trimmed); err == nil && canonical != "" && canonical != trimmed { + forms = append(forms, canonical) + } + // ":latest" is a TAG, not part of the model's identity — Ollama prints it, + // people do not type it, and the two name the same weights. + for _, form := range append([]string(nil), forms...) { + if base, tagged := strings.CutSuffix(form, ":latest"); tagged && base != "" { + forms = append(forms, base) + } + } + return forms +} + +func (tiers modelTiers) modelForRole(role TaskRole) string { + switch role { + case TaskRoleScan: + return tiers.cheap + case TaskRoleImplement: + return tiers.balanced + case TaskRoleVerify: + return tiers.strong + default: + return "" + } +} + +// assignModelsToTaskArgs fills in a model for tasks that did not name one, and +// returns the args to parse plus a human-readable note per assignment. +// +// IT WORKS ON THE ARGS, BEFORE ParsePlan, and that is the whole design. Every +// downstream property then falls out for free: the assigned model is validated +// by the same constructor as a hand-written one, it round-trips through +// Plan.Args() into a saved plan, and resume re-admits exactly what ran. Applying +// it to a parsed Plan instead would have meant a second write path into the one +// object ParsePlan exists to be the sole author of. +// +// A task that NAMED a model is never touched — an explicit choice outranks a +// guess, always. +// assignModelsToTaskArgs fills in a model for tasks that named none. +// +// routed, when non-empty, is a model per task id decided by the router; it wins +// over the classifier for the tasks it covers and leaves the rest to it. Pins +// still outrank both — a pin is the user's own instruction, and neither a +// keyword nor another model overrules that. +func assignModelsToTaskArgs(tasks []any, tiers modelTiers, prefs ModelPreferences, served map[string]bool, routed map[string]string) ([]any, []string) { + // Pins alone are enough: with every role pinned, a provider that reports + // nothing usable still assigns. + if tiers == (modelTiers{}) && prefs.pinned(TaskRoleScan) == "" && + prefs.pinned(TaskRoleImplement) == "" && prefs.pinned(TaskRoleVerify) == "" { + return tasks, nil + } + var notes []string + out := make([]any, 0, len(tasks)) + for _, raw := range tasks { + fields, ok := raw.(map[string]any) + if !ok { + // Not an object: leave it exactly as it came so ParsePlan reports + // the real shape error rather than one this function invented. + out = append(out, raw) + continue + } + if strings.TrimSpace(planString(fields, "model")) != "" { + out = append(out, raw) + continue + } + task := Task{ + ID: planString(fields, "id"), + Prompt: planString(fields, "prompt"), + Tools: planStrings(fields, "tools"), + } + role := classifyTaskRole(task) + model := tiers.modelForRoleWith(role, prefs, served) + byRouter := false + if pin := prefs.pinned(role); pin == "" || (len(served) > 0 && !servedContains(served, pin)) { + if chosen := routed[task.ID]; chosen != "" { + model, byRouter = chosen, true + } + } + if model == "" { + out = append(out, raw) + continue + } + // COPIED, not mutated. These maps come from the tool call's decoded + // arguments and, on the saved-plan path, from a stored plan the caller + // may still hold; writing through would edit someone else's data. + clone := make(map[string]any, len(fields)+1) + for key, value := range fields { + clone[key] = value + } + clone["model"] = model + out = append(out, clone) + + // ONE APPEND, then only the wording differs. This branch once carried its + // own clone-and-append, so a routed task was emitted TWICE and ParsePlan + // rejected the plan with "task id appears more than once" — every plan of + // three or more tasks, because routing does not run below that. The test + // missed it by collecting results into a map keyed by task id, where a + // duplicate silently overwrites its twin. + if byRouter { + notes = append(notes, task.ID+": routed → "+model) + continue + } + note := task.ID + ": " + string(role) + " → " + model + switch pin := prefs.pinned(role); { + case pin == "": + case pin == model: + note += " (pinned)" + default: + // Say WHY the pin was passed over, or a user who configured one and + // sees a different model has no way to find out. + note += " (pin " + pin + " not served by this provider)" + } + notes = append(notes, note) + } + return out, notes +} + +// emitsText reports whether a model can produce what a plan task must produce. +// +// Ranking by price assumes every candidate is doing the same job. An account +// with image or video models breaks that assumption at the top end, where the +// verify tier looks: a real run picked grok-imagine-video-1.5 as the strongest +// model on the account, and every task depending on it was doomed before it +// started. +// +// The declared modality decides it when the provider reports one. When it does +// not — and many do not — the name is the only signal left. That is a heuristic +// and is written as one: it can only ever EXCLUDE a candidate, so the cost of +// being wrong is a model not chosen, never a plan wired to a model that cannot +// answer. +func emitsText(model DiscoveredModel) bool { + for _, modality := range model.OutputModalities { + if strings.EqualFold(strings.TrimSpace(modality), "text") { + return true + } + } + if len(model.OutputModalities) > 0 { + return false + } + name := strings.ToLower(model.ID + " " + model.Description) + for _, marker := range []string{"image", "video", "audio", "speech", "tts", "whisper", "embed", "rerank", "moderation", "imagine", "diffusion"} { + if strings.Contains(name, marker) { + return false + } + } + return true +} diff --git a/internal/specialist/plan_model_assign_test.go b/internal/specialist/plan_model_assign_test.go new file mode 100644 index 000000000..6597cb603 --- /dev/null +++ b/internal/specialist/plan_model_assign_test.go @@ -0,0 +1,542 @@ +package specialist + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/tools" +) + +func discovered() []DiscoveredModel { + return []DiscoveredModel{ + {ID: "claude-opus-4.1", ToolCall: true, Reasoning: true, InputCost: 15, OutputCost: 75}, + {ID: "gpt-4.1-nano", ToolCall: true, InputCost: 0.1, OutputCost: 0.4}, + {ID: "claude-sonnet-4.5", ToolCall: true, InputCost: 3, OutputCost: 15}, + {ID: "gpt-4.1-mini", ToolCall: false, InputCost: 0.01}, + {ID: "some-uncurated-proxy-model", ToolCall: true, InputCost: 0.001}, + } +} + +// Tiers rank by PRICE, and a model that cannot call a tool is not eligible at +// all — a plan task that cannot call a tool cannot do a plan task's job. +func TestTiersRankByCostAndRequireToolCalling(t *testing.T) { + tiers := buildModelTiers(discovered(), ModelPreferences{}) + if tiers.strong != "claude-opus-4.1" { + t.Errorf("strong = %q", tiers.strong) + } + if tiers.strong != "claude-opus-4.1" { + t.Errorf("strong = %q", tiers.strong) + } + if tiers.cheap == "gpt-4.1-mini" || tiers.balanced == "gpt-4.1-mini" || tiers.strong == "gpt-4.1-mini" { + t.Errorf("a model that cannot call tools was made eligible: %+v", tiers) + } + // An UNCURATED model IS eligible — discovery asked the provider what it + // serves, and that is the authority. Requiring curation as well made + // auto-assign do nothing on an xAI or Ollama account. + if tiers.cheap != "some-uncurated-proxy-model" { + t.Errorf("cheap = %q; the cheapest model the provider serves must be eligible even when uncurated", tiers.cheap) + } + + // One model means every task gets it — today's behaviour, not a failure. + single := buildModelTiers([]DiscoveredModel{{ID: "gpt-4.1", ToolCall: true}}, ModelPreferences{}) + if single.cheap != "gpt-4.1" || single.balanced != "gpt-4.1" || single.strong != "gpt-4.1" { + t.Errorf("a single-model provider must fill every tier: %+v", single) + } + + // A provider that publishes a bare id list with no capabilities at all is + // USABLE: "cannot call tools" and "said nothing" are the same bool, and + // requiring the flag outright made auto_assign silently do nothing on every + // such provider. The parent is calling tools on it already. + if got := buildModelTiers([]DiscoveredModel{{ID: "gpt-4.1", ToolCall: false}}, ModelPreferences{}); got == (modelTiers{}) { + t.Error("a provider that reports no capabilities at all must still be assignable") + } + // But when SOME model claims tool calling, the ones that do not are excluded + // — there the flag is real information rather than an absent field. + mixed := buildModelTiers([]DiscoveredModel{ + {ID: "gpt-4.1-nano", ToolCall: true, InputCost: 1}, + {ID: "gpt-4o", ToolCall: false, InputCost: 0.01}, + }, ModelPreferences{}) + if mixed.cheap == "gpt-4o" { + t.Errorf("a model that explicitly cannot call tools was chosen over one that can: %+v", mixed) + } + // Nothing at all still yields nothing. + if got := buildModelTiers(nil, ModelPreferences{}); got != (modelTiers{}) { + t.Errorf("no models must yield no tiers, got %+v", got) + } +} + +// The strong tier prefers a REASONING model when the priciest is not one — +// verify is what that tier exists for. +func TestTheStrongTierPrefersAReasoningModel(t *testing.T) { + tiers := buildModelTiers([]DiscoveredModel{ + {ID: "gpt-4.1-nano", ToolCall: true, InputCost: 1}, + {ID: "claude-opus-4.1", ToolCall: true, Reasoning: true, InputCost: 5}, + {ID: "gpt-4o", ToolCall: true, InputCost: 20}, + }, ModelPreferences{}) + if tiers.strong != "claude-opus-4.1" { + t.Errorf("strong = %q, want the reasoning model over the merely expensive one", tiers.strong) + } +} + +// AN EXPLICIT MODEL IS NEVER OVERRIDDEN, and assignment works on the ARGS so an +// assigned model is indistinguishable from a hand-written one downstream. +func TestAssignmentFillsOnlyTasksThatNamedNoModel(t *testing.T) { + tasks := []any{ + map[string]any{"id": "scan", "prompt": "find every caller"}, + map[string]any{"id": "judge", "prompt": "review the result"}, + map[string]any{"id": "mine", "prompt": "find things", "model": "claude-haiku-4.5"}, + map[string]any{"id": "vague", "prompt": "consider the situation"}, + } + out, notes := assignModelsToTaskArgs(tasks, buildModelTiers(discovered(), ModelPreferences{}), ModelPreferences{}, servedModels(discovered()), nil) + + got := map[string]string{} + for _, raw := range out { + fields := raw.(map[string]any) + got[planString(fields, "id")] = planString(fields, "model") + } + if got["scan"] != "some-uncurated-proxy-model" { + t.Errorf("scan got %q, want the cheapest model the provider serves", got["scan"]) + } + if got["judge"] != "claude-opus-4.1" { + t.Errorf("judge got %q, want the strong tier", got["judge"]) + } + if got["mine"] != "claude-haiku-4.5" { + t.Errorf("an explicit model was overridden: %q", got["mine"]) + } + if got["vague"] != "" { + t.Errorf("an unclassifiable task must inherit, got %q", got["vague"]) + } + if len(notes) != 2 { + t.Errorf("expected a note per assignment, got %v", notes) + } + + // The caller's maps must not be mutated — on the saved-plan path they belong + // to a stored plan the caller may still be holding. + if _, mutated := tasks[0].(map[string]any)["model"]; mutated { + t.Error("assignment wrote through to the caller's task map") + } +} + +// OFF UNLESS ASKED. Default-on would change which model every existing plan runs +// on, and what it costs, without anyone choosing that. +func TestAutoAssignIsOffByDefaultAndRefusesWhenUnavailable(t *testing.T) { + gate := &PostureGate{} + gate.Set(true) + base := func() *OrchestrateTool { + return &OrchestrateTool{ + PostureActive: gate.Active, + ParentTools: []string{"read_file"}, + RunTask: NewPlanRunner(PlanTaskContext{ + Executor: progressExecutor(t), Cwd: t.TempDir(), SpecialistName: "explorer", + }), + } + } + args := func(auto bool) map[string]any { + a := map[string]any{ + "name": "p", + "tasks": []any{map[string]any{"id": "a", "prompt": "find every caller"}}, + "budget": map[string]any{"max_workers": float64(1), "max_tokens": float64(100000)}, + } + if auto { + a["auto_assign"] = true + } + return a + } + + // Default off: no discoverer wired, and the plan runs anyway. + if res := base().RunWithOptions(context.Background(), args(false), tools.RunOptions{}); res.Status == tools.StatusError { + t.Fatalf("a plan that did not ask for auto_assign must not need a discoverer: %s", res.Output) + } + + // Asked for, unavailable: refused with a reason, not run silently without it. + res := base().RunWithOptions(context.Background(), args(true), tools.RunOptions{}) + if res.Status != tools.StatusError { + t.Fatalf("auto_assign with no discoverer must be refused, got %s", res.Status) + } + if !strings.Contains(res.Output, "auto_assign is not available") { + t.Errorf("the refusal must say why: %q", res.Output) + } + + // Asked for and available: assigned, and the result SAYS what it chose. + tool := base() + tool.DiscoverModels = func(context.Context) ([]DiscoveredModel, error) { return discovered(), nil } + res = tool.RunWithOptions(context.Background(), args(true), tools.RunOptions{}) + if res.Status == tools.StatusError { + t.Fatalf("auto_assign failed: %s", res.Output) + } + if !strings.Contains(res.Output, "Models assigned automatically") { + t.Errorf("the result must report what it assigned:\n%s", res.Output) + } + if !strings.Contains(res.Output, "some-uncurated-proxy-model") { + t.Errorf("the scan task's model is missing from the report:\n%s", res.Output) + } +} + +// THE SCHEMA IS THE ONLY WAY THE MODEL LEARNS THE OPTION EXISTS. +// +// auto_assign was implemented, wired, unit-tested and unreachable: the property +// was never added to Parameters(), and additionalProperties is false, so a model +// asked point-blank for auto_assign could not send it. The tool ran the plan +// without assignment and reported nothing, which is correct behaviour for an +// absent flag and looked exactly like a broken feature. +// +// The unit tests passed because they put auto_assign straight into the args map, +// which is a thing only a test can do. This asserts the advertisement instead. +func TestEveryArgumentTheToolReadsIsAdvertised(t *testing.T) { + schema := (&OrchestrateTool{}).Parameters() + for _, key := range []string{"auto_assign", "background", "saved", "tasks", "budget", "name", "description"} { + property, ok := schema.Properties[key] + if !ok { + t.Errorf("the tool reads %q but never advertises it; a model cannot send what it cannot see", key) + continue + } + if strings.TrimSpace(property.Description) == "" { + t.Errorf("%q is advertised with no description", key) + } + } + // additionalProperties:false is what makes an unadvertised key not merely + // undiscoverable but unsendable, so the check above is not cosmetic. + if schema.AdditionalProperties { + t.Log("note: additionalProperties is true; an unadvertised key might still arrive") + } +} + +// The description must not promise behaviour the code no longer has. It said an +// unknown model was "refused when the plan is admitted" long after that stopped +// being true, which is a lie told directly to the model composing the call. +func TestTheTaskDescriptionMatchesWhatAdmissionActuallyDoes(t *testing.T) { + tasks := (&OrchestrateTool{}).Parameters().Properties["tasks"].Description + if strings.Contains(tasks, "refused when the plan is admitted") { + t.Errorf("the schema still claims unknown models are refused at admission; they now pass through:\n%s", tasks) + } + if !strings.Contains(tasks, "model") { + t.Errorf("the schema must tell the model a per-task model exists:\n%s", tasks) + } +} + +// A PLAN TASK MUST PRODUCE TEXT. Ranking by price assumes every candidate does +// the same job; an account with image or video models breaks that at the top +// end, exactly where the verify tier looks. A real xAI run picked +// grok-imagine-video-1.5 as the strongest model on the account and every task +// depending on it was doomed before it started. +func TestANonTextModelIsNeverAssigned(t *testing.T) { + tiers := buildModelTiers([]DiscoveredModel{ + {ID: "grok-4.20-0309-non-reasoning", ToolCall: true, InputCost: 1}, + {ID: "grok-4.3", ToolCall: true, Reasoning: true, InputCost: 5}, + {ID: "grok-imagine-video-1.5", ToolCall: true, InputCost: 90}, + }, ModelPreferences{}) + for tier, id := range map[string]string{"cheap": tiers.cheap, "balanced": tiers.balanced, "strong": tiers.strong} { + if strings.Contains(id, "video") { + t.Errorf("%s tier = %q; a video model cannot answer a plan task", tier, id) + } + } + if tiers.strong != "grok-4.3" { + t.Errorf("strong = %q, want the strongest model that emits text", tiers.strong) + } + + // A DECLARED modality is believed over the name, in both directions. + declared := buildModelTiers([]DiscoveredModel{ + {ID: "plain-a", ToolCall: true, InputCost: 1, OutputModalities: []string{"text"}}, + {ID: "plain-b", ToolCall: true, InputCost: 9, OutputModalities: []string{"image"}}, + }, ModelPreferences{}) + if declared.strong == "plain-b" { + t.Errorf("a model declaring image-only output was assigned: %+v", declared) + } + // A name that merely mentions a marker is still excluded — the heuristic only + // ever removes candidates, so a false positive costs a model, not a broken plan. + if emitsText(DiscoveredModel{ID: "some-embedding-model"}) { + t.Error("an embedding model must not be assignable") + } + if !emitsText(DiscoveredModel{ID: "grok-4.3"}) { + t.Error("an ordinary chat model must remain assignable") + } +} + +// EFFORT IS ONLY SENT FOR A MODEL THE REGISTRY CAN VOUCH FOR. +// +// The child clamps a requested effort only for models it can look up; for +// anything else it forwards the value verbatim, and a provider that does not +// accept the parameter rejects the request outright. A real run died three times +// with "Model grok-build-0.1 does not support parameter reasoningEffort". +func TestEffortIsNotSentToAModelNobodyCanVouchFor(t *testing.T) { + if got := planTaskReasoningEffort("grok-build-0.1", "high", "high"); got != "" { + t.Errorf("effort %q was sent for an uncurated model; the provider rejects the whole request", got) + } + if got := planTaskReasoningEffort("claude-haiku-4.5", "high", "high"); got != "high" { + t.Errorf("effort for a curated reasoning model = %q, want high", got) + } + // And the argv proves it, not just the helper. + manifest := planTaskManifest("explorer", "grok-build-0.1", + planTaskReasoningEffort("grok-build-0.1", "high", "high"), []string{"read_file"}) + argv := appendModelArgs(nil, manifest, "grok-4.3", "high") + for i, arg := range argv { + if arg == "--reasoning-effort" { + t.Fatalf("--reasoning-effort %q reached the child for an uncurated model: %v", argv[i+1], argv) + } + } + if !containsArg(argv, "--model", "grok-build-0.1") { + t.Errorf("the model itself must still be passed: %v", argv) + } +} + +func containsArg(argv []string, flag, want string) bool { + for i, arg := range argv { + if arg == flag && i+1 < len(argv) && argv[i+1] == want { + return true + } + } + return false +} + +// PINS BEAT DISCOVERY, and work where discovery cannot. +// +// The automatic choice ranks by price, which fails both ways on real accounts: +// an xAI account put a build preview on verify because it was the priciest +// thing there, and an Ollama account reports no prices at all so the ranking +// collapses to alphabetical. Neither is a heuristics problem — the person with +// the account knows which model is strongest and the code does not. +func TestPinnedModelsWinAndWorkWithoutAnyDiscoverySignal(t *testing.T) { + prefs := ModelPreferences{Scan: "deepseek-v4-flash", Verify: "deepseek-v4-pro"} + // An Ollama-shaped account: ids only, no cost, no capabilities. The pinned + // models are among them, which is the ordinary case — you pin what you have. + ollama := []DiscoveredModel{ + {ID: "deepseek-v4-flash"}, {ID: "deepseek-v4-pro"}, + {ID: "gemma4:31b"}, {ID: "glm-5.1"}, {ID: "gpt-oss:120b"}, + } + + tasks := []any{ + map[string]any{"id": "s", "prompt": "find every caller"}, + map[string]any{"id": "i", "prompt": "fix the parser"}, + map[string]any{"id": "v", "prompt": "review the change"}, + } + out, notes := assignModelsToTaskArgs(tasks, buildModelTiers(ollama, prefs), prefs, servedModels(ollama), nil) + got := map[string]string{} + for _, raw := range out { + fields := raw.(map[string]any) + got[planString(fields, "id")] = planString(fields, "model") + } + if got["s"] != "deepseek-v4-flash" { + t.Errorf("scan got %q, want the pin", got["s"]) + } + if got["v"] != "deepseek-v4-pro" { + t.Errorf("verify got %q, want the pin", got["v"]) + } + // An UNPINNED role still falls back to discovery. + if got["i"] == "" { + t.Error("an unpinned role must still be assigned from discovery") + } + // The note says which were the user's choice rather than the code's. + joined := strings.Join(notes, " | ") + if !strings.Contains(joined, "(pinned)") { + t.Errorf("a pinned assignment must be marked as such: %s", joined) + } + + // PINS ALONE ARE ENOUGH: a provider that offers nothing usable still assigns. + only, _ := assignModelsToTaskArgs(tasks, buildModelTiers(nil, prefs), prefs, nil, nil) + fields := only[0].(map[string]any) + if planString(fields, "model") != "deepseek-v4-flash" { + t.Errorf("with no discovery at all, a pin must still apply: %v", fields) + } +} + +// EXCLUSIONS remove a model from every tier — for the ones eligible on paper and +// wrong in practice, like a preview build that ranked highest on price and +// failed every task it was given. +func TestAnExcludedModelIsNeverChosen(t *testing.T) { + models := []DiscoveredModel{ + {ID: "grok-4.20-0309-non-reasoning", ToolCall: true, InputCost: 1}, + {ID: "grok-4.3", ToolCall: true, Reasoning: true, InputCost: 5}, + // Reasoning: true matches what discovery actually reported for it — which + // is why the reasoning-preference could not save the real run and price + // alone decided. + {ID: "grok-build-0.1", ToolCall: true, Reasoning: true, InputCost: 20}, + } + if got := buildModelTiers(models, ModelPreferences{}); got.strong != "grok-build-0.1" { + t.Fatalf("sanity check failed: unexcluded, price puts %q on top", got.strong) + } + tiers := buildModelTiers(models, ModelPreferences{Exclude: []string{"grok-build-0.1"}}) + for tier, id := range map[string]string{"cheap": tiers.cheap, "balanced": tiers.balanced, "strong": tiers.strong} { + if id == "grok-build-0.1" { + t.Errorf("%s tier = %q despite being excluded", tier, id) + } + } + if tiers.strong != "grok-4.3" { + t.Errorf("strong = %q, want the best remaining model", tiers.strong) + } + // Case-insensitive, because a config file is written by a human. + if !(ModelPreferences{Exclude: []string{"GROK-Build-0.1"}}).excluded("grok-build-0.1") { + t.Error("exclusion must not depend on case") + } +} + +// CONFIGURED ON, BUT STILL OVERRIDABLE PER PLAN. +// +// auto_assign is off unless asked for, which means a plain zeromaxing prompt +// never routes models — the user has to type the flag every time. A configured +// default fixes that, but only if a single plan can still say no: without +// presence detection an absent argument and an explicit false are the same +// value, and a config default could never be turned off. +func TestConfiguredAutoAssignAppliesUnlessThePlanSaysOtherwise(t *testing.T) { + gate := &PostureGate{} + gate.Set(true) + build := func(prefs ModelPreferences) *OrchestrateTool { + return &OrchestrateTool{ + PostureActive: gate.Active, + ParentTools: []string{"read_file"}, + ModelPrefs: prefs, + DiscoverModels: func(context.Context) ([]DiscoveredModel, error) { return discovered(), nil }, + RunTask: NewPlanRunner(PlanTaskContext{ + Executor: progressExecutor(t), Cwd: t.TempDir(), SpecialistName: "explorer", + }), + } + } + args := func(mutate func(map[string]any)) map[string]any { + a := map[string]any{ + "name": "p", + "tasks": []any{map[string]any{"id": "a", "prompt": "find every caller"}}, + "budget": map[string]any{"max_workers": float64(1), "max_tokens": float64(100000)}, + } + if mutate != nil { + mutate(a) + } + return a + } + + // Configured on, plan silent: assignment happens without anyone asking. + on := build(ModelPreferences{AutoAssign: true}) + res := on.RunWithOptions(context.Background(), args(nil), tools.RunOptions{}) + if !strings.Contains(res.Output, "Models assigned automatically") { + t.Errorf("a configured default must apply to a plan that never mentions it:\n%s", res.Output) + } + + // Configured on, plan says NO: the plan wins. + res = on.RunWithOptions(context.Background(), args(func(a map[string]any) { a["auto_assign"] = false }), tools.RunOptions{}) + if strings.Contains(res.Output, "Models assigned automatically") { + t.Errorf("an explicit auto_assign:false must override the configured default:\n%s", res.Output) + } + + // Configured off, plan silent: unchanged from before this existed. + off := build(ModelPreferences{}) + res = off.RunWithOptions(context.Background(), args(nil), tools.RunOptions{}) + if strings.Contains(res.Output, "Models assigned automatically") { + t.Errorf("with nothing configured and nothing asked, nothing must be assigned:\n%s", res.Output) + } + + // Configured off, plan says yes: still works. + res = off.RunWithOptions(context.Background(), args(func(a map[string]any) { a["auto_assign"] = true }), tools.RunOptions{}) + if !strings.Contains(res.Output, "Models assigned automatically") { + t.Errorf("an explicit request must work with nothing configured:\n%s", res.Output) + } +} + +// A STANDING PREFERENCE MUST NOT BREAK PLANNING WHEN IT CANNOT BE HONOURED. +// +// A plan that ASKS for auto_assign wants it, so an unavailable run is refused — +// running silently without it is what the request exists to prevent. A CONFIGURED +// default is not a demand: refusing every plan because a models endpoint blinked +// would let one setting break all planning offline or behind a proxy. Found by a +// real test failing the moment the setting was switched on. +func TestAConfiguredDefaultDegradesWhereAnExplicitRequestRefuses(t *testing.T) { + gate := &PostureGate{} + gate.Set(true) + build := func(prefs ModelPreferences, discover ModelDiscoverer) *OrchestrateTool { + return &OrchestrateTool{ + PostureActive: gate.Active, ParentTools: []string{"read_file"}, + ModelPrefs: prefs, DiscoverModels: discover, + RunTask: NewPlanRunner(PlanTaskContext{ + Executor: progressExecutor(t), Cwd: t.TempDir(), SpecialistName: "explorer", + }), + } + } + plan := func(mutate func(map[string]any)) map[string]any { + a := map[string]any{ + "name": "p", + "tasks": []any{map[string]any{"id": "a", "prompt": "find every caller"}}, + "budget": map[string]any{"max_workers": float64(1), "max_tokens": float64(100000)}, + } + if mutate != nil { + mutate(a) + } + return a + } + broken := func(context.Context) ([]DiscoveredModel, error) { + return nil, fmt.Errorf("decode models response: unexpected end of input") + } + + // Configured on, discovery broken, plan silent: the PLAN STILL RUNS. + res := build(ModelPreferences{AutoAssign: true}, broken). + RunWithOptions(context.Background(), plan(nil), tools.RunOptions{}) + if res.Status == tools.StatusError { + t.Fatalf("a configured default must not refuse the plan when discovery fails: %s", res.Output) + } + if !strings.Contains(res.Output, "could not list the provider's models") { + t.Errorf("it must still say what it could not do:\n%s", res.Output) + } + + // Same failure, but the PLAN asked: refused, because it asked. + res = build(ModelPreferences{}, broken). + RunWithOptions(context.Background(), plan(func(a map[string]any) { a["auto_assign"] = true }), tools.RunOptions{}) + if res.Status != tools.StatusError { + t.Errorf("an explicit auto_assign must be refused when it cannot be honoured, got %s", res.Status) + } + + // And the same split when the run simply has no discoverer at all. + res = build(ModelPreferences{AutoAssign: true}, nil). + RunWithOptions(context.Background(), plan(nil), tools.RunOptions{}) + if res.Status == tools.StatusError { + t.Errorf("a configured default must degrade when the run cannot discover: %s", res.Output) + } + res = build(ModelPreferences{}, nil). + RunWithOptions(context.Background(), plan(func(a map[string]any) { a["auto_assign"] = true }), tools.RunOptions{}) + if res.Status != tools.StatusError { + t.Errorf("an explicit request with no discoverer must be refused, got %s", res.Status) + } +} + +// A PIN THE ACTIVE PROVIDER DOES NOT SERVE MUST NOT KILL THE PLAN. +// +// Pins name models and models belong to providers. Switching provider — which a +// user does when an account hits its quota, mid-session — leaves every pin +// naming something the new provider has never heard of. Applied regardless, that +// turned a provider switch into total failure: four tasks dead with +// `model "grok-4.3" not found` before one of them had run. +func TestAPinTheProviderCannotServeIsPassedOverNotForced(t *testing.T) { + // Pins from a previous provider; discovery reports a different account. + prefs := ModelPreferences{Scan: "grok-4.20-0309-non-reasoning", Verify: "grok-4.3"} + nowServing := []DiscoveredModel{{ID: "glm-5.2"}, {ID: "deepseek-v4-pro"}} + served := servedModels(nowServing) + + tasks := []any{ + map[string]any{"id": "s", "prompt": "searching for every caller"}, + map[string]any{"id": "v", "prompt": "reviewing the change"}, + } + out, notes := assignModelsToTaskArgs(tasks, buildModelTiers(nowServing, prefs), prefs, served, nil) + + for _, raw := range out { + fields := raw.(map[string]any) + got := planString(fields, "model") + if strings.HasPrefix(got, "grok") { + t.Errorf("task %q was assigned %q, which this provider does not serve", + planString(fields, "id"), got) + } + if got != "" && !served[got] { + t.Errorf("task %q was assigned %q, which is not in the served set", + planString(fields, "id"), got) + } + } + // And it SAYS the pin was passed over, or a user who configured one and sees + // a different model has no way to find out why. + joined := strings.Join(notes, " | ") + if !strings.Contains(joined, "not served by this provider") { + t.Errorf("a passed-over pin must explain itself: %s", joined) + } + + // With NO discovery at all, a pin is still honoured — that is the case pins + // exist for, and an empty list is not evidence the pin is wrong. + out, _ = assignModelsToTaskArgs(tasks, buildModelTiers(nil, prefs), prefs, nil, nil) + if got := planString(out[0].(map[string]any), "model"); got != "grok-4.20-0309-non-reasoning" { + t.Errorf("with no discovery the pin must still apply, got %q", got) + } +} diff --git a/internal/specialist/plan_model_fallback_test.go b/internal/specialist/plan_model_fallback_test.go new file mode 100644 index 000000000..09ceb3bfa --- /dev/null +++ b/internal/specialist/plan_model_fallback_test.go @@ -0,0 +1,338 @@ +package specialist + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/Gitlawb/zero/internal/streamjson" +) + +// modelFromArgs reads the model the child was actually launched with. Asserted +// from ARGV rather than from the manifest struct, because argv is what the +// child receives — a field set and then dropped by appendModelArgs would pass +// a struct assertion and fail in production. +func modelFromArgs(args []string) string { + for index, arg := range args { + if arg == "--model" && index+1 < len(args) { + return args[index+1] + } + } + return "" +} + +// A model the provider will not run must cost the task a retry, not its life. +// +// Auto-assignment picks from the list the provider itself published, so +// "listed but unusable" is the NORMAL failure of a discovery endpoint that +// describes products rather than endpoints. Two real ones, both fatal before +// this: a model belonging to another account, and grok-4.20-multi-agent-0309, +// whose id lists on /v1/models while chat completions answers "Multi Agent +// requests are not allowed on chat completions". One task of four died. +// +// The trigger is structural on purpose — the runner already warns that choosing +// to spend another child by reading error prose is the wrong instrument. +func TestATaskKilledByAnUnusableAssignedModelRetriesOnTheParentModel(t *testing.T) { + var ran []string + exec := Executor{ + RunChild: func(_ context.Context, _ string, args []string, progress func(streamjson.Event)) (ChildRunResult, error) { + model := modelFromArgs(args) + ran = append(ran, model) + if model == "grok-4.20-multi-agent-0309" { + // Exactly the shape of the real failure: refused before generating + // anything, so no events, no output and no tokens. + return ChildRunResult{Started: true, ExitCode: 3}, + errors.New("provider request error: Multi Agent requests are not allowed on chat completions") + } + if progress != nil { + progress(streamjson.Event{Type: streamjson.EventToolCall, Name: "read_file"}) + } + return ChildRunResult{Started: true}, nil + }, + } + report := runOneTaskPlan(t, exec, "config-overrides", "survey config precedence", "grok-4.20-multi-agent-0309") + result := report.Tasks[0] + if result.Outcome != TaskSucceeded { + t.Fatalf("the task was not rescued: %s / %s", result.Outcome, result.Err) + } + if len(ran) != 2 || ran[0] != "grok-4.20-multi-agent-0309" || ran[1] != "" { + t.Fatalf("expected the assigned model then the parent's, got %q", ran) + } + // The failed choice must be NAMED, or the plan silently uses a different + // model than it reports and the next plan picks the broken one again. + if result.RetriedOnParentModel != "grok-4.20-multi-agent-0309" { + t.Errorf("the unusable model was not recorded: %q", result.RetriedOnParentModel) + } + if result.Model != "" { + t.Errorf("the result claims it ran on %q, but the fallback ran on the parent's model", result.Model) + } + // THE SPEND MUST BE COUNTED. A retry hidden inside the runner reported one + // attempt for two children and dropped the first one's duration — which is + // exactly why the executor owns retries. + if result.Attempts != 2 { + t.Errorf("two children ran but the plan recorded %d attempt(s)", result.Attempts) + } + + if summary := report.Summary(); !strings.Contains(summary, "fell back from grok-4.20-multi-agent-0309") { + t.Errorf("the summary hides the fallback:\n%s", summary) + } +} + +// runOneTaskPlan drives a single task through ExecutePlan, so the retry policy, +// the wall deadline and the attempt record are the real ones. Asserting against +// the runner alone would miss precisely the defect this file exists for. +func runOneTaskPlan(t *testing.T, exec Executor, id, prompt, model string) PlanReport { + t.Helper() + fields := map[string]any{"id": id, "prompt": prompt} + if model != "" { + fields["model"] = model + } + plan := mustPlan(t, []any{fields}, okBudget(), readOnlyLimits()) + run := NewPlanRunner(PlanTaskContext{Executor: exec, Cwd: t.TempDir(), SpecialistName: "explorer"}) + report := ExecutePlan(context.Background(), plan, []string{"read_file"}, run, nil) + if len(report.Tasks) != 1 { + t.Fatalf("expected one task result, got %d", len(report.Tasks)) + } + return report +} + +// WORK THAT ACTUALLY RAN AND FAILED MUST NOT BE RETRIED. A task that reasoned, +// answered and got it wrong has spent tokens; re-running it on another model is +// a second opinion nobody asked for, and it would double the cost of every +// genuine failure in a plan. +func TestATaskThatFailedAfterDoingWorkIsNotRetriedOnTheParentModel(t *testing.T) { + attempts := 0 + exec := Executor{ + RunChild: func(_ context.Context, _ string, _ []string, progress func(streamjson.Event)) (ChildRunResult, error) { + attempts++ + // The child RAN: it reasoned, spent tokens and answered wrongly. + // Events go on ChildRunResult, not only through progress — that is + // where the executor reads usage from. + spent := 4000 + events := []streamjson.Event{ + {Type: streamjson.EventText, Text: "I could not determine the answer"}, + {Type: streamjson.EventUsage, TotalTokens: &spent}, + } + for _, event := range events { + if progress != nil { + progress(event) + } + } + return ChildRunResult{Started: true, ExitCode: 1, Events: events}, errors.New("task failed") + }, + } + result := runOneTaskPlan(t, exec, "j", "decide", "grok-4.3").Tasks[0] + if attempts != 1 { + t.Fatalf("real work that failed was retried %d times", attempts) + } + if result.Outcome != TaskFailed || result.RetriedOnParentModel != "" { + t.Errorf("a genuine failure was laundered into a fallback: %+v", result) + } +} + +// A task with NO assigned model already runs on the parent's, so there is +// nothing to fall back to and a retry would just be a free second attempt that +// no other failing task gets. +func TestATaskWithNoAssignedModelIsNotRetried(t *testing.T) { + attempts := 0 + exec := Executor{ + RunChild: func(_ context.Context, _ string, _ []string, _ func(streamjson.Event)) (ChildRunResult, error) { + attempts++ + return ChildRunResult{Started: true, ExitCode: 3}, errors.New("network unreachable") + }, + } + runOneTaskPlan(t, exec, "a", "p", "") + if attempts != 1 { + t.Fatalf("a task with no assigned model ran %d times", attempts) + } +} + +// If the retry fails too, the FIRST result is what the plan reports. The +// fallback must not launder a genuine failure into a different-looking one. +func TestWhenTheFallbackAlsoFailsTheOriginalFailureIsReported(t *testing.T) { + exec := Executor{ + RunChild: func(_ context.Context, _ string, _ []string, _ func(streamjson.Event)) (ChildRunResult, error) { + return ChildRunResult{Started: true, ExitCode: 3}, errors.New("the workspace is gone") + }, + } + result := runOneTaskPlan(t, exec, "a", "p", "grok-4.3").Tasks[0] + if result.Outcome != TaskFailed { + t.Fatalf("outcome: %s", result.Outcome) + } + // The fallback is BOUNDED AT ONE. A provider refusing the parent's model too + // must not put the loop into a spawn cycle. + if result.Attempts != 2 { + t.Errorf("the fallback was not bounded at one: %d attempts", result.Attempts) + } + // The model that could not run is still named, even though the fallback + // failed for its own reason — otherwise the next plan picks it again. + if result.RetriedOnParentModel != "grok-4.3" { + t.Errorf("the refused model was not recorded: %q", result.RetriedOnParentModel) + } +} + +// THE FALLBACK SPENDS A CHILD, so the plan's wall budget must be able to refuse +// it — exactly as it refuses a stall retry. +// +// This is why the decision belongs in the executor rather than the runner. A +// retry hidden in the runner sees no deadline, no attempt budget and no record: +// it would overrun a plan's wall budget on behalf of the task that already +// exhausted it, and the plan would report one attempt for two children. +func TestTheModelFallbackIsRefusedOnceThePlansWallBudgetIsGone(t *testing.T) { + attempts := 0 + exec := Executor{ + RunChild: func(_ context.Context, _ string, _ []string, _ func(streamjson.Event)) (ChildRunResult, error) { + attempts++ + // Burn the whole wall budget, then fail the way a refused model does. + time.Sleep(1100 * time.Millisecond) + return ChildRunResult{Started: true, ExitCode: 3}, errors.New("model not found") + }, + } + plan := mustPlan(t, []any{ + map[string]any{"id": "a", "prompt": "p", "model": "grok-4.20-multi-agent-0309"}, + }, map[string]any{"max_workers": float64(1), "max_tokens": float64(500_000), "max_wall_seconds": float64(1)}, + readOnlyLimits()) + + run := NewPlanRunner(PlanTaskContext{Executor: exec, Cwd: t.TempDir(), SpecialistName: "explorer"}) + report := ExecutePlan(context.Background(), plan, []string{"read_file"}, run, nil) + + if attempts != 1 { + t.Fatalf("the fallback overran an exhausted wall budget: %d children spawned", attempts) + } + // THE OUTCOME IS CANCELLED, NOT FAILED, and that is a deliberate distinction + // made where the wall budget is enforced: an expired plan deadline cancels + // the plan's context, and a task stopped by it did not fail — it ran out of + // time. This test asserted "failed" while the budget was only checked between + // dispatches; the property it exists for is the one above, that the fallback + // spends no second child once the budget is gone. + if outcome := report.Tasks[0].Outcome; outcome == TaskSucceeded { + t.Errorf("a task stopped by the wall budget reported success: %s", outcome) + } +} + +// A CANCELLED PLAN MUST NOT SPAWN A FALLBACK CHILD. The prototype's retry loop +// retried a cancelled task, which turned Ctrl-C into another spawn; the model +// fallback spends a child the same way and must answer to the same stop. +func TestTheModelFallbackIsRefusedOnceThePlanIsCancelled(t *testing.T) { + attempts := 0 + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + exec := Executor{ + RunChild: func(_ context.Context, _ string, _ []string, _ func(streamjson.Event)) (ChildRunResult, error) { + attempts++ + // The user stops the plan while this child is dying on a bad model. + cancel() + return ChildRunResult{Started: true, ExitCode: 3}, errors.New("model not found") + }, + } + plan := mustPlan(t, []any{ + map[string]any{"id": "a", "prompt": "p", "model": "grok-4.20-multi-agent-0309"}, + }, okBudget(), readOnlyLimits()) + + run := NewPlanRunner(PlanTaskContext{Executor: exec, Cwd: t.TempDir(), SpecialistName: "explorer"}) + ExecutePlan(ctx, plan, []string{"read_file"}, run, nil) + + if attempts != 1 { + t.Fatalf("a cancelled plan spawned a fallback child: %d children", attempts) + } +} + +// THE REAL FAILURE PATH, and it is not the one the tests above drive. +// +// A child that dies on a refused model exits non-zero WITHOUT runChild returning +// an error, so Executor.Run reaches BuildFinalResult — which writes a diagnostic +// into Result.Output: "Subagent failed (exit 3)\nerrors: provider request error: +// ...". Every earlier test in this file returned an error from runChild instead, +// taking the branch that leaves Result empty. +// +// That difference hid a live defect. The fallback's "the child produced nothing" +// test read Result.Output, which is NEVER empty on this path, so ModelRejected +// was never set and two tasks in a real ten-task plan died on +// grok-4.20-multi-agent-0309 with the rescue sitting one branch away. The signal +// now comes from the child's own stream — tool calls and tokens — which describes +// the child rather than the harness's account of its death. +func TestAChildThatExitsNonZeroOnARefusedModelIsStillRescued(t *testing.T) { + var ran []string + exec := Executor{ + RunChild: func(_ context.Context, _ string, args []string, progress func(streamjson.Event)) (ChildRunResult, error) { + model := modelFromArgs(args) + ran = append(ran, model) + if model == "grok-4.20-multi-agent-0309" { + // Exactly production: non-zero exit, NO error from runChild, an + // error event in the stream. BuildFinalResult turns this into + // StatusError with a non-empty diagnostic Output. + events := []streamjson.Event{{ + Type: streamjson.EventError, + Message: `provider request error: "Multi Agent requests are not allowed on chat completions"`, + }} + for _, event := range events { + if progress != nil { + progress(event) + } + } + return ChildRunResult{Started: true, ExitCode: 3, Events: events}, nil + } + if progress != nil { + progress(streamjson.Event{Type: streamjson.EventToolCall, Name: "read_file"}) + } + return ChildRunResult{Started: true}, nil + }, + } + + report := runOneTaskPlan(t, exec, "grant", "read the actual source", "grok-4.20-multi-agent-0309") + result := report.Tasks[0] + + if len(ran) != 2 { + t.Fatalf("the refused model was not retried on the parent's: children ran on %q", ran) + } + if result.Outcome != TaskSucceeded { + t.Fatalf("the task died on a refused model instead of being rescued: %s / %s", + result.Outcome, result.Err) + } + if result.RetriedOnParentModel != "grok-4.20-multi-agent-0309" { + t.Errorf("the refused model was not recorded: %q", result.RetriedOnParentModel) + } + // RED MUST TURN GREEN. The recorder sees only the final result, so a rescued + // task reports completed — the card must not stay on the failure. + if report.Failed != 0 || report.Succeeded != 1 { + t.Errorf("a rescued task was still counted as a failure: %d failed, %d succeeded", + report.Failed, report.Succeeded) + } +} + +// A TASK THAT CALLED TOOLS DID WORK, whatever the usage numbers say. +// +// Tokens alone are not enough to tell work from a refusal: plenty of providers +// never report usage, and on those every genuine failure would look like a model +// the provider would not run — so a task that read files, thought, and failed for +// its own reasons would be silently re-run on a different model. Tool calls come +// from the child's own stream and cannot be absent when the child did something. +func TestATaskThatCalledToolsIsNotRetriedEvenWhenUsageIsNeverReported(t *testing.T) { + attempts := 0 + exec := Executor{ + RunChild: func(_ context.Context, _ string, _ []string, progress func(streamjson.Event)) (ChildRunResult, error) { + attempts++ + // Real work, and a provider that reports no usage at all. + events := []streamjson.Event{ + {Type: streamjson.EventToolCall, Name: "read_file"}, + {Type: streamjson.EventError, Message: "the file it needed does not exist"}, + } + for _, event := range events { + if progress != nil { + progress(event) + } + } + return ChildRunResult{Started: true, ExitCode: 1, Events: events}, nil + }, + } + + result := runOneTaskPlan(t, exec, "t", "read and explain", "grok-4.3").Tasks[0] + if attempts != 1 { + t.Fatalf("a task that called tools was re-run on another model: %d attempts", attempts) + } + if result.RetriedOnParentModel != "" { + t.Errorf("a genuine failure was reported as a model fallback: %q", result.RetriedOnParentModel) + } +} diff --git a/internal/specialist/plan_model_probe.go b/internal/specialist/plan_model_probe.go new file mode 100644 index 000000000..979679969 --- /dev/null +++ b/internal/specialist/plan_model_probe.go @@ -0,0 +1,204 @@ +package specialist + +import ( + "context" + "strings" + "sync" +) + +// Proving a model, as opposed to believing a list. +// +// DISCOVERY READS /v1/models, WHICH IS AN ADVERTISEMENT. It reports what a +// provider is willing to name, not what it will actually run, and the gap +// between those is where this feature has repeatedly fallen in: +// +// - grok-4.20-multi-agent-0309 lists like any other model and answers +// "Multi Agent requests are not allowed on chat completions". Six tasks were +// assigned it; all six died. +// - A model list fetched for one provider while the session had switched to +// another lists perfectly and serves nothing. +// - grok-build-0.1 and grok-imagine-video-1.5 list, price high enough to win +// the strong tier, and fail every task they touch. +// +// The user's answer so far has been a hand-maintained exclude list — three +// entries, each added after a plan died on it. A probe replaces that with +// evidence: one trivial request per candidate, once, and a model that will not +// answer it is not offered to the router. + +// ModelProbeVerdict is what a single probe learned. +type ModelProbeVerdict int + +const ( + // ProbeUnknown means the probe could not reach a conclusion — a timeout, a + // transport error, a provider that was briefly unwell. NOT a refusal: the + // model keeps its place, because "we could not ask" and "it said no" call for + // opposite responses and conflating them would drop a working model on a + // flaky network. + ProbeUnknown ModelProbeVerdict = iota + // ProbeServes means the model answered. + ProbeServes + // ProbeRefuses means the provider said this model will not serve this kind of + // request — no such model, no access, wrong endpoint. Unambiguous, and the + // only verdict that removes a candidate. + ProbeRefuses +) + +// ModelProbeResult is one model's verdict and the provider's own words for it. +type ModelProbeResult struct { + Verdict ModelProbeVerdict + // Reason is the provider's message, kept verbatim so a report can say WHY a + // model was dropped rather than only that it was. + Reason string +} + +// ModelProber asks a provider whether it will actually run a model. Supplied as +// a hook for the same reason ModelDiscoverer is: internal/specialist runs on the +// child-execution path and must not drag the provider stack in behind it. +type ModelProber func(ctx context.Context, modelID string) ModelProbeResult + +// probeCache remembers verdicts for the life of the process. +// +// ONCE PER MODEL, NOT ONCE PER PLAN. The whole point is that this costs a +// trivial request; paying it again on every plan in a session would turn a +// cheap guarantee into a per-plan tax. Verdicts do not change within a run — +// a provider that refuses a model at 10:00 refuses it at 10:05. +// +// ProbeUnknown is deliberately NOT cached. It records a failure to ask, not an +// answer, and caching it would let one flaky moment exclude a working model for +// the rest of the session. +type probeCache struct { + mu sync.Mutex + results map[string]ModelProbeResult +} + +func (cache *probeCache) get(id string) (ModelProbeResult, bool) { + if cache == nil { + return ModelProbeResult{}, false + } + cache.mu.Lock() + defer cache.mu.Unlock() + result, ok := cache.results[id] + return result, ok +} + +func (cache *probeCache) put(id string, result ModelProbeResult) { + if cache == nil || result.Verdict == ProbeUnknown { + return + } + cache.mu.Lock() + defer cache.mu.Unlock() + if cache.results == nil { + cache.results = map[string]ModelProbeResult{} + } + cache.results[id] = result +} + +// ClassifyProbeError turns a provider's error into a verdict. +// +// THE ONLY PLACE MESSAGE TEXT IS READ, and it is confined here on purpose. Every +// other decision in this package is structural — Stalled is a flag, ModelRejected +// is a flag — because matching prose to decide whether to spend a child is the +// bug class this codebase keeps re-learning. Here there is no structure to use: +// providers return "the model does not exist", "your team does not have access", +// "not allowed on chat completions" as ordinary errors on the same code path as +// a timeout, and the difference genuinely only exists in the words. +// +// So it is written to FAIL TOWARD KEEPING THE MODEL. Anything not recognised is +// ProbeUnknown, which changes nothing. Being wrong in that direction costs one +// failed task; being wrong the other way silently removes a model the user paid +// for. +func ClassifyProbeError(err error) ModelProbeResult { + if err == nil { + return ModelProbeResult{Verdict: ProbeServes} + } + message := err.Error() + lower := strings.ToLower(message) + for _, marker := range []string{ + "does not exist", + "do not have access", + "does not have access", + "not allowed on", + "model_not_found", + "unknown model", + "no such model", + "unsupported model", + "not supported for this", + } { + if strings.Contains(lower, marker) { + return ModelProbeResult{Verdict: ProbeRefuses, Reason: strings.TrimSpace(message)} + } + } + return ModelProbeResult{Verdict: ProbeUnknown, Reason: strings.TrimSpace(message)} +} + +// proveModels removes candidates the provider will not actually run. +// +// CONCURRENT AND BOUNDED. One trivial request per unproven model, all in flight +// together, so the wait is one round trip rather than nineteen. The caller's +// context bounds it; a probe that outlives its plan's patience simply returns +// ProbeUnknown and the model keeps its place. +// +// Returns the survivors and a note per model removed. The note matters as much +// as the removal: a model vanishing from routing with no explanation is +// indistinguishable from a bug, and the user has spent this week hand-adding +// exactly these ids to an exclude list. +func proveModels(ctx context.Context, models []DiscoveredModel, probe ModelProber, cache *probeCache) ([]DiscoveredModel, []string) { + if probe == nil || len(models) == 0 { + return models, nil + } + + verdicts := make([]ModelProbeResult, len(models)) + var wait sync.WaitGroup + for index, model := range models { + if cached, ok := cache.get(model.ID); ok { + verdicts[index] = cached + continue + } + wait.Add(1) + go func(index int, id string) { + defer wait.Done() + // A panicking prober must not take the plan with it: the worst it can + // do is leave this model unproven, which keeps it. + defer func() { _ = recover() }() + verdicts[index] = probe(ctx, id) + }(index, model.ID) + } + wait.Wait() + + kept := make([]DiscoveredModel, 0, len(models)) + var notes []string + for index, model := range models { + cache.put(model.ID, verdicts[index]) + if verdicts[index].Verdict != ProbeRefuses { + kept = append(kept, model) + continue + } + note := model.ID + ": this provider will not run it" + if reason := strings.TrimSpace(verdicts[index].Reason); reason != "" { + note += " (" + firstLine(reason) + ")" + } + notes = append(notes, note) + } + // EVERY CANDIDATE REFUSED IS NOT A REASON TO ASSIGN NOTHING — it is a reason + // to distrust the probe. A provider rejecting its own entire model list is far + // more likely to be a credential or endpoint problem than nineteen genuinely + // dead models, and dropping them all would silently disable routing at exactly + // the moment the user most needs to be told something is wrong. + if len(kept) == 0 { + return models, []string{"every discovered model failed its probe, which points at this provider rather than at the models — routing left them in place"} + } + return kept, notes +} + +// firstLine keeps a provider's message to one line so a note stays readable; +// these arrive as multi-line JSON blobs often enough to matter. +func firstLine(s string) string { + if index := strings.IndexAny(s, "\r\n"); index >= 0 { + s = s[:index] + } + const limit = 160 + if len(s) > limit { + s = s[:limit] + "…" + } + return strings.TrimSpace(s) +} diff --git a/internal/specialist/plan_model_probe_test.go b/internal/specialist/plan_model_probe_test.go new file mode 100644 index 000000000..abdc146ba --- /dev/null +++ b/internal/specialist/plan_model_probe_test.go @@ -0,0 +1,268 @@ +package specialist + +import ( + "context" + "errors" + "strings" + "sync/atomic" + "testing" + + "github.com/Gitlawb/zero/internal/streamjson" + "github.com/Gitlawb/zero/internal/tools" +) + +// THE PROVIDER'S OWN REFUSALS, CLASSIFIED. These are the exact messages that +// killed real plans this week. +func TestAProviderRefusalIsDistinguishedFromAnUnreachableProvider(t *testing.T) { + refusals := []string{ + `{"code":"not-found","error":"The model deepseek-v4-flash does not exist or your team does not have access to it."}`, + `provider request error: "Multi Agent requests are not allowed on chat completions"`, + `model_not_found`, + `unknown model: banana`, + } + for _, message := range refusals { + got := ClassifyProbeError(errors.New(message)) + if got.Verdict != ProbeRefuses { + t.Errorf("a refusal was not recognised: %q -> %v", message, got.Verdict) + } + if got.Reason == "" { + t.Errorf("a refusal kept no reason to report: %q", message) + } + } + + // FAIL TOWARD KEEPING THE MODEL. Anything not recognised changes nothing: + // being wrong here costs one failed task, while being wrong the other way + // silently removes a model the user pays for. + for _, message := range []string{ + "context deadline exceeded", + "connection refused", + "429 too many requests", + "internal server error", + } { + if got := ClassifyProbeError(errors.New(message)); got.Verdict != ProbeUnknown { + t.Errorf("a transient failure was read as a refusal: %q -> %v", message, got.Verdict) + } + } + + if got := ClassifyProbeError(nil); got.Verdict != ProbeServes { + t.Errorf("a successful probe was not read as serving: %v", got.Verdict) + } +} + +// A MODEL THE PROVIDER WILL NOT RUN IS NEVER OFFERED — before tiers, the served +// set or the router's list are built from it. +func TestAModelThatFailsItsProbeIsNotOfferedToAnyTask(t *testing.T) { + discovered := []DiscoveredModel{ + {ID: "grok-code-fast", ToolCall: true, InputCost: 1}, + {ID: "grok-4.20-multi-agent-0309", ToolCall: true, InputCost: 4}, + {ID: "grok-4.5", ToolCall: true, InputCost: 9}, + } + tool := &OrchestrateTool{ + DiscoverModels: func(context.Context) ([]DiscoveredModel, error) { return discovered, nil }, + ProbeModel: func(_ context.Context, id string) ModelProbeResult { + if id == "grok-4.20-multi-agent-0309" { + return ClassifyProbeError(errors.New(`"Multi Agent requests are not allowed on chat completions"`)) + } + return ModelProbeResult{Verdict: ProbeServes} + }, + ModelPrefs: ModelPreferences{AutoAssign: true}, + } + args := map[string]any{"tasks": []any{ + map[string]any{"id": "a", "prompt": "list the files"}, + map[string]any{"id": "b", "prompt": "audit it and judge whether it holds"}, + }} + + notes, err := tool.autoAssignModels(context.Background(), args, tools.RunOptions{Model: "grok-4.5"}) + if err != nil { + t.Fatalf("auto-assign: %v", err) + } + for _, entry := range args["tasks"].([]any) { + if model := planString(entry.(map[string]any), "model"); model == "grok-4.20-multi-agent-0309" { + t.Errorf("a task was assigned a model the provider refuses: %v", entry) + } + } + // SAID, NOT SILENT — this is the id the user would otherwise add to their + // exclude list by hand, after a plan died on it. + joined := strings.Join(notes, " ") + if !strings.Contains(joined, "grok-4.20-multi-agent-0309") || !strings.Contains(joined, "will not run it") { + t.Errorf("the dropped model was not reported: %v", notes) + } + if !strings.Contains(joined, "Multi Agent requests are not allowed") { + t.Errorf("the provider's own reason was not carried into the note: %v", notes) + } +} + +// ONCE PER MODEL, NOT ONCE PER PLAN. The guarantee is cheap only if it is paid +// for once; per-plan probing turns it into a tax on every plan in a session. +func TestAModelIsProbedOnceAndRememberedForTheSession(t *testing.T) { + var probes atomic.Int64 + tool := &OrchestrateTool{ + DiscoverModels: func(context.Context) ([]DiscoveredModel, error) { + return []DiscoveredModel{{ID: "a", ToolCall: true, InputCost: 1}, {ID: "b", ToolCall: true, InputCost: 2}}, nil + }, + ProbeModel: func(context.Context, string) ModelProbeResult { + probes.Add(1) + return ModelProbeResult{Verdict: ProbeServes} + }, + ModelPrefs: ModelPreferences{AutoAssign: true}, + } + for round := 0; round < 3; round++ { + args := map[string]any{"tasks": []any{map[string]any{"id": "t", "prompt": "look"}}} + if _, err := tool.autoAssignModels(context.Background(), args, tools.RunOptions{Model: "a"}); err != nil { + t.Fatalf("round %d: %v", round, err) + } + } + if got := probes.Load(); got != 2 { + t.Errorf("expected one probe per model for the session, got %d across three plans", got) + } +} + +// AN UNREACHABLE PROBE MUST NOT BE REMEMBERED. It records a failure to ask, not +// an answer; caching it would let one flaky moment exclude a working model for +// the rest of the session. +func TestAnUnknownVerdictIsNotCached(t *testing.T) { + cache := &probeCache{} + cache.put("m", ModelProbeResult{Verdict: ProbeUnknown, Reason: "timeout"}) + if _, ok := cache.get("m"); ok { + t.Error("a failure to reach the provider was remembered as an answer") + } + cache.put("m", ModelProbeResult{Verdict: ProbeRefuses, Reason: "no such model"}) + if _, ok := cache.get("m"); !ok { + t.Error("a real verdict was not remembered") + } +} + +// EVERY CANDIDATE REFUSED POINTS AT THE PROVIDER, NOT THE MODELS. A credential +// or endpoint problem rejects the whole list, and dropping all of them would +// disable routing silently at the moment the user most needs telling. +func TestWhenEveryModelFailsItsProbeTheListIsKeptAndTheUserIsTold(t *testing.T) { + models := []DiscoveredModel{{ID: "a"}, {ID: "b"}} + kept, notes := proveModels(context.Background(), models, + func(context.Context, string) ModelProbeResult { + return ClassifyProbeError(errors.New("the model does not exist")) + }, &probeCache{}) + if len(kept) != len(models) { + t.Fatalf("a whole-provider failure emptied the candidate list: %d kept", len(kept)) + } + if len(notes) != 1 || !strings.Contains(notes[0], "points at this provider") { + t.Errorf("the user was not told the provider looks wrong: %v", notes) + } +} + +// No prober wired means no proving — exactly what every plan did before. +func TestWithoutAProberEveryDiscoveredModelIsStillOffered(t *testing.T) { + models := []DiscoveredModel{{ID: "a"}, {ID: "b"}} + kept, notes := proveModels(context.Background(), models, nil, &probeCache{}) + if len(kept) != 2 || len(notes) != 0 { + t.Errorf("an unwired prober changed the candidate list: %d kept, notes %v", len(kept), notes) + } +} + +// A TASK THAT DECLINED GETS ONE MORE ATTEMPT. A wrong answer does not. +// +// From a real plan: a-fsutil said it could not find a directory that existed and +// that its three sibling tasks read without trouble. That single refusal cost the +// task, its dependent, and the final report — a third of the plan — and the +// parent then did the work itself, serially, losing the parallelism the plan was +// for. +func TestATaskThatDeclinedIsRetriedOnce(t *testing.T) { + var attempts atomic.Int64 + exec := Executor{ + BinaryPath: "/bin/true", + NewSessionID: func() (string, error) { return "specialist_00000000000000000000000a", nil }, + Load: func(LoadOptions) (LoadResult, error) { return LoadResult{}, nil }, + RunChild: func(_ context.Context, _ string, _ []string, progress func(streamjson.Event)) (ChildRunResult, error) { + round := attempts.Add(1) + if round == 1 { + // The child exits with the "work unfinished" code. + events := []streamjson.Event{{Type: streamjson.EventError, Message: `the final message admits the objective was not met ("i cannot …")`}} + for _, e := range events { + if progress != nil { + progress(e) + } + } + return ChildRunResult{Started: true, ExitCode: 4, Events: events}, nil + } + if progress != nil { + progress(streamjson.Event{Type: streamjson.EventToolCall, Name: "read_file"}) + } + return ChildRunResult{Started: true}, nil + }, + } + plan := mustPlan(t, []any{task("a-fsutil", "audit the fsutil package")}, okBudget(), readOnlyLimits()) + run := NewPlanRunner(PlanTaskContext{Executor: exec, Cwd: t.TempDir(), SpecialistName: "explorer"}) + report := ExecutePlan(context.Background(), plan, []string{"read_file"}, run, nil) + + if got := attempts.Load(); got != 2 { + t.Fatalf("a declined task was not retried: %d attempt(s)", got) + } + if report.Succeeded != 1 { + t.Fatalf("the retry did not rescue the task: %+v", report.Tasks) + } + if report.Tasks[0].Attempts != 2 { + t.Errorf("the second attempt was not recorded: %d", report.Tasks[0].Attempts) + } +} + +// BOUNDED AT ONE. A model that declines twice is telling us about the task, not +// having a bad moment — and an unbounded retry is a spend cycle. +func TestARepeatedlyDecliningTaskIsRetriedOnlyOnce(t *testing.T) { + var attempts atomic.Int64 + exec := Executor{ + BinaryPath: "/bin/true", + NewSessionID: func() (string, error) { return "specialist_00000000000000000000000a", nil }, + Load: func(LoadOptions) (LoadResult, error) { return LoadResult{}, nil }, + RunChild: func(_ context.Context, _ string, _ []string, progress func(streamjson.Event)) (ChildRunResult, error) { + attempts.Add(1) + events := []streamjson.Event{{Type: streamjson.EventError, Message: "i cannot do this"}} + if progress != nil { + progress(events[0]) + } + return ChildRunResult{Started: true, ExitCode: 4, Events: events}, nil + }, + } + plan := mustPlan(t, []any{task("a", "do it")}, okBudget(), readOnlyLimits()) + run := NewPlanRunner(PlanTaskContext{Executor: exec, Cwd: t.TempDir(), SpecialistName: "explorer"}) + report := ExecutePlan(context.Background(), plan, []string{"read_file"}, run, nil) + + if got := attempts.Load(); got != 2 { + t.Fatalf("the decline retry is not bounded at one: %d attempts", got) + } + if report.Failed != 1 { + t.Errorf("a task that declined twice should still fail: %+v", report.Tasks) + } +} + +// A WRONG ANSWER IS STILL NOT RETRIED. The existing rule holds: the child ran and +// reported, and running it again buys the same report. +func TestATaskThatFailedWithARealErrorIsStillNotRetried(t *testing.T) { + var attempts atomic.Int64 + exec := Executor{ + BinaryPath: "/bin/true", + NewSessionID: func() (string, error) { return "specialist_00000000000000000000000a", nil }, + Load: func(LoadOptions) (LoadResult, error) { return LoadResult{}, nil }, + RunChild: func(_ context.Context, _ string, _ []string, progress func(streamjson.Event)) (ChildRunResult, error) { + attempts.Add(1) + spent := 4000 + events := []streamjson.Event{ + {Type: streamjson.EventToolCall, Name: "read_file"}, + {Type: streamjson.EventUsage, TotalTokens: &spent}, + {Type: streamjson.EventError, Message: "the file it needed does not exist"}, + } + for _, e := range events { + if progress != nil { + progress(e) + } + } + // Exit 1: a real failure, not a decline. + return ChildRunResult{Started: true, ExitCode: 1, Events: events}, nil + }, + } + plan := mustPlan(t, []any{task("a", "do it")}, okBudget(), readOnlyLimits()) + run := NewPlanRunner(PlanTaskContext{Executor: exec, Cwd: t.TempDir(), SpecialistName: "explorer"}) + ExecutePlan(context.Background(), plan, []string{"read_file"}, run, nil) + + if got := attempts.Load(); got != 1 { + t.Errorf("a genuine failure was retried %d times", got) + } +} diff --git a/internal/specialist/plan_model_router.go b/internal/specialist/plan_model_router.go new file mode 100644 index 000000000..ebfb204b8 --- /dev/null +++ b/internal/specialist/plan_model_router.go @@ -0,0 +1,300 @@ +package specialist + +import ( + "context" + "encoding/json" + "fmt" + "strings" +) + +// Model-based routing: ask the provider's strongest model which model should run +// each task. +// +// WHY THE KEYWORD CLASSIFIER IS NOT ENOUGH. classifyTaskRole matches verbs — +// "searching" is cheap, "auditing" is strong — and a verb is a proxy for +// difficulty, not a measurement of it. A hard architectural question phrased as +// "find every place X happens" is billed as a scan; a trivial "review this typo" +// gets the flagship. The proxy was worth having because it costs nothing. It is +// still a proxy. +// +// A model reading the actual task text can judge what the work needs, which is +// the thing the verb was standing in for. It costs one extra call on an +// expensive model before the plan starts, which is why this is OPT-IN and why a +// plan too small to benefit skips it. +// +// EVERY FAILURE FALLS BACK TO THE CLASSIFIER. A router that errors, times out, +// returns malformed JSON, names a task that does not exist or a model the +// provider does not serve must never be able to stop a plan from running: the +// worst outcome is the routing we already had. + +// routerMinimumTasks is the plan size below which routing is not worth its own +// call. Two tasks cannot differ enough in difficulty to justify spending a +// frontier-model round trip deciding between them. +const routerMinimumTasks = 3 + +// routedAssignment is one task-to-model decision from the router. +type routedAssignment struct { + ID string `json:"id"` + Model string `json:"model"` + Reason string `json:"reason"` +} + +type routerResponse struct { + Assignments []routedAssignment `json:"assignments"` +} + +// routerModel picks who does the routing. +// +// THE SESSION'S OWN MODEL COMES FIRST, and that is the whole point. A user who +// picked a model with /model has already said which one they trust to think: +// routing on "whatever discovery priced highest" ignores that and can hand the +// decision to a model they deliberately did not choose — on one account the +// priciest thing was a build preview, on another it was a video model. +// +// Order: an explicitly configured router, then the session's model, then the +// strongest tier as a last resort. Each is checked against what the provider +// actually serves, so a stale name falls through instead of failing the call. +func routerModel(prefs ModelPreferences, tiers modelTiers, served map[string]bool, sessionModel string) string { + for _, candidate := range []string{strings.TrimSpace(prefs.Router), strings.TrimSpace(sessionModel)} { + if candidate == "" { + continue + } + if len(served) == 0 || servedContains(served, candidate) { + return candidate + } + } + return tiers.strong +} + +// routerPrompt asks for a model per task and nothing else. +// +// The candidate list carries PRICE ORDER rather than prices: what matters is +// which model is cheaper than which, and a router given raw numbers starts +// optimising cost against a budget it cannot see. +func routerPrompt(tasks []routableTask, candidates []DiscoveredModel, guidance string) string { + var b strings.Builder + b.WriteString("You are choosing which model should run each task of a plan. ") + b.WriteString("Judge what each task actually requires: how much it must read, whether it must decide ") + b.WriteString("something or merely report what it found, and how costly a wrong answer would be.\n\n") + + // THE LIST IS A SPECTRUM, NOT A PAIR, and saying so is the whole difference. + // + // This once read "mechanical lookups belong on the cheapest model, work that + // judges belongs on the strongest" — a binary instruction. A router given + // nineteen models obeyed it exactly and used two of them, always the ends, + // while every mid-priced model on the account sat unused. The guidance has to + // name the middle and say that most work lives there, or the middle may as + // well not be on the list. + b.WriteString("Match the model to the work, using the whole range:\n") + b.WriteString(" - Trivial mechanical work — listing files, grepping a constant, reading one short file: the cheapest capable model.\n") + b.WriteString(" - Ordinary work needing care but not deep reasoning — reading a file and explaining it, tracing a call path, ") + b.WriteString("summarising findings: a MIDDLE model. Most tasks belong here.\n") + b.WriteString(" - Work that decides something where being wrong is costly — auditing, judging correctness, proving a guarantee, ") + b.WriteString("weighing alternatives: the most capable model.\n\n") + b.WriteString("Do not answer with only the cheapest and the most capable. A middle model exists to be used, ") + b.WriteString("and reaching for an extreme when the work sits between them is the mistake to avoid.\n\n") + + // THE OPERATOR'S ADVICE OUTRANKS THE GENERAL RULE, and is placed after it so + // it reads as the more specific instruction rather than as background. + // + // It is ADDED, never a replacement. A router prompt has to end with a JSON + // contract and a candidate list the caller validates against; letting this + // replace the whole thing would let one config typo produce a router whose + // output nothing can parse — and the failure would look like the model being + // stupid rather than the prompt being broken. + if advice := strings.TrimSpace(guidance); advice != "" { + b.WriteString("The operator of this machine adds, and this outranks the general rule above:\n") + b.WriteString(" " + advice + "\n\n") + } + + b.WriteString("Available models, cheapest first:\n") + for index, model := range candidates { + fmt.Fprintf(&b, " %d. %s", index+1, model.ID) + if desc := strings.TrimSpace(model.Description); desc != "" { + fmt.Fprintf(&b, " — %s", desc) + } + if model.Reasoning { + b.WriteString(" [reasoning]") + } + b.WriteString("\n") + } + + b.WriteString("\nTasks:\n") + for _, task := range tasks { + fmt.Fprintf(&b, " id %q: %s\n", task.ID, task.Prompt) + if len(task.Tools) > 0 { + fmt.Fprintf(&b, " tools: %s\n", strings.Join(task.Tools, ", ")) + } + } + + b.WriteString("\nReply with JSON only, no prose and no code fence:\n") + b.WriteString(`{"assignments":[{"id":"","model":"","reason":""}]}`) + b.WriteString("\nEvery task must appear exactly once. Use only model ids from the list above.") + return b.String() +} + +// routerSystemPrompt houses the router, and deliberately not the plan-task prompt. +// +// The plan-task prompt tells its reader "You have read-only tools: USE THEM. +// Start with a tool call, not prose" — correct for a task that must go and look, +// and the exact opposite of what the router is asked to do one message later: +// answer from the list in front of it, in JSON, with no prose. A model handed +// both obeys one of them, and which one is a coin toss. +// +// It keeps its grant regardless. The child is refused outright for holding no +// tools, so the router carries one it is told not to reach for. +const routerSystemPrompt = "You are a routing classifier. You are given a list of models and a list of tasks, " + + "and you decide which model should run each task.\n\n" + + "Everything you need is in the message. Do not read files, do not search, do not call any tool — " + + "the answer is a judgement about the text in front of you, not something to go and look up.\n\n" + + "Reply with the requested JSON object and nothing else: no explanation before it, no summary after it, " + + "no code fence around it." + +// routableTask is the slice of a task the router is shown. Deliberately not the +// whole Task: dependencies and phases describe ORDER, and a router shown them +// starts reasoning about scheduling instead of difficulty. +type routableTask struct { + ID string + Prompt string + Tools []string +} + +// routeTaskModels asks the router model for an assignment per task and returns +// the ones that survive validation. +// +// A partial answer is USABLE: any task the router named validly is routed, and +// anything it missed or got wrong falls through to the classifier. Discarding +// the whole response because one entry was bad would throw away good decisions +// to punish a bad one. +func routeTaskModels( + ctx context.Context, + run PlanRunner, + req PlanTaskRequest, + model string, + tasks []routableTask, + candidates []DiscoveredModel, + guidance string, +) (map[string]string, int, error) { + if run == nil || strings.TrimSpace(model) == "" || len(tasks) < routerMinimumTasks || len(candidates) == 0 { + return nil, 0, nil + } + req.Task = Task{ + ID: "plan-model-router", + Prompt: routerPrompt(tasks, candidates, guidance), + Model: model, + } + req.SystemPrompt = routerSystemPrompt + result, err := run(ctx, req) + // SPENT WHETHER OR NOT IT ANSWERED. The routing call runs on the strongest + // model over a prompt listing every candidate and every task; a router that + // errored or replied with nonsense still cost that, and returning zero on + // those paths would hide the runs most worth knowing about. + spent := result.Tokens + if err != nil { + return nil, spent, err + } + if result.Outcome != TaskSucceeded { + return nil, spent, fmt.Errorf("router task did not succeed: %s", result.Err) + } + + decoded, err := decodeRouterResponse(result.Output) + if err != nil { + return nil, spent, err + } + valid := map[string]bool{} + for _, task := range tasks { + valid[task.ID] = true + } + // THE ANSWER IS BOUND TO WHAT WAS OFFERED, not merely to what the provider + // serves, and the difference is a bypass rather than a nicety. + // + // This checked the SERVED set — every id discovery returned. But candidates + // have already been filtered: an excluded id, a video model, one that cannot + // call tools. All of those are still served, so a router naming one was + // accepted and dispatched, and a user's own planModels.exclude was silently + // overridden by the model it was written to avoid. Reproduced with three + // tasks routed onto an excluded id. + // + // Candidates are themselves drawn from discovery, so membership here is + // strictly stronger than the served check and subsumes it. + offered := make(map[string]bool, len(candidates)) + for _, model := range candidates { + if id := strings.TrimSpace(model.ID); id != "" { + offered[id] = true + } + } + out := map[string]string{} + for _, assignment := range decoded.Assignments { + id := strings.TrimSpace(assignment.ID) + chosen := strings.TrimSpace(assignment.Model) + if !valid[id] || chosen == "" { + continue + } + // Not offered means the router invented a name or reached past its list; + // applying it would fail the task at dispatch, or worse, succeed on a + // model the plan had already ruled out. + if !offered[chosen] { + continue + } + out[id] = chosen + } + return out, spent, nil +} + +// decodeRouterResponse pulls the JSON object out of a model's reply. +// +// Models wrap JSON in prose and code fences however firmly they are asked not +// to, so the object is located by braces rather than by trusting the whole reply +// to parse. A reply with no object at all is an error, not an empty result: the +// difference between "the router said nothing useful" and "the router named no +// tasks" matters to the caller deciding whether to report a failure. +func decodeRouterResponse(output string) (routerResponse, error) { + start := strings.Index(output, "{") + end := strings.LastIndex(output, "}") + if start < 0 || end <= start { + return routerResponse{}, fmt.Errorf("router reply contained no JSON object") + } + var decoded routerResponse + if err := json.Unmarshal([]byte(output[start:end+1]), &decoded); err != nil { + return routerResponse{}, fmt.Errorf("router reply is not valid JSON: %w", err) + } + return decoded, nil +} + +// routableTasks projects the raw task arguments into what the router is shown, +// skipping any that already name a model — an explicit choice is not up for +// reconsideration. +func routableTasks(raw []any) []routableTask { + out := make([]routableTask, 0, len(raw)) + for _, entry := range raw { + fields, ok := entry.(map[string]any) + if !ok { + continue + } + if strings.TrimSpace(planString(fields, "model")) != "" { + continue + } + id := strings.TrimSpace(planString(fields, "id")) + prompt := strings.TrimSpace(planString(fields, "prompt")) + if id == "" || prompt == "" { + continue + } + out = append(out, routableTask{ID: id, Prompt: prompt, Tools: planStrings(fields, "tools")}) + } + return out +} + +// eligibleForRouting is the candidate list the router may choose from: the same +// models the tiers were built from, in the same order. +// +// The ROUTER MUST NOT SEE A WIDER SET THAN THE TIERS. Offering it a model the +// tier logic excluded — a video model, an excluded id, one that cannot call +// tools — lets the router pick something the fallback path has already judged +// unusable. +// +// This is a NARROWING, not the enforcement. It once read as though the served-set +// check downstream would drop such a choice; it would not, because every excluded +// model is still served. routeTaskModels now validates against this list itself. +func eligibleForRouting(models []DiscoveredModel, prefs ModelPreferences) []DiscoveredModel { + return rankedEligibleModels(models, prefs) +} diff --git a/internal/specialist/plan_model_router_test.go b/internal/specialist/plan_model_router_test.go new file mode 100644 index 000000000..d17a49203 --- /dev/null +++ b/internal/specialist/plan_model_router_test.go @@ -0,0 +1,384 @@ +package specialist + +import ( + "context" + "fmt" + "strings" + "testing" +) + +func routerCandidates() []DiscoveredModel { + return []DiscoveredModel{ + {ID: "deepseek-v4-flash", InputCost: 0.1}, + {ID: "glm-5.2", InputCost: 1}, + {ID: "qwen3.5:397b", InputCost: 9, Reasoning: true}, + } +} + +func routerTasks() []routableTask { + return []routableTask{ + {ID: "l-files", Prompt: "Listing every .go file under internal/specialist"}, + {ID: "j-race", Prompt: "Deciding whether a task can begin before its dependencies resolve"}, + {ID: "j-merge", Prompt: "Deciding whether a project config can raise a user limit"}, + } +} + +// A router that ANSWERS is believed, and its choices reach the tasks. +func TestTheRouterDecidesWhichModelRunsEachTask(t *testing.T) { + run := func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + // It must be asked on the router model, and shown the real task text. + if req.Task.Model != "qwen3.5:397b" { + t.Errorf("router ran on %q, want the strongest model", req.Task.Model) + } + for _, want := range []string{"l-files", "j-race", "Listing every .go file"} { + if !strings.Contains(req.Task.Prompt, want) { + t.Errorf("the router was not shown %q", want) + } + } + return TaskResult{Outcome: TaskSucceeded, Output: `Here you go: +{"assignments":[ + {"id":"l-files","model":"deepseek-v4-flash","reason":"mechanical listing"}, + {"id":"j-race","model":"qwen3.5:397b","reason":"needs judgement"}, + {"id":"j-merge","model":"qwen3.5:397b","reason":"needs judgement"} +]}`}, nil + } + got, _, err := routeTaskModels(context.Background(), run, PlanTaskRequest{}, "qwen3.5:397b", + routerTasks(), routerCandidates(), "") + if err != nil { + t.Fatalf("router: %v", err) + } + if got["l-files"] != "deepseek-v4-flash" { + t.Errorf("a mechanical listing went to %q", got["l-files"]) + } + if got["j-race"] != "qwen3.5:397b" || got["j-merge"] != "qwen3.5:397b" { + t.Errorf("judgement tasks went to %v", got) + } +} + +// EVERY FAILURE FALLS BACK. A router is an optimisation; it must never be able +// to stop a plan, and the worst outcome is the classifier routing we had. +func TestEveryRouterFailureFallsBackInsteadOfBreaking(t *testing.T) { + tasks, candidates := routerTasks(), routerCandidates() + + for name, run := range map[string]PlanRunner{ + "errors": func(context.Context, PlanTaskRequest) (TaskResult, error) { + return TaskResult{}, fmt.Errorf("provider exploded") + }, + "fails": func(context.Context, PlanTaskRequest) (TaskResult, error) { + return TaskResult{Outcome: TaskFailed, Err: "exit 4"}, nil + }, + "prose": func(context.Context, PlanTaskRequest) (TaskResult, error) { + return TaskResult{Outcome: TaskSucceeded, Output: "I think flash is fine for all of them."}, nil + }, + "broken json": func(context.Context, PlanTaskRequest) (TaskResult, error) { + return TaskResult{Outcome: TaskSucceeded, Output: `{"assignments":[{"id":`}, nil + }, + } { + got, _, err := routeTaskModels(context.Background(), run, PlanTaskRequest{}, "qwen3.5:397b", tasks, candidates, "") + if err == nil { + t.Errorf("%s: expected a reported error so the caller can say so", name) + } + if len(got) != 0 { + t.Errorf("%s: a failed router must route nothing, got %v", name, got) + } + } + + // A HALLUCINATED model is dropped, and the rest of the answer still counts — + // discarding good decisions to punish a bad one helps nobody. + partial := func(context.Context, PlanTaskRequest) (TaskResult, error) { + return TaskResult{Outcome: TaskSucceeded, Output: `{"assignments":[ + {"id":"l-files","model":"deepseek-v4-flash"}, + {"id":"j-race","model":"gpt-9-imaginary"}, + {"id":"not-a-task","model":"glm-5.2"}]}`}, nil + } + got, _, err := routeTaskModels(context.Background(), partial, PlanTaskRequest{}, "qwen3.5:397b", tasks, candidates, "") + if err != nil { + t.Fatalf("a partially valid answer is usable: %v", err) + } + if got["l-files"] != "deepseek-v4-flash" { + t.Errorf("the valid assignment was discarded: %v", got) + } + if _, ok := got["j-race"]; ok { + t.Error("a model the provider does not serve was accepted") + } + if _, ok := got["not-a-task"]; ok { + t.Error("an id that is not in this plan was accepted") + } +} + +// TOO SMALL TO BE WORTH A CALL. Two tasks cannot differ enough to justify a +// frontier-model round trip deciding between them. +func TestRoutingIsSkippedWhenItCannotPayForItself(t *testing.T) { + called := false + run := func(context.Context, PlanTaskRequest) (TaskResult, error) { + called = true + return TaskResult{Outcome: TaskSucceeded, Output: "{}"}, nil + } + small := routerTasks()[:2] + if got, _, err := routeTaskModels(context.Background(), run, PlanTaskRequest{}, "qwen3.5:397b", + small, routerCandidates(), ""); err != nil || len(got) != 0 { + t.Errorf("a two-task plan must skip routing, got %v %v", got, err) + } + if called { + t.Error("the router was called for a plan too small to benefit") + } + // No router model, no candidates, no runner: all skip silently. + for name, args := range map[string][3]any{ + "no model": {"", routerCandidates(), run}, + "no candidates": {"qwen3.5:397b", []DiscoveredModel(nil), run}, + } { + called = false + model := args[0].(string) + cands, _ := args[1].([]DiscoveredModel) + if _, _, err := routeTaskModels(context.Background(), run, PlanTaskRequest{}, model, + routerTasks(), cands, ""); err != nil { + t.Errorf("%s: expected a silent skip, got %v", name, err) + } + if called { + t.Errorf("%s: the router was called anyway", name) + } + } +} + +// A task that NAMED its own model is not shown to the router at all — an +// explicit choice is not up for reconsideration. +func TestARoutersOpinionIsNotSoughtOnAnExplicitModel(t *testing.T) { + raw := []any{ + map[string]any{"id": "a", "prompt": "listing files"}, + map[string]any{"id": "b", "prompt": "deciding something", "model": "glm-5.2"}, + } + got := routableTasks(raw) + if len(got) != 1 || got[0].ID != "a" { + t.Errorf("only the unassigned task is routable, got %+v", got) + } +} + +// THE DECISION MUST REACH THE TASK. Every test above exercises the router in +// isolation, which passes perfectly while the assignment path ignores what it +// returned — the producer tested, the wiring not. +func TestARoutedChoiceActuallyLandsOnTheTask(t *testing.T) { + tasks := []any{ + map[string]any{"id": "l-files", "prompt": "listing every go file"}, + map[string]any{"id": "j-race", "prompt": "deciding whether a race exists"}, + } + candidates := routerCandidates() + routed := map[string]string{"l-files": "deepseek-v4-flash", "j-race": "qwen3.5:397b"} + + out, notes := assignModelsToTaskArgs(tasks, buildModelTiers(candidates, ModelPreferences{}), + ModelPreferences{}, servedModels(candidates), routed) + + got := map[string]string{} + for _, raw := range out { + fields := raw.(map[string]any) + got[planString(fields, "id")] = planString(fields, "model") + } + if got["j-race"] != "qwen3.5:397b" { + t.Errorf("the router chose qwen for the judgement; the task got %q", got["j-race"]) + } + if got["l-files"] != "deepseek-v4-flash" { + t.Errorf("the router chose flash for the listing; the task got %q", got["l-files"]) + } + if joined := strings.Join(notes, " | "); !strings.Contains(joined, "routed") { + t.Errorf("a routed choice must be reported as routed, not as a role guess: %s", joined) + } + + // A PIN STILL OUTRANKS THE ROUTER. A pin is the user's own instruction, and + // another model does not get to overrule it. + prefs := ModelPreferences{Scan: "glm-5.2"} + out, _ = assignModelsToTaskArgs(tasks, buildModelTiers(candidates, prefs), prefs, + servedModels(candidates), routed) + for _, raw := range out { + fields := raw.(map[string]any) + if planString(fields, "id") == "l-files" && planString(fields, "model") != "glm-5.2" { + t.Errorf("the router overruled a user pin: %q", planString(fields, "model")) + } + } +} + +// THE MODEL THE USER PICKED DOES THE ROUTING. +// +// Choosing a model with /model is a statement about which one you trust to +// think. Routing on "whatever discovery priced highest" ignores that and can +// hand the decision to a model the user deliberately did not choose — on one +// real account the priciest thing was a build preview, on another a video model. +func TestTheSessionsOwnModelRoutesByDefault(t *testing.T) { + tiers := buildModelTiers(routerCandidates(), ModelPreferences{}) + served := servedModels(routerCandidates()) + + // No configured router: the session's model decides. + if got := routerModel(ModelPreferences{}, tiers, served, "glm-5.2"); got != "glm-5.2" { + t.Errorf("router = %q, want the model the user selected", got) + } + // An explicit router still wins — it is a more specific instruction. + if got := routerModel(ModelPreferences{Router: "qwen3.5:397b"}, tiers, served, "glm-5.2"); got != "qwen3.5:397b" { + t.Errorf("router = %q, want the configured one", got) + } + // A session model this provider does not serve falls through rather than + // failing the call — the same staleness pins already survive. + if got := routerModel(ModelPreferences{}, tiers, served, "grok-4.3"); got != tiers.strong { + t.Errorf("router = %q, want the strongest tier as the fallback", got) + } + // And with nothing at all to go on, the strongest tier still routes. + if got := routerModel(ModelPreferences{}, tiers, served, ""); got != tiers.strong { + t.Errorf("router = %q, want the strongest tier", got) + } +} + +// ONE TASK IN, ONE TASK OUT. Counted, not keyed. +// +// The routed branch once appended its own clone on top of the one already +// emitted, so every routed task came out twice and ParsePlan rejected the plan: +// "task id appears more than once". Every plan of three or more tasks failed, +// because routing does not run below that — and the test that should have caught +// it collected results into a map keyed by id, where a duplicate overwrites its +// twin and vanishes. +func TestAssignmentEmitsExactlyOneEntryPerTask(t *testing.T) { + candidates := routerCandidates() + served := servedModels(candidates) + tiers := buildModelTiers(candidates, ModelPreferences{}) + + for name, routed := range map[string]map[string]string{ + "all routed": {"a": "glm-5.2", "b": "glm-5.2", "c": "qwen3.5:397b"}, + "some routed": {"b": "qwen3.5:397b"}, + "none routed": {}, + } { + tasks := []any{ + map[string]any{"id": "a", "prompt": "listing every go file"}, + map[string]any{"id": "b", "prompt": "deciding whether a race exists"}, + map[string]any{"id": "c", "prompt": "fixing the comment"}, + } + out, _ := assignModelsToTaskArgs(tasks, tiers, ModelPreferences{}, served, routed) + if len(out) != len(tasks) { + t.Errorf("%s: %d tasks in, %d out — a plan with duplicates is rejected outright", name, len(tasks), len(out)) + } + seen := map[string]int{} + for _, raw := range out { + seen[planString(raw.(map[string]any), "id")]++ + } + for id, count := range seen { + if count != 1 { + t.Errorf("%s: task %q emitted %d times", name, id, count) + } + } + } +} + +// THE GUIDANCE MUST DESCRIBE A SPECTRUM, NOT A PAIR. +// +// This once told the router "mechanical lookups belong on the cheapest model, +// work that judges belongs on the strongest" — a binary. A router given nineteen +// models obeyed it precisely and used two of them, always the ends, while every +// mid-priced model on the account went untouched. The middle has to be named, or +// it may as well not be on the list. +func TestTheRouterIsToldToUseTheWholeRangeNotJustTheEnds(t *testing.T) { + prompt := routerPrompt(routerTasks(), routerCandidates(), "") + for _, want := range []string{"MIDDLE", "Most tasks belong here", "Do not answer with only the cheapest"} { + if !strings.Contains(prompt, want) { + t.Errorf("the router prompt no longer steers toward the middle (missing %q)", want) + } + } + // And every candidate is offered, or the middle cannot be chosen even when + // the guidance asks for it. + for _, model := range routerCandidates() { + if !strings.Contains(prompt, model.ID) { + t.Errorf("candidate %q was not offered to the router", model.ID) + } + } +} + +// The operator's own words must reach the router, and must ADD to the built-in +// guidance rather than replace it. +// +// This exists because the code cannot know the account. Discovery orders models +// by price, and price is not capability: one provider's dearest model was a +// build preview that failed every task, another reports no prices at all. The +// person running the machine knows which model reasons well. Without this they +// can only say so by naming models in every prompt — which is the thing +// auto-assignment exists to remove. +func TestOperatorGuidanceIsAddedToTheRouterPromptNotSubstitutedForIt(t *testing.T) { + const advice = "kimi-k2.6 is the best reasoner here; qwen3.5:397b is slow, save it for judgements." + prompt := routerPrompt(routerTasks(), routerCandidates(), advice) + + if !strings.Contains(prompt, advice) { + t.Fatalf("the operator's guidance never reached the router:\n%s", prompt) + } + // ADDED, not substituted. The bands, the JSON contract and the candidate list + // are what make the reply parseable; guidance that replaced them would produce + // output nothing can decode, and the failure would read as a stupid model + // rather than a broken prompt. + for _, required := range []string{ + "Most tasks belong here", + "Do not answer with only the cheapest", + `{"assignments":[{"id":"","model":""`, + "Every task must appear exactly once", + } { + if !strings.Contains(prompt, required) { + t.Errorf("guidance displaced the built-in prompt; missing %q", required) + } + } + for _, model := range routerCandidates() { + if !strings.Contains(prompt, model.ID) { + t.Errorf("candidate %q is no longer offered", model.ID) + } + } + // It must be placed AFTER the general rule, so it reads as the more specific + // instruction. Before it, a model treats it as background and the general rule + // is what it ends up obeying. + if strings.Index(prompt, advice) < strings.Index(prompt, "Do not answer with only the cheapest") { + t.Error("the operator's guidance is placed before the general rule it is meant to outrank") + } + + // Empty guidance changes nothing at all: nobody who has not configured this + // should see a difference in what their router is asked. + if routerPrompt(routerTasks(), routerCandidates(), " ") != routerPrompt(routerTasks(), routerCandidates(), "") { + t.Error("blank guidance altered the prompt") + } +} + +// A ROUTER MAY NOT REACH PAST ITS OWN LIST, and the user's exclusions are the +// case that proves why. +// +// The answer was once validated against every id DISCOVERY returned rather than +// against the candidates actually offered. Excluded models, video models and +// non-tool-callers are all still served, so a router naming one was accepted and +// dispatched — silently overriding the planModels.exclude the user wrote to +// avoid exactly that model. Reproduced with three tasks routed onto an excluded +// id before this check existed. +func TestTheRouterCannotAssignAModelThatWasNotOfferedToIt(t *testing.T) { + all := []DiscoveredModel{ + {ID: "grok-code-fast", ToolCall: true, InputCost: 1}, + {ID: "grok-build-0.1", ToolCall: true, InputCost: 4}, // excluded by the user + {ID: "grok-4.5", ToolCall: true, InputCost: 9}, + } + prefs := ModelPreferences{Exclude: []string{"grok-build-0.1"}} + candidates := eligibleForRouting(all, prefs) + for _, model := range candidates { + if model.ID == "grok-build-0.1" { + t.Fatal("the fixture is wrong: the excluded model is still a candidate") + } + } + + run := func(context.Context, PlanTaskRequest) (TaskResult, error) { + return TaskResult{Outcome: TaskSucceeded, Output: `{"assignments":[ + {"id":"a","model":"grok-build-0.1","reason":"x"}, + {"id":"b","model":"grok-4.5","reason":"legitimate"}, + {"id":"c","model":"grok-not-a-real-model","reason":"invented"}]}`}, nil + } + tasks := []routableTask{{ID: "a", Prompt: "p"}, {ID: "b", Prompt: "p"}, {ID: "c", Prompt: "p"}} + + got, _, err := routeTaskModels(context.Background(), run, PlanTaskRequest{}, "grok-4.5", tasks, candidates, "") + if err != nil { + t.Fatalf("router: %v", err) + } + if model, ok := got["a"]; ok { + t.Errorf("the user's exclusion was overridden by the router: task a → %q", model) + } + if model, ok := got["c"]; ok { + t.Errorf("an invented model was accepted: task c → %q", model) + } + // A PARTIAL ANSWER IS STILL USABLE: the one valid assignment must survive, or + // rejecting a bad entry would throw away good decisions to punish it. + if got["b"] != "grok-4.5" { + t.Errorf("a legitimate assignment was discarded with the bad ones: %v", got) + } +} diff --git a/internal/specialist/plan_model_test.go b/internal/specialist/plan_model_test.go new file mode 100644 index 000000000..55d2f8c6c --- /dev/null +++ b/internal/specialist/plan_model_test.go @@ -0,0 +1,299 @@ +package specialist + +import ( + "context" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/execprofile" + "github.com/Gitlawb/zero/internal/modelregistry" +) + +// THE RESOLVER MUST MATCH THE CHILD'S. The child resolves its model through +// ResolveWithFallback; validating with ResolveID would refuse inputs the child +// accepts, and would let a deprecated id be saved and displayed while the child +// silently ran its replacement. +func TestTaskModelResolvesTheSameWayTheChildWill(t *testing.T) { + registry, err := modelregistry.DefaultRegistry() + if err != nil { + t.Fatalf("registry: %v", err) + } + + // An empty request inherits the parent, and is not an error. + if got, err := resolveTaskModel(" "); err != nil || got != "" { + t.Fatalf("empty model = %q, %v; want \"\", nil", got, err) + } + + // A canonical id round-trips to itself. + if got, err := resolveTaskModel("claude-haiku-4.5"); err != nil { + t.Fatalf("canonical id rejected: %v", err) + } else if want, _ := registry.ResolveID("claude-haiku-4.5"); got != want { + t.Errorf("canonical id = %q, want %q", got, want) + } + + // An alias resolves to the canonical id, not back to the alias — the id is + // what reaches argv. + got, err := resolveTaskModel("haiku-4.5") + if err != nil { + t.Fatalf("alias rejected: %v", err) + } + if got == "haiku-4.5" { + t.Errorf("the alias was echoed back instead of resolved: %q", got) + } + if entry, _, ok := registry.ResolveWithFallback("haiku-4.5"); !ok || got != entry.ID { + t.Errorf("alias resolved to %q, want the registry's id", got) + } + + // A DEPRECATED id must come back as its replacement, so the plan stores what + // the child will actually run. + deprecated, _, ok := registry.ResolveWithFallback("claude-haiku-3.5") + if !ok { + t.Skip("no deprecated fixture in the registry") + } + resolved, err := resolveTaskModel("claude-haiku-3.5") + if err != nil { + t.Fatalf("deprecated id rejected outright: %v", err) + } + if resolved != deprecated.ID { + t.Errorf("deprecated id resolved to %q, want the child's %q", resolved, deprecated.ID) + } +} + +// AN UNCURATED MODEL PASSES THROUGH, deliberately. The registry is a curated +// subset for aliases, pricing and display — not an inventory of what a provider +// serves. An xAI account offers Grok models the registry has never heard of and +// the picker lists in full; refusing them made per-task models unusable there. +// +// The check moves rather than disappears: the child resolves the name through +// its own provider config and fails with "zero model X belongs to ..." when the +// provider cannot serve it. +func TestAnUncuratedTaskModelPassesThroughForTheProviderToJudge(t *testing.T) { + got, err := resolveTaskModel("grok-4.20-reasoning") + if err != nil { + t.Fatalf("an uncurated model must not be refused at admission: %v", err) + } + if got != "grok-4.20-reasoning" { + t.Errorf("model = %q, want it carried through unchanged", got) + } + // A KNOWN model is still canonicalised, so aliases and deprecations keep + // resolving to what the child will actually run. + if got, _ := resolveTaskModel("haiku-4.5"); got == "haiku-4.5" { + t.Errorf("a known alias must still canonicalise, got %q", got) + } +} + +// THE ROUND TRIP, which is where a per-task model gets silently lost. Args() is +// what /plans save writes, /plans show renders, and resume re-admits. A model on +// the struct but absent from that map means the plan reruns on the parent's +// model with nothing anywhere reporting the change. +func TestATasksModelSurvivesTheArgsRoundTrip(t *testing.T) { + plan := mustPlan(t, []any{ + map[string]any{"id": "scan", "prompt": "look", "model": "haiku-4.5"}, + map[string]any{"id": "judge", "prompt": "assess", "depends_on": []any{"scan"}}, + }, map[string]any{"max_workers": float64(1)}, readOnlyLimits()) + + first := planTaskByID(t, plan, "scan") + if first.Model == "" { + t.Fatal("the task's model was dropped at parse") + } + if first.Model == "haiku-4.5" { + t.Errorf("the alias was stored instead of the canonical id: %q", first.Model) + } + if other := planTaskByID(t, plan, "judge"); other.Model != "" { + t.Errorf("a task that named no model must inherit, got %q", other.Model) + } + + // Re-admit exactly what would have been saved. + again, err := ParsePlan(plan.Args(), readOnlyLimits()) + if err != nil { + t.Fatalf("the saved plan does not re-admit: %v", err) + } + if got := planTaskByID(t, again, "scan").Model; got != first.Model { + t.Errorf("model after a save/reload round trip = %q, want %q", got, first.Model) + } + if got := planTaskByID(t, again, "judge").Model; got != "" { + t.Errorf("an inheriting task gained a model across the round trip: %q", got) + } +} + +// A plan naming an uncurated model is ADMITTED and carries it to the task, so a +// provider's own models are usable without waiting for them to be curated. +func TestAPlanMayNameAModelTheRegistryDoesNotKnow(t *testing.T) { + plan, err := ParsePlan(map[string]any{ + "name": "p", + "tasks": []any{ + map[string]any{"id": "a", "prompt": "one", "model": "grok-4.20-reasoning"}, + }, + "budget": map[string]any{"max_workers": float64(1)}, + }, readOnlyLimits()) + if err != nil { + t.Fatalf("a plan naming a provider's own model must be admitted: %v", err) + } + if got := planTaskByID(t, plan, "a").Model; got != "grok-4.20-reasoning" { + t.Errorf("task model = %q, want it carried through", got) + } + // And it survives save/reload, or the plan would run on something else the + // second time. + again, err := ParsePlan(plan.Args(), readOnlyLimits()) + if err != nil { + t.Fatalf("the saved plan does not re-admit: %v", err) + } + if got := planTaskByID(t, again, "a").Model; got != "grok-4.20-reasoning" { + t.Errorf("model after the round trip = %q", got) + } +} + +func planTaskByID(t *testing.T, plan Plan, id string) Task { + t.Helper() + for _, task := range plan.Tasks() { + if task.ID == id { + return task + } + } + t.Fatalf("task %q not in plan", id) + return Task{} +} + +// ASSERTED ON ARGV, not on the manifest struct. appendModelArgs is what turns a +// manifest into the child's command line, and it has its own rule about when +// effort is inherited — so a test that checked Metadata.Model would pass while +// the flag never reached the process. +func TestATasksModelAndEffortReachTheChildsCommandLine(t *testing.T) { + const parentModel, parentEffort = "gpt-4.1", "medium" + + // A task that names a model: both flags present, the effort carried across. + named := planTaskManifest("explorer", "claude-haiku-4.5", + planTaskReasoningEffort("claude-haiku-4.5", parentEffort, "high"), []string{"read_file"}) + argv := appendModelArgs(nil, named, parentModel, parentEffort) + assertFlag(t, argv, "--model", "claude-haiku-4.5") + assertFlag(t, argv, "--reasoning-effort", parentEffort) + + // A task that names none inherits the parent's model, and the untouched path + // is exactly what it was before this feature existed. + inherit := planTaskManifest("explorer", "", planTaskReasoningEffort("", parentEffort, "high"), []string{"read_file"}) + argv = appendModelArgs(nil, inherit, parentModel, parentEffort) + assertFlag(t, argv, "--model", parentModel) + assertFlag(t, argv, "--reasoning-effort", parentEffort) +} + +// THE CASE THE OLD RULE LOST. appendModelArgs inherits parent effort only when +// no model is named, so a task naming a model would have run at the provider's +// default — thinking LESS than its siblings, under the posture whose entire +// point is thinking more. +func TestNamingAModelDoesNotSilentlyDropTheRaisedEffort(t *testing.T) { + named := planTaskManifest("explorer", "claude-haiku-4.5", + planTaskReasoningEffort("claude-haiku-4.5", "high", "high"), []string{"read_file"}) + argv := appendModelArgs(nil, named, "gpt-4.1", "high") + assertFlag(t, argv, "--reasoning-effort", "high") + + // And when the parent has no effort to give — which is exactly when the + // posture could not raise it — the posture's own effort is used rather than + // nothing. + if got := planTaskReasoningEffort("claude-haiku-4.5", "", string(execprofile.Zeromaxing.ReasoningEffort)); got != string(execprofile.Zeromaxing.ReasoningEffort) { + t.Errorf("effort with no parent value = %q, want the posture's %q", + got, execprofile.Zeromaxing.ReasoningEffort) + } + // A task naming NO model must still contribute nothing, or the posture-off + // path stops being byte-identical. + if got := planTaskReasoningEffort("", "", "high"); got != "" { + t.Errorf("a task with no model must not gain an effort, got %q", got) + } +} + +func assertFlag(t *testing.T, argv []string, flag, want string) { + t.Helper() + for i, arg := range argv { + if arg == flag { + if i+1 >= len(argv) { + t.Fatalf("%s has no value in %v", flag, argv) + } + if argv[i+1] != want { + t.Errorf("%s = %q, want %q (argv %v)", flag, argv[i+1], want, argv) + } + return + } + } + t.Errorf("%s missing from argv %v", flag, argv) +} + +// END TO END. Two tasks naming different models, run through the real executor +// path, each child asked for its own — the wiring, not the pieces. +func TestAPlanRunsItsTasksOnTheModelsTheyNamed(t *testing.T) { + plan := mustPlan(t, []any{ + map[string]any{"id": "scan", "prompt": "look", "model": "haiku-4.5"}, + map[string]any{"id": "judge", "prompt": "assess", "model": "claude-sonnet-4.5"}, + map[string]any{"id": "plain", "prompt": "inherit"}, + }, map[string]any{"max_workers": float64(1)}, readOnlyLimits()) + + seen := map[string]string{} + report := ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + manifest := planTaskManifest("explorer", req.Task.Model, + planTaskReasoningEffort(req.Task.Model, req.ParentReasoningEffort, "high"), req.Tools) + argv := appendModelArgs(nil, manifest, "gpt-4.1", "high") + for i, arg := range argv { + if arg == "--model" && i+1 < len(argv) { + seen[req.Task.ID] = argv[i+1] + } + } + return TaskResult{Outcome: TaskSucceeded}, nil + }, nil) + + if report.Succeeded != 3 { + t.Fatalf("report = %+v", report) + } + if seen["scan"] == seen["judge"] { + t.Errorf("two tasks naming different models ran on the same one: %v", seen) + } + if seen["scan"] != "claude-haiku-4.5" { + t.Errorf("scan ran on %q, want the resolved haiku id", seen["scan"]) + } + if seen["judge"] != "claude-sonnet-4.5" { + t.Errorf("judge ran on %q", seen["judge"]) + } + if seen["plain"] != "gpt-4.1" { + t.Errorf("a task naming no model must inherit the parent's, got %q", seen["plain"]) + } +} + +// OBSERVABLE, or it cannot be checked. A per-task model changes which model does +// the work and what it costs; the first real test run showed nothing had changed +// because nothing anywhere reported which model each task used. +func TestTheReportSaysWhichModelEachTaskRanOn(t *testing.T) { + report := PlanReport{ + Status: PlanCompleted, + Tasks: []TaskResult{ + {ID: "scan", Outcome: TaskSucceeded, Model: "claude-haiku-4.5"}, + {ID: "plain", Outcome: TaskSucceeded}, + }, + } + summary := report.Summary() + if !strings.Contains(summary, "scan") || !strings.Contains(summary, "on claude-haiku-4.5") { + t.Errorf("the report must say what a task ran on:\n%s", summary) + } + // A task that inherited gets no model line — otherwise every task carries + // "on " and the one that differs is buried. + for _, line := range strings.Split(summary, "\n") { + if strings.Contains(line, "plain") && strings.Contains(line, " on ") { + t.Errorf("an inheriting task must not claim a model: %q", line) + } + } +} + +// And the runner carries it, so the report is fed by what actually ran rather +// than by what the plan asked for somewhere else. +func TestTheRunnerReportsTheModelTheTaskRanOn(t *testing.T) { + run := NewPlanRunner(PlanTaskContext{ + Executor: progressExecutor(t), Cwd: t.TempDir(), SpecialistName: "explorer", + }) + result, err := run(context.Background(), PlanTaskRequest{ + Task: Task{ID: "a", Prompt: "look", Model: "claude-haiku-4.5"}, + Tools: []string{"read_file"}, + }) + if err != nil { + t.Fatalf("runner: %v", err) + } + if result.Model != "claude-haiku-4.5" { + t.Errorf("TaskResult.Model = %q, want what the task named", result.Model) + } +} diff --git a/internal/specialist/plan_provider_mismatch_test.go b/internal/specialist/plan_provider_mismatch_test.go new file mode 100644 index 000000000..ed624c414 --- /dev/null +++ b/internal/specialist/plan_provider_mismatch_test.go @@ -0,0 +1,159 @@ +package specialist + +import ( + "context" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/tools" +) + +// A DISCOVERED LIST THAT LACKS THE SESSION'S OWN MODEL IS THE WRONG PROVIDER'S +// LIST, and assigning from it fails every task it touches. +// +// From a real run: the session was xai/grok-4.5, discovery returned nineteen +// Ollama ids, and every guard downstream passed because the wrong list was +// internally consistent with itself. The served-set check compared Ollama +// against Ollama. routerModel's "the session's model comes first" rule rejected +// grok-4.5 for not being served — so the ROUTER ran on qwen3.5:397b and xAI +// refused it. Four of six tasks died at dispatch on models that did not exist +// for them. +// +// The session's own model is the one id that MUST appear in a correct list: the +// parent is running on it right now. Its absence is proof, not suspicion. +func TestADiscoveredListMissingTheSessionModelIsRefusedAsTheWrongProvider(t *testing.T) { + // Exactly the shape of the real failure: a session on grok-4.5, a list from + // somewhere else entirely. + elsewhere := []DiscoveredModel{ + {ID: "deepseek-v4-flash", ToolCall: true, InputCost: 1}, + {ID: "glm-5.2", ToolCall: true, InputCost: 5}, + {ID: "qwen3.5:397b", ToolCall: true, InputCost: 9}, + } + dispatched := false + tool := &OrchestrateTool{ + DiscoverModels: func(context.Context) ([]DiscoveredModel, error) { return elsewhere, nil }, + ModelPrefs: ModelPreferences{AutoAssign: true}, + } + args := map[string]any{"tasks": []any{ + map[string]any{"id": "a", "prompt": "list the files"}, + map[string]any{"id": "b", "prompt": "trace the call path and explain it"}, + map[string]any{"id": "c", "prompt": "decide whether the guarantee holds"}, + }} + + notes, err := tool.autoAssignModels(context.Background(), args, tools.RunOptions{Model: "grok-4.5"}) + if err != nil { + t.Fatalf("a configured default must degrade, not fail the plan: %v", err) + } + if dispatched { + t.Error("the router was called against a provider that cannot serve it") + } + + joined := strings.Join(notes, " ") + if !strings.Contains(joined, "grok-4.5") || !strings.Contains(joined, "different provider") { + t.Errorf("the refusal must name the session model and the reason, got %q", joined) + } + + // THE TASKS MUST BE UNTOUCHED. Degrading means every task keeps the session's + // model — precisely what it did before auto-assignment existed. A partially + // assigned plan would be the same failure with fewer casualties. + for _, entry := range args["tasks"].([]any) { + fields := entry.(map[string]any) + if model := strings.TrimSpace(planString(fields, "model")); model != "" { + t.Errorf("task %v was assigned %q from the wrong provider's list", fields["id"], model) + } + } +} + +// The tripwire must not fire on the honest case, or it disables the feature. +func TestAListContainingTheSessionModelStillAssigns(t *testing.T) { + here := []DiscoveredModel{ + {ID: "grok-code-fast", ToolCall: true, InputCost: 1}, + {ID: "grok-4.5", ToolCall: true, InputCost: 5}, + {ID: "grok-4.5-heavy", ToolCall: true, InputCost: 9}, + } + tool := &OrchestrateTool{ + DiscoverModels: func(context.Context) ([]DiscoveredModel, error) { return here, nil }, + ModelPrefs: ModelPreferences{AutoAssign: true}, + } + args := map[string]any{"tasks": []any{ + map[string]any{"id": "a", "prompt": "search for every caller"}, + map[string]any{"id": "b", "prompt": "audit the result and judge whether it is correct"}, + }} + notes, err := tool.autoAssignModels(context.Background(), args, tools.RunOptions{Model: "grok-4.5"}) + if err != nil { + t.Fatalf("assignment on a matching provider must work: %v", err) + } + if strings.Contains(strings.Join(notes, " "), "different provider") { + t.Fatalf("the tripwire fired on a list that does contain the session model: %v", notes) + } + assigned := 0 + for _, entry := range args["tasks"].([]any) { + if strings.TrimSpace(planString(entry.(map[string]any), "model")) != "" { + assigned++ + } + } + if assigned == 0 { + t.Error("nothing was assigned from a provider that serves the session model") + } +} + +// An EMPTY discovered set is not evidence of disagreement — discovery simply told +// us nothing. That is the pre-existing fail-open and must stay open, or a provider +// with no models endpoint starts refusing plans. +func TestAnEmptyDiscoveredListDoesNotTripTheProviderMismatchGuard(t *testing.T) { + tool := &OrchestrateTool{ + DiscoverModels: func(context.Context) ([]DiscoveredModel, error) { return nil, nil }, + ModelPrefs: ModelPreferences{AutoAssign: true}, + } + args := map[string]any{"tasks": []any{map[string]any{"id": "a", "prompt": "list the files"}}} + notes, err := tool.autoAssignModels(context.Background(), args, tools.RunOptions{Model: "grok-4.5"}) + if err != nil { + t.Fatalf("an empty list must degrade quietly: %v", err) + } + if strings.Contains(strings.Join(notes, " "), "different provider") { + t.Errorf("an empty list was misread as the wrong provider: %v", notes) + } +} + +// AN INVALID PLAN MUST NOT COST A PROVIDER CALL. Auto-assignment lists the +// provider's models and, with routing on, spends a call on a frontier model. It +// was doing that before the plan was validated, so a plan rejected for a +// duplicate id or a cycle paid for routing and got nothing — and a model that +// proposes the same oversized plan twice paid twice. +func TestAnInvalidPlanIsRejectedBeforeAnyModelDiscoveryHappens(t *testing.T) { + discovered := false + tool := &OrchestrateTool{ + DiscoverModels: func(context.Context) ([]DiscoveredModel, error) { + discovered = true + return []DiscoveredModel{{ID: "m", ToolCall: true}}, nil + }, + ModelPrefs: ModelPreferences{AutoAssign: true}, + RunTask: func(context.Context, PlanTaskRequest) (TaskResult, error) { return TaskResult{}, nil }, + PostureActive: func() bool { return true }, + } + // The same id twice: ParsePlan rejects it, and nothing about assigning a + // model could ever make it valid. + result := tool.RunWithOptions(context.Background(), map[string]any{ + "name": "dupes", + "tasks": []any{ + map[string]any{"id": "a", "prompt": "one"}, + map[string]any{"id": "a", "prompt": "two"}, + }, + }, tools.RunOptions{Model: "parent-model"}) + + if result.Status != tools.StatusError { + t.Fatalf("a duplicate task id was admitted: %+v", result) + } + // GUARD THE GUARD. The posture gate refuses orchestrate before any of this + // runs, and a test that trips it passes without exercising the ordering at + // all — which is exactly what the first version of this test did. + if strings.Contains(result.Output, "zeromaxing posture") { + t.Fatalf("the posture gate rejected the plan, so nothing here was tested: %q", result.Output) + } + if !strings.Contains(result.Output, "more than once") { + t.Fatalf("rejected for the wrong reason: %q", result.Output) + } + if discovered { + t.Error("the provider was probed for models on a plan that was never going to run") + } +} diff --git a/internal/specialist/plan_runner.go b/internal/specialist/plan_runner.go index 62b122039..e65cb8420 100644 --- a/internal/specialist/plan_runner.go +++ b/internal/specialist/plan_runner.go @@ -2,9 +2,13 @@ package specialist import ( "context" + "fmt" + "strconv" "strings" + "sync/atomic" "time" + "github.com/Gitlawb/zero/internal/streamjson" "github.com/Gitlawb/zero/internal/tools" ) @@ -31,6 +35,15 @@ type PlanTaskContext struct { Depth int // SpecialistName is the read-only specialist each plan task runs as. SpecialistName string + // PostureReasoningEffort is the effort the zeromaxing posture asks for, used + // only as the fallback in planTaskReasoningEffort. + // + // PASSED IN rather than read from execprofile, and not for tidiness: this + // package cannot import execprofile at all. execprofile imports agent, and + // agent's own test binary imports specialist, so the reference compiled fine + // and broke `go test ./internal/agent` with an import cycle. The caller + // already knows the posture; it hands over the one value needed. + PostureReasoningEffort string } // NewPlanRunner adapts Executor.Run into a PlanRunner. @@ -68,8 +81,54 @@ func NewPlanRunner(planCtx PlanTaskContext) PlanRunner { stopWatchdog := watchdog.watch(taskCtx, cancelTask) defer stopWatchdog() + // A WRITE TASK THAT CALLED NO TOOL DID NOT DO THE WORK, and the plan must + // not record it as success. Counted from the child's own stream, which is + // the only evidence the parent has of what the child actually did. + toolCalls := &atomic.Int64{} + // THE METER READS THE STREAM, so a budget can be enforced while the task + // is running rather than after it has finished. + // + // Every usage event a child emits is one provider call it has already + // been billed for. Waiting for the child to exit before counting them is + // what let a measured plan spend 3,091,618 against a 200,000 budget: four + // tasks in flight, none of them bounded, and the first number landing + // after all four were done. + taskTokens := &atomic.Int64{} + overspent := &atomic.Value{} + counted := func(event streamjson.Event) { + if event.Type == streamjson.EventToolCall { + toolCalls.Add(1) + } + if event.Type == streamjson.EventUsage && event.TotalTokens != nil { + spent := *event.TotalTokens + task := taskTokens.Add(int64(spent)) + // BOTH LIMITS, and the task's own is checked first so its message + // names the cap the caller actually set for it. + switch { + case req.MaxTaskTokens > 0 && task > int64(req.MaxTaskTokens): + overspent.Store(fmt.Sprintf( + "task %s stopped after %d tokens: budget.max_tokens_per_task is %d", + task_ID(req), task, req.MaxTaskTokens)) + cancelTask() + case req.Spend.add(spent): + overspent.Store(fmt.Sprintf( + "task %s stopped: the plan's token budget ran out while it was running", + task_ID(req))) + cancelTask() + } + } + if req.Progress != nil { + req.Progress(event) + } + } + started := time.Now() - manifest := planTaskManifest(planCtx.SpecialistName, grantedTools) + manifest := planTaskManifest(planCtx.SpecialistName, task.Model, + planTaskReasoningEffort(task.Model, req.ParentReasoningEffort, planCtx.PostureReasoningEffort), + grantedTools) + if override := strings.TrimSpace(req.SystemPrompt); override != "" { + manifest.SystemPrompt = override + } res, err := planCtx.Executor.Run(taskCtx, TaskParameters{ Name: planCtx.SpecialistName, Prompt: task.Prompt, @@ -80,6 +139,7 @@ func NewPlanRunner(planCtx PlanTaskContext) PlanRunner { ParentSessionID: req.ParentSessionID, ParentModel: req.ParentModel, ParentReasoningEffort: req.ParentReasoningEffort, + ToolCallID: req.ParentToolCallID, CurrentDepth: planCtx.Depth, // The plan's own workspace when it has one, the parent's otherwise. // A write-capable plan runs in an isolated worktree, and this is @@ -92,13 +152,34 @@ func NewPlanRunner(planCtx PlanTaskContext) PlanRunner { // streamed to nobody. Same class as finding 7 (ParentModel): a // second construction path that does not carry what the first one // did. Fixed with the tool-side half, not separately. - Progress: watchedProgress(watchdog, req.Progress), - // Explicitly NOT MemberAutonomy: Phase 2 tasks are read-only, and - // granting it here would be the authority widening that was ruled - // out as needing its own decision. + Progress: watchedProgress(watchdog, counted), + // THE RUNG MUST MATCH THE GRANT, and for a long time it did not. + // + // This said "explicitly NOT MemberAutonomy: Phase 2 tasks are + // read-only", which was true when every plan task was. Write-capable + // plans then arrived with a grant, an approval prompt and a worktree + // — and nobody came back here. specialistAutonomy maps every + // non-unsafe parent to "low", so the child was handed write_file in + // its manifest and launched at read-only autonomy, which never + // advertises it. The model, wanting a tool it could not see, + // narrated the write and leaked fragments of the call into its text + // ("belwrite_file"). Zero tool calls, no file, and until the guard + // above it also reported success. + // + // DERIVED FROM THE GRANT, like the prompt: a read-only task stays at + // "low" exactly as before, so nothing about an ordinary plan changes. + // member-auto is the rung built for this — it advertises in-workspace + // write/edit and sandboxed shell while the sandbox still gates every + // call, and it normalizes to Auto everywhere except advertisement, so + // authority is not widened beyond what an interactive auto agent + // already has. For a plan task the workspace IS the isolated + // worktree, which is what makes that bound meaningful here. + MemberAutonomy: grantsPlanWriteTool(grantedTools), }) result := TaskResult{ - ID: task.ID, + ID: task.ID, + // What it actually ran on, carried back so the report can say so. + Model: task.Model, Duration: time.Since(started), SessionID: res.SessionID, Output: res.Result.Output, @@ -108,6 +189,15 @@ func NewPlanRunner(planCtx PlanTaskContext) PlanRunner { // token count; MaxWall is the backstop in that case. Tokens: res.TotalTokens, } + // CHECKED BEFORE THE WATCHDOG. Both cancel the same context, so a task + // stopped for budget looks exactly like a stalled one from the outside — + // and a stall is RETRYABLE. Retrying a task that was stopped for spending + // too much is the worst possible response to it. + if reason, ok := overspent.Load().(string); ok && reason != "" { + result.Outcome = TaskCancelled + result.Err = reason + return result, nil + } if watchdog.didFire() { // A stall and a user cancellation both surface as a context error; // they are not the same event and must not read the same. @@ -124,6 +214,7 @@ func NewPlanRunner(planCtx PlanTaskContext) PlanRunner { if err != nil { result.Outcome = TaskFailed result.Err = err.Error() + result.ModelRejected = modelLookedUnusable(result, task, toolCalls.Load()) return result, err } if res.Result.Status == tools.StatusError { @@ -131,6 +222,30 @@ func NewPlanRunner(planCtx PlanTaskContext) PlanRunner { // let a plan report work that did not happen. result.Outcome = TaskFailed result.Err = res.Result.Output + result.ModelRejected = modelLookedUnusable(result, task, toolCalls.Load()) + // A DECLINE IS NOT A WRONG ANSWER. The child exits with this code when + // it stopped with work unfinished — it said it could not, rather than + // doing it badly — and that is worth one more attempt in a way a wrong + // answer never is. + result.Declined = res.ExitCode == childExitIncomplete + return result, nil + } + // THE LAST CHECK, and the one a plausible answer gets past. A task granted + // write tools that emitted not one tool call cannot have written anything, + // whatever its output says. A real run produced exactly this: seventeen + // completion tokens reading "Creating notes.md now.", no tool call, and a + // plan reporting succeeded 1. + // + // Only for tasks that CAN write, because only there is the inference + // airtight — a file cannot appear without a tool call, while a read-only + // task answering from its own prompt is unusual rather than impossible. + // The task prompt already asks every task to start with a tool call; this + // makes the write half enforceable instead of merely requested. + if grantsPlanWriteTool(grantedTools) && toolCalls.Load() == 0 { + result.Outcome = TaskFailed + result.Err = "task " + task.ID + " was granted write tools and finished without calling a single one, " + + "so it changed nothing — its output describes work that did not happen. " + + "Re-run it, or state the change the task must make unambiguously." return result, nil } result.Outcome = TaskSucceeded @@ -138,6 +253,55 @@ func NewPlanRunner(planCtx PlanTaskContext) PlanRunner { } } +// task_ID names the task for a message, tolerating an unnamed one. +func task_ID(req PlanTaskRequest) string { + if id := strings.TrimSpace(req.Task.ID); id != "" { + return strconv.Quote(id) + } + return "(unnamed)" +} + +// modelLookedUnusable reports that a task died without the assigned model ever +// producing anything — the signature of a provider refusing the MODEL rather +// than the work failing. +// +// A FLAG, not a message match, for the same reason Stalled is one: choosing to +// spend another child by looking for a phrase in an error is the class of bug +// that silently disabled every stall retry in the prototype. Providers word this +// differently every time — "does not exist or your team does not have access to +// it", "Multi Agent requests are not allowed on chat completions" — and the next +// provider will word it a fourth way. +// +// The structural facts are provider-independent: +// +// - A model was ASSIGNED. With none the task already ran on the parent's, so +// there is nothing to fall back to. +// - The child did NO WORK: no tool call, no token. This is what separates "the +// provider refused this model" from "the work failed": a task that reasoned, +// answered and got it wrong has spent tokens, and re-running it elsewhere +// would be a second opinion nobody asked for. +// +// MEASURED FROM THE CHILD, NOT FROM Result.Output, and that distinction is the +// whole defect this signal was written to catch. On a failed child +// BuildFinalResult returns a DIAGNOSTIC as the output — "Subagent failed (exit +// 3)\nerrors: provider request error: ..." — so Output is never empty on exactly +// the path that matters, and an emptiness test there can never fire. It did not: +// two tasks died on a refused model with the fallback sitting right there. Tool +// calls and tokens come from the child's own stream and describe the child. +// +// THE DECISION TO RETRY IS NOT MADE HERE, and that placement is the point. This +// file's own retry loop lives in the executor because the executor owns the +// budget, the wall deadline, cancellation and the record — a retry hidden in the +// runner spends a child none of them can see, under-reports the plan's duration +// and leaves Attempts saying one when two ran. This classifies; runTaskWithRetries +// decides. +func modelLookedUnusable(result TaskResult, task Task, toolCalls int64) bool { + if strings.TrimSpace(task.Model) == "" { + return false + } + return result.Tokens == 0 && toolCalls == 0 +} + // planTaskCwd prefers the plan's own workspace over the parent's. An empty // override is the read-only case and means "wherever the parent runs". func planTaskCwd(parentCwd, override string) string { @@ -150,15 +314,32 @@ func planTaskCwd(parentCwd, override string) string { // planTaskManifest builds the inline manifest a plan task runs under. The tool // list is the ALREADY-INTERSECTED grant ExecutePlan computed, so this cannot // widen it — it only carries it. -func planTaskManifest(name string, grantedTools []string) Manifest { +func planTaskManifest(name, model, reasoningEffort string, grantedTools []string) Manifest { if strings.TrimSpace(name) == "" { name = "explorer" } + // DERIVED FROM THE GRANT, never carried beside it. The prompt below used to + // say "you have read-only tools … do not attempt to modify anything" for + // every task, including a write-capable plan the user had approved and the + // executor had isolated in a worktree. That task was handed write_file and + // told in the same sentence not to use it; a model that obeys produces a + // plan which reports success and changed nothing, and one that disobeys was + // never being steered in the first place. + // + // A writeCapable bool threaded down alongside grantedTools would be the same + // defect waiting: two statements of one fact, free to drift apart. The grant + // IS the fact, and it is already an argument. + description := "Read-only plan task." + if grantsPlanWriteTool(grantedTools) { + description = "Plan task with write access." + } return Manifest{ Metadata: Metadata{ - Name: name, - Description: "Read-only plan task.", - Tools: grantedTools, + Name: name, + Description: description, + Model: model, + ReasoningEffort: reasoningEffort, + Tools: grantedTools, }, // IT MUST SAY "USE THEM". The first version said "You have read-only // tools" and stopped there, which states a fact and asks for nothing. A @@ -225,3 +406,56 @@ func grantsPlanWriteTool(grantedTools []string) bool { } return false } + +// planTaskReasoningEffort decides what effort a task runs at, and exists because +// appendModelArgs inherits the parent's ONLY when the manifest names no model: +// +// if reasoningEffort == "" && manifest.Metadata.Model == "" { +// reasoningEffort = parentReasoningEffort +// } +// +// That rule is right in general — an effort tier is meaningful only against the +// model it was chosen for — and it means naming a model on a task silently drops +// the posture's raised effort, which is most of what the posture IS. A plan that +// pointed its hardest task at a stronger model would get that model thinking +// less than the tasks around it. +// +// So the effort is stated explicitly, and ONLY when the task names a model: +// leaving it empty otherwise keeps the untouched path byte-identical to what it +// was, which is what the additivity guarantee rests on. +// +// The parent's effort is the first choice because it is already clamped to the +// parent's model and reflects any /effort the user set. It is EMPTY exactly when +// the posture could not raise it — a model whose tiers the catalog cannot vouch +// for — and that user is the one most likely to point a task somewhere else, so +// falling through to the posture's own effort serves precisely the case that +// would otherwise be served worst. +// +// FORWARDED ONLY FOR A MODEL THE REGISTRY KNOWS, and that qualifier was learned +// the hard way. This used to reason "the child re-clamps via +// forwardedReasoningEffort, so forwarding an unsupported tier is safe" — true +// while every nameable model was curated. Once uncurated models were allowed +// through, that clamp stopped applying to them: +// +// entry, ok := registry.Get(modelID) +// if !ok { return requested } // unknown model: forwarded verbatim +// +// so "high" went straight to the provider and a real run died three times over +// with `Model grok-build-0.1 does not support parameter reasoningEffort`. +// +// For a model nobody can vouch for, the honest answer is to say nothing and let +// the provider apply its own default. Sending a parameter it may not accept, to +// buy an effort level we cannot confirm it has, trades a working task for a +// guess. +func planTaskReasoningEffort(model, parentReasoningEffort, postureReasoningEffort string) string { + if strings.TrimSpace(model) == "" { + return "" + } + if !modelTakesExplicitEffort(model) { + return "" + } + if effort := strings.TrimSpace(parentReasoningEffort); effort != "" { + return effort + } + return strings.TrimSpace(postureReasoningEffort) +} diff --git a/internal/specialist/plan_store_test.go b/internal/specialist/plan_store_test.go index 199fdf7dd..a6e346b52 100644 --- a/internal/specialist/plan_store_test.go +++ b/internal/specialist/plan_store_test.go @@ -21,7 +21,7 @@ func savedPlanFixture(t *testing.T) Plan { "tools": []any{"grep"}, "phase": "analysis"}, task("right", "read b", "root"), }, map[string]any{ - "max_workers": float64(1), "max_tokens": float64(5000), + "max_workers": float64(1), "max_tokens": float64(500_000), "max_wall_seconds": float64(600), "max_stall_seconds": float64(45), "max_retries": float64(2), }, readOnlyLimits()) } diff --git a/internal/specialist/plan_test.go b/internal/specialist/plan_test.go index 4c6c45d40..ac2d90560 100644 --- a/internal/specialist/plan_test.go +++ b/internal/specialist/plan_test.go @@ -14,8 +14,12 @@ func planArgs(tasks []any, budget map[string]any) map[string]any { return map[string]any{"name": "p", "tasks": tasks, "budget": budget} } +// okBudget is a budget that PASSES admission — refuseImplausibleBudget rejects a +// plan whose budget cannot cover its own tasks, and 1000 tokens for anything +// could not. Generous on purpose: these fixtures are about task behaviour, and a +// budget that runs out mid-fixture would make them about the budget instead. func okBudget() map[string]any { - return map[string]any{"max_workers": float64(1), "max_tokens": float64(1000)} + return map[string]any{"max_workers": float64(1), "max_tokens": float64(2_000_000)} } func task(id, prompt string, deps ...string) map[string]any { @@ -31,7 +35,11 @@ func task(id, prompt string, deps ...string) map[string]any { } func readOnlyLimits() Limits { - return Limits{MaxTasks: 20, MaxTokens: 100_000, ParentTools: []string{"read_file", "grep", "glob"}} + // MaxTokens 0 mirrors production: defaultPlanMaxTokens is 0, meaning this run + // puts NO ceiling on what a plan may request. A fixture with a ceiling + // production does not have makes every budget test answer a question nobody + // asks. + return Limits{MaxTasks: 20, ParentTools: []string{"read_file", "grep", "glob"}} } // (e) ParsePlan is the ONLY constructor. A zero Plan is inert, so no exported @@ -208,14 +216,18 @@ func TestParsePlanRequiresABudgetButNotATokenCap(t *testing.T) { // A caller that DOES want a bound still gets one, enforced exactly as before. func TestAnExplicitTokenBoundStillStopsThePlan(t *testing.T) { budget := okBudget() - budget["max_tokens"] = float64(1) + // Two tasks at the admission floor; each reports more than half of it, so + // the bound is crossed after the first. + budget["max_tokens"] = float64(100_000) plan, err := ParsePlan(planArgs([]any{task("a", "x"), task("b", "y")}, budget), readOnlyLimits()) if err != nil { t.Fatalf("ParsePlan: %v", err) } report := ExecutePlan(context.Background(), plan, []string{"read_file"}, func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { - return TaskResult{ID: req.Task.ID, Outcome: TaskSucceeded, Tokens: 100}, nil + // More than the whole budget, so the meter is negative before the + // second dispatch is considered. + return TaskResult{ID: req.Task.ID, Outcome: TaskSucceeded, Tokens: 110_000}, nil }, nil) if report.Skipped != 1 { t.Fatalf("an explicit bound must still cut the plan short: %+v", report) @@ -364,7 +376,7 @@ func TestAnUnqualifiedTaskNeverInheritsWriteTools(t *testing.T) { func TestParsePlanRejectsToolsOutsideTheParentGrant(t *testing.T) { raw := task("a", "x") raw["tools"] = []any{"read_file", "grep"} - limits := Limits{MaxTasks: 20, MaxTokens: 1000, ParentTools: []string{"read_file"}} + limits := Limits{MaxTasks: 20, ParentTools: []string{"read_file"}} _, err := ParsePlan(planArgs([]any{raw}, okBudget()), limits) if err == nil { t.Fatal("a task requesting a tool the parent does not hold must be rejected") diff --git a/internal/specialist/plan_tool.go b/internal/specialist/plan_tool.go index 150f4f655..233f46873 100644 --- a/internal/specialist/plan_tool.go +++ b/internal/specialist/plan_tool.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strconv" + "strings" "github.com/Gitlawb/zero/internal/config" "github.com/Gitlawb/zero/internal/streamjson" @@ -66,6 +67,21 @@ type OrchestrateTool struct { // cannot isolate, and a plan requiring it is REFUSED rather than run in the // parent's tree — see resolvePlanWorkspace. Isolate PlanIsolator + // DiscoverModels reports what the active provider can serve, for + // auto_assign. nil means auto-assignment is unavailable and a plan asking + // for it is told so rather than silently running without it. + DiscoverModels ModelDiscoverer + // ProbeModel asks whether the provider will actually RUN a model, as opposed + // to merely listing it. nil skips proving entirely, which is the behaviour + // every plan had before this existed. + ProbeModel ModelProber + // probes remembers verdicts for the life of the process, so the cost is one + // trivial request per model per session rather than per plan. + probes probeCache + // ModelPrefs is what the user has said about which models plans may use: + // per-role pins and an exclusion list. Empty leaves the automatic choice + // alone, which is the behaviour for anyone who never configures it. + ModelPrefs ModelPreferences // Plans locates saved plans. Both directories empty means saved plans are // simply unavailable — the tool refuses a `saved` reference with a reason // rather than searching nothing and reporting "not found", which would read @@ -136,14 +152,40 @@ func (tool *OrchestrateTool) Parameters() tools.Schema { "name": {Type: "string", Description: "Short label for the plan."}, "description": {Type: "string", Description: "What the plan is for."}, "tasks": { - Type: "array", - Description: "The plan's tasks. Each has an id, a prompt, optional depends_on ids, an optional read-only tool subset, and an optional phase label.", + Type: "array", + Description: "The plan's tasks. Each has an id, a prompt, optional depends_on ids, an optional read-only tool subset, and an optional phase label. " + + "A task may also name a model to run on — any model this provider serves, by id or alias; omit it to inherit " + + "this session's model. Use it to spend where it matters: a cheaper model for mechanical scanning, a stronger " + + "one for the tasks that judge or decide. A name the provider cannot serve fails when the task runs.\n\n" + + // WRITING THE TASKS IS THE LEVER, and it sits upstream of everything + // else this tool does. Routing cannot rescue a badly split plan and + // verification cannot rescue a task that never asked a real question. + // A measured run produced ten tasks whose prompts all opened with the + // same wrapper — ten paraphrases of one job rather than ten jobs. + "Writing good tasks matters more than how many you write:\n" + + "- A task should be a whole question someone could answer alone and be judged right or wrong about. " + + "\"Trace how X is computed and quote file:line for each step\" is a task; \"look at X\" is not.\n" + + "- Split by SUBJECT, never by phrasing. Two tasks that would read the same files are one task.\n" + + "- Say what the task must return. A task whose output cannot be checked cannot be verified by anything downstream.\n" + + "- Use depends_on for real dependencies only. A dependent receives its dependencies' results in its prompt, " + + "so chain a task that must build on findings — and leave independent work independent so it runs in parallel.\n" + + "- Do not split one modest job into pieces to look thorough. Fewer, larger, genuinely independent tasks beat many small ones: " + + "each task pays for its own context.\n" + + "- Be honest about what a task can do. A task that reads and reports is not the same as one that decides; " + + "give the deciding work to a task that says it is deciding, and name a stronger model for it.", }, "saved": { Type: "string", Description: "Run a plan saved earlier, by name, instead of supplying tasks. " + "Mutually exclusive with tasks/budget/name/description: a saved plan runs as it was saved.", }, + "auto_assign": { + Type: "boolean", + Description: "Pick a model per task automatically from what this provider serves: the cheapest tool-calling model " + + "for tasks that scan or read, a mid-priced one for tasks that change code, and the strongest for tasks that " + + "judge or verify. Off by default. A task that names its own model is never overridden, and the result reports " + + "what was chosen.", + }, "background": { Type: "boolean", Description: "Run the plan in the background and report when it finishes, instead of holding this turn. " + @@ -151,7 +193,7 @@ func (tool *OrchestrateTool) Parameters() tools.Schema { }, "budget": { Type: "object", - Description: "Required. max_workers (1-16) is how many tasks may run at once; 1 is sequential and is the right answer unless the tasks are genuinely independent. The machine's own capacity may be lower and the report says which number applied. max_tokens and max_wall_seconds are optional bounds; omit them to run unbounded — spend is reported either way. max_stall_seconds bounds how long ONE task may emit nothing (default 180); it resets on every event, so a slow-but-working task is never stopped. max_retries (0-3, default 1) is how many extra attempts a STALLED task gets; a task that failed with a real error is never retried.", + Description: "Required. max_workers (1-16) is how many tasks may run at once; 1 is sequential and is the right answer unless the tasks are genuinely independent. The machine's own capacity may be lower and the report says which number applied. max_tokens and max_wall_seconds are optional bounds; omit them to run unbounded — spend is reported either way. max_stall_seconds bounds how long ONE task may emit nothing (default 180); it resets on every event, so a slow-but-working task is never stopped. max_retries (0-3, default 1) is how many extra attempts a STALLED task gets; a task that failed with a real error is never retried. max_tokens_per_task bounds what ONE task may spend; without it a single task can spend many times the whole plan's budget. Sizing, from measured runs: a task that traces or audits a large repo cost 510k-1,017k tokens; one reasoning over what its dependencies already found costs far less. A cap BELOW what a task needs saves nothing — a plan capping tasks at 200k lost all six of them between 213k and 259k and still spent 1,437,049 tokens, finishing none. A capped task is told its budget and asked to write a partial answer before reaching it, so tight is survivable and too-tight is not. Budget from task count x expected depth, or omit max_tokens to run unbounded within this run's own ceiling. A budget too small for its task count is refused at admission rather than half-spent: when the plan runs out, tasks in flight are cancelled and the rest never run, so the answer comes back incomplete.", }, }, // EMPTY, and the either/or lives in the DESCRIPTION instead. @@ -354,6 +396,7 @@ func (tool *OrchestrateTool) runnerForCall(options tools.RunOptions) PlanRunner req.ParentSessionID = options.SessionID req.ParentModel = options.Model req.ParentReasoningEffort = options.ReasoningEffort + req.ParentToolCallID = options.ToolCallID return run(ctx, req) } } @@ -435,6 +478,35 @@ func (tool *OrchestrateTool) RunWithOptions(ctx context.Context, args map[string if err != nil { return tools.Result{Status: tools.StatusError, Output: "Error: " + err.Error()} } + // AUTO-ASSIGNMENT RUNS BEFORE THE CONSTRUCTOR, on the arguments. + // + // Not inside ParsePlan: that has six call sites, four of them TUI verb and + // render paths through savedPlanLimits(), and a provider probe on those would + // block the interface, make parsing non-deterministic, and give a saved plan + // different models every time it was re-admitted. Here it happens once, on + // the dispatch path, where a network call is already expected. + // + // Working on the args rather than the parsed plan is what makes an assigned + // model indistinguishable from a hand-written one: same validation, same + // Args() round trip into a saved plan, same resume. + // VALIDATE BEFORE SPENDING ANYTHING. Auto-assignment lists the provider's + // models and, when routing is on, spends a call on a frontier model before a + // single task runs — and it was doing that for plans that were then rejected + // outright: a duplicate task id, a cycle, one task over the size tier. A + // model that proposes an oversized plan twice paid for routing twice and got + // nothing either time. + // + // ParsePlan is pure — no I/O, no network — so running it first costs + // microseconds against a provider round trip. Assignment can only ADD a model + // field to tasks that already parsed, so nothing it does can rescue a plan + // that failed here, and the real parse below still has the final word. + if _, err := ParsePlan(args, tool.limits(options)); err != nil { + return tools.Result{Status: tools.StatusError, Output: "Error: " + err.Error()} + } + assignNotes, autoErr := tool.autoAssignModels(ctx, args, options) + if autoErr != nil { + return tools.Result{Status: tools.StatusError, Output: "Error: " + autoErr.Error()} + } // ParsePlan validates as part of parsing; there is no other constructor, so // this call cannot be bypassed by any argument shape. plan, err := ParsePlan(args, tool.limits(options)) @@ -536,7 +608,7 @@ func (tool *OrchestrateTool) RunWithOptions(ctx context.Context, args map[string result := tools.Result{ Status: tools.StatusOK, - Output: report.Summary(), + Output: report.Summary() + planWorkspaceNote(workspace) + autoAssignSummary(assignNotes), Meta: map[string]string{ "plan_status": string(report.Status), "max_speedup": strconv.FormatFloat(report.MaxSpeedup, 'f', 2, 64), @@ -550,3 +622,199 @@ func (tool *OrchestrateTool) RunWithOptions(ctx context.Context, args map[string } return result } + +// autoAssignModels fills in a model per task when the plan asked for it, +// returning one note per assignment for the result. +// +// OFF UNLESS ASKED. planBool reports false for a missing key, and that is the +// wanted default: turning it on by default would change which model every +// existing plan runs on — and what it costs — without anyone choosing that. +// +// The two unavailable cases are told apart deliberately. A plan that asked for +// auto-assignment on a run that CANNOT do it is refused, because silently +// running without it is the thing the user asked not to happen. A provider that +// answers but offers nothing usable is NOT an error: every task simply inherits, +// which is exactly the behaviour before this existed. +func (tool *OrchestrateTool) autoAssignModels(ctx context.Context, args map[string]any, options tools.RunOptions) ([]string, error) { + // THE ARGUMENT WINS IN BOTH DIRECTIONS. A plan that supplies auto_assign is + // believed — true or false — and only a plan that says nothing falls back to + // what the user configured. Without the presence check a configured default + // of true could never be turned off for a single plan, because an absent + // argument and an explicit false look identical. + requested, supplied := planBoolSet(args, "auto_assign") + if !supplied { + requested = tool.ModelPrefs.AutoAssign + } + if !requested { + return nil, nil + } + // ASKED FOR vs CONFIGURED, and they must fail differently. + // + // A plan that SAYS auto_assign wants it: running silently without it is the + // thing that request exists to prevent, so an unavailable run is refused. + // A CONFIGURED default is a standing preference, not a demand — refusing + // every plan because a models endpoint blinked would make one setting break + // all planning, offline or behind a proxy. There it degrades: nothing is + // assigned, the reason is reported, and the plan runs on the session's model + // exactly as it did before the setting existed. + if tool.DiscoverModels == nil { + if !supplied { + return []string{"none — this run cannot list the provider's models, so every task kept this session's model"}, nil + } + return nil, fmt.Errorf( + "auto_assign is not available in this run — it needs a provider that can list its models. " + + "Name a model per task instead, or omit auto_assign to inherit this session's model") + } + raw, ok := args["tasks"].([]any) + if !ok { + // A saved plan or a malformed one: leave it entirely to ParsePlan, which + // owns every message about task shape. + return nil, nil + } + models, err := tool.DiscoverModels(ctx) + if err != nil { + if !supplied { + return []string{"none — could not list the provider's models (" + err.Error() + + "), so every task kept this session's model"}, nil + } + return nil, fmt.Errorf("auto_assign could not list the provider's models: %w", err) + } + // PROVED BEFORE ANYTHING IS BUILT FROM IT. Tiers, the served set and the + // router's candidate list are all derived from this slice, so a model that + // cannot run has to be gone before any of them exist — filtering later would + // leave it in the tiers it was already ranked into. + models, probeNotes := proveModels(ctx, models, tool.ProbeModel, &tool.probes) + + tiers := buildModelTiers(models, tool.ModelPrefs) + served := servedModels(models) + + // THE SESSION'S OWN MODEL MUST BE IN THE LIST, or the list is not this + // provider's and nothing built from it can be trusted. + // + // Discovery and child execution resolve the provider by different routes, and + // when those routes disagree this is the only place that can notice. A real + // run proved how bad the disagreement is: the session was xai/grok-4.5, the + // discovered list was nineteen Ollama models, and because grok-4.5 was absent + // from it even routerModel's "the session's model comes first" rule rejected + // the session's model — so the ROUTER ITSELF ran on qwen3.5:397b and the + // provider refused it. Four tasks died at dispatch on models that never + // existed for them. + // + // Every downstream guard was defeated by the same root fact. The served-set + // check passed, because the wrong list was internally consistent. Refusing + // here turns a plan-wide failure into the no-op it should always have been: + // every task keeps the session's model, which is exactly what it did before + // this feature existed. + // + // Guarded on a NON-EMPTY served set: an empty one means discovery told us + // nothing, which is the existing fail-open and not evidence of disagreement. + if parent := strings.TrimSpace(options.Model); parent != "" && len(served) > 0 && !servedContains(served, parent) { + mismatch := fmt.Sprintf( + "none — this session runs on %q, which is not among the %d model(s) discovered, "+ + "so that list belongs to a different provider and was ignored "+ + "(every task kept this session's model)", parent, len(models)) + if !supplied { + return []string{mismatch}, nil + } + // Asked for explicitly: the same evidence, raised rather than reported, + // because a plan that demanded auto-assignment must not quietly not get it. + return nil, fmt.Errorf("auto_assign refused: %s", mismatch) + } + + // THE ROUTER READS THE TASKS; the classifier only reads their verbs. It runs + // once per plan, on the strongest model available, and every way it can fail + // falls back to the classifier — a routing decision is worth having, never + // worth stopping a plan for. + // The router needs a grant only so the child is not refused for holding + // none; it reads nothing. Parent identity is attached by runnerForCall. + routerGrant, _ := planToolGrant(Task{}, tool.ParentTools) + router := routerModel(tool.ModelPrefs, tiers, served, options.Model) + routed, routerTokens, routeErr := routeTaskModels(ctx, tool.runnerForCall(options), PlanTaskRequest{Tools: routerGrant}, + router, routableTasks(raw), eligibleForRouting(models, tool.ModelPrefs), tool.ModelPrefs.RouterGuidance) + + tasks, notes := assignModelsToTaskArgs(raw, tiers, tool.ModelPrefs, served, routed) + // SAID, NOT SILENT. A model disappearing from routing with no explanation is + // indistinguishable from a bug, and these are precisely the ids the user has + // been adding to planModels.exclude by hand after each plan died on one. + for _, dropped := range probeNotes { + notes = append(notes, "not offered — "+dropped) + } + args["tasks"] = tasks + if len(routed) > 0 { + // NAME THE ROUTER. A per-task model that appeared from nowhere is + // indistinguishable from the classifier's guess, and the whole reason to + // pay for a routing call is being able to see whose judgement it was. + // NAME THE PRICE WITH THE NAME. Routing spends a frontier-model call + // before a single task runs, and it is not part of any task's total — + // so without this line the plan's reported spend is under its real spend + // by exactly this much, every run, invisibly. + headline := "routed by " + router + if routerTokens > 0 { + headline += fmt.Sprintf(" (%d tokens)", routerTokens) + } + notes = append([]string{headline}, notes...) + } + if routeErr != nil { + // Reported, not raised. The plan ran with classifier routing, and a user + // who configured a router deserves to know it did not answer. + notes = append(notes, "router unavailable ("+routeErr.Error()+"); roles chosen from task wording instead") + } + if len(notes) == 0 { + // ASKED FOR AND DID NOTHING IS A RESULT, and it has to say WHICH nothing. + // + // One message covered two unrelated causes and blamed the wrong one: a + // run with nineteen perfectly good models reported "none were usable" + // when the truth was that no task looked like scan, implement or verify, + // so nothing was assigned. That sent the reader hunting through provider + // capabilities for a problem that was in the task wording. + if tiers == (modelTiers{}) { + notes = append(notes, fmt.Sprintf( + "none — %d model(s) discovered, none of them usable for a plan task "+ + "(every task kept this session's model)", len(models))) + } else { + notes = append(notes, fmt.Sprintf( + "none — %d model(s) available, but no task called for a different one "+ + "(every task kept this session's model)", len(models))) + } + } + return notes, nil +} + +// planWorkspaceNote says WHERE a write-capable plan did its work. +// +// PlanWorkspace.Describe exists "for the approval card and the run" and nothing +// read it, so a plan that wrote files reported what it did and never where. The +// work lands in a git worktree that is deliberately NOT removed afterwards — +// "a plan that wrote produced work nobody has reviewed; deleting it would delete +// the only copy" — which makes an unnamed location the difference between work +// the user can review and work they cannot find. +// +// Empty for a read-only plan, which runs in the parent's own tree and has +// nothing to disclose. +func planWorkspaceNote(workspace PlanWorkspace) string { + if !workspace.Isolated { + return "" + } + where := strings.TrimSpace(workspace.Describe) + if where == "" { + where = strings.TrimSpace(workspace.Path) + } + if where == "" { + return "" + } + return "\n\nThis plan wrote in an isolated workspace: " + where + + "\nIt is left in place for review; nothing was written to the parent tree." +} + +// autoAssignSummary reports what auto-assignment chose. +// +// REPORTED, not merely applied. A feature that silently changes which model +// each task runs on — and therefore what the plan costs — has to say what it +// did, or the user cannot tell a plan that ran as they expected from one that +// did not. Empty when nothing was assigned, so the untouched path is unchanged. +func autoAssignSummary(notes []string) string { + if len(notes) == 0 { + return "" + } + return "\n\nModels assigned automatically:\n " + strings.Join(notes, "\n ") +} diff --git a/internal/specialist/plan_worktree_test.go b/internal/specialist/plan_worktree_test.go index 0c79cf151..929737518 100644 --- a/internal/specialist/plan_worktree_test.go +++ b/internal/specialist/plan_worktree_test.go @@ -248,7 +248,7 @@ func TestTheRealRunnerLaunchesTheChildInThePlansWorkspace(t *testing.T) { // task from its own weights. The stall watchdog cannot catch that: it keys on // silence, and a model writing prose is not silent. func TestThePlanTaskPromptDemandsToolUse(t *testing.T) { - manifest := planTaskManifest("explorer", []string{"read_file", "grep"}) + manifest := planTaskManifest("explorer", "", "", []string{"read_file", "grep"}) prompt := manifest.SystemPrompt // The obligation, not just the offer. diff --git a/internal/specialist/plan_write_test.go b/internal/specialist/plan_write_test.go index b7e5a2a3e..42f36b9c7 100644 --- a/internal/specialist/plan_write_test.go +++ b/internal/specialist/plan_write_test.go @@ -2,9 +2,11 @@ package specialist import ( "context" + "fmt" "strings" "testing" + "github.com/Gitlawb/zero/internal/streamjson" "github.com/Gitlawb/zero/internal/tools" ) @@ -201,3 +203,240 @@ func TestOrchestrateRefusesAPersistentApproval(t *testing.T) { t.Fatal("bash is not in the plan write set; the compositional argument no longer holds and this rule needs rethinking") } } + +// THE PROMPT MUST NOT CONTRADICT THE GRANT. A write-capable plan is approved by +// the user and isolated in a worktree, and its task was still told "you have +// read-only tools … do not attempt to modify anything". Handed write_file and +// instructed not to use it, a model that obeys produces a plan that reports +// success and changed nothing. +func TestTheTaskPromptMatchesTheToolsItWasGranted(t *testing.T) { + readOnly := planTaskManifest("explorer", "", "", []string{"read_file", "grep"}) + if !strings.Contains(readOnly.SystemPrompt, "read-only tools") { + t.Errorf("a read-only task must still be told so:\n%s", readOnly.SystemPrompt) + } + if !strings.Contains(readOnly.SystemPrompt, "do not attempt to modify anything") { + t.Errorf("a read-only task must still be told not to modify:\n%s", readOnly.SystemPrompt) + } + if !strings.Contains(readOnly.Metadata.Description, "Read-only") { + t.Errorf("description = %q", readOnly.Metadata.Description) + } + + writable := planTaskManifest("explorer", "", "", []string{"read_file", "write_file"}) + if strings.Contains(writable.SystemPrompt, "read-only tools") { + t.Errorf("a task granted write_file must not be told its tools are read-only:\n%s", writable.SystemPrompt) + } + if strings.Contains(writable.SystemPrompt, "do not attempt to modify anything") { + t.Errorf("a task granted write_file must not be told not to modify:\n%s", writable.SystemPrompt) + } + // THE BOUNDARY, not one phrasing of it. The worktree bounds WHERE a task may + // write and cannot bound HOW MUCH, so the prompt has to — but asserting my + // own wording made this a test of the sentence rather than of the property, + // and it failed the moment the reviewed wording landed instead. + if !strings.Contains(writable.SystemPrompt, "nothing beyond it") { + t.Errorf("a write task still needs a boundary the worktree cannot enforce:\n%s", writable.SystemPrompt) + } + if strings.Contains(writable.Metadata.Description, "Read-only") { + t.Errorf("description = %q", writable.Metadata.Description) + } + + // The obligation to go and look is shared: it is why plan tasks stopped + // answering from memory, and it applies to a task that writes just as much. + for name, manifest := range map[string]Manifest{"read-only": readOnly, "writable": writable} { + if !strings.Contains(manifest.SystemPrompt, "USE THEM") { + t.Errorf("%s: the prompt must still demand tool use:\n%s", name, manifest.SystemPrompt) + } + if !strings.Contains(manifest.SystemPrompt, "file:line") { + t.Errorf("%s: the prompt must still demand quoted evidence:\n%s", name, manifest.SystemPrompt) + } + } +} + +// EVERY named write tool flips it, not just write_file — the grant is the fact. +func TestEveryWriteToolFlipsTheTaskPrompt(t *testing.T) { + for _, name := range PlanWriteToolNames() { + manifest := planTaskManifest("explorer", "", "", []string{"read_file", name}) + if strings.Contains(manifest.SystemPrompt, "read-only tools") { + t.Errorf("a task granted %q was told its tools are read-only", name) + } + } + if grantsPlanWriteTool([]string{"read_file", "grep", "glob"}) { + t.Error("a read-only grant must not read as writable") + } + if grantsPlanWriteTool(nil) { + t.Error("an empty grant must not read as writable") + } +} + +// A WRITE TASK THAT CALLED NO TOOL MUST NOT REPORT SUCCESS. +// +// From a real run: the child emitted seventeen completion tokens reading +// "Creating notes.md now.", made no tool call, wrote nothing — and the plan +// recorded succeeded 1, status completed. A plan that reports work which did not +// happen is worse than one that fails, because nothing downstream can tell. +func TestAWriteTaskThatCalledNoToolIsNotASuccess(t *testing.T) { + silent := Executor{ + RunChild: func(_ context.Context, _ string, _ []string, _ func(streamjson.Event)) (ChildRunResult, error) { + // Emits nothing at all — exactly the observed child. + return ChildRunResult{Started: true}, nil + }, + } + run := NewPlanRunner(PlanTaskContext{Executor: silent, Cwd: t.TempDir(), SpecialistName: "explorer"}) + + result, err := run(context.Background(), PlanTaskRequest{ + Task: Task{ID: "w", Prompt: "create notes.md"}, + Tools: []string{"read_file", "write_file"}, + }) + if err != nil { + t.Fatalf("runner error: %v", err) + } + if result.Outcome != TaskFailed { + t.Fatalf("outcome = %s, want failed: a write task that called no tool changed nothing", result.Outcome) + } + for _, want := range []string{"without calling a single one", "changed nothing"} { + if !strings.Contains(result.Err, want) { + t.Errorf("the failure must say what happened (missing %q): %s", want, result.Err) + } + } +} + +// A write task that DID call a tool is unaffected, and a READ-ONLY task that +// called none still succeeds — there the inference is not airtight, and failing +// it would break every task that legitimately answers from its own prompt. +func TestTheNoToolGuardOnlyCatchesSilentWriteTasks(t *testing.T) { + calling := Executor{ + RunChild: func(_ context.Context, _ string, _ []string, progress func(streamjson.Event)) (ChildRunResult, error) { + if progress != nil { + progress(streamjson.Event{Type: streamjson.EventToolCall, Name: "write_file"}) + } + return ChildRunResult{Started: true}, nil + }, + } + silent := Executor{ + RunChild: func(_ context.Context, _ string, _ []string, _ func(streamjson.Event)) (ChildRunResult, error) { + return ChildRunResult{Started: true}, nil + }, + } + + wrote := NewPlanRunner(PlanTaskContext{Executor: calling, Cwd: t.TempDir(), SpecialistName: "explorer"}) + res, _ := wrote(context.Background(), PlanTaskRequest{ + Task: Task{ID: "w", Prompt: "write"}, Tools: []string{"write_file"}, + }) + if res.Outcome != TaskSucceeded { + t.Errorf("a write task that DID call a tool must succeed, got %s: %s", res.Outcome, res.Err) + } + + readOnly := NewPlanRunner(PlanTaskContext{Executor: silent, Cwd: t.TempDir(), SpecialistName: "explorer"}) + res, _ = readOnly(context.Background(), PlanTaskRequest{ + Task: Task{ID: "r", Prompt: "look"}, Tools: []string{"read_file", "grep"}, + }) + if res.Outcome != TaskSucceeded { + t.Errorf("a read-only task is not caught by this guard, got %s: %s", res.Outcome, res.Err) + } +} + +// THE CHILD'S AUTONOMY MUST MATCH ITS GRANT, asserted on the argv the RUNNER +// actually launches — not on a value the test computed for itself. +// +// specialistAutonomy maps every non-unsafe parent to "low" (read-only), so a +// write-capable plan task was handed write_file in its manifest and launched at +// an autonomy that never advertises it. From a real run: zero tool calls, no +// file, and the child leaking tool-call fragments into its prose because it +// wanted a tool it could not see. +// +// The first version of this test called BuildArgs with MemberAutonomy computed +// in the test body. It passed with the production line reverted — it was +// asserting the helper, not that the runner consults it. +func TestAWriteTaskIsLaunchedAtARungThatAdvertisesWriting(t *testing.T) { + autonomyFor := func(granted []string) string { + t.Helper() + var captured []string + executor := Executor{ + RunChild: func(_ context.Context, _ string, args []string, _ func(streamjson.Event)) (ChildRunResult, error) { + captured = args + return ChildRunResult{Started: true}, nil + }, + } + run := NewPlanRunner(PlanTaskContext{ + Executor: executor, + Cwd: t.TempDir(), + SpecialistName: "explorer", + PermissionMode: "ask", + }) + if _, err := run(context.Background(), PlanTaskRequest{ + Task: Task{ID: "t", Prompt: "do the thing"}, + Tools: granted, + }); err != nil { + t.Fatalf("runner: %v", err) + } + for i, arg := range captured { + if arg == "--auto" && i+1 < len(captured) { + return captured[i+1] + } + } + t.Fatalf("--auto missing from child argv %v", captured) + return "" + } + + if got := autonomyFor([]string{"read_file", "write_file"}); got != "member" { + t.Errorf("a write-granted task launches at --auto %q; it must be a rung that advertises writing", got) + } + // A read-only task is unchanged, so an ordinary plan behaves exactly as before. + if got := autonomyFor([]string{"read_file", "grep"}); got != "low" { + t.Errorf("a read-only task launches at --auto %q, want low", got) + } +} + +// A FAILED TASK'S TOKENS WERE BILLED, so the plan must count them. +// +// The error branch computed the usage summary and then returned an ExecResult +// without it, so a child that spent tokens and crashed reported zero: the plan +// budget was never decremented for it and the total under-counted every failure. +func TestAFailedTaskStillReportsWhatItSpent(t *testing.T) { + executor := Executor{ + RunChild: func(_ context.Context, _ string, _ []string, progress func(streamjson.Event)) (ChildRunResult, error) { + if progress != nil { + progress(streamjson.Event{Type: streamjson.EventToolCall, Name: "read_file"}) + } + // Usage arrives, THEN the child dies. + prompt, completion, total := 900, 100, 1000 + return ChildRunResult{ + Started: true, + Events: []streamjson.Event{{ + Type: streamjson.EventUsage, + PromptTokens: &prompt, + CompletionTokens: &completion, + TotalTokens: &total, + }}, + }, fmt.Errorf("child exited unexpectedly") + }, + } + run := NewPlanRunner(PlanTaskContext{Executor: executor, Cwd: t.TempDir(), SpecialistName: "explorer"}) + result, _ := run(context.Background(), PlanTaskRequest{ + Task: Task{ID: "a", Prompt: "look"}, Tools: []string{"read_file"}, + }) + if result.Outcome != TaskFailed { + t.Fatalf("outcome = %s, want failed", result.Outcome) + } + if result.Tokens == 0 { + t.Error("a task that spent tokens and then failed reported zero; its spend was real") + } +} + +// USAGE IS PRICED AGAINST THE MODEL THAT DID THE WORK. resolvedChildModel is the +// single rule the argv builder and the accounting both read, so the command line +// and the bill cannot disagree about which model ran. +func TestUsageIsAttributedToTheModelTheChildActuallyRan(t *testing.T) { + named := planTaskManifest("explorer", "claude-haiku-4.5", "", []string{"read_file"}) + if got := resolvedChildModel(named, "gpt-4.1"); got != "claude-haiku-4.5" { + t.Errorf("a task naming a model must be priced against it, got %q", got) + } + inherit := planTaskManifest("explorer", "", "", []string{"read_file"}) + if got := resolvedChildModel(inherit, "gpt-4.1"); got != "gpt-4.1" { + t.Errorf("an inheriting task is priced against the parent's model, got %q", got) + } + // The argv builder must agree, or the two drift. + argv := appendModelArgs(nil, named, "gpt-4.1", "") + if !containsArg(argv, "--model", resolvedChildModel(named, "gpt-4.1")) { + t.Errorf("argv and accounting disagree about the model: %v", argv) + } +} diff --git a/internal/specialist/resume_manifest_test.go b/internal/specialist/resume_manifest_test.go index ad83975f3..7ee7e37e2 100644 --- a/internal/specialist/resume_manifest_test.go +++ b/internal/specialist/resume_manifest_test.go @@ -17,7 +17,7 @@ func narrowManifest() Manifest { // ResolvedTools would be re-expanded to the default read-only category — // which is what the first version of this test did, and it then "failed" // against correct code. - return planTaskManifest("explorer", []string{"grep"}) + return planTaskManifest("explorer", "", "", []string{"grep"}) } func enabledToolsOf(args []string) string { diff --git a/internal/specialist/router_manifest_test.go b/internal/specialist/router_manifest_test.go new file mode 100644 index 000000000..7764d1d9a --- /dev/null +++ b/internal/specialist/router_manifest_test.go @@ -0,0 +1,111 @@ +package specialist + +import ( + "context" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/streamjson" +) + +// THE ROUTER MUST NOT BE TOLD TO START WITH A TOOL CALL. +// +// It ran under the plan-task system prompt — "You have read-only tools: USE THEM. +// Start with a tool call, not prose" — while its own prompt demands JSON and +// nothing else. Contradictory instructions to the one prompt that chooses every +// other task's model. A model handed both obeys one, and which one is a coin toss. +// +// Asserted from the manifest the CHILD receives, not from the constant: a +// constant with the right words proves nothing if nothing installs it. +func TestTheRouterRunsUnderItsOwnSystemPromptNotThePlanTaskOne(t *testing.T) { + var seen string + exec := Executor{ + BinaryPath: "/bin/true", + NewSessionID: func() (string, error) { return "specialist_00000000000000000000000a", nil }, + Load: func(LoadOptions) (LoadResult, error) { return LoadResult{}, nil }, + RunChild: func(context.Context, string, []string, func(streamjson.Event)) (ChildRunResult, error) { + return ChildRunResult{Started: true}, nil + }, + } + // The runner builds the manifest; capture it by intercepting the load path. + planCtx := PlanTaskContext{Executor: exec, Cwd: t.TempDir(), SpecialistName: "explorer"} + planCtx.Executor.Load = func(LoadOptions) (LoadResult, error) { return LoadResult{}, nil } + run := NewPlanRunner(planCtx) + planCtx.Executor.RunChild = func(context.Context, string, []string, func(streamjson.Event)) (ChildRunResult, error) { + return ChildRunResult{Started: true}, nil + } + + // Drive the real router entry point so the request it builds is the one tested. + _, _, _ = routeTaskModels(context.Background(), + func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + seen = req.SystemPrompt + return TaskResult{Outcome: TaskSucceeded, Output: `{"assignments":[]}`}, nil + }, + PlanTaskRequest{Tools: []string{"read_file"}}, "m", + routerTasks(), routerCandidates(), "") + _ = run + + if strings.TrimSpace(seen) == "" { + t.Fatal("the router was dispatched with no system prompt of its own") + } + for _, forbidden := range []string{"USE THEM", "Start with a tool call"} { + if strings.Contains(seen, forbidden) { + t.Errorf("the router inherited the plan-task instruction %q:\n%s", forbidden, seen) + } + } + for _, required := range []string{"do not call any tool", "JSON object and nothing else"} { + if !strings.Contains(seen, required) { + t.Errorf("the router prompt is missing %q:\n%s", required, seen) + } + } +} + +// The override must reach the CHILD, or the constant is decoration. +// +// WrapSystemPrompt folds the manifest's system prompt into the prompt the child +// is launched with, so that text is where the override becomes observable. A test +// asserting manifest.SystemPrompt in isolation would pass while the runner +// ignored req.SystemPrompt entirely — which is the wiring gap this feature has +// produced at every seam. +func TestASystemPromptOverrideReachesTheChildAndTheOrdinaryPathIsUnchanged(t *testing.T) { + launch := func(request PlanTaskRequest) string { + t.Helper() + var prompt string + exec := Executor{ + BinaryPath: "/bin/true", + NewSessionID: func() (string, error) { return "specialist_00000000000000000000000a", nil }, + Load: func(LoadOptions) (LoadResult, error) { return LoadResult{}, nil }, + RunChild: func(_ context.Context, _ string, args []string, _ func(streamjson.Event)) (ChildRunResult, error) { + prompt = strings.Join(args, "\n") + return ChildRunResult{Started: true}, nil + }, + PromptFileMaxSize: 1 << 20, // keep the prompt in argv so the test can read it + } + run := NewPlanRunner(PlanTaskContext{Executor: exec, Cwd: t.TempDir(), SpecialistName: "explorer"}) + if _, err := run(context.Background(), request); err != nil { + t.Fatalf("run: %v", err) + } + return prompt + } + + overridden := launch(PlanTaskRequest{ + Task: Task{ID: "t", Prompt: "p"}, + Tools: []string{"read_file"}, + SystemPrompt: "SENTINEL-ROUTER-PROMPT", + }) + if !strings.Contains(overridden, "SENTINEL-ROUTER-PROMPT") { + t.Errorf("the override never reached the child:\n%s", overridden) + } + if strings.Contains(overridden, "USE THEM") { + t.Errorf("the plan-task prompt survived alongside the override:\n%s", overridden) + } + + // THE COMMON PATH IS UNCHANGED. Every task of every plan takes this branch. + ordinary := launch(PlanTaskRequest{Task: Task{ID: "t", Prompt: "p"}, Tools: []string{"read_file"}}) + if !strings.Contains(ordinary, "USE THEM") { + t.Errorf("an ordinary plan task lost its system prompt:\n%s", ordinary) + } + if strings.Contains(ordinary, "SENTINEL") { + t.Error("an override leaked into a request that set none") + } +} diff --git a/internal/specialist/served_forms_test.go b/internal/specialist/served_forms_test.go new file mode 100644 index 000000000..37e9a6abe --- /dev/null +++ b/internal/specialist/served_forms_test.go @@ -0,0 +1,94 @@ +package specialist + +import ( + "context" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/tools" +) + +// A MODEL SPELLED DIFFERENTLY IS THE SAME MODEL, and the provider-mismatch guard +// must not read a spelling difference as a different provider. +// +// The served map held raw discovery ids while the guard probed it with the +// SESSION's model. A session on "sonnet 4.5" against a provider listing +// "claude-sonnet-4.5" missed, and a miss reads as "this list belongs to a +// different provider" — so auto-assignment silently switched off, or refused the +// plan outright when it had been asked for explicitly. Reproduced before the fix +// on both an alias and an Ollama ":latest" tag. +func TestAnAliasNamedSessionModelIsNotMistakenForAnotherProvider(t *testing.T) { + for name, fixture := range map[string]struct { + session string + discovered []string + }{ + "registry alias": {"sonnet 4.5", []string{"claude-sonnet-4.5", "claude-haiku-4.5", "claude-opus-4.1"}}, + "ollama tag": {"glm-5.2", []string{"glm-5.2:latest", "kimi-k2.6:latest", "qwen3.5:397b"}}, + "tagged session": {"glm-5.2:latest", []string{"glm-5.2", "kimi-k2.6", "qwen3.5:397b"}}, + } { + t.Run(name, func(t *testing.T) { + models := make([]DiscoveredModel, 0, len(fixture.discovered)) + for index, id := range fixture.discovered { + models = append(models, DiscoveredModel{ID: id, ToolCall: true, InputCost: float64(index + 1)}) + } + tool := &OrchestrateTool{ + DiscoverModels: func(context.Context) ([]DiscoveredModel, error) { return models, nil }, + ModelPrefs: ModelPreferences{AutoAssign: true}, + } + args := map[string]any{"tasks": []any{ + map[string]any{"id": "a", "prompt": "list the files"}, + map[string]any{"id": "b", "prompt": "audit it and judge whether it holds"}, + }} + + notes, err := tool.autoAssignModels(context.Background(), args, tools.RunOptions{Model: fixture.session}) + if err != nil { + t.Fatalf("a spelling difference refused the whole plan: %v", err) + } + if joined := strings.Join(notes, " "); strings.Contains(joined, "different provider") { + t.Fatalf("the mismatch guard fired on the right provider: %s", joined) + } + assigned := 0 + for _, entry := range args["tasks"].([]any) { + if strings.TrimSpace(planString(entry.(map[string]any), "model")) != "" { + assigned++ + } + } + if assigned == 0 { + t.Errorf("auto-assignment silently switched itself off: %v", notes) + } + }) + } +} + +// THE GUARD MUST STILL FIRE ON A GENUINELY FOREIGN LIST. Widening the comparison +// to every spelling is worthless if it also stops catching the case it was +// written for. +func TestAGenuinelyForeignModelListStillTripsTheGuard(t *testing.T) { + tool := &OrchestrateTool{ + DiscoverModels: func(context.Context) ([]DiscoveredModel, error) { + return []DiscoveredModel{ + {ID: "deepseek-v4-flash", ToolCall: true, InputCost: 1}, + {ID: "qwen3.5:397b", ToolCall: true, InputCost: 9}, + }, nil + }, + ModelPrefs: ModelPreferences{AutoAssign: true}, + } + args := map[string]any{"tasks": []any{map[string]any{"id": "a", "prompt": "list"}}} + notes, _ := tool.autoAssignModels(context.Background(), args, tools.RunOptions{Model: "grok-4.5"}) + if !strings.Contains(strings.Join(notes, " "), "different provider") { + t.Errorf("an Ollama list on an xAI session was accepted: %v", notes) + } +} + +// A pin written in either spelling must survive, for the same reason. +func TestAPinIsHonouredWhicheverSpellingItUses(t *testing.T) { + served := servedModels([]DiscoveredModel{{ID: "claude-sonnet-4.5"}, {ID: "glm-5.2:latest"}}) + for _, pin := range []string{"claude-sonnet-4.5", "sonnet 4.5", "glm-5.2", "glm-5.2:latest"} { + if !servedContains(served, pin) { + t.Errorf("pin %q was treated as unserved", pin) + } + } + if servedContains(served, "grok-4.5") { + t.Error("an unserved model was accepted") + } +} diff --git a/internal/specialist/streamer.go b/internal/specialist/streamer.go index f9a3a2337..d43a475e1 100644 --- a/internal/specialist/streamer.go +++ b/internal/specialist/streamer.go @@ -24,7 +24,15 @@ type StreamUsage struct { PromptTokens int CompletionTokens int TotalTokens int - Events int + // CachedInputTokens, CacheWriteTokens and ReasoningTokens are what make a + // child's turn PRICEABLE by its parent. Absent, every rolled-up sub-agent + // turn was costed as if nothing had been cached — on a plan task, where the + // same large prompt is re-sent each turn, that is the overwhelming majority + // of the input. + CachedInputTokens int + CacheWriteTokens int + ReasoningTokens int + Events int } func (usage StreamUsage) HasUsage() bool { @@ -87,6 +95,17 @@ func SummarizeStream(events []streamjson.Event, processExitCode int) StreamResul } else { result.Usage.TotalTokens += eventPromptTokens + eventCompletionTokens } + // Summed, not overwritten: a task makes one provider call per turn + // and each reports its own cache split. + if event.CachedInputTokens != nil { + result.Usage.CachedInputTokens += *event.CachedInputTokens + } + if event.CacheWriteTokens != nil { + result.Usage.CacheWriteTokens += *event.CacheWriteTokens + } + if event.ReasoningTokens != nil { + result.Usage.ReasoningTokens += *event.ReasoningTokens + } } } if finalText != "" { diff --git a/internal/specialist/task_role.go b/internal/specialist/task_role.go new file mode 100644 index 000000000..f43eea0ab --- /dev/null +++ b/internal/specialist/task_role.go @@ -0,0 +1,145 @@ +package specialist + +import ( + "regexp" + "strings" +) + +// TaskRole is what a plan task is FOR, used to pick a model proportionate to the +// work. A label for spending, not for execution: nothing in the scheduler, +// the grant intersection or the budget reads it. +type TaskRole string + +const ( + // TaskRoleScan reads and reports. Cheap, high-volume, no judgement. + TaskRoleScan TaskRole = "scan" + // TaskRoleImplement changes something. + TaskRoleImplement TaskRole = "implement" + // TaskRoleVerify judges work that already exists — the stage where a weaker + // model costs the most, because a wrong "looks fine" is indistinguishable + // from a right one. + TaskRoleVerify TaskRole = "verify" + // TaskRoleDefault is the honest answer when the heuristic cannot tell, and + // means "inherit the parent's model". + TaskRoleDefault TaskRole = "default" +) + +// taskWordBoundary splits a prompt into words on anything that is not a letter +// or digit. +// +// WHOLE WORDS, and it is not pedantry. Substring matching — which is what the +// obvious implementation does — fires "implement" on "pre**fix**", "scan" on +// "**add**ress", and "verify" on "la**test**". A task classified by an accident +// of spelling gets silently billed to the wrong model, and nothing downstream +// can tell that happened. +var taskWordBoundary = regexp.MustCompile(`[^a-z0-9]+`) + +var ( + implementWords = wordSet("write", "edit", "create", "refactor", "fix", "change", + "add", "remove", "modify", "implement", "rename", "delete", "update", "patch") + // The DECIDE family is here because a real plan asked tasks to "decide + // whether a race exists" and "decide whether a project config can raise a + // user limit" — the two hardest tasks in it — and neither word was listed, so + // both fell through to default and inherited whatever the session was using. + // Judging is the role where a weak model costs the most; the list has to + // contain the words people actually use for it. + verifyWords = wordSet("review", "verify", "check", "audit", "assess", "critique", + "validate", "judge", "confirm", "compare", "decide", "determine", "conclude", + "evaluate", "diagnose", "inspect", "prove", "disprove") + scanWords = wordSet("find", "grep", "search", "list", "read", "scan", "locate", + "identify", "enumerate", "map", "inventory") +) + +func wordSet(words ...string) map[string]bool { + set := make(map[string]bool, len(words)) + for _, word := range words { + set[word] = true + } + return set +} + +// classifyTaskRole guesses what a task is for. +// +// THE GRANT OUTRANKS THE PROSE, and that ordering is the only part of this that +// is not a guess. A task holding write_file will write whatever its prompt says; +// a prompt is an intention and a grant is a fact, so when they disagree the fact +// wins. +// +// Everything below the grant is a heuristic and is allowed to be wrong — which +// is why TaskRoleDefault exists rather than a forced choice, and why the whole +// feature is off unless asked for. +func classifyTaskRole(task Task) TaskRole { + for _, tool := range task.Tools { + if planWriteTools[strings.TrimSpace(tool)] { + return TaskRoleImplement + } + } + + words := promptWords(task.Prompt) + // Verify is tested BEFORE implement: "verify the fix" and "review the change" + // contain implement words and are not implement tasks. The reverse order + // would send every review of a change to the coding model. + switch { + case anyWord(words, verifyWords): + return TaskRoleVerify + case anyWord(words, implementWords): + return TaskRoleImplement + case anyWord(words, scanWords): + return TaskRoleScan + default: + return TaskRoleDefault + } +} + +func promptWords(prompt string) map[string]bool { + found := map[string]bool{} + for _, word := range taskWordBoundary.Split(strings.ToLower(prompt), -1) { + if word != "" { + found[word] = true + } + } + return found +} + +func anyWord(words, set map[string]bool) bool { + for word := range words { + for keyword := range set { + if matchesKeyword(word, keyword) { + return true + } + } + } + return false +} + +// matchesKeyword matches a keyword and its ordinary inflections. +// +// EXACT FORMS ALONE DO NOT WORK. Real prompts say "auditing", "finding", +// "writing", "reviewing" — and matching only "audit", "find", "write", "review" +// missed every one of them, so the intended verb was ignored and whichever +// incidental word happened to appear decided the role. A real run classified +// nine read-only audit tasks as "implement" that way. +// +// The obvious repair — prefix matching — reintroduces exactly what whole-word +// matching was for: "add" is a prefix of "address", "test" of "latest". So this +// enumerates the suffixes English actually uses, including the dropped-e forms +// (write → writing, remove → removing), and nothing else. "address" is not +// "add" plus any of them. +func matchesKeyword(word, keyword string) bool { + if word == keyword { + return true + } + for _, suffix := range []string{"s", "es", "ed", "d", "ing"} { + if word == keyword+suffix { + return true + } + } + if stem, dropped := strings.CutSuffix(keyword, "e"); dropped { + for _, suffix := range []string{"ing", "ed", "es"} { + if word == stem+suffix { + return true + } + } + } + return false +} diff --git a/internal/specialist/task_role_test.go b/internal/specialist/task_role_test.go new file mode 100644 index 000000000..7b49bbfbf --- /dev/null +++ b/internal/specialist/task_role_test.go @@ -0,0 +1,108 @@ +package specialist + +import "testing" + +// WHOLE WORDS. Substring matching — the obvious implementation, and the one the +// source plan specified — fires "fix" on "prefix", "add" on "address" and "test" +// on "latest". A task misclassified by an accident of spelling is billed to the +// wrong model and nothing downstream can tell. +func TestClassificationMatchesWholeWordsNotSubstrings(t *testing.T) { + for prompt, want := range map[string]TaskRole{ + "find the prefix used by the parser": TaskRoleScan, + "report the address format in use": TaskRoleDefault, + "summarise the latest release notes": TaskRoleDefault, + "fix the parser": TaskRoleImplement, + "list every caller": TaskRoleScan, + "review the change for correctness": TaskRoleVerify, + "describe how the scheduler is organised": TaskRoleDefault, + } { + if got := classifyTaskRole(Task{ID: "t", Prompt: prompt}); got != want { + t.Errorf("%q → %s, want %s", prompt, got, want) + } + } +} + +// VERIFY BEFORE IMPLEMENT. "verify the fix" and "review the change" contain +// implement words and are not implement tasks; the reverse order sends every +// review of a change to the coding model instead of the judging one. +func TestVerifyOutranksImplementWords(t *testing.T) { + for _, prompt := range []string{ + "verify the fix landed correctly", + "review the change to the scheduler", + "check that the rename is complete", + } { + if got := classifyTaskRole(Task{ID: "t", Prompt: prompt}); got != TaskRoleVerify { + t.Errorf("%q → %s, want verify", prompt, got) + } + } +} + +// THE GRANT OUTRANKS THE PROSE. A prompt is an intention; a write grant is a +// fact. A task holding write_file will write whatever its prompt says. +func TestAWriteGrantOverridesThePrompt(t *testing.T) { + task := Task{ID: "t", Prompt: "find every caller and list them", Tools: []string{"read_file", "write_file"}} + if got := classifyTaskRole(task); got != TaskRoleImplement { + t.Errorf("a task granted write_file classified as %s, want implement", got) + } + readOnly := Task{ID: "t", Prompt: "find every caller and list them", Tools: []string{"read_file"}} + if got := classifyTaskRole(readOnly); got != TaskRoleScan { + t.Errorf("a read-only scan classified as %s, want scan", got) + } +} + +// INFLECTED VERBS ARE THE NORMAL CASE. Real prompts say "auditing", "finding", +// "writing" — not the bare stem. Matching exact forms only meant the intended +// verb was invisible and whichever incidental word appeared decided the role: a +// real run classified nine read-only audit tasks as "implement". +func TestInflectedVerbsClassifyOnTheirIntent(t *testing.T) { + for prompt, want := range map[string]TaskRole{ + "You are auditing package pkg/execprofile": TaskRoleVerify, + "You are finding duplicated logic inside pkg/reltime": TaskRoleScan, + "reviewing the change for correctness": TaskRoleVerify, + "searching for every caller": TaskRoleScan, + "creating the missing helper": TaskRoleImplement, + "removing the dead branch": TaskRoleImplement, + "writing the migration": TaskRoleImplement, + "checked the invariants hold": TaskRoleVerify, + "lists every exported symbol": TaskRoleScan, + } { + if got := classifyTaskRole(Task{ID: "t", Prompt: prompt}); got != want { + t.Errorf("%q → %s, want %s", prompt, got, want) + } + } +} + +// AND THE TRAP THE WHOLE-WORD RULE EXISTED FOR STAYS CLOSED. Prefix matching +// would have been the obvious repair and reopens it: "add" is a prefix of +// "address", "test" of "latest". Inflection matching enumerates the suffixes +// English uses and nothing else. +func TestInflectionMatchingDoesNotReopenTheSubstringTrap(t *testing.T) { + for prompt, want := range map[string]TaskRole{ + "report the address format used by the parser": TaskRoleDefault, + "summarise the latest release notes": TaskRoleDefault, + "describe the prefix convention": TaskRoleDefault, + } { + if got := classifyTaskRole(Task{ID: "t", Prompt: prompt}); got != want { + t.Errorf("%q → %s, want %s", prompt, got, want) + } + } + for _, tc := range []struct { + word, keyword string + want bool + }{ + {"auditing", "audit", true}, + {"audits", "audit", true}, + {"audited", "audit", true}, + {"writing", "write", true}, + {"removing", "remove", true}, + {"changed", "change", true}, + {"address", "add", false}, + {"latest", "test", false}, + {"prefix", "fix", false}, + {"finder", "find", false}, + } { + if got := matchesKeyword(tc.word, tc.keyword); got != tc.want { + t.Errorf("matchesKeyword(%q, %q) = %v, want %v", tc.word, tc.keyword, got, tc.want) + } + } +} diff --git a/internal/specialist/usage_pricing_test.go b/internal/specialist/usage_pricing_test.go new file mode 100644 index 000000000..310f14777 --- /dev/null +++ b/internal/specialist/usage_pricing_test.go @@ -0,0 +1,209 @@ +package specialist + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/sessions" + "github.com/Gitlawb/zero/internal/streamjson" +) + +func intPtr(n int) *int { return &n } + +// A CHILD'S TURN MUST REACH ITS PARENT PRICEABLE, not merely counted. +// +// The child writes cachedInputTokens / cacheWriteTokens / reasoningTokens to its +// own session record precisely so a turn can be costed exactly. The stream-json +// event the PARENT reads carried only prompt/completion/total, so every rolled-up +// sub-agent turn was priced as if nothing had been cached. +// +// That is not a rounding error. A measured plan task had 49,280 of 49,894 prompt +// tokens served from cache — 98.8% — and plan tasks are the ideal cache case, +// re-sending one large stable prompt every turn. +func TestSummarizeStreamCarriesTheFieldsThatMakeATurnPriceable(t *testing.T) { + // Two turns, as a real task produces: one call per turn, each with its own + // cache split. + summary := SummarizeStream([]streamjson.Event{ + { + Type: streamjson.EventUsage, + PromptTokens: intPtr(30000), CompletionTokens: intPtr(500), TotalTokens: intPtr(30500), + CachedInputTokens: intPtr(29000), CacheWriteTokens: intPtr(1000), ReasoningTokens: intPtr(120), + }, + { + Type: streamjson.EventUsage, + PromptTokens: intPtr(31000), CompletionTokens: intPtr(400), TotalTokens: intPtr(31400), + CachedInputTokens: intPtr(30500), ReasoningTokens: intPtr(80), + }, + }, 0) + + // SUMMED, not overwritten — each turn reports its own split. + if got := summary.Usage.CachedInputTokens; got != 59500 { + t.Errorf("cached input across turns: want 59500, got %d", got) + } + if got := summary.Usage.CacheWriteTokens; got != 1000 { + t.Errorf("cache writes: want 1000, got %d", got) + } + if got := summary.Usage.ReasoningTokens; got != 200 { + t.Errorf("reasoning: want 200, got %d", got) + } + // The pre-existing totals must be untouched by any of this. + if got := summary.Usage.TotalTokens; got != 61900 { + t.Errorf("total tokens changed: want 61900, got %d", got) + } + + // GUARD THE GUARD: with the fields absent the sum must be zero, not garbage — + // most providers report no cache at all and must stay priceable as uncached. + bare := SummarizeStream([]streamjson.Event{ + {Type: streamjson.EventUsage, PromptTokens: intPtr(100), CompletionTokens: intPtr(10), TotalTokens: intPtr(110)}, + }, 0) + if bare.Usage.CachedInputTokens != 0 || bare.Usage.CacheWriteTokens != 0 || bare.Usage.ReasoningTokens != 0 { + t.Errorf("a provider reporting no cache produced non-zero splits: %+v", bare.Usage) + } + if bare.Usage.TotalTokens != 110 { + t.Errorf("the no-cache case lost its total: %d", bare.Usage.TotalTokens) + } +} + +// The rollup written into the PARENT session is where BuildReport reads from, so +// the fields have to survive that hop too — carrying them up the stream and then +// dropping them at the rollup would fix nothing. +// +// Drives appendSpecialistUsageRollup itself. Re-implementing the payload in the +// test would assert the shape I happened to write rather than the shape the code +// writes, which is how a field gets carried three layers and dropped at the +// fourth without a single test noticing. +func TestTheUsageRollupWritesThePricingFields(t *testing.T) { + store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()}) + parent, err := store.Create(sessions.CreateInput{ + SessionID: "zero_00000000000000_0000000000000000000_1", + Cwd: t.TempDir(), + }) + if err != nil { + t.Fatalf("create parent session: %v", err) + } + input := specialistAccountingInput{ + ParentSessionID: parent.SessionID, + ChildSessionID: "specialist_00000000000000000000000a", + SpecialistName: "explorer", + Model: "grok-4.3", + } + summary := StreamResult{RunID: "run-1", Usage: StreamUsage{ + PromptTokens: 30000, CompletionTokens: 500, TotalTokens: 30500, + CachedInputTokens: 29000, CacheWriteTokens: 1000, ReasoningTokens: 120, Events: 1, + }} + + rolledUp, err := appendSpecialistUsageRollup(store, input, summary) + if err != nil { + t.Fatalf("rollup: %v", err) + } + if !rolledUp { + t.Fatal("the rollup did not record anything") + } + + events, err := store.ReadEvents(parent.SessionID) + if err != nil { + t.Fatalf("read parent events: %v", err) + } + var usage map[string]any + for _, event := range events { + if event.Type != sessions.EventUsage { + continue + } + if err := json.Unmarshal(event.Payload, &usage); err != nil { + t.Fatalf("decode usage payload: %v", err) + } + } + if usage == nil { + t.Fatalf("no usage event reached the parent session: %+v", events) + } + // These exact keys are what internal/usage reads back to price a turn. + for key, want := range map[string]float64{ + "cachedInputTokens": 29000, "cacheWriteTokens": 1000, "reasoningTokens": 120, + } { + got, ok := usage[key] + if !ok { + t.Errorf("the rollup omits %q, so BuildReport prices this turn as uncached", key) + continue + } + if number, ok := got.(float64); !ok || number != want { + t.Errorf("%s: want %v, got %v", key, want, got) + } + } +} + +// ROUTING IS NOT FREE and is not part of any task's total, so a plan that does +// not name its cost reports less than it spent — every run, invisibly. +func TestTheRoutingCallReportsWhatItSpent(t *testing.T) { + notes := []string{"routed by grok-4.5 (4210 tokens)", "a: routed → grok-4.3"} + summary := autoAssignSummary(notes) + if !strings.Contains(summary, "4210 tokens") { + t.Errorf("the router's own spend is invisible in the plan's report:\n%s", summary) + } +} + +// SPEND SURVIVES FAILURE, in the session record as well as in the plan. +// +// The error branch of Executor.Run passed a hard-coded false for usageRolledUp +// and appended no usage event at all, so a child killed mid-run had every token +// it had already spent vanish from its parent's record — `zero usage` under- +// reported by exactly that much. The plan's own budget was already correct; +// this is the other ledger. +func TestAChildThatFailedStillRollsUpWhatItSpent(t *testing.T) { + store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()}) + parent, err := store.Create(sessions.CreateInput{ + SessionID: "zero_00000000000000_0000000000000001111_1", + Cwd: t.TempDir(), + }) + if err != nil { + t.Fatalf("create parent: %v", err) + } + + spent := 4200 + exec := Executor{ + BinaryPath: "/bin/true", + SessionStore: store, + NewSessionID: func() (string, error) { return "specialist_0000000000000000000000ff", nil }, + Load: func(LoadOptions) (LoadResult, error) { + return LoadResult{Specialists: []Manifest{resumeTestManifest()}}, nil + }, + RunChild: func(_ context.Context, _ string, _ []string, _ func(streamjson.Event)) (ChildRunResult, error) { + // Spent real tokens, then died — a kill, an OOM, a broken pipe. + return ChildRunResult{ + Started: true, + ExitCode: -1, + Events: []streamjson.Event{{ + Type: streamjson.EventUsage, TotalTokens: &spent, + PromptTokens: intPtr(4000), CompletionTokens: intPtr(200), + }}, + }, errors.New("the child was killed") + }, + } + + result, runErr := exec.Run(context.Background(), + TaskParameters{Name: "explorer", Prompt: "work"}, + TaskRunOptions{Cwd: t.TempDir(), ParentSessionID: parent.SessionID}) + if runErr == nil { + t.Fatal("setup: this test needs the failing branch") + } + // Already true before this fix, and asserted so it stays true. + if result.TotalTokens != spent { + t.Errorf("the plan's own accounting lost the tokens: want %d, got %d", spent, result.TotalTokens) + } + + events, err := store.ReadEvents(parent.SessionID) + if err != nil { + t.Fatalf("read parent events: %v", err) + } + found := false + for _, event := range events { + if event.Type == sessions.EventUsage { + found = true + } + } + if !found { + t.Errorf("a failed child's spend never reached the parent's session record: %d events", len(events)) + } +} From 79da709ecf4d20975d119680dfb08ac28fd3da62 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:39:24 +0530 Subject: [PATCH 79/86] feat(cli): wire model discovery, proving and child scope to the live run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hooks specialist declares, supplied from the two surfaces that own a provider and a sandbox. DISCOVERY FOLLOWS THE LIVE PROVIDER. It was bound once at startup, so a session that switched provider with /model kept probing the one it began on: a run on xai/grok-4.5 had its plan assigned from a nineteen-model Ollama list and the provider refused every task. It now re-resolves through the same path a freshly spawned child takes, config plus ZERO_PROVIDER, so discovery and child execution read one source of truth rather than two kept in sync. The captured profile still wins when the provider is unchanged, because --provider, --base-url and an inline --api-key are applied on top of Resolve and re-resolving would discard them. A CHILD STANDS ON THE SAME GROUND AS ITS PARENT. A run granted a directory outside its workspace could create it, populate it, and then watch every plan task be refused at that same directory — a child rebuilds its sandbox from --cwd alone and the grant lived only in the parent's engine. The run's roots now reach the child as --add-dir, read at launch so a mid-session approval lands, and on the resume path too: this file has already seen the resume path forget what the fresh path carried. PROVING, wired to the provider stack and exposed as `providers models --verify` — which model this provider will actually run, as opposed to which it lists. That is the difference the exclude list was being maintained by hand to express. --- internal/cli/app.go | 56 ++++-- .../cli/child_exit_code_agreement_test.go | 28 +++ internal/cli/exec.go | 39 +++- internal/cli/exec_usage_cache_test.go | 88 +++++++++ internal/cli/exec_writer.go | 19 +- internal/cli/exec_zeromaxing_test.go | 4 +- internal/cli/plan_grant_test.go | 85 +++++++++ internal/cli/plan_live_provider_test.go | 96 ++++++++++ internal/cli/plan_model_prefs_carry_test.go | 62 +++++++ internal/cli/plan_model_probe.go | 105 +++++++++++ internal/cli/provider_models.go | 170 +++++++++++++++++- 11 files changed, 732 insertions(+), 20 deletions(-) create mode 100644 internal/cli/child_exit_code_agreement_test.go create mode 100644 internal/cli/exec_usage_cache_test.go create mode 100644 internal/cli/plan_live_provider_test.go create mode 100644 internal/cli/plan_model_prefs_carry_test.go create mode 100644 internal/cli/plan_model_probe.go diff --git a/internal/cli/app.go b/internal/cli/app.go index 738752650..a5fd8c38d 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -17,6 +17,7 @@ import ( "github.com/Gitlawb/zero/internal/agent" "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/execprofile" "github.com/Gitlawb/zero/internal/execution" "github.com/Gitlawb/zero/internal/hooks" "github.com/Gitlawb/zero/internal/localcontrol" @@ -735,11 +736,20 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a // so the run's grant is every read-only tool the registry holds. specialistRuntime, err := registerSpecialistTools(registry, workspaceRoot, resolved.Swarm.MaxTeamSize, nil, nil, planProgress, orchestrateWiring{ - Gate: zeromaxingGate, + DiscoverModels: planModelDiscoverer(workspaceRoot, resolved.Provider), + // The LIVE scope, not a snapshot of it. A request_permissions grant + // lands mid-session and must reach the children dispatched after it. + ExtraWriteRoots: scope.Roots, + ProbeModel: planModelProber(workspaceRoot, resolved.Provider, deps.newProvider), + ModelPrefs: planModelPreferences(resolved.Profiles.PlanModels), + Gate: zeromaxingGate, // Depth stays 0: the TUI is always a root session — it has no // --depth and is never launched as a child — so zero is the // measured value here, not an unset one. - PlanContext: specialist.PlanTaskContext{Cwd: workspaceRoot, Depth: 0}, + PlanContext: specialist.PlanTaskContext{ + Cwd: workspaceRoot, Depth: 0, + PostureReasoningEffort: string(execprofile.Zeromaxing.ReasoningEffort), + }, // The plan-size tier, from the SAME resolved config the rest of this // wiring reads. Project config may only have tightened it. Size: resolved.Profiles.PlanSizeTier(), @@ -1121,6 +1131,13 @@ type orchestrateWiring struct { Gate *specialist.PostureGate // PlanContext supplies the run-invariant state a plan task inherits. PlanContext specialist.PlanTaskContext + // ExtraWriteRoots reports the run's non-workspace roots at LAUNCH time, so a + // child's sandbox covers the same ground its parent's does. nil means the + // workspace only, which is what every child got before this existed. + ExtraWriteRoots func() []string + // ProbeModel proves a model will actually run before any task is assigned it. + // nil skips proving, which is what every plan did before this existed. + ProbeModel specialist.ModelProber // Size is the configured plan-size tier. The zero value is the default tier, // so a call site that has no resolved config yet still gets a real ceiling // rather than none. @@ -1133,6 +1150,11 @@ type orchestrateWiring struct { // Launch runs a plan in the background. nil — the headless default — makes // a background plan refuse rather than start one nothing can report. Launch func(run func(ctx context.Context)) bool + // DiscoverModels lists what the active provider can serve, for auto_assign. + // nil makes a plan asking for it refuse with a reason. + DiscoverModels specialist.ModelDiscoverer + // ModelPrefs carries the user's per-role model pins and exclusions. + ModelPrefs specialist.ModelPreferences } // planParentTools is the run's grant: the tools a plan task may inherit. @@ -1177,7 +1199,14 @@ func registerSpecialistTools(registry *tools.Registry, workspaceRoot string, max if err != nil { return nil, err } - executor := specialist.Executor{Paths: paths} + executor := specialist.Executor{ + Paths: paths, + // The run's own roots, read at every launch. Without this a child is + // confined more tightly than its parent: a run granted a directory + // outside its workspace could create and populate it, then watch every + // plan task be refused at that same directory. + ExtraWriteRoots: wiring.ExtraWriteRoots, + } runtime, err := specialist.RegisterTools(registry, executor) if err != nil { return nil, err @@ -1211,15 +1240,18 @@ func registerSpecialistTools(registry *tools.Registry, workspaceRoot string, max planContext.SpecialistName = "explorer" } registry.Register(&specialist.OrchestrateTool{ - PostureActive: wiring.Gate.Active, - RunTask: specialist.NewPlanRunner(planContext), - Recorder: recorder, - ParentTools: planParentTools(registry, enabledTools, disabledTools), - Depth: planContext.Depth, - Size: wiring.Size, - Plans: wiring.Plans, - Launch: wiring.Launch, - Isolate: wiring.Isolate, + PostureActive: wiring.Gate.Active, + RunTask: specialist.NewPlanRunner(planContext), + Recorder: recorder, + ParentTools: planParentTools(registry, enabledTools, disabledTools), + Depth: planContext.Depth, + Size: wiring.Size, + Plans: wiring.Plans, + Launch: wiring.Launch, + Isolate: wiring.Isolate, + DiscoverModels: wiring.DiscoverModels, + ProbeModel: wiring.ProbeModel, + ModelPrefs: wiring.ModelPrefs, }) return &agentToolRuntime{specialist: runtime, swarm: sw, specialists: specialistSummaries(paths)}, nil } diff --git a/internal/cli/child_exit_code_agreement_test.go b/internal/cli/child_exit_code_agreement_test.go new file mode 100644 index 000000000..0dab2522f --- /dev/null +++ b/internal/cli/child_exit_code_agreement_test.go @@ -0,0 +1,28 @@ +package cli + +import "testing" + +// TWO DEFINITIONS OF ONE NUMBER, and this is the only defence against them +// drifting apart. +// +// internal/specialist decides whether to retry a task from the child's exit +// code — a structural signal rather than its prose. It cannot import this +// package to read exitIncomplete, because cli imports specialist and the +// dependency cannot run both ways, so the constant is duplicated there. +// +// If this file's exitIncomplete ever moves and specialist's copy does not, a +// declined task stops being recognised as declined and silently loses its one +// retry — with nothing failing anywhere. That is the exact shape of defect this +// session has produced repeatedly: a value that exists at one layer, is consumed +// at another, and quietly stops agreeing. +func TestTheChildIncompleteExitCodeAgreesWithWhatSpecialistExpects(t *testing.T) { + // Kept as a literal on purpose. Reading specialist's unexported constant is + // impossible from here, so this asserts the NUMBER both sides were written + // against; changing one without the other now fails. + const specialistChildExitIncomplete = 4 + if exitIncomplete != specialistChildExitIncomplete { + t.Fatalf("exitIncomplete is %d but internal/specialist retries declines on %d — "+ + "a declined plan task will no longer be recognised, and will silently lose its retry", + exitIncomplete, specialistChildExitIncomplete) + } +} diff --git a/internal/cli/exec.go b/internal/cli/exec.go index 8012ddf29..9be5b649f 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -237,6 +237,11 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in // no-op, so events before that point are simply not recorded — recording is // best-effort and must never be the thing that fails a run. planRecorder := &planSessionRecorder{} + // DECLARED HERE, built further down. The specialist wiring below closes over + // it so a child launched later is confined to the same roots this run holds; + // reading it at registration time would capture nil forever, which is the + // same reason planGate is a pointer. + var execScope *sandbox.Scope if shouldRegisterExecSpecialistTools(options) { // Specialist tools register before the full config resolve below (so // --list-tools stays offline). swarm.maxTeamSize is not affected by @@ -247,9 +252,17 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in // the same ceiling an unset value gets, never "no ceiling". A config // error must not be the thing that removes a bound. planSize := config.DefaultPlanSize + // The provider profile is captured alongside the other early config so + // auto_assign can list this provider's models. Hoisted out of the if + // because the wiring below needs it; the zero value simply means + // discovery is unavailable and a plan asking for it is told so. + var planProvider config.ProviderProfile + var planModelPrefs config.PlanModelsConfig if earlyCfg, cfgErr := deps.resolveConfig(workspaceRoot, config.Overrides{}); cfgErr == nil { maxTeamSize = earlyCfg.Swarm.MaxTeamSize planSize = earlyCfg.Profiles.PlanSizeTier() + planProvider = earlyCfg.Provider + planModelPrefs = earlyCfg.Profiles.PlanModels } var err error // The posture is fixed for a headless run, so the gate is set once here @@ -262,9 +275,29 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in // parent grant, so a task can never hold a tool this run was denied. specialistRuntime, err = registerSpecialistTools(registry, workspaceRoot, maxTeamSize, options.enabledTools, options.disabledTools, planRecorder, orchestrateWiring{ - Gate: planGate, + DiscoverModels: planModelDiscoverer(workspaceRoot, planProvider), + // Same live scope the run's own tools are confined by, so a child + // stands on the same ground rather than on less. + // + // A CLOSURE OVER THE VARIABLE, not over its value: the scope is + // built further down this function, and a child is launched long + // after both. Reading it here would capture nil forever — the same + // reason planGate above is a pointer. + ExtraWriteRoots: func() []string { + if execScope == nil { + return nil + } + return execScope.Roots() + }, + // Proves a model will actually run before a task is assigned it, + // so the plan never dispatches onto something the provider only + // advertises. + ProbeModel: planModelProber(workspaceRoot, planProvider, deps.newProvider), + ModelPrefs: planModelPreferences(planModelPrefs), + Gate: planGate, PlanContext: specialist.PlanTaskContext{ - Cwd: workspaceRoot, + PostureReasoningEffort: string(execprofile.Zeromaxing.ReasoningEffort), + Cwd: workspaceRoot, // Resolved permission mode is not available this early; the // executor applies its own fail-safe mapping from an empty mode // (never unsafe), so a plan task can never exceed the parent. @@ -368,7 +401,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in } var displacedMaxTurns int resolved.MaxTurns, displacedMaxTurns = applyProfileTurnBudget(execProfile, options.maxTurns, resolved.MaxTurns) - execScope, err := sandbox.NewScope(workspaceRoot, append(append([]string{}, resolved.Sandbox.AdditionalWriteRoots...), options.addDirs...)) + execScope, err = sandbox.NewScope(workspaceRoot, append(append([]string{}, resolved.Sandbox.AdditionalWriteRoots...), options.addDirs...)) if err != nil { return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error()) } diff --git a/internal/cli/exec_usage_cache_test.go b/internal/cli/exec_usage_cache_test.go new file mode 100644 index 000000000..13ae2bcb3 --- /dev/null +++ b/internal/cli/exec_usage_cache_test.go @@ -0,0 +1,88 @@ +package cli + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/agent" +) + +// THE CHILD MUST PUBLISH WHAT MAKES ITS TURN PRICEABLE. +// +// This writer is the only channel a parent has. Its session record already keeps +// cachedInputTokens / cacheWriteTokens / reasoningTokens so a turn can be costed +// exactly; the stream carried prompt/completion/total alone, so every sub-agent +// turn rolled up to its parent was priced as if nothing had been cached. +// +// A measured plan task had 49,280 of 49,894 prompt tokens served from cache — +// 98.8%. Plan tasks are the ideal cache case: one large stable prompt, re-sent +// every turn. +func TestTheChildPublishesCacheAndReasoningTokensOnTheUsageStream(t *testing.T) { + var stdout, stderr bytes.Buffer + writer := execEventWriter{ + stdout: &stdout, + stderr: &stderr, + format: execOutputStreamJSON, + runID: "run_usage", + streamedText: &strings.Builder{}, + } + writer.usage(agent.Usage{ + PromptTokens: 30000, + CompletionTokens: 500, + CachedInputTokens: 29000, + CacheWriteTokens: 1000, + ReasoningTokens: 120, + }) + if writer.err != nil { + t.Fatalf("usage: %v", writer.err) + } + + var payload map[string]any + if err := json.Unmarshal([]byte(strings.TrimSpace(stdout.String())), &payload); err != nil { + t.Fatalf("decode stream event %q: %v", stdout.String(), err) + } + for key, want := range map[string]float64{ + "cachedInputTokens": 29000, "cacheWriteTokens": 1000, "reasoningTokens": 120, + } { + got, ok := payload[key] + if !ok { + t.Errorf("the stream omits %q, so a parent prices this turn as uncached: %v", key, payload) + continue + } + if number, ok := got.(float64); !ok || number != want { + t.Errorf("%s: want %v, got %v", key, want, got) + } + } + // The three original fields must be exactly as before. + for key, want := range map[string]float64{ + "promptTokens": 30000, "completionTokens": 500, "totalTokens": 30500, + } { + if got, ok := payload[key].(float64); !ok || got != want { + t.Errorf("%s changed: want %v, got %v", key, want, payload[key]) + } + } +} + +// NON-ZERO ONLY, exactly like usage.EventUsagePayload. A provider reporting no +// cache must produce the same three-field event it always did, or every existing +// reader of this stream sees a shape it has never seen. +func TestAProviderWithNoCacheStillEmitsTheOriginalUsageShape(t *testing.T) { + var stdout, stderr bytes.Buffer + writer := execEventWriter{ + stdout: &stdout, stderr: &stderr, format: execOutputStreamJSON, + runID: "run_usage", streamedText: &strings.Builder{}, + } + writer.usage(agent.Usage{PromptTokens: 100, CompletionTokens: 10}) + + var payload map[string]any + if err := json.Unmarshal([]byte(strings.TrimSpace(stdout.String())), &payload); err != nil { + t.Fatalf("decode: %v", err) + } + for _, key := range []string{"cachedInputTokens", "cacheWriteTokens", "reasoningTokens"} { + if _, present := payload[key]; present { + t.Errorf("%q was emitted as a zero, widening the event for every provider that reports no cache", key) + } + } +} diff --git a/internal/cli/exec_writer.go b/internal/cli/exec_writer.go index 824b9c00f..76738c8fc 100644 --- a/internal/cli/exec_writer.go +++ b/internal/cli/exec_writer.go @@ -276,13 +276,28 @@ func (writer *execEventWriter) usage(usage agent.Usage) { promptTokens := usage.EffectiveInputTokens() completionTokens := usage.EffectiveOutputTokens() totalTokens := usage.TotalTokens() - writer.writeStreamJSON(streamjson.Event{ + event := streamjson.Event{ Type: streamjson.EventUsage, RunID: writer.runID, PromptTokens: &promptTokens, CompletionTokens: &completionTokens, TotalTokens: &totalTokens, - }) + } + // THE SAME FIELDS THE SESSION RECORD KEEPS. A parent summarising this + // stream is the only place a sub-agent's turn can be priced, and it was + // being handed prompt/completion/total alone — so every child turn was + // priced as fully uncached. Non-zero only, exactly like + // usage.EventUsagePayload, so the common shape is unchanged. + if cached := usage.CachedInputTokens; cached > 0 { + event.CachedInputTokens = &cached + } + if written := usage.CacheWriteTokens; written > 0 { + event.CacheWriteTokens = &written + } + if reasoning := usage.ReasoningTokens; reasoning > 0 { + event.ReasoningTokens = &reasoning + } + writer.writeStreamJSON(event) } } diff --git a/internal/cli/exec_zeromaxing_test.go b/internal/cli/exec_zeromaxing_test.go index a07b69584..87133d265 100644 --- a/internal/cli/exec_zeromaxing_test.go +++ b/internal/cli/exec_zeromaxing_test.go @@ -428,8 +428,8 @@ func TestPlanPartialMapsToIncompleteExit(t *testing.T) { plan, err := specialist.ParsePlan(map[string]any{ "name": "p", "tasks": []any{map[string]any{"id": "a", "prompt": "x"}}, - "budget": map[string]any{"max_workers": float64(1), "max_tokens": float64(10)}, - }, specialist.Limits{MaxTasks: 5, MaxTokens: 100}) + "budget": map[string]any{"max_workers": float64(1), "max_tokens": float64(100_000)}, + }, specialist.Limits{MaxTasks: 5}) if err != nil { t.Fatalf("ParsePlan: %v", err) } diff --git a/internal/cli/plan_grant_test.go b/internal/cli/plan_grant_test.go index 9fe2d0065..6a0056c4d 100644 --- a/internal/cli/plan_grant_test.go +++ b/internal/cli/plan_grant_test.go @@ -1,6 +1,7 @@ package cli import ( + "context" "strings" "testing" @@ -297,3 +298,87 @@ func TestAPlanNameBecomesAUsableWorktreeName(t *testing.T) { t.Fatalf("a long plan name produced a %d-character worktree name", len(got)) } } + +// THE HOOK MUST ACTUALLY BE WIRED. DiscoverModels is consulted only when a plan +// sets auto_assign, so a nil left in the wiring compiles, ships, and refuses +// every such plan with "not available in this run" — the feature present in the +// schema and reachable from nowhere. That is this branch's most repeated defect, +// and it is checked here rather than trusted. +func TestOrchestrateGetsAModelDiscoverer(t *testing.T) { + registry := tools.NewRegistry() + gate := &specialist.PostureGate{} + gate.Set(true) + workspace := t.TempDir() + + if _, err := registerSpecialistTools(registry, workspace, 0, nil, nil, nil, orchestrateWiring{ + Gate: gate, + PlanContext: specialist.PlanTaskContext{Cwd: workspace}, + DiscoverModels: planModelDiscoverer(t.TempDir(), config.ProviderProfile{Name: "test", Model: "gpt-4.1"}), + }); err != nil { + t.Fatalf("register: %v", err) + } + + tool, ok := registry.Get(specialist.OrchestrateToolName) + if !ok { + t.Fatal("the orchestrate tool was not registered") + } + orchestrate, ok := tool.(*specialist.OrchestrateTool) + if !ok { + t.Fatalf("registered tool is %T", tool) + } + if orchestrate.DiscoverModels == nil { + t.Error("DiscoverModels is nil on the registered tool; auto_assign would refuse every plan") + } +} + +// The user's model pins and exclusions must reach the tool. Configured and not +// wired, they are a config key that silently does nothing — the same shape as +// DiscoverModels above, which shipped nil. +func TestOrchestrateGetsTheConfiguredModelPreferences(t *testing.T) { + registry := tools.NewRegistry() + gate := &specialist.PostureGate{} + gate.Set(true) + workspace := t.TempDir() + + if _, err := registerSpecialistTools(registry, workspace, 0, nil, nil, nil, orchestrateWiring{ + Gate: gate, + PlanContext: specialist.PlanTaskContext{Cwd: workspace}, + ModelPrefs: planModelPreferences(config.PlanModelsConfig{ + Verify: "deepseek-v4-pro", + Exclude: []string{"grok-build-0.1"}, + }), + }); err != nil { + t.Fatalf("register: %v", err) + } + tool, _ := registry.Get(specialist.OrchestrateToolName) + orchestrate, ok := tool.(*specialist.OrchestrateTool) + if !ok { + t.Fatalf("registered tool is %T", tool) + } + if orchestrate.ModelPrefs.Verify != "deepseek-v4-pro" { + t.Errorf("the verify pin did not reach the tool: %+v", orchestrate.ModelPrefs) + } + if len(orchestrate.ModelPrefs.Exclude) != 1 { + t.Errorf("the exclusion list did not reach the tool: %+v", orchestrate.ModelPrefs) + } +} + +// And the adapter really produces the shape specialist chooses between, rather +// than an empty struct that would make every tier unassignable. +func TestTheModelDiscovererCarriesWhatAPlanChoosesBetween(t *testing.T) { + discover := planModelDiscoverer(t.TempDir(), config.ProviderProfile{Name: "test", Model: "gpt-4.1"}) + if discover == nil { + t.Fatal("adapter returned nil") + } + // The network call is expected to fail in a test environment; what matters is + // that a failure is REPORTED rather than silently yielding no models, which + // would make auto_assign a no-op nobody could diagnose. + models, err := discover(context.Background()) + if err == nil && len(models) > 0 { + for _, model := range models { + if strings.TrimSpace(model.ID) == "" { + t.Errorf("a discovered model arrived with no id: %+v", model) + } + } + } +} diff --git a/internal/cli/plan_live_provider_test.go b/internal/cli/plan_live_provider_test.go new file mode 100644 index 000000000..0c7b08102 --- /dev/null +++ b/internal/cli/plan_live_provider_test.go @@ -0,0 +1,96 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Gitlawb/zero/internal/config" +) + +func writeTwoProviderConfig(t *testing.T, active string) string { + t.Helper() + home := t.TempDir() + dir := filepath.Join(home, "zero") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + // The provider shape a real config uses — provider_kind + apiFormat matter: + // without them "xai" is read as an OpenAI profile and refused for not using + // the official base URL. + body := `{ + "activeProvider": "` + active + `", + "providers": [ + {"name": "ollama-cloud", "provider_kind": "openai-compatible", "catalogID": "ollama-cloud", + "baseURL": "https://ollama.com/v1", "apiFormat": "chat-completions", + "model": "glm-5.2", "apiKey": "k1"}, + {"name": "xai", "provider_kind": "openai-compatible", "catalogID": "xai", + "baseURL": "https://api.x.ai/v1", "apiFormat": "chat-completions", + "model": "grok-4.5", "apiKey": "k2"} + ] + }` + if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("XDG_CONFIG_HOME", home) + return home +} + +// DISCOVERY MUST FOLLOW AN IN-SESSION PROVIDER SWITCH, because child execution +// already does and the two must never describe different providers. +// +// The orchestrate tool is wired once at startup. The TUI's /model picker can +// switch provider afterwards; switchProviderModel re-points the parent client and +// exports ZERO_PROVIDER so spawned children follow. Before this, nothing +// re-pointed model discovery — a session on xai/grok-4.5 had its plan assigned +// from a nineteen-model Ollama list and the provider rejected every task. +func TestPlanDiscoveryFollowsAnInSessionProviderSwitch(t *testing.T) { + writeTwoProviderConfig(t, "ollama-cloud") + captured := config.ProviderProfile{Name: "ollama-cloud", BaseURL: "https://ollama.com/v1", Model: "glm-5.2"} + + // What switchProviderModel does when the user picks a model from another + // saved provider. + t.Setenv(config.ActiveProviderEnv, "xai") + + got := livePlanProvider(t.TempDir(), captured) + if got.Name != "xai" { + t.Fatalf("discovery stayed on the launch-time provider %q after a switch to xai", got.Name) + } + if got.BaseURL != "https://api.x.ai/v1" { + t.Errorf("the switched profile carries the wrong endpoint: %q", got.BaseURL) + } +} + +// THE CAPTURED PROFILE WINS WHEN NOTHING SWITCHED, and this is what keeps +// headless runs correct. --provider, --base-url and an inline --api-key are +// applied by the caller on top of Resolve, so re-resolving would silently discard +// them and probe the wrong endpoint with the wrong key. +func TestPlanDiscoveryKeepsFlagOverridesWhenTheProviderDidNotChange(t *testing.T) { + writeTwoProviderConfig(t, "xai") + // A profile that exists nowhere in the config file: exactly what a + // --base-url / --api-key override produces. + captured := config.ProviderProfile{ + Name: "xai", + BaseURL: "https://gateway.internal/v1", + Model: "grok-4.5", + APIKey: "flag-only-key", + } + os.Unsetenv(config.ActiveProviderEnv) + + got := livePlanProvider(t.TempDir(), captured) + if got.BaseURL != "https://gateway.internal/v1" || got.APIKey != "flag-only-key" { + t.Fatalf("re-resolution discarded the caller's overrides: %+v", got) + } +} + +// An unreadable or absent config must not blank the profile discovery probes +// with. Falling back to the captured one leaves behaviour exactly as it was. +func TestPlanDiscoveryFallsBackToTheCapturedProfileWhenResolutionFails(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "does-not-exist")) + os.Unsetenv(config.ActiveProviderEnv) + captured := config.ProviderProfile{Name: "xai", BaseURL: "https://api.x.ai/v1", Model: "grok-4.5"} + + if got := livePlanProvider(t.TempDir(), captured); got.Name != "xai" || got.BaseURL != captured.BaseURL { + t.Fatalf("a failed resolve must leave the captured profile intact, got %+v", got) + } +} diff --git a/internal/cli/plan_model_prefs_carry_test.go b/internal/cli/plan_model_prefs_carry_test.go new file mode 100644 index 000000000..696d19ea2 --- /dev/null +++ b/internal/cli/plan_model_prefs_carry_test.go @@ -0,0 +1,62 @@ +package cli + +import ( + "reflect" + "testing" + + "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/specialist" +) + +// EVERY CONFIGURED PREFERENCE MUST SURVIVE THE TRANSLATION, and this is checked +// by reflection rather than field by field on purpose. +// +// planModelPreferences is a hand-written copy between two structs that are +// deliberately kept apart, so specialist need not import config. Hand-written +// copies rot silently: a field gets added to both sides and to nothing in the +// middle, and the symptom is a setting the user wrote in their config having no +// effect — with nothing logged, nothing failing, and no way to tell it from the +// feature simply not working. RouterGuidance was added this way; the next one +// will be too. +// +// Naming the fields here would only pin today's set. Walking them means a new +// field is caught the moment it exists. +func TestEveryPlanModelPreferenceSurvivesTheCarryIntoSpecialist(t *testing.T) { + source := config.PlanModelsConfig{ + Scan: "scan-model", + Implement: "implement-model", + Verify: "verify-model", + Router: "router-model", + RouterGuidance: "prefer kimi for judgement", + AutoAssign: true, + Exclude: []string{"never-this"}, + } + carried := planModelPreferences(source) + + from := reflect.ValueOf(source) + to := reflect.ValueOf(carried) + toType := to.Type() + for i := 0; i < from.NumField(); i++ { + name := from.Type().Field(i).Name + if _, ok := toType.FieldByName(name); !ok { + // A config field with no counterpart is a deliberate decision — say so + // here when you make one, so the next reader knows it was not an + // oversight. Today every field has one. + t.Errorf("config field %q has no counterpart in specialist.ModelPreferences; "+ + "if that is intended, exempt it here with a reason", name) + continue + } + want := from.Field(i).Interface() + got := to.FieldByName(name).Interface() + if !reflect.DeepEqual(want, got) { + t.Errorf("%s was dropped or altered on the way into specialist: config had %#v, specialist got %#v", + name, want, got) + } + } + + // Guard the guard: a zero source would make every comparison trivially pass, + // so prove the fixture actually set something. + if reflect.DeepEqual(carried, specialist.ModelPreferences{}) { + t.Fatal("the fixture carried nothing, so this test proves nothing") + } +} diff --git a/internal/cli/plan_model_probe.go b/internal/cli/plan_model_probe.go new file mode 100644 index 000000000..90eb8e300 --- /dev/null +++ b/internal/cli/plan_model_probe.go @@ -0,0 +1,105 @@ +package cli + +import ( + "context" + "strings" + "time" + + "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/specialist" + "github.com/Gitlawb/zero/internal/zeroruntime" +) + +// probeTimeout bounds one model's proof. Generous enough for a cold model to +// load and short enough that proving nineteen of them concurrently costs one +// pause rather than a stall — and a model too slow to answer this is not one a +// plan wants either. +const probeTimeout = 20 * time.Second + +// planModelProber proves that a provider will actually RUN a model. +// +// DISCOVERY IS AN ADVERTISEMENT AND THIS IS THE RECEIPT. /v1/models reports what +// a provider is willing to name; only a real request reports what it will serve. +// Every model failure this feature has produced lived in that gap: +// grok-4.20-multi-agent-0309 lists like anything else and answers "Multi Agent +// requests are not allowed on chat completions"; a stale list from another +// provider lists perfectly and serves nothing. +// +// The request is deliberately the smallest real one: a single word, one token +// back. It proves the route end to end — credential, endpoint, model id, +// entitlement — which is exactly the set of things a list cannot prove. +func planModelProber(workspaceRoot string, profile config.ProviderProfile, newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)) specialist.ModelProber { + if newProvider == nil { + return nil + } + return func(ctx context.Context, modelID string) specialist.ModelProbeResult { + id := strings.TrimSpace(modelID) + if id == "" { + return specialist.ModelProbeResult{Verdict: specialist.ProbeUnknown} + } + // The LIVE profile, for the same reason discovery re-resolves it: a + // session that switched provider must be proved against the one it is + // actually on, not the one it started with. + target := livePlanProvider(workspaceRoot, profile) + target.Model = id + provider, err := newProvider(discoveryCredentialProfile(target)) + if err != nil { + // Could not even build a client: a fact about this run, not about + // this model. Unknown keeps the model. + return specialist.ModelProbeResult{Verdict: specialist.ProbeUnknown, Reason: err.Error()} + } + + probeCtx, cancel := context.WithTimeout(ctx, probeTimeout) + defer cancel() + stream, err := provider.StreamCompletion(probeCtx, zeroruntime.CompletionRequest{ + Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "ping"}}, + }) + if err != nil { + return specialist.ClassifyProbeError(err) + } + collected := zeroruntime.CollectStream(probeCtx, stream) + if collected.Error != "" { + // A refusal usually arrives HERE rather than from StreamCompletion: + // the request is accepted, the stream opens, and the provider's + // complaint about the model comes back as the first event. Reading + // only the call's own error is how "the model does not exist" was + // mistaken for a working model. + return specialist.ClassifyProbeError(errStub(collected.Error)) + } + return specialist.ModelProbeResult{Verdict: specialist.ProbeServes} + } +} + +// errStub carries a provider's message into the classifier, which takes an error +// because every other caller has one. +type errStub string + +func (e errStub) Error() string { return string(e) } + +// probeStatusWord names a verdict for a person. The three words are deliberately +// distinct: "unreachable" is a fact about the connection, not about the model, +// and reading it as a refusal is how a working model gets dropped on a flaky +// network. +func probeStatusWord(v specialist.ModelProbeVerdict) string { + switch v { + case specialist.ProbeServes: + return "serves" + case specialist.ProbeRefuses: + return "refuses" + default: + return "unreachable" + } +} + +// firstProbeLine keeps a provider's complaint to one readable line; these arrive +// as multi-line JSON often enough to matter. +func firstProbeLine(s string) string { + if index := strings.IndexAny(s, "\r\n"); index >= 0 { + s = s[:index] + } + const limit = 120 + if len(s) > limit { + s = s[:limit] + "…" + } + return strings.TrimSpace(s) +} diff --git a/internal/cli/provider_models.go b/internal/cli/provider_models.go index 542ad1207..64d06d53d 100644 --- a/internal/cli/provider_models.go +++ b/internal/cli/provider_models.go @@ -6,16 +6,21 @@ import ( "io" "os" "strings" + "sync" "github.com/Gitlawb/zero/internal/config" "github.com/Gitlawb/zero/internal/providermodelcatalog" "github.com/Gitlawb/zero/internal/providermodeldiscovery" "github.com/Gitlawb/zero/internal/providers" + "github.com/Gitlawb/zero/internal/specialist" ) type providerModelsOptions struct { name string json bool + // verify asks the provider to actually RUN each model, rather than trusting + // that listing it means it works. + verify bool } // runProvidersModels lists the models a saved provider actually serves by probing @@ -66,6 +71,37 @@ func runProvidersModels(args []string, stdout io.Writer, stderr io.Writer, deps models = filtered } + // PROVED, not merely listed. /v1/models is an advertisement: every model that + // has broken a real plan appeared on it looking ordinary — one refused at + // chat completions, one belonging to a provider the session had switched away + // from, two that fail every task they touch. Only a real request settles it. + verdicts := map[string]specialist.ModelProbeResult{} + if options.verify { + workspaceRoot, wderr := deps.getwd() + if wderr != nil { + return writeAppError(stderr, "resolve working directory: "+wderr.Error(), exitCrash) + } + probe := planModelProber(workspaceRoot, profile, deps.newProvider) + if probe == nil { + return writeAppError(stderr, "this build cannot open provider connections, so nothing can be proved", exitCrash) + } + results := make([]specialist.ModelProbeResult, len(models)) + var wait sync.WaitGroup + // CONCURRENT: nineteen models one after another is a minute of staring at + // nothing, and they are independent. + for index, model := range models { + wait.Add(1) + go func(index int, id string) { + defer wait.Done() + results[index] = probe(ctx, id) + }(index, model.ID) + } + wait.Wait() + for index, model := range models { + verdicts[model.ID] = results[index] + } + } + if options.json { items := make([]map[string]any, 0, len(models)) for _, model := range models { @@ -73,6 +109,12 @@ func runProvidersModels(args []string, stdout io.Writer, stderr io.Writer, deps if description := strings.TrimSpace(model.Description); description != "" { entry["description"] = description } + if verdict, ok := verdicts[model.ID]; ok { + entry["status"] = probeStatusWord(verdict.Verdict) + if reason := strings.TrimSpace(verdict.Reason); reason != "" { + entry["details"] = reason + } + } items = append(items, entry) } payload := map[string]any{ @@ -93,15 +135,56 @@ func runProvidersModels(args []string, stdout io.Writer, stderr io.Writer, deps if _, err := fmt.Fprintf(stdout, "Provider models (%s)\n", name); err != nil { return exitCrash } + var serves, refuses, unreachable int for _, model := range models { line := strings.TrimSpace(model.ID) - if description := strings.TrimSpace(model.Description); description != "" { + if verdict, ok := verdicts[model.ID]; ok { + // The VERDICT LEADS, because it is the reason to run this at all: a + // reader scanning for what is wrong should not have to reach the end + // of each line to find out. + switch verdict.Verdict { + case specialist.ProbeServes: + serves++ + line = "ok " + line + case specialist.ProbeRefuses: + refuses++ + line = "REFUSES " + line + default: + unreachable++ + line = "? " + line + } + if verdict.Verdict != specialist.ProbeServes { + if reason := strings.TrimSpace(verdict.Reason); reason != "" { + line += " — " + firstProbeLine(reason) + } + } + } else if description := strings.TrimSpace(model.Description); description != "" { line += " — " + description } if _, err := fmt.Fprintln(stdout, line); err != nil { return exitCrash } } + if options.verify { + if _, err := fmt.Fprintf(stdout, "\n%d serve, %d refuse, %d could not be reached.\n", + serves, refuses, unreachable); err != nil { + return exitCrash + } + if refuses > 0 { + // The point of --verify, stated plainly: these are the ids that would + // otherwise be found mid-plan, one dead task at a time. + if _, err := fmt.Fprintln(stdout, "A model that REFUSES is listed by this provider but will not run. "+ + "Plans skip these automatically — nothing needs excluding by hand."); err != nil { + return exitCrash + } + } + if unreachable > 0 { + if _, err := fmt.Fprintln(stdout, "A model that could not be reached is left in place: "+ + "that is a fact about the connection, not about the model."); err != nil { + return exitCrash + } + } + } suffix := "s" if len(models) == 1 { suffix = "" @@ -117,6 +200,45 @@ func runProvidersModels(args []string, stdout io.Writer, stderr io.Writer, deps return exitSuccess } +// livePlanProvider answers WHICH provider this plan's models should be listed +// from, at the moment the plan runs rather than at the moment the tool was built. +// +// THE CAPTURED PROFILE GOES STALE. The orchestrate tool is wired once, at +// startup, from the provider active then; the TUI's /model picker can switch +// provider mid-session, and switchProviderModel re-points the parent client and +// exports ZERO_PROVIDER so spawned children follow. Nothing re-pointed this. The +// result was a session running xai/grok-4.5 whose plan was handed nineteen Ollama +// model ids — the router assigned them, the children were dispatched with them, +// and the provider rejected every one. +// +// So it re-resolves through the SAME path a freshly spawned child takes: +// config plus ZERO_PROVIDER. Discovery and child execution now read one source of +// truth, which is stronger than keeping two in sync — they cannot drift apart +// again because there is no longer a second place to update. +// +// THE CAPTURED PROFILE WINS WHEN THE NAME MATCHES, and that is not an +// optimisation. It carries what re-resolution cannot see: --provider, --base-url +// and an inline --api-key are applied by the caller ON TOP of Resolve, so a +// headless run that never switches must keep exactly the profile it was given. +// Only a genuine change of provider — the one case that broke — takes the new one. +func livePlanProvider(workspaceRoot string, captured config.ProviderProfile) config.ProviderProfile { + options, err := config.DefaultResolveOptions(workspaceRoot) + if err != nil { + return captured + } + // Env deliberately left nil: config.Resolve then reads the live process + // environment, which is where switchProviderModel wrote ZERO_PROVIDER. + resolved, err := config.Resolve(options) + if err != nil { + return captured + } + if strings.TrimSpace(resolved.Provider.Name) == "" || + strings.EqualFold(strings.TrimSpace(resolved.Provider.Name), strings.TrimSpace(captured.Name)) { + return captured + } + return resolved.Provider +} + // discoveryCredentialProfile resolves the profile's API key the same way the // runtime does — inline, then the stored credential, then the configured env var — // so a `providers models` probe authenticates exactly like a real request. Mirrors @@ -155,6 +277,8 @@ func parseProviderModelsArgs(args []string) (providerModelsOptions, bool, error) return options, true, nil case arg == "--json": options.json = true + case arg == "--verify": + options.verify = true case strings.HasPrefix(arg, "-"): return options, false, execUsageError{fmt.Sprintf("unknown flag %q", arg)} default: @@ -166,3 +290,47 @@ func parseProviderModelsArgs(args []string) (providerModelsOptions, bool, error) } return options, false, nil } + +// planModelDiscoverer adapts provider discovery for the orchestrate tool's +// auto_assign. +// +// THE ADAPTER LIVES HERE, not in specialist, and that is the point of the +// narrower type. internal/specialist runs on the child-execution path; importing +// config and providercatalog to describe a model would drag the whole provider +// stack in behind it. The surface that already holds a provider profile does the +// translation and hands over the four facts a plan actually chooses between. +func planModelDiscoverer(workspaceRoot string, profile config.ProviderProfile) specialist.ModelDiscoverer { + return func(ctx context.Context) ([]specialist.DiscoveredModel, error) { + found, err := defaultDiscoverProviderModels(ctx, discoveryCredentialProfile(livePlanProvider(workspaceRoot, profile))) + if err != nil { + return nil, err + } + out := make([]specialist.DiscoveredModel, 0, len(found)) + for _, model := range found { + out = append(out, specialist.DiscoveredModel{ + ID: model.ID, + Description: model.Description, + ToolCall: model.ToolCall, + Reasoning: model.Reasoning, + InputCost: model.InputCost, + OutputCost: model.OutputCost, + OutputModalities: model.OutputModalities, + }) + } + return out, nil + } +} + +// planModelPreferences carries the configured per-role pins and exclusions into +// specialist's own narrow type, so that package keeps its distance from config. +func planModelPreferences(cfg config.PlanModelsConfig) specialist.ModelPreferences { + return specialist.ModelPreferences{ + Scan: cfg.Scan, + Implement: cfg.Implement, + Verify: cfg.Verify, + Exclude: cfg.Exclude, + Router: cfg.Router, + RouterGuidance: cfg.RouterGuidance, + AutoAssign: cfg.AutoAssign, + } +} From c80a5185aed0e390bb3d0171731e9bc13fa36c50 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:40:04 +0530 Subject: [PATCH 80/86] feat(tui): show which model ran a task, and what a plan left undone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The surface a person reads was the last to learn what the plan already knew. THE MODEL THAT RAN, NOT THE ONE THAT WAS CHOSEN. A card's model is set once at dispatch from the assigned model, and a task whose model the provider refuses is re-run on the session's — so the row went on naming a model that never executed it. The plan report the MODEL reads was corrected first; this is the half a person looks at. A refused model is still named, because it stays in the provider's list and the next plan will pick it again. WHY WORK REMAINS, on resume. A plan that ran to the end with failures and one stopped partway both leave tasks behind, and they call for different decisions: re-running things that already failed, or finishing work that never started. PlanProgress.Complete distinguished them and was recorded, never read. WHAT A BUDGET CUT SHORT, in the headline rather than in per-row text. A measured run came back missing a third of its questions and nothing above the fold said which — an incomplete answer that does not announce itself gets used as a complete one. Also removes a workers count that rode planAdmittedMsg for planRunningCardKey, which was deleted when per-task progress began carrying its own task id. The field outlived its only consumer and its comment still sent readers to a function that no longer exists. --- internal/tui/model.go | 12 +- internal/tui/orchestrate_panel.go | 30 +++- internal/tui/orchestrate_panel_test.go | 34 ++-- internal/tui/orchestrate_saved.go | 14 +- internal/tui/orchestrate_window_test.go | 6 +- internal/tui/plan_card_regression_test.go | 2 +- internal/tui/plan_fallback_display_test.go | 172 +++++++++++++++++++++ internal/tui/plan_messages.go | 22 ++- internal/tui/plan_progress.go | 45 ++++-- internal/tui/plan_progress_test.go | 31 +++- internal/tui/sidebar.go | 6 + internal/tui/sidebar_plan_detail.go | 13 ++ internal/tui/sidebar_plan_detail_test.go | 4 +- internal/tui/sidebar_plan_test.go | 6 +- internal/tui/sidebar_test.go | 46 ++++++ internal/tui/specialist_card.go | 16 ++ 16 files changed, 403 insertions(+), 56 deletions(-) create mode 100644 internal/tui/plan_fallback_display_test.go diff --git a/internal/tui/model.go b/internal/tui/model.go index ecb3bb0b2..3e6b27645 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -2711,8 +2711,9 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { if !msg.background && msg.runID != m.activeRunID { return m, nil } - m.orchestrate.markStarted(msg.taskID, msg.summary, msg.cardKey, m.now()) + m.orchestrate.markStarted(msg.taskID, msg.summary, msg.cardKey, msg.model, m.now()) m.specialists.start(msg.taskID, msg.summary, msg.cardKey, m.now()) + m.specialists.setModel(msg.cardKey, msg.model) return m, nil case planTaskDoneMsg: // A BACKGROUND plan outlives the run that launched it, so the @@ -2722,7 +2723,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { if !msg.background && msg.runID != m.activeRunID { return m, nil } - m.orchestrate.markDone(msg.taskID, msg.outcome, msg.tokens, msg.attempts, m.now()) + m.orchestrate.markDoneOn(msg.taskID, msg.outcome, msg.model, msg.fellBackFrom, msg.tokens, msg.attempts, m.now()) cardKey := msg.cardKey if !msg.dispatched { // Never started, so it has no card. Give it its own key and open one @@ -2734,6 +2735,13 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m.specialists.complete(cardKey, msg.status, 0, msg.reason, m.now()) m.specialists.setTokens(cardKey, msg.tokens) m.specialists.setResult(cardKey, msg.output) + // WHAT IT RAN ON, not what it was dispatched with. set at start from the + // assigned model; a provider refusal re-runs on the session's and arrives + // here empty. Without this write the AGENTS row keeps naming the refused + // model after the PLAN row has already stopped claiming it. + if msg.fellBackFrom != "" || msg.model != "" { + m.specialists.setModel(cardKey, msg.model) + } if msg.sessionID != "" && msg.sessionID != cardKey { // The expansion follows the rename. It is keyed by the card id, and // finishing swaps that for the child's real session id — so a row diff --git a/internal/tui/orchestrate_panel.go b/internal/tui/orchestrate_panel.go index eba13146b..21af12a58 100644 --- a/internal/tui/orchestrate_panel.go +++ b/internal/tui/orchestrate_panel.go @@ -58,6 +58,14 @@ type orchestrateTask struct { // the retry happens INSIDE the executor — one dispatch, one card — so // without this the panel shows a task that silently took twice as long. attempts int + // model is what the task runs on, empty when it inherits the session's. Shown + // only when set: a line saying "on " against + // every task buries the one that differs. + model string + // fellBackFrom names the model this task was assigned and could not run on. + // Empty for the overwhelming majority; set only after a provider refused the + // assigned model and the task was re-run on the session's. + fellBackFrom string // cardKey links this task to its live agent state in the specialist // tracker: the temporary key while it runs, the child's real session id // once it finishes. The detail pane reads tool counts, the current tool and @@ -148,7 +156,7 @@ func (s *orchestratePanelState) computeDepths() { } } -func (s *orchestratePanelState) markStarted(taskID, summary, cardKey string, now time.Time) { +func (s *orchestratePanelState) markStarted(taskID, summary, cardKey, model string, now time.Time) { index, ok := s.byID[taskID] if !ok { return @@ -156,6 +164,9 @@ func (s *orchestratePanelState) markStarted(taskID, summary, cardKey string, now s.tasks[index].status = orchestrateRunning s.tasks[index].summary = summary s.tasks[index].startedAt = now + if strings.TrimSpace(model) != "" { + s.tasks[index].model = model + } if cardKey != "" { s.tasks[index].cardKey = cardKey } @@ -170,6 +181,16 @@ func (s *orchestratePanelState) linkCard(taskID, cardKey string) { } func (s *orchestratePanelState) markDone(taskID, outcome string, tokens, attempts int, now time.Time) { + s.markDoneOn(taskID, outcome, "", "", tokens, attempts, now) +} + +// markDoneOn is markDone knowing WHICH MODEL ACTUALLY RAN. +// +// ranOn is the model the task finished on and is authoritative: empty means it +// inherited the session's, which is exactly what a fallback produces. Correcting +// it here is the difference between a row that names the model that worked and +// one that names the model that was refused. +func (s *orchestratePanelState) markDoneOn(taskID, outcome, ranOn, fellBackFrom string, tokens, attempts int, now time.Time) { index, ok := s.byID[taskID] if !ok { return @@ -183,6 +204,13 @@ func (s *orchestratePanelState) markDone(taskID, outcome string, tokens, attempt task := &s.tasks[index] task.status = orchestrateStatusFromOutcome(outcome) task.attempts = attempts + if strings.TrimSpace(fellBackFrom) != "" { + // Assigned one model, ran on another. Both facts are shown: the row must + // stop claiming the refused model, and must still name it — an unusable + // model stays in the provider's list and the next plan picks it again. + task.model = strings.TrimSpace(ranOn) + task.fellBackFrom = strings.TrimSpace(fellBackFrom) + } task.endedAt = now if task.startedAt.IsZero() { // Never dispatched: it has no duration, and pretending otherwise would diff --git a/internal/tui/orchestrate_panel_test.go b/internal/tui/orchestrate_panel_test.go index be2eda61b..5b402675d 100644 --- a/internal/tui/orchestrate_panel_test.go +++ b/internal/tui/orchestrate_panel_test.go @@ -150,9 +150,9 @@ func TestPanelTracksEveryOutcome(t *testing.T) { m := admittedModel(t, diamondAdmitted()) now := m.now() - m.orchestrate.markStarted("a", "root", "", now) + m.orchestrate.markStarted("a", "root", "", "", now) m.orchestrate.markDone("a", "succeeded", 0, 1, now.Add(time.Second)) - m.orchestrate.markStarted("b", "left", "", now.Add(time.Second)) + m.orchestrate.markStarted("b", "left", "", "", now.Add(time.Second)) m.orchestrate.markDone("b", "failed", 0, 1, now.Add(2*time.Second)) m.orchestrate.markDone("c", "cancelled", 0, 1, now.Add(2*time.Second)) m.orchestrate.markDone("d", "dependency_failed", 0, 1, now.Add(2*time.Second)) @@ -177,7 +177,7 @@ func TestPanelTracksEveryOutcome(t *testing.T) { func TestCancelledPlanIsNotShownAsFailures(t *testing.T) { m := admittedModel(t, diamondAdmitted()) now := m.now() - m.orchestrate.markStarted("a", "root", "", now) + m.orchestrate.markStarted("a", "root", "", "", now) m.orchestrate.markDone("a", "succeeded", 0, 1, now) for _, id := range []string{"b", "c", "d"} { m.orchestrate.markDone(id, "cancelled", 0, 1, now) @@ -231,7 +231,7 @@ func TestALargePlanIsBoundedAndSaysWhatItHid(t *testing.T) { // Narrow terminals must not panic or produce garbage. func TestPanelSurvivesNarrowWidths(t *testing.T) { m := admittedModel(t, diamondAdmitted()) - m.orchestrate.markStarted("a", strings.Repeat("verbose ", 40), "", m.now()) + m.orchestrate.markStarted("a", strings.Repeat("verbose ", 40), "", "", m.now()) for _, width := range []int{0, 1, 10, 24, 40, 200} { rendered := m.renderOrchestratePanel(width) if rendered == "" { @@ -248,7 +248,7 @@ func TestPanelSurvivesNarrowWidths(t *testing.T) { // A task with no output must still render — the panel shows status, not results. func TestATaskWithNoSummaryStillRenders(t *testing.T) { m := admittedModel(t, diamondAdmitted()) - m.orchestrate.markStarted("a", "", "", m.now()) + m.orchestrate.markStarted("a", "", "", "", m.now()) rendered := m.renderOrchestratePanel(100) if !strings.Contains(rendered, " a ") { t.Fatalf("a task with no summary vanished from the panel:\n%s", rendered) @@ -260,7 +260,7 @@ func TestATaskWithNoSummaryStillRenders(t *testing.T) { // runes. func TestPanelTruncatesSummariesOnRuneBoundaries(t *testing.T) { m := admittedModel(t, diamondAdmitted()) - m.orchestrate.markStarted("a", strings.Repeat("日", 300), "", m.now()) + m.orchestrate.markStarted("a", strings.Repeat("日", 300), "", "", m.now()) rendered := m.renderOrchestratePanel(100) for _, line := range strings.Split(rendered, "\n") { if len([]rune(line)) > 140 { @@ -310,7 +310,7 @@ func TestTheHeaderClockStopsWhenThePlanEnds(t *testing.T) { func TestAnInterruptedPlansClockFreezesWithTheRun(t *testing.T) { m := admittedModel(t, diamondAdmitted()) start := m.now() - m.orchestrate.markStarted("a", "root", "", start) + m.orchestrate.markStarted("a", "root", "", "", start) m.orchestrate.frozenAt = start.Add(5 * time.Second) m.activeRunID = 0 @@ -563,9 +563,9 @@ func TestFinishedTasksFadeOutOfThePanel(t *testing.T) { m := admittedModel(t, diamondAdmitted()) start := m.now() - m.orchestrate.markStarted("a", "root", "", start) + m.orchestrate.markStarted("a", "root", "", "", start) m.orchestrate.markDone("a", "succeeded", 0, 1, start) - m.orchestrate.markStarted("b", "left", "", start) + m.orchestrate.markStarted("b", "left", "", "", start) // Immediately after finishing, a is still there — you have to be able to // SEE it land. @@ -590,7 +590,7 @@ func TestFinishedTasksFadeOutOfThePanel(t *testing.T) { func TestAFadedTaskIsStillCountedAndStillInPlans(t *testing.T) { m := admittedModel(t, diamondAdmitted()) start := m.now() - m.orchestrate.markStarted("a", "root", "", start) + m.orchestrate.markStarted("a", "root", "", "", start) m.orchestrate.markDone("a", "succeeded", 0, 1, start) m.now = func() time.Time { return start.Add(orchestrateTaskLinger + time.Second) } @@ -630,12 +630,12 @@ func TestTheBudgetLineCountsDuringTheRun(t *testing.T) { m := admittedModel(t, diamondAdmitted()) now := m.now() - m.orchestrate.markStarted("a", "root", "", now) + m.orchestrate.markStarted("a", "root", "", "", now) m.orchestrate.markDone("a", "succeeded", 16470, 1, now) if m.orchestrate.tokensUsed != 16470 { t.Fatalf("tokensUsed = %d after one task, want it counted as it finishes", m.orchestrate.tokensUsed) } - m.orchestrate.markStarted("b", "left", "", now) + m.orchestrate.markStarted("b", "left", "", "", now) m.orchestrate.markDone("b", "succeeded", 68483, 1, now) rendered := m.renderOrchestratePanel(100) @@ -650,7 +650,7 @@ func TestTheBudgetLineCountsDuringTheRun(t *testing.T) { func TestThePlansOwnTotalWinsAtTheEnd(t *testing.T) { m := admittedModel(t, diamondAdmitted()) now := m.now() - m.orchestrate.markStarted("a", "root", "", now) + m.orchestrate.markStarted("a", "root", "", "", now) m.orchestrate.markDone("a", "succeeded", 100, 1, now) m.orchestrate.complete(planCompletedMsg{status: "partial", tokensUsed: 379773}, now) @@ -663,7 +663,7 @@ func TestThePlansOwnTotalWinsAtTheEnd(t *testing.T) { func TestAMissingFinalTotalDoesNotZeroTheCount(t *testing.T) { m := admittedModel(t, diamondAdmitted()) now := m.now() - m.orchestrate.markStarted("a", "root", "", now) + m.orchestrate.markStarted("a", "root", "", "", now) m.orchestrate.markDone("a", "succeeded", 4242, 1, now) m.orchestrate.complete(planCompletedMsg{status: "completed"}, now) @@ -866,7 +866,7 @@ func TestTheBarDoesNotCountRunningTasksAsProgress(t *testing.T) { running := admit() for i := 0; i < 4; i++ { - running.orchestrate.markStarted(fmt.Sprintf("t%d", i), "s", "", now) + running.orchestrate.markStarted(fmt.Sprintf("t%d", i), "s", "", "", now) } bar := plainRender(t, sidebarProgressBar(running.orchestrate, 36)) if strings.Contains(bar, "█") { @@ -882,10 +882,10 @@ func TestTheBarDoesNotCountRunningTasksAsProgress(t *testing.T) { // A finished task IS solid, so the two are told apart at a glance. mixed := admit() for i := 0; i < 3; i++ { - mixed.orchestrate.markStarted(fmt.Sprintf("t%d", i), "s", "", now) + mixed.orchestrate.markStarted(fmt.Sprintf("t%d", i), "s", "", "", now) mixed.orchestrate.markDone(fmt.Sprintf("t%d", i), "succeeded", 0, 1, now) } - mixed.orchestrate.markStarted("t3", "s", "", now) + mixed.orchestrate.markStarted("t3", "s", "", "", now) bar = plainRender(t, sidebarProgressBar(mixed.orchestrate, 36)) if !strings.Contains(bar, "█") || !strings.Contains(bar, "▓") { t.Errorf("three done and one running must show both marks: %q", bar) diff --git a/internal/tui/orchestrate_saved.go b/internal/tui/orchestrate_saved.go index 8a49440ff..dda67d572 100644 --- a/internal/tui/orchestrate_saved.go +++ b/internal/tui/orchestrate_saved.go @@ -400,7 +400,17 @@ func (m model) resumeSavedPlan(stored specialist.SavedPlan) (name string, notice if _, err := specialist.SavePlan(dir, resumeName, remaining); err != nil { return "", planControlNotice("warning", "Could not stage the remaining plan: "+err.Error()), false } + // WHY there is work left, not just how much. A plan that RAN TO THE END with + // failures and one that was stopped partway both leave tasks behind, and + // they call for different decisions: the first means re-running things that + // already failed once, the second means finishing work that never started. + // PlanProgress.Complete is the only thing that distinguishes them, and it + // was recorded and never read. + why := "the plan was interrupted before it finished" + if progress.Complete { + why = "the plan ran to the end and these did not succeed" + } return resumeName, planControlNotice("info", fmt.Sprintf( - "Resuming %q: %d of %d tasks already succeeded, %d left.\nSaved the remainder as %q — /plans show %s to read it first.", - stored.Name, len(progress.Succeeded), len(progress.Order), remaining.TaskCount(), resumeName, resumeName)), true + "Resuming %q: %d of %d tasks already succeeded, %d left — %s.\nSaved the remainder as %q — /plans show %s to read it first.", + stored.Name, len(progress.Succeeded), len(progress.Order), remaining.TaskCount(), why, resumeName, resumeName)), true } diff --git a/internal/tui/orchestrate_window_test.go b/internal/tui/orchestrate_window_test.go index d34cb9991..3acd5cf0e 100644 --- a/internal/tui/orchestrate_window_test.go +++ b/internal/tui/orchestrate_window_test.go @@ -21,7 +21,7 @@ func longPlanModel(t *testing.T, n int) model { // progress. func TestTheWindowFollowsTheRunningTask(t *testing.T) { m := longPlanModel(t, 40) - m.orchestrate.markStarted("t30", "working", "k30", m.now()) + m.orchestrate.markStarted("t30", "working", "k30", "", m.now()) rendered := m.renderOrchestratePanel(100) if !strings.Contains(rendered, "t30") { @@ -63,7 +63,7 @@ func TestTheWindowFollowsTheSelectionWhenNothingRuns(t *testing.T) { func TestARunningTaskOutranksTheSelectionForTheWindow(t *testing.T) { m := longPlanModel(t, 40) m.orchestrateSelected = 1 - m.orchestrate.markStarted("t30", "working", "k30", m.now()) + m.orchestrate.markStarted("t30", "working", "k30", "", m.now()) rows, _, _ := m.orchestrateVisibleRows(6) var sawRunning bool @@ -97,7 +97,7 @@ func TestAShortPlanIsNotWindowed(t *testing.T) { // like before anyone notices. func TestTheSidebarListAndItsHitTableSeeTheSameTasks(t *testing.T) { m := longPlanModel(t, 40) - m.orchestrate.markStarted("t30", "working", "k30", m.now()) + m.orchestrate.markStarted("t30", "working", "k30", "", m.now()) m.width = 160 drawn, _, _ := m.orchestrateVisibleRows(maxSidebarOrchestrateLines) diff --git a/internal/tui/plan_card_regression_test.go b/internal/tui/plan_card_regression_test.go index 19fb4990b..c27a9d174 100644 --- a/internal/tui/plan_card_regression_test.go +++ b/internal/tui/plan_card_regression_test.go @@ -94,7 +94,7 @@ func TestAPlanTasksSpendReachesItsCard(t *testing.T) { func TestTheSidebarDoesNotDenyARunningPlan(t *testing.T) { m := model{now: func() time.Time { return time.Unix(1000, 0) }} m.orchestrate.admit(diamondAdmitted(), m.now()) - m.orchestrate.markStarted("a", "root", "", m.now()) + m.orchestrate.markStarted("a", "root", "", "", m.now()) // Drives the REAL sidebar assembly, not the line helper: the helper // returning the right string proves nothing if the PLAN section never calls diff --git a/internal/tui/plan_fallback_display_test.go b/internal/tui/plan_fallback_display_test.go new file mode 100644 index 000000000..b36a3bc8c --- /dev/null +++ b/internal/tui/plan_fallback_display_test.go @@ -0,0 +1,172 @@ +package tui + +import ( + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/Gitlawb/zero/internal/specialist" +) + +// THE ROW MUST NAME THE MODEL THAT RAN, not the one that was chosen. +// +// A task's model is set once at DISPATCH, from the model auto-assignment picked. +// When the provider refuses that model the executor re-runs the task on the +// session's — and the card went on displaying the refused one. The plan report +// the MODEL reads was corrected; the sidebar a PERSON reads was not, which is +// the wrong half to get right. +func TestTheSidebarNamesTheModelThatRanNotTheOneThatWasRefused(t *testing.T) { + now := time.Now() + state := &orchestratePanelState{} + state.admit(planAdmittedMsg{name: "p", tasks: []planGraphTask{{id: "config-overrides"}}}, now) + state.markStarted("config-overrides", "survey config precedence", "card_1", + "grok-4.20-multi-agent-0309", now) + + // Dispatched on the assigned model, finished on the session's. + state.markDoneOn("config-overrides", string(specialist.TaskSucceeded), + "", "grok-4.20-multi-agent-0309", 1200, 2, now.Add(time.Second)) + + task := state.tasks[state.byID["config-overrides"]] + if task.model == "grok-4.20-multi-agent-0309" { + t.Error("the row still claims the model the provider refused") + } + if task.fellBackFrom != "grok-4.20-multi-agent-0309" { + t.Errorf("the refused model was not recorded for display: %q", task.fellBackFrom) + } +} + +// An ordinary task must be untouched: markDone is the overwhelming majority of +// calls and a fallback is rare, so nothing about the common row may change. +func TestAnOrdinaryTaskKeepsItsAssignedModelOnCompletion(t *testing.T) { + now := time.Now() + state := &orchestratePanelState{} + state.admit(planAdmittedMsg{name: "p", tasks: []planGraphTask{{id: "t"}}}, now) + state.markStarted("t", "work", "card_1", "grok-4.5", now) + state.markDone("t", string(specialist.TaskSucceeded), 900, 1, now.Add(time.Second)) + + task := state.tasks[state.byID["t"]] + if task.model != "grok-4.5" { + t.Errorf("a normal task lost its model on completion: %q", task.model) + } + if task.fellBackFrom != "" { + t.Errorf("a normal task was marked as a fallback: %q", task.fellBackFrom) + } +} + +// The refused model is shown, because it stays in the provider's list and the +// next plan will choose it again unless someone excludes it. +func TestTheRefusedModelIsRenderedInTheTaskDetail(t *testing.T) { + m := model{now: func() time.Time { return time.Unix(1000, 0) }} + now := m.now() + m.orchestrate.admit(planAdmittedMsg{name: "p", tasks: []planGraphTask{{id: "t"}}}, now) + m.orchestrate.markStarted("t", "work", "k1", "grok-4.20-multi-agent-0309", now) + m.orchestrate.markDoneOn("t", string(specialist.TaskSucceeded), + "", "grok-4.20-multi-agent-0309", 10, 2, now) + m.orchestrate.linkCard("t", "k1") + m.orchestrateSelected = 0 + m.width, m.height = 140, 40 + m.altScreen = true + + rendered := strings.Join(m.sidebarPlanDetailLines(60, 40), "\n") + if !strings.Contains(rendered, "grok-4.20-multi-agent-0309") { + t.Errorf("the refused model is invisible to the user:\n%s", rendered) + } + if !strings.Contains(rendered, "would not run") { + t.Errorf("the detail does not say the model was refused:\n%s", rendered) + } +} + +// THE BRIDGE MUST ACTUALLY CARRY IT. The three tests above drive markDoneOn +// directly, which proves the panel handles a fallback and proves nothing about +// whether anything ever tells it one happened — the "assert the helper, not the +// caller that consults it" shape. A mutation that stopped the bridge sending +// RetriedOnParentModel passed all three. +func TestTheBridgeCarriesWhatTheTaskActuallyRanOnToTheSurface(t *testing.T) { + var sent []tea.Msg + bridge := NewPlanProgressBridge() + bridge.Attach(func(msg tea.Msg) { sent = append(sent, msg) }, 7, nil, "") + + bridge.TaskDispatched(specialist.Task{ + ID: "config-overrides", Prompt: "survey", Model: "grok-4.20-multi-agent-0309", + }) + bridge.TaskCompleted(specialist.TaskResult{ + ID: "config-overrides", + Outcome: specialist.TaskSucceeded, + Attempts: 2, + // Ran on the session's model after the assigned one was refused. + Model: "", + RetriedOnParentModel: "grok-4.20-multi-agent-0309", + }) + + var done *planTaskDoneMsg + for _, msg := range sent { + if typed, ok := msg.(planTaskDoneMsg); ok { + done = &typed + } + } + if done == nil { + t.Fatalf("no done message reached the surface: %#v", sent) + } + if done.fellBackFrom != "grok-4.20-multi-agent-0309" { + t.Errorf("the bridge dropped the refused model: %q", done.fellBackFrom) + } + if done.model != "" { + t.Errorf("the bridge reported a model the task did not run on: %q", done.model) + } +} + +// AND THE HANDLER MUST PASS IT ON. Bridge→message is covered above and +// message→panel by markDoneOn, which leaves the seam BETWEEN them: the Update +// case that unpacks the message. A mutation dropping fellBackFrom there passed +// every other test in this file — the same shape, one link further along. +// +// Three links, three tests, because a chain is only as covered as its weakest +// joint and this defect class is exactly a joint nobody asserted. +func TestTheUpdateHandlerPassesTheRefusedModelIntoThePanel(t *testing.T) { + m := model{now: func() time.Time { return time.Unix(1000, 0) }} + m.activeRunID = 3 + m.orchestrate.admit(planAdmittedMsg{name: "p", tasks: []planGraphTask{{id: "t"}}}, m.now()) + m.orchestrate.markStarted("t", "work", "k1", "grok-4.20-multi-agent-0309", m.now()) + // The AGENTS tracker is set at START from the assigned model — same path a + // real dispatch takes. Without that seed this test would pass even if the + // done handler never corrected the card. + m.specialists.start("t", "work", "k1", m.now()) + m.specialists.setModel("k1", "grok-4.20-multi-agent-0309") + + updated, _ := m.Update(planTaskDoneMsg{ + runID: 3, + taskID: "t", + cardKey: "k1", + dispatched: true, + status: specialistCompleted, + outcome: string(specialist.TaskSucceeded), + attempts: 2, + model: "", + fellBackFrom: "grok-4.20-multi-agent-0309", + }) + next, ok := updated.(model) + if !ok { + t.Fatalf("Update returned %T", updated) + } + task := next.orchestrate.tasks[next.orchestrate.byID["t"]] + if task.fellBackFrom != "grok-4.20-multi-agent-0309" { + t.Errorf("the handler dropped the refused model: %q", task.fellBackFrom) + } + if task.model == "grok-4.20-multi-agent-0309" { + t.Error("the panel still claims the model the provider refused") + } + // THE AGENTS SURFACE MUST AGREE. PLAN was corrected first; leaving AGENTS on + // the refused model is the same silent-fallback defect one column over. + info, ok := next.specialists.getBySessionID("k1") + if !ok { + t.Fatal("the agent card disappeared on completion") + } + if info.model == "grok-4.20-multi-agent-0309" { + t.Error("the AGENTS row still claims the model the provider refused") + } + if info.model != "" { + t.Errorf("fallback finished on the session's model, but the card names %q", info.model) + } +} diff --git a/internal/tui/plan_messages.go b/internal/tui/plan_messages.go index 3bf4d777b..0b7c39ea7 100644 --- a/internal/tui/plan_messages.go +++ b/internal/tui/plan_messages.go @@ -19,12 +19,9 @@ import ( // the order — so sending it here means the panel is complete before the first // task starts rather than assembling itself as tasks finish. type planAdmittedMsg struct { - runID int - name string - taskCount int - // workers is how many tasks this plan may run at once. More than one means - // child progress CANNOT be attributed to a task — see planRunningCardKey. - workers int + runID int + name string + taskCount int tasks []planGraphTask tokenLimit int // background marks a plan that outlives the run that launched it, so the @@ -46,6 +43,11 @@ type planTaskStartMsg struct { taskID string summary string cardKey string + // model is what this task will run on, empty when it inherits the session's. + // Known at DISPATCH, which is why it rides the start message rather than the + // done one: a task's model is worth seeing while it is running, not only in + // the report afterwards. + model string // background marks a plan that outlives the run that launched it, so the // stale-run guard must not drop its progress. background bool @@ -79,6 +81,14 @@ type planTaskDoneMsg struct { // watchdog fired and the executor retried it. Carried so the detail can say // why an apparently single run took twice as long as its siblings. attempts int + // model is what the task ACTUALLY ran on, which is not always what it was + // dispatched with: a model the provider refuses is retried on the session's, + // and this arrives empty in that case because empty means "the session's". + model string + // fellBackFrom names the assigned model that could not run. Carried to the + // surface a PERSON reads, not only to the report the model reads — otherwise + // the card goes on claiming a model that never executed the task. + fellBackFrom string // background marks a plan that outlives the run that launched it, so the // stale-run guard must not drop its progress. background bool diff --git a/internal/tui/plan_progress.go b/internal/tui/plan_progress.go index 45b3a8510..592147450 100644 --- a/internal/tui/plan_progress.go +++ b/internal/tui/plan_progress.go @@ -473,7 +473,6 @@ func (bridge *PlanProgressBridge) PlanAdmitted(plan specialist.Plan) { name := plan.Name() count := plan.TaskCount() limit := plan.Budget().MaxTokens - workers := plan.Budget().MaxWorkers // Copied into the message in EXECUTION ORDER, with the dependency edges, so // the panel can draw the graph without reaching back into the plan — which @@ -494,8 +493,14 @@ func (bridge *PlanProgressBridge) PlanAdmitted(plan specialist.Plan) { } bridge.send(func(runID int) tea.Msg { + // REMOVED: a workers count rode this message for planRunningCardKey, which + // attributed a plan's child progress to "whichever task was dispatched + // last". That was deleted when per-task progress arrived carrying its own + // task id — and the field outlived its only consumer, still pointing + // readers at a function that no longer exists. The panel gets the number + // it displays from the plan report. return planAdmittedMsg{runID: runID, name: name, taskCount: count, tasks: graph, tokenLimit: limit, - workers: workers, background: bridge.isBackground()} + background: bridge.isBackground()} }) } @@ -514,9 +519,10 @@ func (bridge *PlanProgressBridge) TaskDispatched(task specialist.Task) { bridge.cardByTask[task.ID] = key bridge.mu.Unlock() - id, summary := task.ID, planTaskSummary(task) + id, summary, model := task.ID, planTaskSummary(task), task.Model bridge.send(func(runID int) tea.Msg { - return planTaskStartMsg{runID: runID, taskID: id, summary: summary, cardKey: key, background: bridge.isBackground()} + return planTaskStartMsg{runID: runID, taskID: id, summary: summary, cardKey: key, + model: model, background: bridge.isBackground()} }) } @@ -566,21 +572,28 @@ func (bridge *PlanProgressBridge) finish(result specialist.TaskResult, status sp // handler creates the card on demand. taskID, sessionID, reason := result.ID, result.SessionID, result.Err outcome, tokens, attempts := result.Outcome, result.Tokens, result.Attempts + // WHAT IT RAN ON, not what it was dispatched with. The card's model is set + // once at dispatch from the ASSIGNED model; a task whose model the provider + // refused then runs on the session's, and without this the row goes on + // naming a model that never executed it. + ranOn, fellBackFrom := result.Model, result.RetriedOnParentModel output := boundTaskOutput(result.Output) bridge.send(func(runID int) tea.Msg { return planTaskDoneMsg{ - runID: runID, - taskID: taskID, - cardKey: key, - dispatched: dispatched, - sessionID: sessionID, - status: status, - outcome: string(outcome), - reason: reason, - tokens: tokens, - attempts: attempts, - output: output, - background: bridge.isBackground(), + runID: runID, + taskID: taskID, + cardKey: key, + dispatched: dispatched, + sessionID: sessionID, + status: status, + outcome: string(outcome), + reason: reason, + tokens: tokens, + attempts: attempts, + output: output, + model: ranOn, + fellBackFrom: fellBackFrom, + background: bridge.isBackground(), } }) } diff --git a/internal/tui/plan_progress_test.go b/internal/tui/plan_progress_test.go index ee8cb5439..fa2f8a3da 100644 --- a/internal/tui/plan_progress_test.go +++ b/internal/tui/plan_progress_test.go @@ -212,7 +212,7 @@ func TestARetriedTaskShowsItsAttemptCount(t *testing.T) { state := &orchestratePanelState{} state.admit(planAdmittedMsg{name: "p", taskCount: 1, tasks: []planGraphTask{{id: "a"}}}, time.Now()) - state.markStarted("a", "look", "k", time.Now()) + state.markStarted("a", "look", "k", "", time.Now()) state.markDone("a", done.outcome, done.tokens, done.attempts, time.Now()) if got := state.tasks[0].attempts; got != 3 { t.Fatalf("panel attempts = %d; want 3", got) @@ -224,7 +224,7 @@ func TestARetriedTaskShowsItsAttemptCount(t *testing.T) { func TestASingleAttemptAddsNoAttemptCount(t *testing.T) { state := &orchestratePanelState{} state.admit(planAdmittedMsg{name: "p", taskCount: 1, tasks: []planGraphTask{{id: "a"}}}, time.Now()) - state.markStarted("a", "look", "k", time.Now()) + state.markStarted("a", "look", "k", "", time.Now()) state.markDone("a", string(specialist.TaskSucceeded), 0, 1, time.Now()) if got := state.tasks[0].attempts; got != 1 { t.Fatalf("attempts = %d; want 1", got) @@ -461,10 +461,35 @@ func mustPlan(t *testing.T, name string) specialist.Plan { plan, err := specialist.ParsePlan(map[string]any{ "name": name, "tasks": []any{map[string]any{"id": "a", "prompt": "one"}}, - "budget": map[string]any{"max_workers": float64(1), "max_tokens": float64(1000)}, + "budget": map[string]any{"max_workers": float64(1), "max_tokens": float64(500_000)}, }, specialist.Limits{ParentTools: []string{"read_file"}}) if err != nil { t.Fatalf("building the fixture plan: %v", err) } return plan } + +// THE BRIDGE MUST CARRY THE MODEL, not just the message type having a field for +// it. The terminal surfaces read planTaskStartMsg.model; a test that builds that +// message by hand passes while the bridge sends "" and nothing is ever shown. +func TestTheBridgeCarriesTheModelATaskWillRunOn(t *testing.T) { + var got []tea.Msg + bridge := NewPlanProgressBridge() + bridge.Attach(func(msg tea.Msg) { got = append(got, msg) }, 1, nil, "") + + bridge.TaskDispatched(specialist.Task{ID: "s", Prompt: "scan", Model: "grok-4.3"}) + bridge.TaskDispatched(specialist.Task{ID: "plain", Prompt: "inherits"}) + + models := map[string]string{} + for _, msg := range got { + if start, ok := msg.(planTaskStartMsg); ok { + models[start.taskID] = start.model + } + } + if models["s"] != "grok-4.3" { + t.Errorf("the dispatch message carried model %q, want the task's", models["s"]) + } + if models["plain"] != "" { + t.Errorf("a task that named no model must carry none, got %q", models["plain"]) + } +} diff --git a/internal/tui/sidebar.go b/internal/tui/sidebar.go index 0bbf772a5..bd6f16b62 100644 --- a/internal/tui/sidebar.go +++ b/internal/tui/sidebar.go @@ -649,6 +649,12 @@ func (m model) sidebarAgentExpansion(info specialistInfo, room int) []string { // grouped digits — "3,400" does not fit beside the other segments here. spent = append(spent, humanCount(info.tokenCount)+" tok") } + // The MODEL goes on its own line rather than into the spend segments: names + // like grok-4.20-0309-non-reasoning are longer than everything else combined, + // and fitSegments would drop the elapsed and the spend to make room for it. + if model := strings.TrimSpace(info.model); model != "" { + out = append(out, indent+zeroTheme.accent.Render(truncateStep("on "+model, body))) + } if info.toolCount > 0 { spent = append(spent, fmt.Sprintf("%d tools", info.toolCount)) } diff --git a/internal/tui/sidebar_plan_detail.go b/internal/tui/sidebar_plan_detail.go index 3dcf3a424..7bfe4560c 100644 --- a/internal/tui/sidebar_plan_detail.go +++ b/internal/tui/sidebar_plan_detail.go @@ -175,6 +175,19 @@ func (m model) sidebarPlanDetailLines(width, budget int) []string { } lines = append(lines, " "+zeroTheme.muted.Render(truncateStep(head, room))) + // WHICH MODEL DID THIS. Shown only when the task named one, because a line + // against every task saying "on " buries + // the one that differs — and a mixed-model plan is the only reason to look. + if strings.TrimSpace(task.model) != "" { + lines = append(lines, " "+zeroTheme.accent.Render(truncateStep("on "+task.model, room))) + } + // A REFUSED MODEL IS WORTH A LINE. It stays in the provider's list, so the + // next plan chooses it again; without this the fallback is invisible here and + // the only record of it is in the report the model reads, not the person. + if fell := strings.TrimSpace(task.fellBackFrom); fell != "" { + lines = append(lines, " "+zeroTheme.muted.Render(truncateStep(fell+" would not run", room))) + } + info, hasCard := m.specialists.getBySessionID(task.cardKey) if hasCard { meta := fmt.Sprintf("%d tool calls", info.toolCount) diff --git a/internal/tui/sidebar_plan_detail_test.go b/internal/tui/sidebar_plan_detail_test.go index e4778361e..536af3d66 100644 --- a/internal/tui/sidebar_plan_detail_test.go +++ b/internal/tui/sidebar_plan_detail_test.go @@ -14,9 +14,9 @@ func sidebarDetailModel(t *testing.T) model { m := model{now: func() time.Time { return time.Unix(1000, 0) }} m.orchestrate.admit(diamondAdmitted(), m.now()) now := m.now() - m.orchestrate.markStarted("a", "survey the packages", "k1", now) + m.orchestrate.markStarted("a", "survey the packages", "k1", "", now) m.orchestrate.markDone("a", "succeeded", 9000, 1, now) - m.orchestrate.markStarted("b", "read the left branch", "k2", now) + m.orchestrate.markStarted("b", "read the left branch", "k2", "", now) m.specialists.start("b", "read the left branch", "k2", now) m.specialists.incrementToolCount("k2") m.specialists.setCurrentTool("k2", "read_file", "internal/agent/loop.go") diff --git a/internal/tui/sidebar_plan_test.go b/internal/tui/sidebar_plan_test.go index 1e2f7126a..288d18e99 100644 --- a/internal/tui/sidebar_plan_test.go +++ b/internal/tui/sidebar_plan_test.go @@ -13,11 +13,11 @@ func sidebarPlanModel(t *testing.T) model { m := model{now: func() time.Time { return time.Unix(1000, 0) }} m.orchestrate.admit(diamondAdmitted(), m.now()) now := m.now() - m.orchestrate.markStarted("a", "root", "k1", now) + m.orchestrate.markStarted("a", "root", "k1", "", now) m.orchestrate.markDone("a", "succeeded", 100, 1, now) - m.orchestrate.markStarted("b", "left", "k2", now) + m.orchestrate.markStarted("b", "left", "k2", "", now) m.orchestrate.markDone("b", "failed", 200, 1, now) - m.orchestrate.markStarted("c", "right", "k3", now) + m.orchestrate.markStarted("c", "right", "k3", "", now) return m } diff --git a/internal/tui/sidebar_test.go b/internal/tui/sidebar_test.go index fb8d06511..9c7186d07 100644 --- a/internal/tui/sidebar_test.go +++ b/internal/tui/sidebar_test.go @@ -1104,3 +1104,49 @@ func TestOrchestrateTaskClicksLandWithBothPlansInTheSection(t *testing.T) { } } } + +// WHICH MODEL RAN THIS TASK, on screen rather than only in the tool result. +// +// The report said "on " from the start; the terminal did not, so a +// mixed-model plan looked identical to a single-model one while it ran. Driven +// through the real message handler, because the model is carried on the START +// message and a test that set the field directly would pass with the bridge +// sending nothing. +func TestTheModelATaskRunsOnIsVisibleInTheTerminal(t *testing.T) { + now := time.Unix(50000, 0) + m := sidebarTestModel() + m.plan = planPanelState{} + m.now = func() time.Time { return now } + m.activeRunID = 1 + m.orchestrate.admit(planAdmittedMsg{runID: 1, name: "auto", taskCount: 2, + tasks: []planGraphTask{{id: "s"}, {id: "plain"}}}, now) + + updated, _ := m.Update(planTaskStartMsg{runID: 1, taskID: "s", + summary: "scan", cardKey: "plantask_1", model: "grok-4.3"}) + m = updated.(model) + updated, _ = m.Update(planTaskStartMsg{runID: 1, taskID: "plain", + summary: "inherits", cardKey: "plantask_2"}) + m = updated.(model) + + width := sidebarWidth(m.width) + + // The TASK detail pane names it. + m.orchestrateSelected = 0 + detail := plainRender(t, strings.Join(m.sidebarPlanDetailLines(width, 14), "\n")) + if !strings.Contains(detail, "on grok-4.3") { + t.Errorf("the TASK pane must say which model ran it:\n%s", detail) + } + // A task that inherited says nothing, or every task carries a line naming + // the model already on screen and the one that differs is buried. + m.orchestrateSelected = 1 + if plain := plainRender(t, strings.Join(m.sidebarPlanDetailLines(width, 14), "\n")); strings.Contains(plain, " on ") { + t.Errorf("an inheriting task must not claim a model:\n%s", plain) + } + + // And the expanded agent row names it too. + m.expandedAgent = "plantask_1" + agents := plainRender(t, strings.Join(m.sidebarAgentLines(width), "\n")) + if !strings.Contains(agents, "on grok-4.3") { + t.Errorf("the expanded agent row must say which model it ran on:\n%s", agents) + } +} diff --git a/internal/tui/specialist_card.go b/internal/tui/specialist_card.go index ad3e26a1a..ed94bee50 100644 --- a/internal/tui/specialist_card.go +++ b/internal/tui/specialist_card.go @@ -47,6 +47,8 @@ type specialistInfo struct { tokenCount int // total tokens consumed currentTool string currentDetail string + // model is what this agent runs on, empty when it inherits the session's. + model string // result is what the agent PRODUCED, bounded at the bridge. The sidebar // shows the head of it when its row is expanded; the whole thing lives in // the child's own session, which the card's drill-in opens. @@ -115,6 +117,20 @@ func (t *specialistTracker) setTokens(childSessionID string, tokens int) { } } +// setModel records which model an agent runs on. +// +// Empty clears the field: that is what a model fallback produces (the task +// finished on the session's model), and refusing to write empty left the AGENTS +// row naming the refused model after the PLAN row had already corrected itself. +func (t *specialistTracker) setModel(childSessionID, model string) { + for index := range t.specialists { + if t.specialists[index].childSessionID == childSessionID { + t.specialists[index].model = strings.TrimSpace(model) + return + } + } +} + // setResult records what a finished agent produced. func (t *specialistTracker) setResult(childSessionID, result string) { if strings.TrimSpace(result) == "" { From 1396d9eaa402de69656d67c048ace4afa6f904f0 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:02:09 +0530 Subject: [PATCH 81/86] fix(specialist): five admission and lifecycle defects from review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each lands with the test that fails without it. A MALFORMED DEPENDENCY WAS DROPPED, NOT REFUSED. planStrings skips an entry it cannot decode — right for a display label, wrong for an edge: "depends_on": [42] decoded to no dependency at all, so the task was admitted as dependency-free and ran BEFORE the precondition it declared. Silently; nothing failed and nothing warned. That defeats this file's own rule that an unknown edge is rejected and never skipped. depends_on and tools now go through a strict variant that names the offending index, and the lenient one stays for the fields where dropping is harmless. Tasks() SHARED ITS SLICES. copy() duplicates the structs and shares their backing arrays, so plan.Tasks()[0].Tools[0] = "bash" rewrote the validated grant in place, from outside, after every check had passed — the widening this file exists to prevent, reached through the accessor rather than around it. SavePlan GUARDED THE DOOR AND LEFT THE WALL OPEN. Symlinks were refused on the directory and on .json, and the bytes go to .json.tmp, which nothing checked. A repo shipping that name as a symlink turned "save my plan" into the file-overwrite primitive the other two checks exist to prevent. A SAVED PLAN LOST THE CALLER'S EXECUTION FLAGS. resolveSavedPlan replaces the caller's arguments with the stored plan's, which is right for plan content and wrong for the two flags that say HOW to run rather than what: {"saved":"sweep","background":true} passed the refusal list and then ran in the foreground, because background was read from the map that had replaced it. Plan content is still refused. SHUTDOWN RAN BACKWARDS. Defers are LIFO, so registering planLaunch.Close() beside the launcher it belongs to — which reads naturally — meant it ran AFTER closeSpecialistRuntime. Close cancels AND WAITS, so a background plan was being waited on with the specialist runtime it needs already torn down. The ordering is invisible at the point where the next defer would be added, and a comment did not prevent it happening once, so a source-order guard asserts it. --- internal/cli/app.go | 16 +- internal/cli/shutdown_order_test.go | 51 ++++++ internal/specialist/plan.go | 67 +++++++- internal/specialist/plan_store.go | 10 ++ internal/specialist/plan_store_test.go | 35 ++++ internal/specialist/plan_strict_lists_test.go | 162 ++++++++++++++++++ internal/specialist/plan_tool.go | 13 ++ 7 files changed, 347 insertions(+), 7 deletions(-) create mode 100644 internal/cli/shutdown_order_test.go create mode 100644 internal/specialist/plan_strict_lists_test.go diff --git a/internal/cli/app.go b/internal/cli/app.go index c9157f76e..3d69bd648 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -727,7 +727,8 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a sessionCtx, cancelSession := context.WithCancel(context.Background()) defer cancelSession() planLaunch := newPlanLauncher(sessionCtx, planProgress) - defer planLaunch.Close() + // ITS defer IS REGISTERED LAST, further down, and that is load-bearing — + // see the ordering note beside closeSpecialistRuntime. // Saved plans, resolved ONCE and handed to both consumers: the orchestrate // tool (which loads a plan named with `saved`) and the TUI (which saves, // lists and shows them). Two computations of the same pair of directories @@ -766,7 +767,20 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a if err != nil { return writeAppError(stderr, "failed to initialize specialist tools: "+err.Error(), 1) } + // SHUTDOWN ORDER, and defers run LIFO so the registration order here is the + // REVERSE of what happens. + // + // Required: stop the plan, then close the runtime it was using, then cancel + // the session. It was the exact opposite. planLaunch.Close() was registered + // early (right where the launcher is built, which reads naturally), so it ran + // AFTER closeSpecialistRuntime — and Close "cancels AND WAITS", so a + // background plan was still being waited on with the specialist runtime it + // needs already torn down. + // + // Registering Close here, last, is what puts it first at shutdown. Anything + // added below this line runs BEFORE the plan is stopped; put it above. defer closeSpecialistRuntime(stderr, specialistRuntime) + defer planLaunch.Close() // The TUI has no --worktree reassignment, so trustRoot == workspaceRoot here. // Gate the project MCP layer behind the workspace-trust check (fail-closed): an // untrusted workspace must not spawn its ./.zero/config.json stdio MCP servers. diff --git a/internal/cli/shutdown_order_test.go b/internal/cli/shutdown_order_test.go new file mode 100644 index 000000000..34b10f5cf --- /dev/null +++ b/internal/cli/shutdown_order_test.go @@ -0,0 +1,51 @@ +package cli + +import ( + "os" + "strings" + "testing" +) + +// SHUTDOWN ORDER, guarded at the source because defers cannot be observed from +// a test. +// +// Required at shutdown: stop the plan, then close the runtime it was using, then +// cancel the session. Defers run LIFO, so registration order is the reverse — +// which is subtle enough that it was already wrong once. planLaunch.Close() was +// registered beside the launcher it belongs to, which reads naturally and meant +// it ran AFTER closeSpecialistRuntime. Close "cancels AND WAITS", so a +// background plan was waited on with the runtime it needs already torn down. +// +// A source-order assertion is a blunt instrument. It is here because the +// alternative is a comment, and a comment did not stop this happening: the +// ordering is invisible at the point where someone would add the next defer. +func TestShutdownDefersAreRegisteredInReverseOfTheirRequiredOrder(t *testing.T) { + source, err := os.ReadFile("app.go") + if err != nil { + t.Fatalf("read app.go: %v", err) + } + text := string(source) + + // Registration order, top to bottom. + positions := map[string]int{ + "cancelSession": strings.Index(text, "defer cancelSession()"), + "closeSpecialistRuntime": strings.Index(text, "defer closeSpecialistRuntime(stderr, specialistRuntime)"), + "planLaunch.Close": strings.Index(text, "defer planLaunch.Close()"), + } + for name, at := range positions { + if at < 0 { + t.Fatalf("could not find the %s defer; this guard has gone stale and must be re-pointed, not deleted", name) + } + } + + // LIFO: later registration runs earlier. The plan must stop first, so its + // defer must be registered last. + if positions["planLaunch.Close"] < positions["closeSpecialistRuntime"] { + t.Error("planLaunch.Close() is registered before closeSpecialistRuntime, so it RUNS after it — " + + "a background plan would be waited on with its specialist runtime already closed") + } + if positions["closeSpecialistRuntime"] < positions["cancelSession"] { + t.Error("closeSpecialistRuntime is registered before cancelSession, so it RUNS after it — " + + "the session would be cancelled while the runtime is still open") + } +} diff --git a/internal/specialist/plan.go b/internal/specialist/plan.go index 8b800b39d..ccaa94ff6 100644 --- a/internal/specialist/plan.go +++ b/internal/specialist/plan.go @@ -173,7 +173,17 @@ func (p Plan) Budget() Budget { return p.budget } // Tasks returns a copy so a caller cannot mutate a validated plan. func (p Plan) Tasks() []Task { out := make([]Task, len(p.tasks)) - copy(out, p.tasks) + for index, task := range p.tasks { + // DEEP, because copy() duplicates the structs and shares their slices. + // Task.Tools is the VALIDATED GRANT: with the backing array shared, + // plan.Tasks()[0].Tools[0] = "bash" rewrote the admitted plan in place, + // from outside, after every check had passed — the widening this whole + // file exists to make impossible. DependsOn is the same hazard aimed at + // execution order rather than authority. + task.DependsOn = append([]string(nil), task.DependsOn...) + task.Tools = append([]string(nil), task.Tools...) + out[index] = task + } return out } @@ -691,11 +701,26 @@ func planTask(raw any, index int) (Task, error) { if !ok { return Task{}, fmt.Errorf("task at position %d is not an object", index) } + // STRICT for the two lists that carry AUTHORITY AND ORDER. planStrings drops + // an entry it cannot read, which is fine for a display label and wrong here: + // "depends_on": [42] decoded to no dependency at all, so the task was admitted + // as dependency-free and ran BEFORE the precondition it declared — silently, + // with nothing to notice. That defeats this file's own rule that an unknown + // edge is rejected and never skipped. The same argument covers "tools", where + // a dropped entry quietly narrows a grant the caller believed they asked for. + dependsOn, err := planStringsStrict(fields, "depends_on") + if err != nil { + return Task{}, fmt.Errorf("task at position %d: %w", index, err) + } + tools, err := planStringsStrict(fields, "tools") + if err != nil { + return Task{}, fmt.Errorf("task at position %d: %w", index, err) + } task := Task{ ID: planString(fields, "id"), Prompt: planString(fields, "prompt"), - DependsOn: planStrings(fields, "depends_on"), - Tools: planStrings(fields, "tools"), + DependsOn: dependsOn, + Tools: tools, Phase: planString(fields, "phase"), } // REFUSED AT ADMISSION, not degraded at dispatch. The manifest loader already @@ -703,9 +728,9 @@ func planTask(raw any, index int) (Task, error) { // would soften an existing rejection — and a plan that asked for a stronger // model on its verify stage, silently ran on a weaker one, and still reported // success is indistinguishable from one that worked. - model, err := resolveTaskModel(planString(fields, "model")) - if err != nil { - return Task{}, fmt.Errorf("task at position %d: %w", index, err) + model, modelErr := resolveTaskModel(planString(fields, "model")) + if modelErr != nil { + return Task{}, fmt.Errorf("task at position %d: %w", index, modelErr) } task.Model = model if task.ID == "" { @@ -740,6 +765,36 @@ func planString(args map[string]any, key string) string { return strings.TrimSpace(value) } +// planStringsStrict is planStrings for lists whose entries MUST be readable. +// +// Its lenient twin skips what it cannot decode, which suits a label nobody acts +// on. It does not suit a dependency edge or a tool name: a skipped entry there +// is not a missing label, it is a precondition that stopped existing or an +// authority the caller thinks they narrowed. Refused with the offending value +// named, so the author can see WHICH entry was wrong rather than diffing what +// they sent against what ran. +func planStringsStrict(args map[string]any, key string) ([]string, error) { + raw, ok := args[key].([]any) + if !ok { + // Absent or not a list at all: left to the caller's own shape checks, + // exactly as the lenient version does. + return nil, nil + } + out := make([]string, 0, len(raw)) + for index, item := range raw { + text, ok := item.(string) + if !ok { + return nil, fmt.Errorf("%s[%d] must be a string, got %T", key, index, item) + } + trimmed := strings.TrimSpace(text) + if trimmed == "" { + return nil, fmt.Errorf("%s[%d] is empty", key, index) + } + out = append(out, trimmed) + } + return out, nil +} + func planStrings(args map[string]any, key string) []string { raw, ok := args[key].([]any) if !ok { diff --git a/internal/specialist/plan_store.go b/internal/specialist/plan_store.go index fe9b42d7f..e5a8b29c8 100644 --- a/internal/specialist/plan_store.go +++ b/internal/specialist/plan_store.go @@ -127,6 +127,16 @@ func SavePlan(dir, name string, plan Plan) (string, error) { // Write-then-rename, so a crash mid-write leaves the previous plan intact // rather than a truncated file that fails to parse on the next run. temp := path + ".tmp" + // THE BYTES GO HERE, so this is the path that has to be safe. + // + // dir and path were both checked and the write went to neither: it goes to + // path + ".tmp", which nothing looked at. A repo shipping + // .zero/plans/.json.tmp as a symlink turned "save my plan" into the + // file-overwrite primitive the two checks above exist to prevent — the guard + // was on the door and the wall was open. + if err := refuseSymlink(temp); err != nil { + return "", err + } if err := os.WriteFile(temp, append(body, '\n'), 0o600); err != nil { return "", fmt.Errorf("write %s: %w", path, err) } diff --git a/internal/specialist/plan_store_test.go b/internal/specialist/plan_store_test.go index a6e346b52..433880441 100644 --- a/internal/specialist/plan_store_test.go +++ b/internal/specialist/plan_store_test.go @@ -579,3 +579,38 @@ func TestBackgroundIsOptInOnly(t *testing.T) { t.Fatal("an explicit true must be honoured") } } + +// THE TEMP PATH IS WHERE THE BYTES GO, so it is the path that has to be safe. +// +// dir and .json were both refused as symlinks and the write went to +// neither: it goes to .json.tmp, which nothing looked at. A repo shipping +// that name as a symlink turned "save my plan" into the file-overwrite +// primitive the other two checks exist to prevent — the guard was on the door +// and the wall was open. +func TestSavingRefusesToWriteThroughASymlinkedTempFile(t *testing.T) { + base := t.TempDir() + target := filepath.Join(base, "precious") + if err := os.WriteFile(target, []byte("do not clobber"), 0o600); err != nil { + t.Fatal(err) + } + dir := filepath.Join(base, "plans") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + // Only the TEMP name is planted; .json itself is absent, so the two + // existing checks both pass and this is the only thing standing in the way. + if err := os.Symlink(target, filepath.Join(dir, "sweep"+planFileExt+".tmp")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + + if _, err := SavePlan(dir, "sweep", savedPlanFixture(t)); err == nil { + t.Fatal("SavePlan wrote through a symlinked temp file") + } + body, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if string(body) != "do not clobber" { + t.Fatalf("the symlink target was overwritten through the temp path: %q", body) + } +} diff --git a/internal/specialist/plan_strict_lists_test.go b/internal/specialist/plan_strict_lists_test.go new file mode 100644 index 000000000..a7767b875 --- /dev/null +++ b/internal/specialist/plan_strict_lists_test.go @@ -0,0 +1,162 @@ +package specialist + +import ( + "strings" + "testing" +) + +// A MALFORMED EDGE MUST BE REFUSED, NOT DROPPED. +// +// planStrings skips an entry it cannot decode, which is right for a display +// label and wrong for a dependency: "depends_on": [42] decoded to no dependency +// at all, so the task was admitted as dependency-free and ran BEFORE the +// precondition it declared. Silently — nothing failed, nothing warned, and the +// only symptom is work happening in the wrong order. Reproduced before this fix. +func TestAMalformedDependencyIsRefusedRatherThanSilentlyDropped(t *testing.T) { + for name, entry := range map[string]any{ + "a number": float64(42), + "an object": map[string]any{"id": "a"}, + "a list": []any{"a"}, + "empty": "", + "blank": " ", + } { + t.Run(name, func(t *testing.T) { + _, err := ParsePlan(planArgs([]any{ + task("a", "x"), + map[string]any{"id": "b", "prompt": "y", "depends_on": []any{entry}}, + }, okBudget()), readOnlyLimits()) + if err == nil { + t.Fatal("a task whose dependency could not be read was admitted as dependency-free") + } + if !strings.Contains(err.Error(), "depends_on") { + t.Errorf("the refusal does not name the field: %v", err) + } + // WHICH entry, not just that one was wrong — the author has to be able + // to find it without diffing what they sent against what ran. + if !strings.Contains(err.Error(), "[0]") { + t.Errorf("the refusal does not name the offending index: %v", err) + } + if !strings.Contains(err.Error(), "task at position 1") { + t.Errorf("the refusal does not name the task: %v", err) + } + }) + } +} + +// The same for tools, where a dropped entry quietly NARROWS a grant the caller +// believed they asked for — the mirror image of widening, and just as invisible. +func TestAMalformedToolEntryIsRefused(t *testing.T) { + _, err := ParsePlan(planArgs([]any{ + map[string]any{"id": "a", "prompt": "x", "tools": []any{"grep", float64(7)}}, + }, okBudget()), readOnlyLimits()) + if err == nil { + t.Fatal("a task with an unreadable tool entry was admitted") + } + if !strings.Contains(err.Error(), "tools") || !strings.Contains(err.Error(), "[1]") { + t.Errorf("the refusal does not locate the entry: %v", err) + } +} + +// WELL-FORMED LISTS ARE UNCHANGED, including the ordinary absent and empty +// cases, so nothing about a normal plan moves. +func TestWellFormedDependencyAndToolListsStillParse(t *testing.T) { + plan, err := ParsePlan(planArgs([]any{ + map[string]any{"id": "a", "prompt": "x", "tools": []any{"read_file", " grep "}}, + map[string]any{"id": "b", "prompt": "y", "depends_on": []any{"a"}}, + map[string]any{"id": "c", "prompt": "z"}, + }, okBudget()), readOnlyLimits()) + if err != nil { + t.Fatalf("a well-formed plan was refused: %v", err) + } + byID := map[string]Task{} + for _, task := range plan.Tasks() { + byID[task.ID] = task + } + if got := byID["a"].Tools; len(got) != 2 || got[1] != "grep" { + t.Errorf("tools = %v, want the trimmed pair", got) + } + if got := byID["b"].DependsOn; len(got) != 1 || got[0] != "a" { + t.Errorf("depends_on = %v", got) + } + if len(byID["c"].DependsOn) != 0 || len(byID["c"].Tools) != 0 { + t.Errorf("an absent list became non-empty: %+v", byID["c"]) + } +} + +// A CALLER MUST NOT BE ABLE TO REWRITE AN ADMITTED PLAN. +// +// Tasks() used copy(), which duplicates the Task structs and SHARES their +// slices. Task.Tools is the validated grant, so plan.Tasks()[0].Tools[0] = +// "bash" edited the admitted plan in place, from outside, after every check had +// passed — the widening this file exists to make impossible, reached through +// the accessor rather than around it. +func TestTasksCannotBeUsedToRewriteTheAdmittedPlan(t *testing.T) { + plan, err := ParsePlan(planArgs([]any{ + map[string]any{"id": "a", "prompt": "x", "tools": []any{"read_file"}}, + map[string]any{"id": "b", "prompt": "y", "depends_on": []any{"a"}}, + }, okBudget()), readOnlyLimits()) + if err != nil { + t.Fatalf("ParsePlan: %v", err) + } + + handed := plan.Tasks() + handed[0].Tools[0] = "bash" + handed[1].DependsOn[0] = "nonexistent" + + fresh := plan.Tasks() + if fresh[0].Tools[0] != "read_file" { + t.Errorf("a caller widened the admitted plan's grant to %q", fresh[0].Tools[0]) + } + if fresh[1].DependsOn[0] != "a" { + t.Errorf("a caller rewrote the admitted plan's dependency to %q", fresh[1].DependsOn[0]) + } +} + +// A SAVED PLAN STILL RUNS THE WAY THE CALLER ASKED. +// +// resolveSavedPlan replaces the caller's arguments with the stored plan's, which +// is right for plan CONTENT — a half-overridden plan is not the plan that was +// saved. It is wrong for the two flags that say HOW to run it rather than what: +// `{"saved":"sweep","background":true}` passed the refusal list, then ran in the +// FOREGROUND, because background was read from the map that had replaced it. +func TestASavedPlanKeepsTheCallersExecutionDirectives(t *testing.T) { + dir := t.TempDir() + stored := mustPlan(t, []any{task("a", "x")}, okBudget(), readOnlyLimits()) + if _, err := SavePlan(dir, "sweep", stored); err != nil { + t.Fatalf("SavePlan: %v", err) + } + tool := &OrchestrateTool{Plans: PlanPaths{UserDir: dir}} + + resolved, err := tool.resolveSavedPlan(map[string]any{ + "saved": "sweep", "background": true, "auto_assign": false, + }) + if err != nil { + t.Fatalf("resolveSavedPlan: %v", err) + } + if got, _ := resolved["background"].(bool); !got { + t.Error("the caller asked for background and the saved plan's args replaced it") + } + value, present := resolved["auto_assign"] + if !present { + t.Fatal("auto_assign was dropped, so a configured default cannot be overridden per run") + } + if enabled, _ := value.(bool); enabled { + t.Error("auto_assign: false was lost") + } + // The plan itself is still the STORED one — the refusal's whole point. + if tasks, _ := resolved["tasks"].([]any); len(tasks) != 1 { + t.Errorf("the stored plan's tasks did not survive: %v", resolved["tasks"]) + } +} + +// Supplying plan CONTENT alongside `saved` is still refused — the directive +// carve-out must not become a way to half-override a saved plan. +func TestASavedPlanStillRefusesInlineContent(t *testing.T) { + tool := &OrchestrateTool{Plans: PlanPaths{UserDir: t.TempDir()}} + for _, field := range []string{"tasks", "budget", "name", "description"} { + _, err := tool.resolveSavedPlan(map[string]any{"saved": "sweep", field: "anything"}) + if err == nil { + t.Errorf("%q was accepted alongside a saved plan", field) + } + } +} diff --git a/internal/specialist/plan_tool.go b/internal/specialist/plan_tool.go index 233f46873..78b2d6a23 100644 --- a/internal/specialist/plan_tool.go +++ b/internal/specialist/plan_tool.go @@ -425,6 +425,19 @@ func (tool *OrchestrateTool) resolveSavedPlan(args map[string]any) (map[string]a if err != nil { return nil, err } + // EXECUTION DIRECTIVES SURVIVE THE SWAP; plan content does not. + // + // The refusal above exists because a half-overridden plan is not the plan + // that was saved. These two are not plan content: they say HOW to run it, + // not WHAT to run, and neither appears in a stored plan's args. Returning + // the stored map wholesale dropped them silently — `{"saved":"sweep", + // "background":true}` parsed, passed the refusal list, and then ran in the + // foreground because the flag was read from the map that had replaced it. + for _, directive := range []string{"background", "auto_assign"} { + if value, present := args[directive]; present { + stored.Args[directive] = value + } + } return stored.Args, nil } From e555fd54922c64630984e75585eb5c3152864a7b Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:20:08 +0530 Subject: [PATCH 82/86] feat(specialist): teach the verify convention, and show what happens before a plan exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TWO RUNS DECIDED THE FIRST ONE. The same audit of this repo, once ending in a verify task and once not: the verified run dropped five overclaims the other passed through to its report, including an inference the unverified run stated as fact. Fan-out multiplies whatever a finder produces, mistakes included, and nothing in this tool attacks a claim once it has been made. So the shape is TAUGHT, not built. No verdict is parsed and no claim is filtered — a plan author adopts find/verify/synthesize with the tasks they already write, or does not, and the only place that can live is the description they read. The prompt rules that make a verifier worth its tokens are named there too: refute first and confirm only when the code forces it, quote what the code says rather than guessing a refutation, judge each claim independently of the finder's confidence, and re-read the cited locations because a briefing carries a claim and not a trace. AND THE SILENCE BEFORE A PLAN. Auto-assignment runs ahead of admission: a /models call, a probe of every candidate, and when routing is on a full child run on the strongest model. Tens of seconds with no plan, so no panel and no rows — a foreground run looked frozen at exactly the moment it was doing the most. Reported as a STATUS, not a plan row, through an optional recorder interface alongside the others: the plan does not exist yet and admission may still refuse it, so putting a task on screen would show work that never runs. Empty clears it, and the stale-run guard applies as it does to every other plan message. --- internal/specialist/plan_exec.go | 22 +++++ internal/specialist/plan_strict_lists_test.go | 34 +++++++ internal/specialist/plan_tool.go | 31 ++++++- internal/tui/model.go | 13 ++- internal/tui/plan_messages.go | 10 +++ internal/tui/plan_preflight_test.go | 90 +++++++++++++++++++ internal/tui/plan_progress.go | 11 +++ internal/tui/sidebar.go | 8 ++ 8 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 internal/tui/plan_preflight_test.go diff --git a/internal/specialist/plan_exec.go b/internal/specialist/plan_exec.go index 5990d54e8..a34b0f536 100644 --- a/internal/specialist/plan_exec.go +++ b/internal/specialist/plan_exec.go @@ -1104,6 +1104,28 @@ func planTaskProgress(recorder PlanRecorder, taskID string, event streamjson.Eve } } +// PlanPreflightReporter is the optional half of a recorder that hears about work +// happening BEFORE a plan exists. +// +// Auto-assignment runs ahead of admission: a /models call, a probe of each +// candidate, and — when routing is on — a full child run on the strongest model. +// Tens of seconds during which there is no plan, so no panel, no rows, and +// nothing on screen: a foreground run looks frozen at exactly the moment it is +// doing the most. +// +// A STATUS, NOT A PLAN ROW. The plan does not exist yet and inventing a row for +// it would put a task on screen that admission may still refuse. Empty clears. +type PlanPreflightReporter interface { + PlanPreflight(status string) +} + +// planPreflight is best-effort and nil-safe, like every other recorder call. +func planPreflight(recorder PlanRecorder, status string) { + if reporter, ok := recorder.(PlanPreflightReporter); ok && reporter != nil { + reporter.PlanPreflight(status) + } +} + // PlanController is the optional CONTROL half of a recorder. // // Stopping a plan meant stopping the whole turn: Ctrl-C cancels the run, and diff --git a/internal/specialist/plan_strict_lists_test.go b/internal/specialist/plan_strict_lists_test.go index a7767b875..318c092bf 100644 --- a/internal/specialist/plan_strict_lists_test.go +++ b/internal/specialist/plan_strict_lists_test.go @@ -160,3 +160,37 @@ func TestASavedPlanStillRefusesInlineContent(t *testing.T) { } } } + +// THE VERIFY CONVENTION IS TAUGHT, because nothing enforces it. +// +// No verdict is parsed and no claim is filtered — a plan author adopts this +// shape with the tasks they already write, or does not. So the only place it can +// exist is the description the author reads. +// +// Earned by measurement: two runs of the same audit on this repo, one ending in +// a verify task and one not. The verified run dropped five overclaims the other +// passed through, including an inference the unverified run stated as fact. +func TestTheToolTeachesTheFindVerifySynthesizeShape(t *testing.T) { + tasks, ok := (&OrchestrateTool{}).Parameters().Properties["tasks"] + if !ok { + t.Fatal("the tasks property is missing") + } + for _, required := range []string{ + "end it in verification", + "A verify task depends on the finders", + "reports only what survived", + "try to REFUTE each claim", + "default to refuted when uncertain", + "judge each claim independently", + "a claim, not a trace", + } { + if !strings.Contains(tasks.Description, required) { + t.Errorf("the description does not teach %q", required) + } + } + // The task-authoring rules must survive alongside it — they are upstream of + // verification and a badly split plan cannot be verified into a good one. + if !strings.Contains(tasks.Description, "Split by SUBJECT") { + t.Error("the task-authoring guidance was displaced by the verify convention") + } +} diff --git a/internal/specialist/plan_tool.go b/internal/specialist/plan_tool.go index 78b2d6a23..fac7b36a4 100644 --- a/internal/specialist/plan_tool.go +++ b/internal/specialist/plan_tool.go @@ -172,7 +172,26 @@ func (tool *OrchestrateTool) Parameters() tools.Schema { "- Do not split one modest job into pieces to look thorough. Fewer, larger, genuinely independent tasks beat many small ones: " + "each task pays for its own context.\n" + "- Be honest about what a task can do. A task that reads and reports is not the same as one that decides; " + - "give the deciding work to a task that says it is deciding, and name a stronger model for it.", + "give the deciding work to a task that says it is deciding, and name a stronger model for it.\n\n" + + // A CONVENTION, NOT MACHINERY. Nothing here parses verdicts or + // filters claims — this teaches a shape the plan author can adopt + // with the tasks they already write. + // + // Earned by measurement rather than argument: two runs of the same + // audit on this repo, one ending in a verify task and one not. The + // verified run dropped FIVE overclaims the other passed through to + // its report, including an inference the unverified run stated as + // fact. Fan-out multiplies whatever a finder produces, including its + // mistakes; nothing else in this tool attacks a claim once made. + "If a plan produces CLAIMS, end it in verification:\n" + + "- Finder tasks gather claims with file:line evidence.\n" + + "- A verify task depends on the finders, so its briefing carries their claims, and attacks every one.\n" + + "- A synthesis task depends on the verifier and reports only what survived.\n\n" + + "A verify task's prompt must tell it to: try to REFUTE each claim from the code first, and confirm only " + + "when the code forces it; default to refuted when uncertain, but quote what the code actually says — a " + + "guessed refutation is worth no more than a guessed confirmation; judge each claim independently, treating " + + "the finder's confidence as no evidence at all; and re-read the cited locations itself, because the " + + "briefing carries a claim, not a trace.", }, "saved": { Type: "string", @@ -684,6 +703,10 @@ func (tool *OrchestrateTool) autoAssignModels(ctx context.Context, args map[stri // owns every message about task shape. return nil, nil } + // SAY WHAT IS HAPPENING. Everything from here to admission is invisible + // otherwise, and it is the slowest part of a plan's start. + planPreflight(tool.Recorder, "listing this provider's models…") + defer planPreflight(tool.Recorder, "") models, err := tool.DiscoverModels(ctx) if err != nil { if !supplied { @@ -696,6 +719,9 @@ func (tool *OrchestrateTool) autoAssignModels(ctx context.Context, args map[stri // router's candidate list are all derived from this slice, so a model that // cannot run has to be gone before any of them exist — filtering later would // leave it in the tiers it was already ranked into. + if tool.ProbeModel != nil { + planPreflight(tool.Recorder, "checking which models this provider will run…") + } models, probeNotes := proveModels(ctx, models, tool.ProbeModel, &tool.probes) tiers := buildModelTiers(models, tool.ModelPrefs) @@ -742,6 +768,9 @@ func (tool *OrchestrateTool) autoAssignModels(ctx context.Context, args map[stri // none; it reads nothing. Parent identity is attached by runnerForCall. routerGrant, _ := planToolGrant(Task{}, tool.ParentTools) router := routerModel(tool.ModelPrefs, tiers, served, options.Model) + if strings.TrimSpace(router) != "" && len(routableTasks(raw)) >= routerMinimumTasks { + planPreflight(tool.Recorder, "asking "+router+" which model each task needs…") + } routed, routerTokens, routeErr := routeTaskModels(ctx, tool.runnerForCall(options), PlanTaskRequest{Tools: routerGrant}, router, routableTasks(raw), eligibleForRouting(models, tool.ModelPrefs), tool.ModelPrefs.RouterGuidance) diff --git a/internal/tui/model.go b/internal/tui/model.go index 2cf9ddaa9..856dc8f7a 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -135,7 +135,10 @@ type model struct { // planProgress is the shared recorder the orchestrate tool holds. A POINTER // for the same reason PostureGate is one: the TUI model is a value type // copied on every update, so a closure over it would freeze the first run. - planProgress *PlanProgressBridge + // planPreflight is what auto-assignment is doing before a plan is admitted. + // Empty when nothing is pending, which is almost always. + planPreflight string + planProgress *PlanProgressBridge // orchestrate is the live view of the running orchestrate plan. Distinct // from m.plan, which is the update_plan tool's TODO list — two different // things called "plan", kept apart by name everywhere but the command. @@ -2729,6 +2732,14 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m.specialists.start(msg.taskID, msg.summary, msg.cardKey, m.now()) m.specialists.setModel(msg.cardKey, msg.model) return m, nil + case planPreflightMsg: + // The stale-run guard applies as it does to every plan message: a status + // from a finished run must not linger over the next one. + if msg.runID != m.activeRunID { + return m, nil + } + m.planPreflight = msg.status + return m, nil case planTaskDoneMsg: // A BACKGROUND plan outlives the run that launched it, so the // stale-run guard must not drop its progress: dropping it is diff --git a/internal/tui/plan_messages.go b/internal/tui/plan_messages.go index 0b7c39ea7..a3d3aace6 100644 --- a/internal/tui/plan_messages.go +++ b/internal/tui/plan_messages.go @@ -53,6 +53,16 @@ type planTaskStartMsg struct { background bool } +// planPreflightMsg reports work happening BEFORE a plan exists — listing the +// provider's models, probing them, asking the router. Empty status clears it. +// +// NOT A PLAN ROW. There is no plan yet; admission may still refuse one. A row +// would put a task on screen that never runs. +type planPreflightMsg struct { + runID int + status string +} + // planTaskDoneMsg closes a task's card. // // dispatched distinguishes a task that ran from one that never started diff --git a/internal/tui/plan_preflight_test.go b/internal/tui/plan_preflight_test.go new file mode 100644 index 000000000..3bd3f03a0 --- /dev/null +++ b/internal/tui/plan_preflight_test.go @@ -0,0 +1,90 @@ +package tui + +import ( + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" +) + +// WORK BEFORE A PLAN EXISTS MUST BE VISIBLE. +// +// Auto-assignment runs ahead of admission: a /models call, a probe of every +// candidate, and when routing is on a full child run on the strongest model. +// Tens of seconds with no plan, so no panel and no rows — a foreground run looks +// frozen at exactly the moment it is doing the most. +func TestPreflightStatusIsShownAndThenCleared(t *testing.T) { + m := model{now: func() time.Time { return time.Unix(1000, 0) }} + m.activeRunID = 3 + m.width, m.height = 140, 40 + m.altScreen = true + m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowUser, text: "hello"}) + + updated, _ := m.Update(planPreflightMsg{runID: 3, status: "listing this provider's models"}) + next, ok := updated.(model) + if !ok { + t.Fatalf("Update returned %T", updated) + } + if next.planPreflight == "" { + t.Fatal("the preflight status never reached the model") + } + rendered := stripANSILines(next.renderContextSidebar(34, 28)) + if !strings.Contains(rendered, "listing this provider") { + t.Errorf("the status is invisible to the user:\n%s", rendered) + } + + // CLEARED BY AN EMPTY STATUS, or it outlives the work it describes. + cleared, _ := next.Update(planPreflightMsg{runID: 3, status: ""}) + after, _ := cleared.(model) + if after.planPreflight != "" { + t.Errorf("the status was not cleared: %q", after.planPreflight) + } + if strings.Contains(stripANSILines(after.renderContextSidebar(34, 28)), "listing this provider") { + t.Error("a cleared status is still rendered") + } +} + +// A STATUS FROM A FINISHED RUN MUST NOT LINGER OVER THE NEXT ONE — the same +// stale-run guard every other plan message carries. +func TestAPreflightStatusFromAStaleRunIsDropped(t *testing.T) { + m := model{now: func() time.Time { return time.Unix(1000, 0) }} + m.activeRunID = 7 + updated, _ := m.Update(planPreflightMsg{runID: 6, status: "from an older run"}) + next, _ := updated.(model) + if next.planPreflight != "" { + t.Errorf("a stale run's status was accepted: %q", next.planPreflight) + } +} + +// AND THE BRIDGE MUST ACTUALLY EMIT IT. The tests above hand Update a message +// and prove the model and sidebar handle one — they prove nothing about whether +// anything ever sends it. A mutation gutting the bridge's send passed both. +func TestTheBridgeEmitsPreflightStatusToTheSurface(t *testing.T) { + var sent []tea.Msg + bridge := NewPlanProgressBridge() + bridge.Attach(func(msg tea.Msg) { sent = append(sent, msg) }, 4, nil, "") + + bridge.PlanPreflight("checking which models this provider will run…") + bridge.PlanPreflight("") + + var statuses []string + for _, msg := range sent { + if typed, ok := msg.(planPreflightMsg); ok { + if typed.runID != 4 { + t.Errorf("preflight carried run %d, want 4", typed.runID) + } + statuses = append(statuses, typed.status) + } + } + if len(statuses) != 2 { + t.Fatalf("the bridge emitted %d preflight message(s), want 2: %#v", len(statuses), sent) + } + if statuses[0] == "" { + t.Error("the status text was dropped on the way to the surface") + } + // THE CLEAR MUST TRAVEL TOO, or the status outlives the work it describes. + if statuses[1] != "" { + t.Errorf("the clearing message carried %q instead of an empty status", statuses[1]) + } +} diff --git a/internal/tui/plan_progress.go b/internal/tui/plan_progress.go index 592147450..d53f084f3 100644 --- a/internal/tui/plan_progress.go +++ b/internal/tui/plan_progress.go @@ -105,6 +105,7 @@ var ( _ specialist.PlanController = (*PlanProgressBridge)(nil) _ specialist.PlanSurfaceBusy = (*PlanProgressBridge)(nil) _ specialist.PlanTaskProgressRecorder = (*PlanProgressBridge)(nil) + _ specialist.PlanPreflightReporter = (*PlanProgressBridge)(nil) ) // NewPlanProgressBridge returns a bridge that is inert until Attach is called. @@ -159,6 +160,16 @@ func (bridge *PlanProgressBridge) record(eventType sessions.EventType, payload m // PlanRunning takes the cancel scoped to the plan that is starting. Any pause // left over from a previous plan is cleared here: a new plan must never begin // life suspended by a key the user pressed during the last one. +// PlanPreflight surfaces what auto-assignment is doing before a plan exists. +func (bridge *PlanProgressBridge) PlanPreflight(status string) { + if bridge == nil { + return + } + bridge.send(func(runID int) tea.Msg { + return planPreflightMsg{runID: runID, status: status} + }) +} + func (bridge *PlanProgressBridge) PlanRunning(cancel context.CancelFunc) { if bridge == nil { return diff --git a/internal/tui/sidebar.go b/internal/tui/sidebar.go index cac47649b..0ef8f593b 100644 --- a/internal/tui/sidebar.go +++ b/internal/tui/sidebar.go @@ -912,6 +912,14 @@ func (m model) renderContextSidebar(width, height int) []string { add(sidebarHeader("ACTIVITY", width)) lines = append(lines, activityLines...) } + // PREFLIGHT: what auto-assignment is doing before a plan exists. Rendered + // here, under ACTIVITY, because it IS activity — and as a line rather than a + // plan row, since admission may still refuse the plan it is preparing. + // Without it a foreground run looks frozen for the tens of seconds spent + // listing models, probing them and asking the router. + if status := strings.TrimSpace(m.planPreflight); status != "" { + add(" " + zeroTheme.muted.Render(truncateStep("· "+status, maxInt(6, width-2)))) + } // PLAN DETAIL: the selected task, drawn into the space that was otherwise // padded with blank lines down to the token floor. Last, so it only ever From fb80aecbf7410ca006ce6e098c8aad4d1086022c Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:35:48 +0530 Subject: [PATCH 83/86] feat(specialist): give a plan with no bound at all a wall backstop, and say when spend cannot be measured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plan that names no max_tokens has nothing to meter against — and on a provider that reports no usage, max_tokens would not have bounded it either: the meter sums the children's own usage events, and a provider emitting none leaves it at zero forever while the work runs. Nothing then stops the plan but the work finishing. SO A PLAN THAT ASKED FOR NOTHING GETS A WALL, and only that plan. An explicit wall wins, and a named token budget is left alone entirely — defaulting a wall over it would put a new ceiling on a plan deliberately running long inside a bound its author chose. An hour is far beyond the 3-5 minutes measured plans on this repo take; it exists to catch a runaway, not to discipline a slow plan. ONE SOURCE FOR THE WALL. Two sites read it — the context timeout that bounds children in flight, and the deadline that stops dispatching new tasks — and only one was reading the new value, so a backstop would have killed running work while still dispatching more. They now read the same function. AND THE REPORT SAYS WHEN SPEND COULD NOT BE MEASURED, because zero tokens after two successful tasks is not a cheap plan, it is an unmeasured one — and a max_tokens set against that number bounds nothing while reading like a guarantee. Verified while doing this and NOT built: update_plan already cannot reach a plan task. Named explicitly it is refused at admission with the tools it may use instead; inherited from the parent's grant it is filtered out. The reasoning is recorded at planReadOnlyTools and holds. --- internal/specialist/plan_exec.go | 51 ++++++++++++- internal/specialist/unmetered_plan_test.go | 85 ++++++++++++++++++++++ 2 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 internal/specialist/unmetered_plan_test.go diff --git a/internal/specialist/plan_exec.go b/internal/specialist/plan_exec.go index a34b0f536..18992c880 100644 --- a/internal/specialist/plan_exec.go +++ b/internal/specialist/plan_exec.go @@ -264,7 +264,12 @@ func ExecutePlanIn(ctx context.Context, plan Plan, workspace PlanWorkspace, pare // A deadline on the plan context fixes that with the machinery a user stop // already proves works, and the walk keeps its skip-the-rest behaviour for // tasks not yet dispatched. - if wall := plan.Budget().MaxWall; wall > 0 { + // ONE SOURCE FOR THE WALL, read here and at the deadline below. Two sites + // reading the budget differently is how they drift: this one bounds the + // children through the context, that one skips tasks not yet dispatched, and + // a plan whose backstop reached only one of them would kill work in flight + // while still dispatching more, or the reverse. + if wall := planWallBudget(plan.Budget()); wall > 0 { var cancelWall context.CancelFunc ctx, cancelWall = context.WithTimeout(ctx, wall) defer cancelWall() @@ -294,7 +299,7 @@ func ExecutePlanIn(ctx context.Context, plan Plan, workspace PlanWorkspace, pare spend := &planSpend{limit: int64(plan.Budget().MaxTokens)} deadline := time.Time{} - if wall := plan.Budget().MaxWall; wall > 0 { + if wall := planWallBudget(plan.Budget()); wall > 0 { deadline = time.Now().Add(wall) } // One stall timeout for the whole plan, resolved once. @@ -817,6 +822,37 @@ func withDependencyBriefing(task Task, results map[string]TaskResult) string { "## Your task\n\n" + task.Prompt } +// unmeteredWallBudget is the wall bound applied to a plan that asked for NO +// bound of any kind. +// +// EVERY PLAN SHOULD HAVE AT LEAST ONE. A plan with no max_tokens has nothing to +// meter against — and on a provider that reports no usage at all, max_tokens +// would not have bounded it either: the meter reads the child's usage events, +// and a provider that emits none leaves it at zero forever while the work runs. +// Nothing then stops the plan except the work finishing. +// +// Generous on purpose. Measured plans on this repo ran 3-5 minutes of wall time; +// an hour is far beyond any of them and exists to catch the runaway, not to +// discipline a slow plan. A caller who wants longer says so, and one who set +// max_tokens is left alone entirely — they already chose their bound. +const unmeteredWallBudget = time.Hour + +// planWallBudget is the wall bound a plan actually runs under: its own when it +// named one, and otherwise a default ONLY when it named no token bound either. +// +// Applied narrowly on purpose. Defaulting a wall for every plan would put a new +// ceiling over plans that deliberately run unbounded; this only catches the plan +// that asked for no bound at all, which is the one that cannot be stopped. +func planWallBudget(budget Budget) time.Duration { + if budget.MaxWall > 0 { + return budget.MaxWall + } + if budget.MaxTokens > 0 { + return 0 + } + return unmeteredWallBudget +} + // tokensPerToolCall is what one tool call costs a plan task, measured: a task // that made 28 calls spent 232,416 tokens, another 24 calls for 259,705. The // number is a rough proxy and is used as one — a model cannot see its own token @@ -1002,6 +1038,17 @@ func (report PlanReport) Summary() string { // fold said which — the reader had to diff the plan against the report to // find out. An incomplete answer that does not announce itself gets used as // a complete one. + // SPEND THAT COULD NOT BE MEASURED IS NOT SPEND THAT DID NOT HAPPEN. + // + // TokensUsed is summed from the children's own usage events, so a provider + // that emits none reports zero however much it billed — and a max_tokens set + // against that number bounds nothing at all while reading like a guarantee. + // Said plainly, because the alternative is a plan that appears to have cost + // nothing. + if report.TokensUsed == 0 && report.Succeeded > 0 { + b.WriteString("spend could not be measured: this provider reported no token usage, " + + "so max_tokens cannot bound a plan here — use max_wall_seconds instead.\n") + } if unrun := tasksCutForBudget(report.Tasks); len(unrun) > 0 { fmt.Fprintf(&b, "%d task(s) never ran (budget exhausted): %s\n", len(unrun), strings.Join(unrun, ", ")) } diff --git a/internal/specialist/unmetered_plan_test.go b/internal/specialist/unmetered_plan_test.go new file mode 100644 index 000000000..168dde6d2 --- /dev/null +++ b/internal/specialist/unmetered_plan_test.go @@ -0,0 +1,85 @@ +package specialist + +import ( + "context" + "strings" + "testing" + "time" +) + +// A PLAN THAT ASKED FOR NO BOUND STILL GETS ONE. +// +// With no max_tokens there is nothing to meter against — and on a provider that +// reports no usage, max_tokens would not have bounded it either: the meter sums +// the children's usage events, and a provider emitting none leaves it at zero +// forever while the work runs. Nothing then stops the plan but the work ending. +func TestAPlanWithNoBoundAtAllGetsAWallBackstop(t *testing.T) { + if got := planWallBudget(Budget{}); got != unmeteredWallBudget { + t.Errorf("a plan with neither bound got %v, want the wall backstop", got) + } + // AN EXPLICIT WALL WINS. The caller chose; the default is for the plan that + // chose nothing. + if got := planWallBudget(Budget{MaxWall: 90 * time.Second}); got != 90*time.Second { + t.Errorf("an explicit wall was overridden: %v", got) + } + // A TOKEN BOUND IS A BOUND. Defaulting a wall over it would put a new ceiling + // on a plan that deliberately runs long inside a budget it named. + if got := planWallBudget(Budget{MaxTokens: 500_000}); got != 0 { + t.Errorf("a plan with a token budget gained an unasked-for wall of %v", got) + } + if got := planWallBudget(Budget{MaxTokens: 500_000, MaxWall: time.Minute}); got != time.Minute { + t.Errorf("both set: %v", got) + } +} + +// AND THE BACKSTOP MUST REACH THE RUN, not just the helper. +func TestTheWallBackstopActuallyBoundsAnUnboundedPlan(t *testing.T) { + budget := map[string]any{"max_workers": float64(1)} + plan := mustPlan(t, []any{task("a", "x")}, budget, readOnlyLimits()) + if plan.Budget().MaxWall != 0 { + t.Fatal("setup: the fixture already carries a wall bound") + } + + var sawDeadline bool + ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(ctx context.Context, _ PlanTaskRequest) (TaskResult, error) { + if _, ok := ctx.Deadline(); ok { + sawDeadline = true + } + return TaskResult{Outcome: TaskSucceeded, Output: "ok"}, nil + }, nil) + + if !sawDeadline { + t.Error("a plan with no bound of any kind ran with no deadline on its context") + } +} + +// SPEND THAT COULD NOT BE MEASURED IS NOT SPEND THAT DID NOT HAPPEN. A provider +// emitting no usage reports zero however much it billed, and a max_tokens set +// against that number bounds nothing while reading like a guarantee. +func TestTheReportSaysWhenSpendCouldNotBeMeasured(t *testing.T) { + unmetered := PlanReport{ + Status: PlanCompleted, Succeeded: 2, TokensUsed: 0, + Tasks: []TaskResult{{ID: "a", Outcome: TaskSucceeded}, {ID: "b", Outcome: TaskSucceeded}}, + }.Summary() + if !strings.Contains(unmetered, "spend could not be measured") { + t.Errorf("a plan that reported no usage said nothing about it:\n%s", unmetered) + } + if !strings.Contains(unmetered, "max_wall_seconds") { + t.Errorf("the note does not say what to use instead:\n%s", unmetered) + } + + // A metered plan must not gain the line. + metered := PlanReport{ + Status: PlanCompleted, Succeeded: 1, TokensUsed: 12_000, + Tasks: []TaskResult{{ID: "a", Outcome: TaskSucceeded}}, + }.Summary() + if strings.Contains(metered, "could not be measured") { + t.Errorf("a metered plan claimed its spend was unmeasurable:\n%s", metered) + } + // Nor a plan where nothing ran — zero tokens is honest there. + empty := PlanReport{Status: PlanFailed, Succeeded: 0, TokensUsed: 0}.Summary() + if strings.Contains(empty, "could not be measured") { + t.Errorf("a plan that ran nothing reported an unmeasurable spend:\n%s", empty) + } +} From 849d31193c29b63503e21fc11c19173a215a2730 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:55:00 +0530 Subject: [PATCH 84/86] fix(config): project maxTurns may tighten only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cloned repo could raise the per-run tool-turn budget, increasing cost for whoever opened it. Project config now mirrors the rule PlanSize and DisableZeromaxing already follow three fields below; user config stays free to raise, being the user's own file and their own money. COMPARED AGAINST THE EFFECTIVE VALUE, NOT THE FIELD, and that is the whole of it. Zero does not mean "no limit" here, it means "fall back to defaultMaxTurns" — and that fallback runs AFTER this merge. The obvious guard, if src.MaxTurns > 0 && (dst.MaxTurns == 0 || src.MaxTurns < dst.MaxTurns) reads as tighten-only and is not: with no user config dst.MaxTurns is 0, the first clause holds, and the repo's 500 is taken. That is the common case — every user who never wrote a config — and the one the rule exists for. Both the original defect and that near-miss are pinned by tests. --- internal/config/max_turns_project_test.go | 76 +++++++++++++++++++++++ internal/config/resolver.go | 20 +++++- 2 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 internal/config/max_turns_project_test.go diff --git a/internal/config/max_turns_project_test.go b/internal/config/max_turns_project_test.go new file mode 100644 index 000000000..6482f82a5 --- /dev/null +++ b/internal/config/max_turns_project_test.go @@ -0,0 +1,76 @@ +package config + +import ( + "os" + "path/filepath" + "strconv" + "testing" +) + +// PROJECT CONFIG MAY ONLY TIGHTEN THE TURN BUDGET. +// +// A cloned repo setting maxTurns to the ceiling raises the per-run cost for +// whoever opens it — the same hazard PlanSize and DisableZeromaxing are +// tighten-only for. User config stays free to raise: it is the user's own file. +func TestProjectConfigMayLowerTheTurnBudgetButNeverRaiseIt(t *testing.T) { + for name, tc := range map[string]struct { + user, project, want int + }{ + "cannot raise a user value": {user: 80, project: 500, want: 80}, + "may lower a user value": {user: 80, project: 50, want: 50}, + "cannot raise from the default": {user: 0, project: 500, want: defaultMaxTurns}, + "may lower below the default": {user: 0, project: 50, want: 50}, + "equal changes nothing": {user: 80, project: 80, want: 80}, + } { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + userPath := filepath.Join(dir, "user.json") + projectPath := filepath.Join(dir, "project.json") + userBody := `{"providers":[{"name":"p","provider_kind":"openai-compatible","catalogID":"xai",` + + `"baseURL":"https://api.x.ai/v1","apiFormat":"chat-completions","model":"m","apiKey":"k"}],` + + `"activeProvider":"p"` + if tc.user > 0 { + userBody += `,"maxTurns":` + strconv.Itoa(tc.user) + } + userBody += `}` + if err := os.WriteFile(userPath, []byte(userBody), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(projectPath, []byte(`{"maxTurns":`+strconv.Itoa(tc.project)+`}`), 0o600); err != nil { + t.Fatal(err) + } + + resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath}) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if resolved.MaxTurns != tc.want { + t.Errorf("maxTurns = %d, want %d", resolved.MaxTurns, tc.want) + } + }) + } +} + +// ZERO MEANS "THE DEFAULT", NOT "NO LIMIT", and the distinction is the whole +// defect: the fallback to defaultMaxTurns happens AFTER the project merge, so +// treating an unset user value as unbounded lets a repo raise 80 to 500 for +// every user who never wrote a config — the common case, and the one this rule +// exists for. +func TestAnUnsetUserBudgetIsNotTreatedAsUnbounded(t *testing.T) { + dst := FileConfig{} + if err := mergeProjectConfig(&dst, FileConfig{MaxTurns: 500}); err != nil { + t.Fatalf("merge: %v", err) + } + if dst.MaxTurns != 0 { + t.Fatalf("a project raised the budget to %d against an unset user value; it must stay 0 so the default applies", dst.MaxTurns) + } +} + +// The user's own file may still raise it — higher trust, their own money. +func TestUserConfigMayStillRaiseTheTurnBudget(t *testing.T) { + dst := FileConfig{MaxTurns: 80} + mergeConfig(&dst, FileConfig{MaxTurns: 300}) + if dst.MaxTurns != 300 { + t.Errorf("user config could not raise its own budget: %d", dst.MaxTurns) + } +} diff --git a/internal/config/resolver.go b/internal/config/resolver.go index a1f6267a0..a506710c4 100644 --- a/internal/config/resolver.go +++ b/internal/config/resolver.go @@ -315,8 +315,26 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error { if activeProvider := strings.TrimSpace(src.ActiveProvider); activeProvider != "" { dst.ActiveProvider = activeProvider } + // PROJECT CONFIG MAY ONLY TIGHTEN THE TURN BUDGET. + // + // A cloned repo setting maxTurns to the ceiling raises the per-run cost for + // whoever opens it — the same hazard PlanSize and DisableZeromaxing are + // tighten-only for, three fields below. User config stays free to raise: it + // is the user's own file and their own money. + // + // COMPARED AGAINST THE EFFECTIVE VALUE, NOT THE FIELD. Zero here does not + // mean "no limit", it means "fall back to defaultMaxTurns" — and that + // fallback happens after this merge. Treating zero as unbounded lets a repo + // raise 80 to 500 for every user who never wrote a config, which is the + // common case and the one this rule exists for. if src.MaxTurns > 0 { - dst.MaxTurns = src.MaxTurns + effective := dst.MaxTurns + if effective == 0 { + effective = defaultMaxTurns + } + if src.MaxTurns < effective { + dst.MaxTurns = src.MaxTurns + } } for _, provider := range src.Providers { candidate := providerMergeCandidate(*dst, provider) From 9447cca33a1a79817384fc569be5f03f6a5d6f22 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:13:10 +0530 Subject: [PATCH 85/86] feat(specialist): a plan no longer dies because its inputs were cut short MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two runs of the same seven-task audit, 3.6 million tokens, and no report at all. Four finder tasks were stopped at their token budget — three of them holding substantial partial findings — and the verify, sweep and synthesis tasks that depended on them were every one skipped. The evidence to write the report was sitting in the results map the whole time. TWO SEPARATE LOSSES, and both had to go. WHAT THE CHILD WROTE WAS DISCARDED AT THE BOUNDARY. The kill path returned an empty Result, so a task stopped mid-investigation handed on nothing however much it had already found. summary.Text held it; nothing carried it. Status stays StatusError — the task did not succeed and must not read as a finished answer — but the text now travels with it. AND A DEPENDENT WAS SKIPPED IF ANY DEPENDENCY FELL SHORT. Now only if EVERY one did. A task with some evidence can do useful work and name what it lacked; a task with none would produce a confident answer drawn from nothing, which is worse than the gap. That is the line, and it is where the old rule sat one step too far to the left. LABELLED, NOT LAUNDERED. Partial input arrives under an INCOMPLETE heading, the briefing opens and closes by saying which inputs were cut short, and the reader is told plainly that an unfinished input's silence is not evidence of anything — nothing was said about X must not become X is fine. A FAILED dependency is still not partial evidence. It ran and did not deliver; its output is a harness diagnostic or an answer already judged wrong, and passing that on as a finding would launder a failure into a source. Only a CANCELLED task — one interrupted while working — carries anything forward. --- internal/specialist/exec.go | 18 ++ .../specialist/partial_dependency_test.go | 159 ++++++++++++++++++ internal/specialist/plan_exec.go | 95 ++++++++++- 3 files changed, 263 insertions(+), 9 deletions(-) create mode 100644 internal/specialist/partial_dependency_test.go diff --git a/internal/specialist/exec.go b/internal/specialist/exec.go index fc8f32538..f0a889f2a 100644 --- a/internal/specialist/exec.go +++ b/internal/specialist/exec.go @@ -715,7 +715,25 @@ func (executor Executor) runBuiltArgs(ctx context.Context, built BuildArgsResult // work that was billed: a plan's budget was never decremented for a failed // task and its total under-counted every one. Spend does not become // hypothetical because the task failed. + // AND WHAT IT WROTE BEFORE IT DIED. summary.Text holds everything the + // child emitted, and this branch returned an empty Result — so a task + // stopped at its token budget handed on NOTHING, however much it had + // already found. + // + // That is what turned a cut-short task into a lost one: four finders were + // stopped on budget with real partial findings, and every dependent was + // skipped because there was nothing to pass along. The work was done and + // paid for; discarding it at this boundary is the only reason it was lost. + // + // Status stays StatusError — the task did not succeed, and a caller must + // not read this as a finished answer. It is evidence, labelled as partial + // by whoever passes it on. + partial := strings.TrimSpace(summary.Text) return ExecResult{ + Result: tools.Result{ + Status: tools.StatusError, + Output: partial, + }, SessionID: built.SessionID, TotalTokens: summary.Usage.EffectiveTotalTokens(), ExitCode: summary.ExitCode, diff --git a/internal/specialist/partial_dependency_test.go b/internal/specialist/partial_dependency_test.go new file mode 100644 index 000000000..baedb5aee --- /dev/null +++ b/internal/specialist/partial_dependency_test.go @@ -0,0 +1,159 @@ +package specialist + +import ( + "context" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/streamjson" +) + +// A PLAN MUST NOT DIE BECAUSE ITS INPUTS WERE CUT SHORT. +// +// From a real run: four finder tasks stopped at their token budget, three of +// them holding substantial partial findings, and the verify, sweep and synthesis +// tasks that depended on them were all skipped. Two runs, 3.6 million tokens, no +// report — while the evidence to write one sat unread in the results map. +func TestADependentRunsWhenSomeOfItsDependenciesWereCutShort(t *testing.T) { + plan := mustPlan(t, []any{ + task("f1", "audit surface one"), + task("f2", "audit surface two"), + task("verify", "attack every claim", "f1", "f2"), + }, okBudget(), readOnlyLimits()) + + var verifyPrompt string + report := ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + switch req.Task.ID { + case "f1": + return TaskResult{Outcome: TaskSucceeded, Output: "f1 found: engine.go:143 evaluates first"}, nil + case "f2": + // Stopped at its budget, having written real findings first. + return TaskResult{ + Outcome: TaskCancelled, + Output: "f2 found so far: runner.go:163 wires the keys", + Err: `task "f2" stopped: the plan's token budget ran out while it was running`, + }, nil + default: + verifyPrompt = req.Task.Prompt + return TaskResult{Outcome: TaskSucceeded, Output: "verified"}, nil + } + }, nil) + + if verifyPrompt == "" { + t.Fatalf("the dependent was skipped even though a dependency had findings: %+v", report.Tasks) + } + if !strings.Contains(verifyPrompt, "engine.go:143") { + t.Errorf("the succeeded dependency's findings are missing:\n%s", verifyPrompt) + } + if !strings.Contains(verifyPrompt, "runner.go:163") { + t.Errorf("the cut-short dependency's partial findings were discarded:\n%s", verifyPrompt) + } + // LABELLED, and that is not optional: a reader handed an incomplete answer as + // if it were finished treats its silences as findings. + if !strings.Contains(verifyPrompt, "INCOMPLETE") { + t.Errorf("partial work was presented as a finished result:\n%s", verifyPrompt) + } + if !strings.Contains(verifyPrompt, "not absent") { + t.Errorf("the briefing does not say an unfinished input's silence proves nothing:\n%s", verifyPrompt) + } +} + +// WITH NOTHING TO WORK FROM, IT IS STILL SKIPPED. A task drawing a confident +// answer out of no evidence is worse than the gap it would have left. +func TestADependentIsStillSkippedWhenNoDependencyProducedAnything(t *testing.T) { + plan := mustPlan(t, []any{ + task("f1", "audit"), + task("verify", "attack every claim", "f1"), + }, okBudget(), readOnlyLimits()) + + dispatched := 0 + report := ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + dispatched++ + return TaskResult{Outcome: TaskCancelled, Output: "", Err: "stopped before it wrote anything"}, nil + }, nil) + + if dispatched != 1 { + t.Fatalf("a dependent ran with no evidence at all: %d dispatches", dispatched) + } + byID := map[string]TaskResult{} + for _, result := range report.Tasks { + byID[result.ID] = result + } + if byID["verify"].Outcome != TaskSkippedDependency { + t.Errorf("verify = %q, want skipped", byID["verify"].Outcome) + } + if !strings.Contains(byID["verify"].Err, "no dependency produced anything") { + t.Errorf("the skip reason does not say why: %q", byID["verify"].Err) + } +} + +// A FAILED DEPENDENCY IS NOT PARTIAL EVIDENCE. It ran and did not deliver; its +// output is a harness diagnostic or an answer already judged wrong, and passing +// that on as a finding would launder a failure into a source. +func TestAFailedDependencyIsNotTreatedAsPartialEvidence(t *testing.T) { + briefed := withDependencyBriefing( + Task{ID: "v", Prompt: "judge", DependsOn: []string{"failed", "cancelled"}}, + map[string]TaskResult{ + "failed": {Outcome: TaskFailed, Output: "Subagent failed (exit 3)\nerrors: provider request error"}, + "cancelled": {Outcome: TaskCancelled, Output: "real partial finding at foo.go:12"}, + }, + ) + if strings.Contains(briefed, "Subagent failed") { + t.Error("a failed task's diagnostic was handed to a dependent as a finding") + } + if !strings.Contains(briefed, "real partial finding at foo.go:12") { + t.Error("the cut-short task's genuine partial work was discarded") + } +} + +// WHAT A CUT-SHORT CHILD WROTE MUST SURVIVE THE BOUNDARY. +// +// The kill path returned an empty Result, so a task stopped at its token budget +// handed on nothing however much it had already found — and every dependent was +// then skipped for want of evidence that existed. Driven through the real +// Executor, because the discard happened at exactly that seam. +func TestATaskStoppedAtItsBudgetStillHandsOnWhatItWrote(t *testing.T) { + exec := Executor{ + BinaryPath: "/bin/true", + NewSessionID: func() (string, error) { return "specialist_00000000000000000000000a", nil }, + Load: func(LoadOptions) (LoadResult, error) { return LoadResult{}, nil }, + RunChild: func(ctx context.Context, _ string, _ []string, progress func(streamjson.Event)) (ChildRunResult, error) { + // Writes real findings, then keeps spending until the meter stops it. + events := []streamjson.Event{ + {Type: streamjson.EventText, Delta: "found: engine.go:143 evaluates paths first"}, + } + for _, event := range events { + if progress != nil { + progress(event) + } + } + for round := 0; round < 10; round++ { + spent := 50_000 + usage := streamjson.Event{Type: streamjson.EventUsage, TotalTokens: &spent} + events = append(events, usage) + if progress != nil { + progress(usage) + } + if ctx.Err() != nil { + return ChildRunResult{Started: true, ExitCode: -1, Events: events}, ctx.Err() + } + } + return ChildRunResult{Started: true, Events: events}, nil + }, + } + run := NewPlanRunner(PlanTaskContext{Executor: exec, Cwd: t.TempDir(), SpecialistName: "explorer"}) + result, _ := run(context.Background(), PlanTaskRequest{ + Task: Task{ID: "finder", Prompt: "audit"}, + Tools: []string{"read_file"}, + MaxTaskTokens: 120_000, + }) + + if result.Outcome != TaskCancelled { + t.Fatalf("the budget did not stop the task: %s", result.Outcome) + } + if !strings.Contains(result.Output, "engine.go:143") { + t.Fatalf("the work it had already done was discarded at the boundary: %q", result.Output) + } +} diff --git a/internal/specialist/plan_exec.go b/internal/specialist/plan_exec.go index 18992c880..bfc0b24f2 100644 --- a/internal/specialist/plan_exec.go +++ b/internal/specialist/plan_exec.go @@ -446,11 +446,12 @@ func ExecutePlanIn(ctx context.Context, plan Plan, workspace PlanWorkspace, pare continue } - if blocker, blocked := firstFailedDependency(task, failed); blocked { + if missing, skip := unusableDependencies(task, results); skip { result := TaskResult{ ID: id, Outcome: TaskSkippedDependency, - Err: fmt.Sprintf("skipped: dependency %q did not succeed", blocker), + Err: fmt.Sprintf("skipped: no dependency produced anything to work from (%s)", + strings.Join(missing, ", ")), } results[id] = result failed[id] = true // its own dependents are blocked too @@ -785,16 +786,38 @@ func withDependencyBriefing(task Task, results map[string]TaskResult) string { return task.Prompt } var brief strings.Builder + var unfinished []string remaining := dependencyBriefingTotal for _, id := range task.DependsOn { result, ok := results[id] - if !ok || result.Outcome != TaskSucceeded { + if !ok { + continue + } + // PARTIAL WORK IS STILL WORK. A dependency cut short at its budget was + // investigating right up to the moment it stopped, and what it wrote + // before then is real evidence. Discarding it is how four cancelled + // finders took a whole plan down with them while their findings sat + // unread in the results map. + // + // LABELLED, though, and that is not optional: a reader handed an + // incomplete answer as if it were finished will treat its silences as + // findings. Nothing was said about X becomes X is fine. + // CANCELLED, NOT MERELY UNSUCCESSFUL. A cancelled task was investigating + // when it was stopped, so what it wrote is evidence. A FAILED task ran + // and did not deliver: its output is a harness diagnostic or an answer + // already judged wrong, and passing that on as a finding would launder a + // failure into a source. + partial := result.Outcome == TaskCancelled + if result.Outcome != TaskSucceeded && !partial { continue } output := strings.TrimSpace(result.Output) if output == "" || remaining <= 0 { continue } + if partial { + unfinished = append(unfinished, id) + } budget := dependencyBriefingPerTask if budget > remaining { budget = remaining @@ -804,7 +827,11 @@ func withDependencyBriefing(task Task, results map[string]TaskResult) string { output, truncated = output[:budget], true } remaining -= len(output) - fmt.Fprintf(&brief, "### Result of task %q\n%s\n", id, output) + heading := fmt.Sprintf("### Result of task %q", id) + if partial { + heading += " — INCOMPLETE, this task was stopped before it finished" + } + fmt.Fprintf(&brief, "%s\n%s\n", heading, output) if truncated { // SAID, not silent. A reader that cannot tell it was given part of an // answer will treat the part as the whole. @@ -815,11 +842,19 @@ func withDependencyBriefing(task Task, results map[string]TaskResult) string { if brief.Len() == 0 { return task.Prompt } - return "## What the tasks you depend on already found\n\n" + - brief.String() + - "Use this instead of rediscovering it. Verify anything you are about to rely on — " + - "a claim above is a previous task's conclusion, not established fact.\n\n" + - "## Your task\n\n" + task.Prompt + preamble := "## What the tasks you depend on already found\n\n" + closing := "Use this instead of rediscovering it. Verify anything you are about to rely on — " + + "a claim above is a previous task's conclusion, not established fact.\n\n" + if len(unfinished) > 0 { + // SAY IT TWICE, at the top and at the bottom, because this is the sentence + // that stops a partial answer being reported as a complete one. + preamble += "Some of these tasks were STOPPED BEFORE THEY FINISHED (" + + strings.Join(unfinished, ", ") + "). What they wrote is real, and what they did not " + + "reach is unknown — not absent.\n\n" + closing += "Say plainly in your answer which inputs were incomplete and what that leaves " + + "uncovered. An unfinished input's silence is not evidence of anything.\n\n" + } + return preamble + brief.String() + closing + "## Your task\n\n" + task.Prompt } // unmeteredWallBudget is the wall bound applied to a plan that asked for NO @@ -917,6 +952,48 @@ func firstFailedDependency(task Task, failed map[string]bool) (string, bool) { return "", false } +// unusableDependencies reports the dependencies that produced NOTHING a +// dependent could work from, and whether the task should be skipped entirely. +// +// SKIPPED ONLY WHEN EVERY DEPENDENCY CAME BACK EMPTY. It used to be skipped when +// ANY did, and that cost a real run everything: four finder tasks were cut short +// on budget, three of them holding substantial partial findings, and the verify, +// sweep and synthesis tasks that depended on them were all skipped. Two runs, +// 3.6 million tokens, and no report — while the evidence to write one sat in the +// results map. +// +// A task with SOME evidence can still do useful work and say what it lacked; a +// task with NONE cannot, and running it would produce a confident answer drawn +// from nothing, which is worse than the gap. That is the line. +// +// Partial output from a cancelled dependency counts as evidence. A task stopped +// at its budget was working right up to the moment it stopped, and what it wrote +// before then is real — see withDependencyBriefing, which labels it as +// incomplete so nobody mistakes it for a finished answer. +func unusableDependencies(task Task, results map[string]TaskResult) (missing []string, skip bool) { + if len(task.DependsOn) == 0 { + return nil, false + } + usable := 0 + for _, dep := range task.DependsOn { + result, ran := results[dep] + if ran && result.Outcome == TaskSucceeded { + usable++ + continue + } + if ran && result.Outcome == TaskCancelled && strings.TrimSpace(result.Output) != "" { + // Cut short, but it wrote something before it was. A FAILED + // dependency does not count: it ran and did not deliver, and its + // output is a diagnostic rather than a finding. + usable++ + continue + } + missing = append(missing, dep) + } + sort.Strings(missing) + return missing, usable == 0 +} + // planToolGrant intersects a task's requested tools with the parent's grant. // An empty request inherits the parent's read-only grant; it never widens it. // From 7aacde5f18492910c4bb911391a6db5d56105cf9 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:51:55 +0530 Subject: [PATCH 86/86] feat(specialist): reserve budget for later work, and declare the plan schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six runs of one seven-task audit, none of which produced a report. Each failure had a different immediate cause and the same shape: the plan spent its budget and returned nothing. RESERVE, IN THREE LAYERS, because fixing one exposed the next. Feeders consuming the whole budget left nothing to dispatch verify, sweep or synthesis with, so work that others wait on now stops early. A ceiling alone did not hold: four tasks in flight each overshoot by about one usage event, and with max_workers 4 that overshoot was the size of the reserve itself, drawn from the same account it was capping. Two pools fixed that — upstream cannot touch downstream's share however far it overruns. Then a sticky budgetExhausted flag undid it anyway: the moment ANY task was skipped for budget, every task after it was too, including work drawing from a pool nobody had touched. Per pool now. SIZED BY THE PLAN, NOT THE BUDGET. A flat quarter gave three downstream tasks 125,000 between them; verify and sweep used 149,769 and synthesis was skipped. Three of seven tasks downstream reserves three sevenths: a plan that is mostly synthesis keeps most of its budget for synthesis, one that is mostly finding keeps little, and a plan with no dependencies keeps all of it. THE SCHEMA DECLARES ITS SHAPE. tasks was a bare array with a paragraph of English, so a model composing a plan had to invent the field names — and read "an optional read-only tool subset", which names no tool, as tools: ["read-only"]. Twice. The item schema now types every field and the tools enum comes from PlanGrantableToolNames, which admission already validates against and which was exported for exactly this ("ONE list ... so the grant and the validator cannot come to disagree"); the schema was the one caller not using it. AND THE NUMBERS ARE EXPLAINED WHERE THEY ARE CHOSEN. max_tokens bounds the whole plan and max_tokens_per_task bounds one task; setting both so the cap can never be reached is now refused with the arithmetic rather than silently resolved in favour of the smaller. An undersized budget is warned about BEFORE the first task rather than in the output afterwards — the estimate was computed correctly on five consecutive runs and read by nobody, because it rode a result that arrives once the run is already over. Estimates are weighted by position: a task that reads a repository has measured 510k-1,017k, one that reads its dependencies' findings 62k-87k. Pricing both alike over-estimated a correct plan by 60%, and a warning that cries wolf is worse than none. --- .../specialist/budget_enforcement_test.go | 29 +- internal/specialist/budget_reserve_test.go | 362 ++++++++++++++++++ internal/specialist/per_task_budget_test.go | 166 ++++++++ internal/specialist/plan.go | 103 +++++ internal/specialist/plan_exec.go | 214 ++++++++++- internal/specialist/plan_runner.go | 5 +- internal/specialist/plan_tool.go | 78 +++- 7 files changed, 924 insertions(+), 33 deletions(-) create mode 100644 internal/specialist/budget_reserve_test.go create mode 100644 internal/specialist/per_task_budget_test.go diff --git a/internal/specialist/budget_enforcement_test.go b/internal/specialist/budget_enforcement_test.go index 3ab0ac426..02d8ef58b 100644 --- a/internal/specialist/budget_enforcement_test.go +++ b/internal/specialist/budget_enforcement_test.go @@ -146,7 +146,7 @@ func TestThePlanBudgetStopsATaskThatCrossesItWhileRunning(t *testing.T) { }, } run := NewPlanRunner(PlanTaskContext{Executor: exec, Cwd: t.TempDir(), SpecialistName: "explorer"}) - spend := &planSpend{limit: 120_000} + spend := &planSpend{limit: 120_000, downstreamTasks: 1, totalTasks: 4} result, _ := run(context.Background(), PlanTaskRequest{ Task: Task{ID: "solo", Prompt: "p"}, Tools: []string{"read_file"}, @@ -160,19 +160,26 @@ func TestThePlanBudgetStopsATaskThatCrossesItWhileRunning(t *testing.T) { t.Errorf("the reason does not name the plan budget: %q", result.Err) } // Bounded overshoot: one usage event past the line, not five. - if spent := spend.spent.Load(); spent > 200_000 { + if spent := spend.upstream.Load(); spent > 200_000 { t.Errorf("overshoot is unbounded: spent %d against a 120000 limit", spent) } } -// An unbounded plan must behave exactly as it always did. +// An unbounded plan must behave exactly as it always did: the meter still +// counts, and no pool exists to cross. func TestAnUnboundedPlanIsNeverStoppedByTheMeter(t *testing.T) { - spend := &planSpend{limit: 0} + spend := &planSpend{limit: 0, downstreamTasks: 1, totalTasks: 4} for round := 0; round < 100; round++ { - if spend.add(1_000_000) { + if spend.overPool(spend.add(1_000_000, true), true) { t.Fatal("an unset limit reported the plan over budget") } } + if got := spend.ceilingFor(true); got != 0 { + t.Errorf("an unbounded plan produced a feeder ceiling of %d", got) + } + if got := spend.ceilingFor(false); got != 0 { + t.Errorf("an unbounded plan produced a dependent ceiling of %d", got) + } } // THE HEADLINE MUST NAME WHAT NEVER RAN. A budget-skipped task says so in the @@ -187,7 +194,7 @@ func TestTheSummaryNamesTheTasksABudgetCutShort(t *testing.T) { {ID: "skills-mcp-surface", Outcome: TaskSkippedBudget, Err: "skipped: the plan's budget was exhausted"}, }, }.Summary() - if !strings.Contains(summary, "2 task(s) never ran (budget exhausted)") { + if !strings.Contains(summary, "2 task(s) never ran") { t.Fatalf("the headline hides the unanswered questions:\n%s", summary) } for _, id := range []string{"plan-inherit", "skills-mcp-surface"} { @@ -244,7 +251,7 @@ func TestAPerTaskCapAboveThePlanBudgetIsRefused(t *testing.T) { // exists to prevent. func TestATaskCancelledForBudgetIsNeverCountedAsSucceeded(t *testing.T) { budget := okBudget() - budget["max_tokens"] = float64(150_000) + budget["max_tokens"] = float64(1_500_000) plan := mustPlan(t, []any{task("a", "x"), task("b", "y"), task("c", "z")}, budget, readOnlyLimits()) report := ExecutePlan(context.Background(), plan, []string{"read_file"}, @@ -253,7 +260,7 @@ func TestATaskCancelledForBudgetIsNeverCountedAsSucceeded(t *testing.T) { return TaskResult{ ID: req.Task.ID, Outcome: TaskCancelled, - Tokens: 90_000, + Tokens: 900_000, Err: "task " + req.Task.ID + " stopped: the plan's token budget ran out while it was running", }, nil }, nil) @@ -273,7 +280,9 @@ func TestATaskCancelledForBudgetIsNeverCountedAsSucceeded(t *testing.T) { } } // And the headline must say so, since this is what makes the answer partial. - if summary := report.Summary(); !strings.Contains(summary, "never ran (budget exhausted)") { + // CUT SHORT, not "never ran": these tasks ran and were stopped, and the + // difference is what tells a partial report from an empty one. + if summary := report.Summary(); !strings.Contains(summary, "cut short mid-run") { t.Errorf("the headline hides that the plan was cut short:\n%s", summary) } } @@ -282,7 +291,7 @@ func TestATaskCancelledForBudgetIsNeverCountedAsSucceeded(t *testing.T) { // waiting for, so running them on nothing would compound the loss. func TestDependentsOfACancelledTaskAreSkipped(t *testing.T) { budget := okBudget() - budget["max_tokens"] = float64(150_000) + budget["max_tokens"] = float64(600_000) plan := mustPlan(t, []any{task("trace", "x"), task("judge", "y", "trace")}, budget, readOnlyLimits()) dispatched := 0 diff --git a/internal/specialist/budget_reserve_test.go b/internal/specialist/budget_reserve_test.go new file mode 100644 index 000000000..58746fecc --- /dev/null +++ b/internal/specialist/budget_reserve_test.go @@ -0,0 +1,362 @@ +package specialist + +import ( + "context" + "strings" + "sync" + "testing" +) + +// A TASK OTHERS DEPEND ON STOPS EARLIER THAN ONE NOTHING DEPENDS ON. +// +// Four finders consumed a whole budget between them and the verify, sweep and +// synthesis tasks were never dispatched: a measured plan spent 712,222 against +// 500,000 and returned no report, because everything downstream of the finders +// was skipped for a budget the finders had already spent. +func TestATaskWithDependentsStopsBeforeTheWholeBudgetIsGone(t *testing.T) { + spend := &planSpend{limit: 1_000_000, downstreamTasks: 1, totalTasks: 4} + // A feeder is held to three quarters, so a quarter survives for the work + // that waits on it. + if got := spend.ceilingFor(false); got != 750_000 { + t.Errorf("upstream ceiling = %d, want 750000", got) + } + // Untouched by feeders, a dependent may have what they did not use. + if got := spend.ceilingFor(true); got != 1_000_000 { + t.Errorf("downstream ceiling = %d, want the whole budget while upstream has spent nothing", got) + } + + // AND THE RESERVE IS A FLOOR, not a share of what is left. Feeders + // overshooting their own ceiling must not shrink it — that overshoot is what + // ate the reserve when both drew from one pool. + spend.upstream.Store(950_000) + if got := spend.ceilingFor(true); got != 250_000 { + t.Errorf("after upstream overshot to 950000 the downstream pool was %d, want the 250000 reserve", got) + } + + // Unbounded stays unbounded. + empty := &planSpend{downstreamTasks: 1, totalTasks: 4} + if got := empty.ceilingFor(true); got != 0 { + t.Errorf("an unbounded plan gained a ceiling of %d", got) + } +} + +// THE RESERVE MUST REACH THE RUN, computed from the real dependency graph. +func TestTheReserveIsAppliedFromThePlansOwnGraph(t *testing.T) { + budget := okBudget() + budget["max_tokens"] = float64(1_000_000) + plan := mustPlan(t, []any{ + task("finder", "gather"), + task("synthesis", "summarise", "finder"), + }, budget, readOnlyLimits()) + + waits := map[string]bool{} + ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + waits[req.Task.ID] = req.WaitsOnOtherTasks + return TaskResult{Outcome: TaskSucceeded, Output: "ok"}, nil + }, nil) + + if waits["finder"] { + t.Error("a task that waits on nothing was put in the reserved pool") + } + if !waits["synthesis"] { + t.Error("a task that waits on another was not put in the reserved pool, so its feeder can starve it") + } +} + +// A POOL IS ONLY A BOUND WHEN THE PLAN HAS ONE. An unbounded plan crosses +// nothing, however much it spends. +func TestAnUnboundedPlanCrossesNoPool(t *testing.T) { + spend := &planSpend{limit: 0, downstreamTasks: 1, totalTasks: 4} + if spend.overPool(spend.add(10_000_000, true), true) { + t.Error("an unbounded plan reported downstream work over its pool") + } + if spend.overPool(spend.add(10_000_000, false), false) { + t.Error("an unbounded plan reported upstream work over its pool") + } +} + +// A FEEDER'S OVERSHOOT MUST NOT CROSS THE DEPENDENT'S POOL. This is the whole +// point of separating them: one shared counter meant four feeders overshooting +// by one usage event each consumed the reserve, and every dependent was then +// refused for a budget that was never theirs to spend. +func TestAFeederOvershootDoesNotExhaustTheDependentPool(t *testing.T) { + spend := &planSpend{limit: 500_000, downstreamTasks: 1, totalTasks: 4} + // Upstream blows past its 375,000 ceiling, exactly as four finders in flight did. + total := spend.add(534_144, false) + if !spend.overPool(total, false) { + t.Fatal("the upstream pool did not register as crossed") + } + // The downstream pool is untouched and still holds its reserve. + if spend.overPool(spend.add(1_000, true), true) { + t.Error("an upstream overshoot closed the downstream pool, which is the defect this separation exists to fix") + } + if got := spend.ceilingFor(true); got != 125_000 { + t.Errorf("downstream pool = %d, want the 125000 reserve intact", got) + } +} + +// THE TWO WAYS A BUDGET TAKES A TASK MEAN DIFFERENT THINGS TO A READER: one is +// a partial answer in this report, the other a question nobody asked. +func TestTheReportSeparatesCutShortFromNeverRan(t *testing.T) { + summary := PlanReport{ + Status: PlanPartial, TokensUsed: 712_222, TokenLimit: 500_000, + Tasks: []TaskResult{ + {ID: "finder-a", Outcome: TaskCancelled, Output: "found x", Err: "stopped: the plan's token budget ran out while it was running"}, + {ID: "verify", Outcome: TaskSkippedBudget, Err: "skipped: the plan's budget was exhausted before this task ran"}, + }, + }.Summary() + + if !strings.Contains(summary, "712222/500000") { + t.Errorf("the report does not say what was spent against what:\n%s", summary) + } + if !strings.Contains(summary, "cut short mid-run") || !strings.Contains(summary, "finder-a") { + t.Errorf("a task that ran and was stopped is not reported as such:\n%s", summary) + } + if !strings.Contains(summary, "never ran") || !strings.Contains(summary, "verify") { + t.Errorf("a question nobody asked is not reported as such:\n%s", summary) + } +} + +// THE FLOOR CATCHES THE IMPOSSIBLE; THIS CATCHES THE MERELY WRONG. A seven-task +// audit was admitted with 500,000 tokens — over the floor, an eighth of what it +// went on to spend — and nothing told the author until the run had failed. +func TestAnUndersizedBudgetIsWarnedAboutWhileItCanStillBeChanged(t *testing.T) { + // The shape that failed: four finders reading the repo, three tasks working + // from what they found. + shape := []Task{ + {ID: "f1"}, {ID: "f2"}, {ID: "f3"}, {ID: "f4"}, + {ID: "verify", DependsOn: []string{"f1"}}, + {ID: "sweep", DependsOn: []string{"f1"}}, + {ID: "synthesis", DependsOn: []string{"verify"}}, + } + warning := warnBudgetLooksLow(Budget{MaxTokens: 500_000}, shape) + if warning == "" { + t.Fatal("the budget that produced a failed seven-task run drew no warning") + } + for _, want := range []string{"500000", "7 tasks", "510k-1,017k", "omit max_tokens"} { + if !strings.Contains(warning, want) { + t.Errorf("the warning does not mention %q: %s", want, warning) + } + } + // NOT A REFUSAL, and not fired on a plan that budgeted properly. + // 4 x 1M + 3 x 150k = 4.45M, so a 5M budget is comfortably sized and must + // draw no warning: an alarm on a correct plan teaches the author to ignore it. + if got := warnBudgetLooksLow(Budget{MaxTokens: 5_000_000}, shape); got != "" { + t.Errorf("a well-sized budget was warned about: %s", got) + } + // DOWNSTREAM WORK IS NOT PRICED LIKE A FINDER. Seven tasks that all wait on + // something need far less than seven that all read the repo. + allDownstream := make([]Task, 7) + for i := range allDownstream { + allDownstream[i] = Task{ID: "t", DependsOn: []string{"x"}} + } + if got := warnBudgetLooksLow(Budget{MaxTokens: 1_500_000}, allDownstream); got != "" { + t.Errorf("a plan of cheap downstream tasks was priced as if every task read the repo: %s", got) + } + // Nor on an unbounded plan, which has made no claim to be wrong about. + if got := warnBudgetLooksLow(Budget{}, shape); got != "" { + t.Errorf("an unbounded plan was warned about: %s", got) + } +} + +// THE RUN THAT PROVED IT, END TO END. +// +// Four feeders overshoot the whole budget between them; the dependent must still +// be dispatched and run on what they found. Before the pools were separated this +// is exactly what failed: feeders capped at 375,000 landed at 534,144, and +// verify, sweep and synthesis were every one skipped for a budget that was never +// theirs — two runs, no report, while the findings sat in the results map. +func TestDependentsStillRunAfterFeedersOverspendTheirPool(t *testing.T) { + budget := okBudget() + budget["max_tokens"] = float64(2_200_000) + budget["max_workers"] = float64(4) + plan := mustPlan(t, []any{ + task("f1", "find"), task("f2", "find"), task("f3", "find"), task("f4", "find"), + task("verify", "attack the claims", "f1", "f2", "f3", "f4"), + task("synthesis", "report what survived", "verify"), + }, budget, readOnlyLimits()) + + // LOCKED, because max_workers is 4 and that is the point of this test: four + // task goroutines record into this at once. + var mu sync.Mutex + dispatched := map[string]bool{} + report := ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + mu.Lock() + dispatched[req.Task.ID] = true + mu.Unlock() + if strings.HasPrefix(req.Task.ID, "f") { + // Each finder overshoots its share, as four in flight do. + return TaskResult{ + Outcome: TaskCancelled, + Tokens: 600_000, + Output: "partial finding from " + req.Task.ID, + Err: `task stopped: the plan's token budget ran out while it was running`, + }, nil + } + return TaskResult{Outcome: TaskSucceeded, Tokens: 90_000, Output: "done"}, nil + }, nil) + + mu.Lock() + defer mu.Unlock() + if !dispatched["verify"] { + t.Fatalf("the dependent was never dispatched after its feeders overspent: %+v", report.Tasks) + } + if !dispatched["synthesis"] { + t.Errorf("the terminal task never ran, so the plan produced no report: %+v", report.Tasks) + } + if report.Succeeded < 2 { + t.Errorf("expected verify and synthesis to succeed on partial findings, got %d: %+v", + report.Succeeded, report.Tasks) + } +} + +// AND A DEPENDENT POOL CAN STILL BE EXHAUSTED. The reserve is a bound, not an +// exemption: work that overspends its own share is stopped like anything else. +func TestADependentIsStillStoppedWhenItsOwnPoolRunsOut(t *testing.T) { + budget := okBudget() + budget["max_tokens"] = float64(1_400_000) + plan := mustPlan(t, []any{ + task("f1", "find"), + task("t1", "report", "f1"), + task("t2", "report", "f1"), + }, budget, readOnlyLimits()) + + dispatched := 0 + ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + dispatched++ + if req.Task.ID == "f1" { + return TaskResult{Outcome: TaskSucceeded, Tokens: 35_000, Output: "found"}, nil + } + // A dependent that eats the entire reserve on its own. + return TaskResult{Outcome: TaskSucceeded, Tokens: 1_400_000, Output: "done"}, nil + }, nil) + + if dispatched > 2 { + t.Errorf("a dependent that exhausted its own pool did not stop the next one: %d dispatched", dispatched) + } +} + +// A PLAN WITH NO DEPENDENCY EDGES KEEPS ITS WHOLE BUDGET. +// +// The reserve exists to protect later work from earlier work. With no later +// work, holding a quarter back protects nothing and simply cuts the plan short +// of a budget its author set deliberately. +func TestAPlanWithNoDependenciesIsNotChargedTheReserve(t *testing.T) { + budget := okBudget() + budget["max_tokens"] = float64(400_000) + plan := mustPlan(t, []any{task("a", "x"), task("b", "y"), task("c", "z"), task("d", "w")}, budget, readOnlyLimits()) + + dispatched := 0 + ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + dispatched++ + if req.WaitsOnOtherTasks { + t.Errorf("task %q waits on nothing but was put in the reserved pool", req.Task.ID) + } + // Together these come to 480,000. The fourth is dispatched only if the + // full 400,000 is available: a quarter-reserve would leave 300,000 and + // stop it after the third. + return TaskResult{Outcome: TaskSucceeded, Tokens: 120_000, Output: "ok"}, nil + }, nil) + + if dispatched != 4 { + t.Errorf("a plan with no dependencies ran %d of 4 tasks; a reserve it has no use for cut it short", dispatched) + } +} + +// THE RESERVE IS SIZED BY THE PLAN, NOT BY THE BUDGET. +// +// It was a flat quarter. A seven-task plan with three downstream tasks gave them +// 125,000 between them; verify and sweep used 149,769 and synthesis was skipped +// for a budget that had been reserved on their behalf. The idea was right and +// the divisor was measuring the wrong thing. +func TestTheReserveScalesWithHowMuchDownstreamWorkThereIs(t *testing.T) { + // Four finders, three downstream: three sevenths held back. + seven := &planSpend{limit: 700_000, downstreamTasks: 3, totalTasks: 7} + if got := seven.reserve(); got != 300_000 { + t.Errorf("reserve = %d, want 300000 (three sevenths of 700000)", got) + } + if got := seven.ceilingFor(false); got != 400_000 { + t.Errorf("upstream ceiling = %d, want 400000", got) + } + + // A plan that is mostly synthesis keeps most of its budget for synthesis. + mostly := &planSpend{limit: 1_000_000, downstreamTasks: 8, totalTasks: 10} + if got := mostly.reserve(); got != 800_000 { + t.Errorf("reserve = %d, want 800000 (eight tenths)", got) + } + + // A plan that is mostly finding keeps little, which is correct in the other + // direction: there is barely any later work to protect. + barely := &planSpend{limit: 1_000_000, downstreamTasks: 1, totalTasks: 10} + if got := barely.reserve(); got != 100_000 { + t.Errorf("reserve = %d, want 100000 (one tenth)", got) + } + + // No downstream work reserves nothing, and the whole budget stays available. + none := &planSpend{limit: 1_000_000, downstreamTasks: 0, totalTasks: 5} + if got := none.reserve(); got != 0 { + t.Errorf("reserve = %d, want none", got) + } + if got := none.ceilingFor(false); got != 1_000_000 { + t.Errorf("with no later work the upstream ceiling was %d, want the whole budget", got) + } +} + +// THE RUN THAT PROVED IT: three downstream tasks must fit in their own share. +func TestThreeDownstreamTasksFitTheReserveThatFlatQuarterDenied(t *testing.T) { + // The observed spend: verify 86,986 and sweep 62,783 came to 149,769, which a + // flat quarter of 500,000 (125,000) could not hold. + flat := int64(500_000) / 4 + if flat >= 149_769 { + t.Fatal("setup: the flat quarter would have been enough, so this proves nothing") + } + proportional := (&planSpend{limit: 500_000, downstreamTasks: 3, totalTasks: 7}).reserve() + if proportional < 149_769 { + t.Errorf("the proportional reserve is %d, still short of the 149769 that was actually needed", proportional) + } +} + +// THE CAP MUST REACH A REAL RUN, not just the meter. +// +// Unwiring the plan's task counts leaves the reserve at zero, which reads as +// "no later work to protect" and hands every task the whole budget. That is +// MORE permissive, so no test asserting dependents still run can notice it — +// the thing to assert is that upstream work is actually capped. +func TestUpstreamWorkIsCappedInARealRun(t *testing.T) { + budget := okBudget() + budget["max_tokens"] = float64(2_300_000) + // Two upstream, two downstream: half the budget is reserved, so upstream + // stops after 200,000. + plan := mustPlan(t, []any{ + task("f1", "find"), task("f2", "find"), + task("v1", "check", "f1"), task("v2", "check", "f2"), + }, budget, readOnlyLimits()) + + var mu sync.Mutex + ran := map[string]bool{} + report := ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + mu.Lock() + ran[req.Task.ID] = true + mu.Unlock() + if strings.HasPrefix(req.Task.ID, "f") { + // Each finder alone exhausts the upstream half. + return TaskResult{Outcome: TaskSucceeded, Tokens: 1_200_000, Output: "found"}, nil + } + return TaskResult{Outcome: TaskSucceeded, Tokens: 60_000, Output: "checked"}, nil + }, nil) + + mu.Lock() + defer mu.Unlock() + if ran["f2"] { + t.Error("the second finder ran after the first exhausted the upstream share; upstream is not capped") + } + // And the reserve did its job: downstream work still ran. + if !ran["v1"] { + t.Errorf("downstream work was starved by upstream: %+v", report.Tasks) + } +} diff --git a/internal/specialist/per_task_budget_test.go b/internal/specialist/per_task_budget_test.go new file mode 100644 index 000000000..108afa41a --- /dev/null +++ b/internal/specialist/per_task_budget_test.go @@ -0,0 +1,166 @@ +package specialist + +import ( + "context" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/tools" +) + +// A PER-TASK CAP THE TOTAL CANNOT REACH IS REFUSED, not silently ignored. +// +// A run asked for 1,000,000 per task with a 500,000 total across seven tasks. +// Every task was bounded by its share of the total long before its own cap +// mattered: four finders were stopped between 6,688 and 219,698 tokens, nowhere +// near the million they had been given, and the author reasonably concluded the +// per-task limit was broken. It was not; it was unreachable. +func TestAPerTaskCapTheTotalCannotReachIsRefused(t *testing.T) { + // The cap is BELOW the total, so the existing "cap above the budget" check + // passes it — and it is still unreachable, because three tasks at a million + // each need three million. + budget := okBudget() + budget["max_tokens"] = float64(2_000_000) + budget["max_tokens_per_task"] = float64(1_000_000) + _, err := ParsePlan(planArgs([]any{ + task("a", "x"), task("b", "y"), task("c", "z"), + }, budget), readOnlyLimits()) + + if err == nil { + t.Fatal("a per-task cap the plan total can never permit was admitted") + } + // The refusal must carry the arithmetic AND both ways out — the author is the + // only one who can say which number they meant. + for _, want := range []string{"2000000", "1000000", "3 tasks", "3000000", "OMIT it"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not mention %q: %v", want, err) + } + } +} + +// A REACHABLE CAP IS FINE. The total covers every task at its cap, so the cap is +// what bounds a task and the total is a backstop. +func TestAPerTaskCapTheTotalCanCoverIsAccepted(t *testing.T) { + budget := okBudget() + budget["max_tokens"] = float64(3_000_000) + budget["max_tokens_per_task"] = float64(1_000_000) + if _, err := ParsePlan(planArgs([]any{ + task("a", "x"), task("b", "y"), task("c", "z"), + }, budget), readOnlyLimits()); err != nil { + t.Fatalf("a coherent pair was refused: %v", err) + } +} + +// THE INTENDED SHAPE: a per-task cap ALONE, which is what budgeting per +// sub-agent actually means. No total, so no pool to divide and no share to be +// stopped by. +func TestAPerTaskCapAloneIsTheWayToBudgetPerSubAgent(t *testing.T) { + budget := map[string]any{"max_workers": float64(4), "max_tokens_per_task": float64(1_000_000)} + plan, err := ParsePlan(planArgs([]any{ + task("f1", "find"), task("f2", "find"), + task("verify", "check", "f1", "f2"), + }, budget), readOnlyLimits()) + if err != nil { + t.Fatalf("a per-task cap with no total was refused: %v", err) + } + if got := plan.Budget().MaxTokensPerTask; got != 1_000_000 { + t.Errorf("per-task cap = %d, want 1000000", got) + } + if got := plan.Budget().MaxTokens; got != 0 { + t.Errorf("a total appeared from nowhere: %d", got) + } + // With no total there is no pool, so nothing divides the cap between tasks. + spend := &planSpend{limit: int64(plan.Budget().MaxTokens), downstreamTasks: 1, totalTasks: 3} + if got := spend.ceilingFor(false); got != 0 { + t.Errorf("an unbounded plan produced an upstream ceiling of %d", got) + } +} + +// THE SCHEMA MUST SAY SO, since the pair is only incoherent if you know how the +// total is divided — which nothing in the tool call reveals. +func TestTheSchemaSaysToUseThePerTaskCapAlone(t *testing.T) { + budget := (&OrchestrateTool{}).Parameters().Properties["budget"] + perTask, ok := budget.Properties["max_tokens_per_task"] + if !ok { + t.Fatal("max_tokens_per_task is undeclared") + } + for _, want := range []string{"per sub-agent", "ALONE"} { + if !strings.Contains(perTask.Description, want) { + t.Errorf("the per-task field does not say %q: %s", want, perTask.Description) + } + } + total := budget.Properties["max_tokens"] + if !strings.Contains(total.Description, "WHOLE plan") { + t.Errorf("max_tokens does not say it covers the whole plan: %s", total.Description) + } +} + +// THE MODEL IS TOLD NOT TO GUESS, at the point where it decides. +// +// The same seven-task audit was submitted five times with max_tokens 500,000 +// against a need of roughly 4,450,000. Nobody asked for a spending limit; the +// orchestrating model volunteered one each time, because the schema explained at +// length how to size a number it has no way to estimate. Each run spent its +// budget and returned nothing. +func TestTheSchemaTellsThePlannerNotToGuessAtASpendingLimit(t *testing.T) { + total := (&OrchestrateTool{}).Parameters().Properties["budget"].Properties["max_tokens"] + for _, want := range []string{ + "DO NOT SET THIS unless the user asked", + "cannot estimate", + "stops tasks mid-work", + "wall-clock backstop", + } { + if !strings.Contains(total.Description, want) { + t.Errorf("max_tokens does not say %q: %s", want, total.Description) + } + } +} + +// AND THE WARNING ARRIVES BEFORE THE RUN, not with its remains. +// +// warnBudgetLooksLow computed the right number on every one of those five runs +// and rode the plan's OUTPUT, which the author reads once the plan is already +// dead. It now goes out through the preflight channel first. +func TestAnUndersizedBudgetIsReportedBeforeTheFirstTaskRuns(t *testing.T) { + var statuses []string + recorder := &preflightSpy{onStatus: func(s string) { statuses = append(statuses, s) }} + tool := &OrchestrateTool{ + PostureActive: func() bool { return true }, + ParentTools: []string{"read_file"}, + Recorder: recorder, + RunTask: func(context.Context, PlanTaskRequest) (TaskResult, error) { + // By the time any task runs, the warning must already have been sent. + if len(statuses) == 0 { + t.Error("the first task started before the budget warning was reported") + } + return TaskResult{Outcome: TaskSucceeded, Output: "ok"}, nil + }, + } + tool.RunWithOptions(context.Background(), map[string]any{ + "name": "audit", + "budget": map[string]any{"max_workers": float64(1), "max_tokens": float64(500_000)}, + "tasks": []any{ + map[string]any{"id": "f1", "prompt": "trace the repo"}, + map[string]any{"id": "f2", "prompt": "trace the repo"}, + map[string]any{"id": "f3", "prompt": "trace the repo"}, + map[string]any{"id": "f4", "prompt": "trace the repo"}, + }, + }, tools.RunOptions{Model: "m"}) + + joined := strings.Join(statuses, " | ") + if !strings.Contains(joined, "budget may be too low") { + t.Fatalf("no budget warning reached the surface before the run: %q", joined) + } +} + +// preflightSpy is a recorder that only listens for preflight status. +type preflightSpy struct{ onStatus func(string) } + +func (s *preflightSpy) TaskDispatched(Task) {} +func (s *preflightSpy) TaskCompleted(TaskResult) {} +func (s *preflightSpy) TaskFailed(TaskResult) {} +func (s *preflightSpy) PlanPreflight(status string) { + if strings.TrimSpace(status) != "" && s.onStatus != nil { + s.onStatus(status) + } +} diff --git a/internal/specialist/plan.go b/internal/specialist/plan.go index ccaa94ff6..60ddb6a1d 100644 --- a/internal/specialist/plan.go +++ b/internal/specialist/plan.go @@ -344,6 +344,9 @@ func ParsePlan(args map[string]any, limits Limits) (Plan, error) { if err := refuseImplausibleBudget(budget, len(plan.tasks), limits); err != nil { return Plan{}, err } + if err := refuseUnreachablePerTaskCap(budget, len(plan.tasks)); err != nil { + return Plan{}, err + } plan.budget = budget return plan, nil } @@ -362,6 +365,106 @@ func ParsePlan(args map[string]any, limits Limits) (Plan, error) { // nothing more. const minimumPlausibleTaskTokens = 50_000 +// typicalHeavyTaskTokens is what a task that reads and traces a large repo +// actually costs, measured across several runs: 510,017 for the cheapest that +// finished, 1,017,177 for the dearest, and finders repeatedly cut short between +// 700,000 and 790,000 while still working. +// +// SET AT THE TOP OF THAT RANGE, not the middle. This number only decides when to +// WARN, and the two errors are not equal: warning on a plan that would have been +// fine costs a sentence the author can ignore, while staying quiet on a plan +// that cannot finish costs the entire run — which has now happened four times. +// +// It is NOT a floor and nothing is refused for being below it. It is the number +// that tells a plan author their budget is an order of magnitude out while they +// can still change it — the gap the floor deliberately cannot cover, because a +// floor strict enough to catch this would refuse legitimate plans of small +// tasks. +const typicalHeavyTaskTokens = 1_000_000 + +// typicalDownstreamTaskTokens is what a task that works from its dependencies' +// findings costs, measured: 62,783 for a sweep and 86,986 for a verify in the +// same plan whose finders were spending 136,000 to 200,000 apiece. +// +// AN ORDER OF MAGNITUDE CHEAPER, because it reads a briefing rather than a +// repository. Estimating every task at the heavy figure warned on plans that +// were correctly sized, which teaches an author to ignore the warning — the one +// outcome worse than not having it. +const typicalDownstreamTaskTokens = 150_000 + +// refuseUnreachablePerTaskCap rejects a per-task cap the plan's own total can +// never let a task reach. +// +// THE TWO NUMBERS FIGHT AND THE SMALLER ONE ALWAYS WINS, silently. A run asked +// for 1,000,000 per task with a 500,000 total across seven tasks: every task was +// bounded by its share of the total long before its own cap mattered, so the cap +// the author set had no effect at all. Four finders were stopped between 6,688 +// and 219,698 tokens — nowhere near the million they had been given — and the +// author reasonably concluded the per-task limit was broken. It was not; it was +// unreachable. +// +// Refused rather than resolved, because either resolution would be a lie: taking +// the total silently ignores the cap, and taking the cap silently spends past a +// total the author set. Only they can say which they meant. +func refuseUnreachablePerTaskCap(budget Budget, taskCount int) error { + if budget.MaxTokens <= 0 || budget.MaxTokensPerTask <= 0 || taskCount <= 0 { + return nil + } + needed := taskCount * budget.MaxTokensPerTask + if budget.MaxTokens >= needed { + return nil + } + return fmt.Errorf( + "budget.max_tokens is %d but max_tokens_per_task is %d across %d tasks, which needs %d — "+ + "every task would be stopped by its share of the total long before its own cap applied, "+ + "so the cap would do nothing. Raise max_tokens to at least %d, or OMIT it and let "+ + "max_tokens_per_task bound each task on its own", + budget.MaxTokens, budget.MaxTokensPerTask, taskCount, needed, needed) +} + +// likelyPlanTokens estimates a plan's cost, weighting each task by whether it +// reads a repository or reads its dependencies' findings. +func likelyPlanTokens(tasks []Task) int { + likely := 0 + for _, task := range tasks { + if len(task.DependsOn) > 0 { + likely += typicalDownstreamTaskTokens + continue + } + likely += typicalHeavyTaskTokens + } + return likely +} + +// warnBudgetLooksLow returns a warning when a plan's budget is far below what +// its task count usually costs, or "" when it is not. +// +// THE FLOOR CATCHES THE IMPOSSIBLE; THIS CATCHES THE MERELY WRONG, and the band +// between them is where every real failure has been. A seven-task audit was +// admitted with 500,000 tokens — over the 350,000 floor, and about an eighth of +// what those tasks went on to spend. Four finders consumed it between them, the +// verify, sweep and synthesis tasks never ran, and the run produced no report at +// all. Nothing anywhere told the author the number was wrong until it was. +func warnBudgetLooksLow(budget Budget, tasks []Task) string { + taskCount := len(tasks) + if budget.MaxTokens <= 0 || taskCount <= 0 { + return "" + } + // WEIGHTED BY POSITION, via the same estimator the refusal uses: two + // estimates of one number would eventually disagree about whether a plan is + // tight or hopeless. + likely := likelyPlanTokens(tasks) + if budget.MaxTokens >= likely { + return "" + } + return fmt.Sprintf( + "budget.max_tokens is %d for %d tasks. A task that reads and traces a repo has measured 510k-1,017k, and one "+ + "working from its dependencies' findings far less, so this plan may need nearer %d. Below that, tasks are "+ + "stopped mid-run and later ones may never start — omit max_tokens to run unbounded within this run's own "+ + "ceiling if you cannot estimate it", + budget.MaxTokens, taskCount, likely) +} + // refuseImplausibleBudget rejects a plan whose budget cannot cover its own tasks, // BEFORE anything is spent. // diff --git a/internal/specialist/plan_exec.go b/internal/specialist/plan_exec.go index bfc0b24f2..ad3e3a54b 100644 --- a/internal/specialist/plan_exec.go +++ b/internal/specialist/plan_exec.go @@ -141,6 +141,9 @@ type PlanReport struct { // 0 regardless and would decide nothing. MaxSpeedup float64 TokensUsed int + // TokenLimit is what the plan asked to be bounded by, carried so the report + // can say 712222/500000 rather than a spend with nothing to read it against. + TokenLimit int // Workers is how many tasks this plan ACTUALLY ran at once, and // WorkersRequested is what it asked for. Both, because the machine's // capacity may be lower than the request and a plan that asked for sixteen @@ -205,6 +208,14 @@ type PlanTaskRequest struct { // MaxTaskTokens bounds what THIS task may spend. 0 is unbounded, which is // every plan that does not ask for a cap. MaxTaskTokens int + // WaitsOnOtherTasks marks a task with dependencies, which decides WHICH POOL + // it draws from: work that waits draws from the reserve, so it cannot be + // starved by the work it waited for. + // + // The pool, not a precomputed ceiling. A number computed by the caller is one + // more thing a caller can forget to compute; the meter owns the arithmetic + // and a request only has to say which side of it this task is on. + WaitsOnOtherTasks bool // SystemPrompt replaces the plan-task system prompt for this request. Empty // keeps the plan-task prompt, which is every task of every plan. // @@ -293,10 +304,30 @@ func ExecutePlanIn(ctx context.Context, plan Plan, workspace PlanWorkspace, pare // unbounded: spend is metered and reported, not gated. budgetLeft := plan.Budget().MaxTokens bounded := budgetLeft > 0 - budgetExhausted := false + // STICKY PER POOL, not per plan. One flag meant that the moment any task was + // skipped for budget, every task after it was skipped too — including work + // drawing from a pool that had not been touched. Upstream running out closed + // the door on the downstream work the reserve was holding open, which is the + // whole failure this reserve exists to prevent, arriving one flag later. + upstreamExhausted, downstreamExhausted := false, false + // Harvested spend per pool. The live meter stops a task MID-RUN; these decide + // whether the next one may start, and they are separated for the same reason: + // a feeder's overshoot must not close the door on the work it fed. + upstreamUsed, downstreamUsed := 0, 0 // The live meter, shared by every task in flight. Same limit, consulted from // inside a running task rather than between two of them. spend := &planSpend{limit: int64(plan.Budget().MaxTokens)} + // WHO IS DEPENDED ON. Computed once from the validated graph: a task others + // wait for stops earlier than one nothing waits for, so the work downstream + // of it is not starved by it. + waitsOnOthers := map[string]bool{} + for _, task := range plan.Tasks() { + if len(task.DependsOn) > 0 { + waitsOnOthers[task.ID] = true + spend.downstreamTasks++ + } + } + spend.totalTasks = plan.TaskCount() deadline := time.Time{} if wall := planWallBudget(plan.Budget()); wall > 0 { @@ -313,6 +344,7 @@ func ExecutePlanIn(ctx context.Context, plan Plan, workspace PlanWorkspace, pare workers := effectivePlanWorkers(plan.Budget().MaxWorkers) report.Workers = workers report.WorkersRequested = plan.Budget().MaxWorkers + report.TokenLimit = plan.Budget().MaxTokens slots := newPlanSlots(workers) // harvest applies one completed dispatch: its spend, its outcome, its @@ -328,6 +360,15 @@ func ExecutePlanIn(ctx context.Context, plan Plan, workspace PlanWorkspace, pare } report.SequentialTotal += result.Duration budgetLeft -= result.Tokens + // HARVESTED PER POOL, for the same reason the live meter is: a dependent + // must not be refused dispatch because a feeder overspent. budgetLeft + // stays as the whole-plan figure the report reads; these decide who may + // still start. + if waitsOnOthers[id] { + downstreamUsed += result.Tokens + } else { + upstreamUsed += result.Tokens + } report.TokensUsed += result.Tokens if err != nil || result.Outcome == TaskFailed { @@ -463,8 +504,30 @@ func ExecutePlanIn(ctx context.Context, plan Plan, workspace PlanWorkspace, pare // A budget-exhausted or timed-out plan SKIPS the rest rather than // aborting: independent work already done still counts, and the record // must show what was not attempted. - if budgetExhausted || (bounded && budgetLeft <= 0) || (!deadline.IsZero() && time.Now().After(deadline)) { - budgetExhausted = true + // THE TASK'S OWN POOL, not the shared remainder. Checking budgetLeft + // meant a feeder that overshot its ceiling closed the door on every + // dependent — which is precisely what a reserve is supposed to prevent, + // and precisely what happened: feeders capped at 375,000 landed at + // 534,144 and the dependents were skipped for a budget that was never + // theirs to spend. + downstream := waitsOnOthers[id] + poolExhausted := downstreamExhausted + if !downstream { + poolExhausted = upstreamExhausted + } + if bounded && !poolExhausted { + if downstream { + poolExhausted = int64(downstreamUsed) >= spend.ceilingFor(true) + } else { + poolExhausted = int64(upstreamUsed) >= spend.ceilingFor(false) + } + } + if poolExhausted || (!deadline.IsZero() && time.Now().After(deadline)) { + if downstream { + downstreamExhausted = true + } else { + upstreamExhausted = true + } result := TaskResult{ ID: id, Outcome: TaskSkippedBudget, @@ -506,6 +569,7 @@ func ExecutePlanIn(ctx context.Context, plan Plan, workspace PlanWorkspace, pare stallTimeout: stallTimeout, spend: spend, maxTaskTokens: plan.Budget().MaxTokensPerTask, + waitsOnOthers: len(task.DependsOn) > 0, maxRetries: plan.Budget().MaxRetries, deadline: deadline, } @@ -554,6 +618,7 @@ type retryPolicy struct { maxRetries int spend *planSpend maxTaskTokens int + waitsOnOthers bool deadline time.Time } @@ -597,12 +662,13 @@ func runTaskWithRetries(ctx context.Context, policy retryPolicy, run PlanRunner) for attempt := 1; ; attempt++ { started := time.Now() result, err = run(ctx, PlanTaskRequest{ - Task: policy.task, - Tools: policy.tools, - Cwd: policy.cwd, - StallTimeout: policy.stallTimeout, - Spend: policy.spend, - MaxTaskTokens: policy.maxTaskTokens, + Task: policy.task, + Tools: policy.tools, + Cwd: policy.cwd, + StallTimeout: policy.stallTimeout, + Spend: policy.spend, + MaxTaskTokens: policy.maxTaskTokens, + WaitsOnOtherTasks: policy.waitsOnOthers, }) if result.Duration == 0 { result.Duration = time.Since(started) @@ -727,18 +793,93 @@ func terminalStatus(report PlanReport) PlanStatus { // Both sum the same usage events, so they agree at the end; they differ only in // WHEN they can be consulted. type planSpend struct { - spent atomic.Int64 - limit int64 + // TWO POOLS, because one could not hold. A single shared counter meant the + // reserve was drawn from the same account it was capping: four feeders in + // flight each overshoot their ceiling by about one usage event, and with + // max_workers 4 that overshoot was the size of the reserve itself. A measured + // plan stopped its feeders at 375,000 as designed, landed at 534,144, and the + // dependents were then skipped for a budget the feeders had already spent — + // the exact failure the reserve existed to prevent, one layer along. + // + // Separating them is what makes the reserve real: a dependent's pool cannot + // be touched by a feeder, however far the feeder overshoots its own. + upstream atomic.Int64 + downstream atomic.Int64 + limit int64 + // downstreamShare is the fraction of the budget held for work that waits on + // other work, as a count of tasks over the total. Zero means no later work + // exists and nothing is held back. + downstreamTasks int + totalTasks int +} + +// add records tokens against the pool this task draws from and returns that +// pool's running total. +func (spend *planSpend) add(tokens int, downstream bool) int64 { + if spend == nil || tokens <= 0 { + return 0 + } + if downstream { + return spend.downstream.Add(int64(tokens)) + } + return spend.upstream.Add(int64(tokens)) +} + +// ceilingFor is what a task drawing from this pool may spend. +// +// THE AXIS IS EARLIER VERSUS LATER, not "feeds others" versus "terminal". A +// verify task in a find -> verify -> synthesize chain both depends on work and +// is depended on; classifying it as a feeder put it in the pool its own finders +// had already drained, and it was refused dispatch for their spend. Whether a +// task WAITS on other work is what decides which side of the reserve it sits on. +// +// UPSTREAM IS CAPPED; DOWNSTREAM IS FLOORED. Work that waits on nothing stops at +// three quarters so the last quarter survives for the work that waits on it. +// Work that waits gets AT LEAST that quarter, and whatever upstream did not use +// — frugal finders should not leave their synthesis artificially poor. +func (spend *planSpend) ceilingFor(downstream bool) int64 { + if spend == nil || spend.limit <= 0 { + return 0 + } + reserve := spend.reserve() + if reserve <= 0 { + // No later work exists to protect; holding anything back would waste it. + return spend.limit + } + if !downstream { + return spend.limit - reserve + } + if left := spend.limit - spend.upstream.Load(); left > reserve { + return left + } + return reserve } // add records tokens and reports whether the plan has now crossed its limit. // An unset limit never reports crossed — unbounded is a deliberate choice. -func (spend *planSpend) add(tokens int) bool { - if spend == nil || tokens <= 0 { - return false +// overPool reports whether a pool's running total has passed what that pool +// allows. An unbounded plan never has. +func (spend *planSpend) overPool(total int64, downstream bool) bool { + ceiling := spend.ceilingFor(downstream) + return ceiling > 0 && total > ceiling +} + +// reserve is the share of the budget held for work that waits on other work. +// +// PROPORTIONAL TO HOW MUCH LATER WORK THERE IS, not a flat fraction. It was a +// flat quarter, and a seven-task plan with three downstream tasks gave them +// 125,000 between them: verify and sweep used 149,769 and synthesis was skipped. +// The reserve was the right idea sized by the wrong thing — the budget's shape +// rather than the plan's. +// +// Three of seven tasks downstream now reserves three sevenths. A plan that is +// mostly synthesis keeps most of its budget for synthesis; a plan that is mostly +// finding keeps little, which is correct in both directions. +func (spend *planSpend) reserve() int64 { + if spend == nil || spend.limit <= 0 || spend.downstreamTasks <= 0 || spend.totalTasks <= 0 { + return 0 } - total := spend.spent.Add(int64(tokens)) - return spend.limit > 0 && total > spend.limit + return spend.limit * int64(spend.downstreamTasks) / int64(spend.totalTasks) } // Per-dependency and whole-briefing caps on what a task is told about its @@ -1126,8 +1267,24 @@ func (report PlanReport) Summary() string { b.WriteString("spend could not be measured: this provider reported no token usage, " + "so max_tokens cannot bound a plan here — use max_wall_seconds instead.\n") } - if unrun := tasksCutForBudget(report.Tasks); len(unrun) > 0 { - fmt.Fprintf(&b, "%d task(s) never ran (budget exhausted): %s\n", len(unrun), strings.Join(unrun, ", ")) + // WHAT THE BUDGET COST, in two separate numbers, because they mean different + // things to the reader. A task CUT SHORT wrote findings that are in this + // report; a task that NEVER RAN is a question nobody answered. Reporting + // them together as "skipped" hid the first behind the second, and a reader + // who cannot tell them apart cannot tell a partial report from an empty one. + cutShort, neverRan := tasksStoppedByBudget(report.Tasks) + if len(cutShort) > 0 || len(neverRan) > 0 { + if report.TokensUsed > 0 && report.TokenLimit > 0 { + fmt.Fprintf(&b, "budget exhausted at %d/%d tokens.\n", report.TokensUsed, report.TokenLimit) + } + if len(cutShort) > 0 { + fmt.Fprintf(&b, "%d task(s) cut short mid-run; any partial findings are below: %s\n", + len(cutShort), strings.Join(cutShort, ", ")) + } + if len(neverRan) > 0 { + fmt.Fprintf(&b, "%d task(s) never ran, so their questions are unanswered: %s\n", + len(neverRan), strings.Join(neverRan, ", ")) + } } // SAY WHEN THE REQUEST WAS NOT HONOURED. The struct promises both numbers — // "a plan that asked for sixteen and ran six has not been given sixteen" — @@ -1171,6 +1328,27 @@ func (report PlanReport) Summary() string { return b.String() } +// tasksStoppedByBudget separates the two ways a budget takes a task from you. +// +// CUT SHORT means the task ran, wrote something, and was stopped — its findings +// are in the report. NEVER RAN means the question was never asked. Both used to +// be called "skipped", which let a partial report read like an empty one and an +// empty one read like a partial. +func tasksStoppedByBudget(tasks []TaskResult) (cutShort, neverRan []string) { + for _, task := range tasks { + switch { + case task.Outcome == TaskSkippedBudget: + neverRan = append(neverRan, task.ID) + case task.Outcome == TaskCancelled && strings.Contains(task.Err, "token budget"): + // CUT SHORT whether or not it wrote anything. It ran and was stopped; + // calling a task that ran "never ran" because it happened to produce + // nothing would misreport what the budget actually cost. + cutShort = append(cutShort, task.ID) + } + } + return cutShort, neverRan +} + // tasksCutForBudget names the tasks a budget stopped — skipped before they ran, // or cancelled while running. Both are questions the plan did not answer. func tasksCutForBudget(tasks []TaskResult) []string { diff --git a/internal/specialist/plan_runner.go b/internal/specialist/plan_runner.go index e65cb8420..75bef287f 100644 --- a/internal/specialist/plan_runner.go +++ b/internal/specialist/plan_runner.go @@ -110,7 +110,10 @@ func NewPlanRunner(planCtx PlanTaskContext) PlanRunner { "task %s stopped after %d tokens: budget.max_tokens_per_task is %d", task_ID(req), task, req.MaxTaskTokens)) cancelTask() - case req.Spend.add(spent): + case req.Spend.overPool(req.Spend.add(spent, req.WaitsOnOtherTasks), req.WaitsOnOtherTasks): + // THE CEILING IS THIS TASK'S, not the plan's. A task others + // depend on stops before the whole budget is gone, so their + // work still has something to run on — see planSpendCeiling. overspent.Store(fmt.Sprintf( "task %s stopped: the plan's token budget ran out while it was running", task_ID(req))) diff --git a/internal/specialist/plan_tool.go b/internal/specialist/plan_tool.go index fac7b36a4..3c6d25dca 100644 --- a/internal/specialist/plan_tool.go +++ b/internal/specialist/plan_tool.go @@ -153,7 +153,43 @@ func (tool *OrchestrateTool) Parameters() tools.Schema { "description": {Type: "string", Description: "What the plan is for."}, "tasks": { Type: "array", - Description: "The plan's tasks. Each has an id, a prompt, optional depends_on ids, an optional read-only tool subset, and an optional phase label. " + + // THE SHAPE IS DECLARED, not described in prose. + // + // This was a bare array with a paragraph of English, so a model had + // to compose nested objects from a sentence — and did it wrong twice + // in one session. It read "an optional read-only tool subset", which + // names no tool at all, and wrote tools: ["read-only"], taking the + // adjective for a value. Admission then refused the plan and recited + // the legal names in the error: the right list, arriving after the + // failure instead of before it. + // + // The enum comes from PlanGrantableToolNames, which is the list + // admission itself validates against and was already exported so + // "the grant and the validator cannot come to disagree". The schema + // was the one caller not using it. + Items: &tools.PropertySchema{ + Type: "object", + Properties: map[string]tools.PropertySchema{ + "id": {Type: "string", Description: "Short identifier, unique within the plan: letters, digits, hyphen, underscore."}, + "prompt": {Type: "string", Description: "What this task must do, and what it must return."}, + "depends_on": { + Type: "array", + Description: "Ids of tasks that must finish first. A dependent receives their results in its prompt.", + Items: &tools.PropertySchema{Type: "string"}, + }, + "tools": { + Type: "array", + Description: "Tools this task may use, narrowing the run's own grant — it can never widen it. " + + "Omit for the read-only default. Naming a write tool makes the plan write-capable, which " + + "runs it in an isolated worktree and asks for approval first.", + Items: &tools.PropertySchema{Type: "string", Enum: PlanGrantableToolNames()}, + }, + "model": {Type: "string", Description: "Model this task runs on. Omit to inherit the session's."}, + "phase": {Type: "string", Description: "Display label only; it carries no execution meaning."}, + }, + Required: []string{"id", "prompt"}, + }, + Description: "The plan's tasks. Each has an id, a prompt, optional depends_on ids, an optional tool subset, and an optional phase label. " + "A task may also name a model to run on — any model this provider serves, by id or alias; omit it to inherit " + "this session's model. Use it to spend where it matters: a cheaper model for mechanical scanning, a stronger " + "one for the tasks that judge or decide. A name the provider cannot serve fails when the task runs.\n\n" + @@ -211,8 +247,20 @@ func (tool *OrchestrateTool) Parameters() tools.Schema { "Only available in the interactive TUI; a headless run exits when the turn ends, so it refuses this.", }, "budget": { - Type: "object", - Description: "Required. max_workers (1-16) is how many tasks may run at once; 1 is sequential and is the right answer unless the tasks are genuinely independent. The machine's own capacity may be lower and the report says which number applied. max_tokens and max_wall_seconds are optional bounds; omit them to run unbounded — spend is reported either way. max_stall_seconds bounds how long ONE task may emit nothing (default 180); it resets on every event, so a slow-but-working task is never stopped. max_retries (0-3, default 1) is how many extra attempts a STALLED task gets; a task that failed with a real error is never retried. max_tokens_per_task bounds what ONE task may spend; without it a single task can spend many times the whole plan's budget. Sizing, from measured runs: a task that traces or audits a large repo cost 510k-1,017k tokens; one reasoning over what its dependencies already found costs far less. A cap BELOW what a task needs saves nothing — a plan capping tasks at 200k lost all six of them between 213k and 259k and still spent 1,437,049 tokens, finishing none. A capped task is told its budget and asked to write a partial answer before reaching it, so tight is survivable and too-tight is not. Budget from task count x expected depth, or omit max_tokens to run unbounded within this run's own ceiling. A budget too small for its task count is refused at admission rather than half-spent: when the plan runs out, tasks in flight are cancelled and the rest never run, so the answer comes back incomplete.", + Type: "object", + // DECLARED for the same reason tasks is: a model composing this from + // prose has to invent the field names, and inventing one is how a + // plan fails at admission instead of running. + Properties: map[string]tools.PropertySchema{ + "max_workers": {Type: "integer", Description: "How many tasks may run at once, 1 to 16. 1 is sequential."}, + "max_tokens": {Type: "integer", Description: "Bound on the WHOLE plan, shared by every task. DO NOT SET THIS unless the user asked for a spending limit. You cannot estimate what a plan costs, and a number guessed too low does not save money — it stops tasks mid-work and skips the ones that had not started, so the plan spends its budget and returns nothing. Omitted, the plan runs unbounded within this run's own ceiling with a wall-clock backstop, and spend is still metered and reported. If the user did ask for a limit, prefer max_tokens_per_task."}, + "max_tokens_per_task": {Type: "integer", Description: "Optional bound on ONE task, and the right knob for budgeting per sub-agent. Use it ALONE: with max_tokens also set, each task is limited by its share of the total long before its own cap applies, so the cap does nothing and the plan is refused."}, + "max_wall_seconds": {Type: "integer", Description: "Optional wall-clock bound on the whole plan."}, + "max_stall_seconds": {Type: "integer", Description: "How long ONE task may emit nothing before it is stopped. Default 180."}, + "max_retries": {Type: "integer", Description: "Extra attempts a STALLED task gets, 0 to 3. Default 1."}, + }, + Required: []string{"max_workers"}, + Description: "Required. max_workers (1-16) is how many tasks may run at once; 1 is sequential and is the right answer unless the tasks are genuinely independent. The machine's own capacity may be lower and the report says which number applied. max_tokens and max_wall_seconds are optional bounds; omit them to run unbounded — spend is reported either way. max_stall_seconds bounds how long ONE task may emit nothing (default 180); it resets on every event, so a slow-but-working task is never stopped. max_retries (0-3, default 1) is how many extra attempts a STALLED task gets; a task that failed with a real error is never retried. max_tokens_per_task bounds what ONE task may spend, and is the right knob for budgeting per sub-agent — use it ALONE, without max_tokens, or each task is limited by its share of the total instead and the plan is refused as incoherent. Sizing, from measured runs: a task that traces or audits a large repo costs 510k-1,017k tokens, so budget about 1M each; one reasoning over what its dependencies already found costs far less. max_tokens is the TOTAL across every task, not per task — max_tokens_per_task is the per-task one, and it may not exceed the total. A cap BELOW what a task needs saves nothing — a plan capping tasks at 200k lost all six of them between 213k and 259k and still spent 1,437,049 tokens, finishing none. A capped task is told its budget and asked to write a partial answer before reaching it, so tight is survivable and too-tight is not. Omit max_tokens unless the user asked for a spending limit: guessing it low is how a plan spends everything and returns nothing. A budget far below what its task count needs is warned about at admission and left to you. When a plan runs out, tasks in flight are STOPPED MID-RUN — they keep what they had already found, and it reaches both the report and any task depending on them — while tasks not yet started never run at all. So the cost of guessing low is the questions at the END of the plan, which is usually the synthesis you wanted. If you cannot estimate, omit max_tokens and use max_wall_seconds instead.", }, }, // EMPTY, and the either/or lives in the DESCRIPTION instead. @@ -635,12 +683,21 @@ func (tool *OrchestrateTool) RunWithOptions(ctx context.Context, args map[string defer workspace.Release() recordPlanAdmitted(tool.Recorder, plan) + // SAID BEFORE IT RUNS, not deduced from the wreckage afterwards. + budgetWarning := warnBudgetLooksLow(plan.Budget(), plan.Tasks()) + // SAID BEFORE THE FIRST TASK, not only in the output afterwards. The estimate + // was computed correctly on five consecutive runs of the same plan and read + // by nobody, because it rode the result — which arrives once the run is + // already over. A warning delivered with the corpse is not a warning. + if budgetWarning != "" { + planPreflight(tool.Recorder, "budget may be too low: "+budgetWarning) + } report := ExecutePlanIn(ctx, plan, workspace, tool.ParentTools, tool.runnerForCall(options), tool.Recorder) recordPlanCompleted(tool.Recorder, plan, report) result := tools.Result{ Status: tools.StatusOK, - Output: report.Summary() + planWorkspaceNote(workspace) + autoAssignSummary(assignNotes), + Output: report.Summary() + planBudgetWarning(budgetWarning) + planWorkspaceNote(workspace) + autoAssignSummary(assignNotes), Meta: map[string]string{ "plan_status": string(report.Status), "max_speedup": strconv.FormatFloat(report.MaxSpeedup, 'f', 2, 64), @@ -822,6 +879,19 @@ func (tool *OrchestrateTool) autoAssignModels(ctx context.Context, args map[stri return notes, nil } +// planBudgetWarning surfaces a budget that looks an order of magnitude low. +// +// Carried to the OUTPUT rather than raised as an error: the plan may still be +// exactly what the author wanted, and refusing a number that is merely unusual +// would make the tool unusable on cheap plans of small tasks. It is said so the +// next call can be different. +func planBudgetWarning(warning string) string { + if strings.TrimSpace(warning) == "" { + return "" + } + return "\n\nNote: " + warning + ".\n" +} + // planWorkspaceNote says WHERE a write-capable plan did its work. // // PlanWorkspace.Describe exists "for the approval card and the run" and nothing