diff --git a/internal/acp/agent.go b/internal/acp/agent.go index b3fafb32a..7d2b490cf 100644 --- a/internal/acp/agent.go +++ b/internal/acp/agent.go @@ -253,6 +253,7 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string, Cwd: sess.cwd, SessionID: sess.id, ProviderName: resolved.Provider.Name, + ModelFamily: providercatalog.ModelFamilyFor(resolved.Provider.CatalogID), Model: resolved.Provider.Model, Registry: registry, Sandbox: sandboxEngine, 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/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/agent/guardrails.go b/internal/agent/guardrails.go index ea179b459..7af699a45 100644 --- a/internal/agent/guardrails.go +++ b/internal/agent/guardrails.go @@ -168,7 +168,14 @@ var selfReportPhrases = []string{ var inabilityStems = []string{ "i cannot ", "i can't ", "i can not ", "i could not ", "i couldn't ", "i am unable to", "i'm unable to", "i was unable to", "i wasn't able to", - "i was not able to", "i do not have", "i don't have", "unable to ", + "i was not able to", "i do not have", "i don't have", + // "unable to " WITHOUT A SUBJECT WAS REMOVED. It is the only stem here that + // does not name who was unable, and it fired on a report's own section + // heading — "**Unable to verify (1):** - MCP #3 claim was truncated" — in a + // verification task that had completed and was categorising its findings. + // The first-person forms above still catch every genuine admission; a + // heading is not one. + "we are unable to", "we were unable to", "without being able to", } @@ -179,6 +186,19 @@ var inabilityStems = []string{ var successNegationTails = []string{ "find any", "found any", "find a ", "see any", "detect any", "identify any", "reproduce", "spot any", "locate any", + // A NEGATIVE SEARCH RESULT IS THE ANSWER, not a failure to produce one. + // + // The list above already encodes this — "I could not find any remaining + // issues" is success — but only for the "any" phrasings. A finder reporting + // "I could NOT find where AllowManifestToolAutoApproval is set to true in + // production code" was marked INCOMPLETE after 53 tool calls and a 19,145 + // character audit, for doing precisely the job it was given: establishing + // that something is not there. + "find where", "find the", "find it", "find that", "find this", + "found where", "found the", + "locate where", "locate the", "locate it", + "determine where", "identify where", "see where", + "reproduce ", "confirm any", "observe any", } // narrativeMarkers flag a sentence as RETELLING a past exchange rather than @@ -247,11 +267,71 @@ func admissionSentences(lower string) []string { // retells a past exchange (narrativeMarkers) is skipped entirely — an admission // must be the model's own report about the CURRENT objective, not general // language that merely resembles one. +// toolGrantMarkers flag a sentence as reporting WHICH TOOLS this run was given, +// not whether the work was done. +// +// A read-only plan task is SUPPOSED to say this. One wrote "I don't have an +// update_plan tool available in this specialist context (only read-only +// exploration tools were provided)" and then delivered the complete answer — +// helper name, file, line 214, full source — and was marked INCOMPLETE on the +// "i don't have" stem. The prompt asks tasks to name their limits plainly; a +// detector that punishes exactly that teaches the opposite. +// +// NARROW ON PURPOSE: the sentence must name a TOOL or a GRANT. "I do not have +// enough evidence" is still an admission and still fires. +// NARROWED AFTER AN AUDIT OF THIS VERY FIX. The first version listed a bare +// " tool", which exempts any inability sentence that merely mentions one — +// measured at 5/5 on ordinary phrasings: +// +// "I cannot run the build tool, so the change is unverified" +// "I could not use the migration tool and the data is untouched" +// "I was unable to invoke the formatting tool on the output" +// +// Those are genuine admissions, and silently exempting them is the WORSE +// direction: a false positive costs a re-run, a false negative reports +// unfinished work as done. The markers now have to be about what the run WAS +// GIVEN, not about a tool being mentioned at all. +var toolGrantMarkers = []string{ + "tool available", "tools available", "no such tool", "not available in this", + "read-only tools", "read only tools", "only read-only", "only read only", + "tools were provided", "tools were given", "toolset provided", + "in this specialist context", "in this context only", + "is not in my toolset", "not in my toolset", "not in this toolset", +} + +// objectiveFailureMarkers name the OBJECTIVE rather than a capability. A +// sentence carrying one is about whether the job got done, so the tool-grant +// exemption above does not apply to it however many tools it mentions. +// VERB-ANCHORED, not bare nouns. "this task" alone was too crude: a task that +// finished wrote "so i could not record a plan; the task is a single +// read-and-report step and is now complete" — it names the task in order to +// report SUCCESS, and a bare-noun override read that as failure. The marker has +// to be the objective NOT BEING DONE, which needs the verb. +var objectiveFailureMarkers = []string{ + "complete this task", "complete the task", "completing this task", "completing the task", + "finish this task", "finish the task", "finishing this task", + "complete it", "completing it", "finish it", "finishing it", + "the objective", "the assignment", "as requested", "what was asked", + "do this task", "perform this task", "carry out this task", +} + func selfReportedIncompletion(text string) string { for _, sentence := range admissionSentences(strings.ToLower(stripQuoted(text))) { if containsAny(sentence, narrativeMarkers) { continue } + // A sentence about the tool grant is about CAPABILITY, not about the + // objective — UNLESS it also says the task itself could not be done. + // + // THE OVERRIDE IS NOT OPTIONAL. Without it the exemption swallowed a + // genuine failure: "I am unable to complete this task with the current + // tool set … Only write_file is enabled … so I cannot inspect the + // codebase." That task really did fail, and it mentions tools, so a bare + // tool-marker check waved it through. Naming the task is what separates + // "I lack a tool I did not need" from "I lack the tools this needed". + if containsAny(sentence, toolGrantMarkers) && !containsAny(sentence, objectiveFailureMarkers) { + continue + } for _, phrase := range selfReportPhrases { if strings.Contains(sentence, phrase) { return selfReportReason(phrase) diff --git a/internal/agent/guardrails_false_admission_test.go b/internal/agent/guardrails_false_admission_test.go new file mode 100644 index 000000000..8419da8db --- /dev/null +++ b/internal/agent/guardrails_false_admission_test.go @@ -0,0 +1,208 @@ +package agent + +import "testing" + +// FOUR COMPLETED TASKS WERE MARKED INCOMPLETE, and the sentences below are +// verbatim from those sessions. +// +// The detector reads message text — the one place in this feature that does — +// and it is supposed to catch a model admitting it did not do the job. Instead +// it caught four tasks that HAD done the job and were honestly naming a limit, +// which is exactly what the plan-task prompt asks them to do. Two of them had +// made 53 and 60 tool calls; one had written files. +// +// The cost of a false positive here is not cosmetic: the task is reported +// failed, retried on another model, and rendered red in the plan panel. +func TestAnHonestCaveatInsideDeliveredWorkIsNotAnAdmission(t *testing.T) { + for _, tc := range []struct { + name string + text string + }{ + { + // 53 tool calls, a 19,145-character audit. Establishing that + // something is NOT there is the finding, not a failure to find one. + name: "a negative search result is the answer", + text: "I traced every code path where repo-committed files influence tool approval. " + + "I could NOT find where `AllowManifestToolAutoApproval` is set to true in production code paths.", + }, + { + // A completed verification task categorising its own findings. The + // bare "unable to" stem had no first-person subject and fired on a + // markdown heading. + name: "a report section heading", + text: "## Verification Table\n\n**Verified (4):** all confirmed against source.\n" + + "**Unable to verify (1):** - MCP #3 claim was truncated in the input.", + }, + { + // Delivered helper name, file, line 214 and full source. The task was + // read-only by design and said so. + name: "a statement about the tool grant", + text: "I don't have an `update_plan` tool available in this specialist context " + + "(only read-only exploration tools were provided). " + + "The task is a single read-only assessment and I've already gathered all the needed evidence.", + }, + { + name: "read-only tools named plainly", + text: "I did not run the tests because my tools are read-only, " + + "so I can report only what the code and tests say statically.", + }, + } { + t.Run(tc.name, func(t *testing.T) { + if reason := selfReportedIncompletion(tc.text); reason != "" { + t.Fatalf("a completed task was marked incomplete: %s", reason) + } + }) + } +} + +// AND THE DETECTOR MUST STILL BITE. Widening it to stop punishing honesty must +// not turn it off — these are the admissions it exists for, several of them +// verbatim from the same set of sessions. +func TestGenuineAdmissionsAreStillCaught(t *testing.T) { + for _, tc := range []struct { + name string + text string + }{ + { + name: "the measured workspace failure", + text: "I cannot complete this task. The target directory `/Users/kratos/zm-lab/pkg/execprofile` " + + "is outside the workspace boundary.", + }, + { + name: "an empty worktree", + text: "This task cannot be completed in the current workspace because the relevant source tree is absent. " + + "I cannot find BuildFinalResult anywhere.", + }, + { + name: "guessing", + text: "I guessed at the retry semantics because the code was not reachable.", + }, + { + name: "fabrication", + text: "I fabricated the line numbers to fill the table.", + }, + { + name: "stated doubt about the result", + text: "The patch may not be correct — I did not run it.", + }, + { + name: "not enough evidence is still an admission", + text: "I do not have enough evidence to answer the question.", + }, + { + name: "first-person unable, with a subject", + text: "I was unable to determine which branch actually runs.", + }, + } { + t.Run(tc.name, func(t *testing.T) { + if reason := selfReportedIncompletion(tc.text); reason == "" { + t.Fatalf("a real admission was missed: %q", tc.text) + } + }) + } +} + +// The tool-grant exemption is NARROW. A sentence has to be about tools or the +// grant; "not enough evidence" is not, and must keep firing. +func TestTheToolGrantExemptionDoesNotSwallowRealAdmissions(t *testing.T) { + if reason := selfReportedIncompletion("I could not verify the claim and I do not have enough evidence."); reason == "" { + t.Fatal("the tool-grant exemption swallowed an evidence admission") + } + if reason := selfReportedIncompletion("I don't have the file contents, so the answer is a guess."); reason == "" { + t.Fatal("an admission about missing content was exempted as a tool statement") + } +} + +// THE EXEMPTION MUST NOT SWALLOW A TASK THAT REALLY COULD NOT BE DONE. +// +// Verbatim from a session the first version of this fix regressed: the task +// mentions tools, so a bare tool-marker check waved it through — but it names +// the TASK as the thing that failed, which is the whole distinction. +func TestATaskThatCouldNotBeDoneForLackOfToolsStillFails(t *testing.T) { + for _, tc := range []struct { + name string + text string + }{ + { + name: "the measured regression", + text: "I am unable to complete this task with the current tool set. " + + "Only `write_file` is enabled, so I cannot inspect the codebase to collect scan findings.", + }, + { + name: "named differently", + text: "I could not finish the objective because no read tools were provided.", + }, + { + name: "cannot complete it", + text: "I don't have the tools to complete it as requested.", + }, + } { + t.Run(tc.name, func(t *testing.T) { + if reason := selfReportedIncompletion(tc.text); reason == "" { + t.Fatalf("a task that genuinely could not be done was passed as complete: %q", tc.text) + } + }) + } + + // And the exemption must still work for the case it exists for: a tool that + // was not needed, alongside delivered work. + exempt := "I don't have an `update_plan` tool available in this specialist context " + + "(only read-only exploration tools were provided)." + if reason := selfReportedIncompletion(exempt); reason != "" { + t.Fatalf("the override broke the exemption it guards: %s", reason) + } +} + +// NAMING THE TASK TO REPORT SUCCESS IS NOT NAMING IT TO REPORT FAILURE. +// +// Verbatim from the session that the first override regressed. A task that had +// finished wrote a footnote about a tool it did not need, in the same sentence +// as its own completion statement — and a bare-noun override read "the task" as +// an admission. The marker has to be the objective NOT being done. +func TestNamingTheTaskWhileReportingSuccessIsNotAnAdmission(t *testing.T) { + done := "- **note:** the update_plan tool is not available in my current toolset " + + "(only read-only file tools), so i could not record a plan; " + + "the task is a single read-and-report step and is now complete" + if reason := selfReportedIncompletion(done); reason != "" { + t.Fatalf("a finished task was marked incomplete for saying so: %s", reason) + } + // The failure form, which must still fire, differs only in the verb. + failed := "the update_plan tool is not available, so i could not complete this task" + if reason := selfReportedIncompletion(failed); reason == "" { + t.Fatal("a task that could not be completed was passed as complete") + } +} + +// AN INABILITY THAT MERELY MENTIONS A TOOL IS STILL AN ADMISSION. +// +// THE AUDIT FINDING THIS PINS. The first version of the exemption listed a bare +// " tool", so any inability sentence mentioning one escaped — measured at 5/5 on +// ordinary phrasings. That is the worse direction: a false positive costs a +// re-run, a false negative reports unfinished work as done. +func TestMentioningAToolDoesNotExemptAnAdmission(t *testing.T) { + for _, text := range []string{ + "I cannot run the build tool, so the change is unverified", + "I could not use the migration tool and the data is untouched", + "I was unable to invoke the formatting tool on the output", + "I don't have a working compiler tool here", + "I cannot finish because the deploy tools are broken", + } { + if reason := selfReportedIncompletion(text); reason == "" { + t.Errorf("a genuine admission was silently exempted: %q", text) + } + } +} + +// And the exemption still covers what it was built for: a run stating which +// tools it was GIVEN, alongside delivered work. +func TestTheGrantExemptionStillCoversWhatItWasBuiltFor(t *testing.T) { + for _, text := range []string{ + "I don't have an `update_plan` tool available in this specialist context (only read-only exploration tools were provided).", + "I did not run the tests because only read-only tools were provided, so I report what the code says statically.", + "update_plan is not in my toolset, so I could not record a plan.", + } { + if reason := selfReportedIncompletion(text); reason != "" { + t.Errorf("a run naming its own grant was marked incomplete: %q -> %s", text, reason) + } + } +} diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 7c3d17813..42ac8f5eb 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -13,6 +13,7 @@ import ( "github.com/Gitlawb/zero/internal/execution" "github.com/Gitlawb/zero/internal/hooks" + "github.com/Gitlawb/zero/internal/measurements" "github.com/Gitlawb/zero/internal/redaction" "github.com/Gitlawb/zero/internal/sandbox" "github.com/Gitlawb/zero/internal/streamjson" @@ -22,8 +23,16 @@ import ( ) const maxTurnsAnswer = "Agent reached maximum number of turns without a final answer." +const maxTokensAnswer = "Agent reached its token budget without a final answer." const maxTurnsFinalAnswerPrompt = "You have reached the tool-turn limit. Do not call tools. Give a concise final answer now: summarize what you completed, what you found, and any remaining blockers." +// maxTokensFinalAnswerPrompt is the same ending for a different reason, and it +// says which. A run stopped for spend and a run stopped for round trips call for +// the same next action from the model and a different explanation to the reader, +// who otherwise reaches for the wrong lever — raising a turn count that was never +// what ran out. +const maxTokensFinalAnswerPrompt = "You have reached this run's token budget. Do not call tools. Give a concise final answer now: summarize what you completed, what you found, and any remaining blockers." + // maxStreamStallRetries bounds how many times a turn that timed out (idle/stall) // WITH NO OUTPUT yet is re-issued on a fresh connection before giving up. Only // the no-output case is retried (a partial turn would duplicate), so this is a @@ -164,6 +173,10 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options) // this local, so no signatures change anywhere downstream. provider = sessionProvider{session: session} + // The turn's raw user text, carried to tools so a gate can tell "the user + // asked for this" from "the model read a sentence that said to". + options.userMessage = prompt + maxTurns := options.MaxTurns if maxTurns <= 0 { maxTurns = 12 @@ -241,6 +254,18 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options) // FileDiagnostics callback is wired; every method no-ops on nil. postEditDiagnostics := newAsyncDiagnostics(options.FileDiagnostics, options.Cwd) + // THE RUN'S OWN TIMINGS, so a final answer cannot report a number no command + // here produced. Every tool result is read for `go test` durations; the final + // answer is checked against them once before it is returned. + // + // POSTURE-GATED, and nil when the posture is off — every method no-ops on + // nil, so a run that never heard of zeromaxing takes exactly the path it took + // before this existed. See internal/measurements for the failure it is for. + var measured *measurements.Ledger + if options.Zeromaxing != ZeromaxingOff { + measured = measurements.NewLedger() + } + // loaded tracks deferred-eligible tools the model has pulled via tool_search // during THIS run. It is consulted by partitionTools each turn to expose a // loaded tool's full schema; it lives only for the run (v1 within-run scope). @@ -256,6 +281,12 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options) // doesn't re-run the recursive schema→map conversion for every tool every turn. toolDefCache := map[string]zeroruntime.ToolDefinition{} + // SPEND, accumulated from what the provider actually reported rather than + // estimated. Only read when options.MaxTokens > 0, so an unbounded run adds + // one integer and changes nothing else. + spentTokens := 0 + stoppedOnTokens := false + result = Result{Messages: copyMessages(messages)} dispatchSessionStart(ctx, options) defer func() { @@ -268,6 +299,27 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options) for turn := 0; turn < maxTurns; turn++ { result.Turns = turn + 1 + // CHECKED HERE, at the top of a turn, for the same reason the turn count + // is: the previous turn's tool calls have run and their results are in + // hand. Stopping mid-turn would discard work already paid for. + if options.MaxTokens > 0 && spentTokens >= options.MaxTokens { + stoppedOnTokens = true + break + } + + // 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, options.OrchestrateAvailable) { + 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 @@ -279,6 +331,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 @@ -503,6 +568,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options) // assistant reply is appended below, after this, so `messages` is still the // sent request. compactor.calibrate(estimateTokens(messages)+estimateToolDefTokens(exposed), collected.Usage.InputTokens) + // Accumulated at the same point the estimator is calibrated: past error + // recovery, and past any reactive compaction that re-sent the request, so + // this counts the exchange that actually happened rather than one that was + // abandoned and replaced. + spentTokens += collected.Usage.TotalTokens() // Carry the turn's terminal stop reason so a final answer cut off at the // output token cap (or by a content filter) is reported as truncated. A @@ -602,6 +672,19 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options) }) continue } + // THE ANSWER IS CHECKED AGAINST THE TRANSCRIPT before it is returned. + // Same channel and the same gate as the diagnostics drain above, and + // for the same reason: a final answer means there is no later turn, so + // a number that contradicts what actually ran has to be raised now or + // never. Each name is raised at most once by the ledger, so an answer + // that comes back unchanged is returned rather than asked again. + if nudge := measurements.Nudge(measured.Conflicts(collected.Text)); nudge != "" { + messages = append(messages, zeroruntime.Message{ + Role: zeroruntime.MessageRoleUser, + Content: nudge, + }) + continue + } result.FinalAnswer = collected.Text result.Messages = copyMessages(messages) return result, nil @@ -674,6 +757,9 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options) } options.Trace.Counter(trace.CounterToolCalls, 1) recordOutputBudgetTrace(options.Trace, toolResult) + // Read before anything can truncate or summarize it: this is the only + // place the command's own words are in hand. + measured.Record(toolResult.Output) task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: call.Arguments, toolResult: toolResult}) if options.OnToolResult != nil { options.OnToolResult(toolResult) @@ -898,22 +984,31 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options) }) } finalExposed, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache) - if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, planner, messages, finalExposed, options); strings.TrimSpace(answer) != "" { + // WHICH BOUND FIRED reaches the reader, because the two call for different + // responses: a turn-count stop is a round-trip problem, a token stop is a + // spend one, and reporting the first for the second sends someone to raise a + // limit that was never what ran out. + finalPrompt, exhaustedAnswer, limitName := maxTurnsFinalAnswerPrompt, maxTurnsAnswer, "max-turns limit" + if stoppedOnTokens { + finalPrompt, exhaustedAnswer, limitName = maxTokensFinalAnswerPrompt, maxTokensAnswer, "token budget" + } + + if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, planner, messages, finalExposed, options, finalPrompt); strings.TrimSpace(answer) != "" { result.FinalAnswer = answer result.FinishReason = finishReason result.Messages = copyMessages(finalMessages) if options.RequireCompletionSignal { result.Incomplete = true - result.IncompleteReason = "reached the max-turns limit without completing" + result.IncompleteReason = "reached the " + limitName + " without completing" } return result, nil } - result.FinalAnswer = maxTurnsAnswer + result.FinalAnswer = exhaustedAnswer result.Messages = copyMessages(messages) if options.RequireCompletionSignal { result.Incomplete = true - result.IncompleteReason = "reached the max-turns limit without a final answer" + result.IncompleteReason = "reached the " + limitName + " without a final answer" } return result, nil } @@ -965,11 +1060,11 @@ func recordContextPlanTrace(recorder *trace.Recorder, plan contextPlan) { }) } -func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, planner *contextPlanner, messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, options Options) (string, []zeroruntime.Message, string) { +func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, planner *contextPlanner, messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, options Options, prompt string) (string, []zeroruntime.Message, string) { finalMessages := copyMessages(messages) finalMessages = append(finalMessages, zeroruntime.Message{ Role: zeroruntime.MessageRoleUser, - Content: maxTurnsFinalAnswerPrompt, + Content: prompt, }) // The max-turns final-answer call is a pre-content connect, often after a long // autonomous/cron run — route it through the reconnect helper so a single @@ -1357,10 +1452,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) { @@ -1379,6 +1481,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal ReasoningEffort: options.ReasoningEffort, Depth: options.Depth, Cwd: options.Cwd, + UserMessage: options.userMessage, // Per-session file version tracker so write_file/edit_file refuse to clobber // a file that changed on disk outside Zero since it was last read. FileTracker: options.FileTracker, @@ -2735,7 +2838,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) } } @@ -2937,7 +3040,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/loop_test.go b/internal/agent/loop_test.go index 46f0affb1..24c72f524 100644 --- a/internal/agent/loop_test.go +++ b/internal/agent/loop_test.go @@ -3800,3 +3800,139 @@ 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, + ZeromaxingEvidenceNotice, ZeromaxingOrchestrateNotice, + } { + 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, + ZeromaxingEvidenceNotice, ZeromaxingOrchestrateNotice, + } { + 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/measurement_check_test.go b/internal/agent/measurement_check_test.go new file mode 100644 index 000000000..604d860aa --- /dev/null +++ b/internal/agent/measurement_check_test.go @@ -0,0 +1,147 @@ +package agent + +import ( + "context" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/tools" + "github.com/Gitlawb/zero/internal/zeroruntime" +) + +// goTestTool stands in for a shell that ran `go test` and printed its result. +type goTestTool struct{ output string } + +func (goTestTool) Name() string { return "run_tests" } +func (goTestTool) Description() string { return "run the test suite" } +func (goTestTool) Parameters() tools.Schema { + return tools.Schema{Type: "object", AdditionalProperties: false} +} +func (goTestTool) Safety() tools.Safety { + return tools.Safety{SideEffect: tools.SideEffectNone, Permission: tools.PermissionAllow, AdvertiseInAuto: true} +} +func (t goTestTool) Run(context.Context, map[string]any) tools.Result { + return tools.Result{Status: tools.StatusOK, Output: t.output} +} + +// callThenAnswer is a provider that calls run_tests, then gives finalAnswer, then +// gives corrected — so a run that is sent back for a number gets somewhere to go. +func callThenAnswer(finalAnswer, corrected string) *mockProvider { + return &mockProvider{turns: [][]zeroruntime.StreamEvent{ + { + {Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "run_tests"}, + {Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`}, + {Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"}, + {Type: zeroruntime.StreamEventDone}, + }, + {{Type: zeroruntime.StreamEventText, Content: finalAnswer}, {Type: zeroruntime.StreamEventDone}}, + {{Type: zeroruntime.StreamEventText, Content: corrected}, {Type: zeroruntime.StreamEventDone}}, + }} +} + +const suiteOutput = "ok \tgithub.com/x/y\t0.86s\n--- PASS: TestChattyChild (0.86s)\n" + +// A FINAL ANSWER THAT REPORTS A NUMBER THE RUN NEVER PRODUCED IS SENT BACK. +// +// The measured failure: a benchmark table where the same test read 0.86s in one +// paste and 4.20s in the next, with nothing said about the difference. A prompt +// rule cannot catch this — a model willing to write a number it did not measure +// is equally willing to say it re-ran it. The transcript can. +func TestAFinalAnswerContradictingTheTranscriptIsSentBack(t *testing.T) { + root := t.TempDir() + registry := tools.NewRegistry() + registry.Register(goTestTool{output: suiteOutput}) + provider := callThenAnswer("TestChattyChild took 4.20s.", "Corrected: TestChattyChild took 0.86s.") + + result, err := Run(context.Background(), "run the suite", provider, Options{ + Cwd: root, Registry: registry, SessionID: "session-measured", + Zeromaxing: ZeromaxingActive, + }) + if err != nil { + t.Fatal(err) + } + + if len(provider.requests) < 3 { + t.Fatalf("the run ended after %d requests: the contradicted answer was accepted", len(provider.requests)) + } + sentBack := renderZeromaxingMessages(provider.requests[2].Messages) + for _, required := range []string{"TestChattyChild", "4.2s", "0.86s", "Re-run the command"} { + if !strings.Contains(sentBack, required) { + t.Errorf("the correction does not mention %q:\n%s", required, sentBack) + } + } + if !strings.Contains(result.FinalAnswer, "0.86s") { + t.Errorf("the corrected answer was not the one returned: %q", result.FinalAnswer) + } +} + +// AN HONEST ANSWER IS RETURNED UNTOUCHED. A tripwire that fires on a correct +// report gets switched off, and then it catches nothing. +func TestAnAnswerThatMatchesTheTranscriptIsReturnedAsIs(t *testing.T) { + root := t.TempDir() + registry := tools.NewRegistry() + registry.Register(goTestTool{output: suiteOutput}) + provider := callThenAnswer("TestChattyChild took 0.86s.", "should never be reached") + + result, err := Run(context.Background(), "run the suite", provider, Options{ + Cwd: root, Registry: registry, SessionID: "session-honest", + Zeromaxing: ZeromaxingActive, + }) + if err != nil { + t.Fatal(err) + } + if len(provider.requests) != 2 { + t.Fatalf("an honest answer cost %d requests, want 2: the check fired on a correct report", len(provider.requests)) + } + if !strings.Contains(result.FinalAnswer, "0.86s") { + t.Errorf("final answer = %q", result.FinalAnswer) + } +} + +// THE CHECK IS PART OF THE POSTURE. With zeromaxing off the run must take +// exactly the path it took before this existed — the same rule every other +// posture behaviour follows. +func TestThePostureOffRunIsNeverSentBackForANumber(t *testing.T) { + root := t.TempDir() + registry := tools.NewRegistry() + registry.Register(goTestTool{output: suiteOutput}) + provider := callThenAnswer("TestChattyChild took 4.20s.", "should never be reached") + + result, err := Run(context.Background(), "run the suite", provider, Options{ + Cwd: root, Registry: registry, SessionID: "session-posture-off", + }) + if err != nil { + t.Fatal(err) + } + if len(provider.requests) != 2 { + t.Fatalf("a posture-off run cost %d requests, want 2: the check reached a path it must not", len(provider.requests)) + } + if !strings.Contains(result.FinalAnswer, "4.20s") { + t.Errorf("a posture-off run's answer was altered: %q", result.FinalAnswer) + } +} + +// AN UNCORRECTED ANSWER IS RETURNED RATHER THAN ASKED AGAIN. The ledger raises +// each name once, so a model that repeats the number ends the run instead of +// looping until the turn budget is gone. +func TestARepeatedNumberEndsTheRunRatherThanLooping(t *testing.T) { + root := t.TempDir() + registry := tools.NewRegistry() + registry.Register(goTestTool{output: suiteOutput}) + // Says the same wrong number both times. + provider := callThenAnswer("TestChattyChild took 4.20s.", "TestChattyChild took 4.20s.") + + result, err := Run(context.Background(), "run the suite", provider, Options{ + Cwd: root, Registry: registry, SessionID: "session-stubborn", + Zeromaxing: ZeromaxingActive, + }) + if err != nil { + t.Fatal(err) + } + if len(provider.requests) != 3 { + t.Fatalf("the run cost %d requests, want exactly 3: one call, one answer, one correction", len(provider.requests)) + } + if !strings.Contains(result.FinalAnswer, "4.20s") { + t.Errorf("final answer = %q", result.FinalAnswer) + } +} diff --git a/internal/agent/model_family_test.go b/internal/agent/model_family_test.go new file mode 100644 index 000000000..db28c8e4b --- /dev/null +++ b/internal/agent/model_family_test.go @@ -0,0 +1,69 @@ +package agent + +import ( + "strings" + "testing" +) + +// THE PROVIDER OUTRANKS THE MODEL ID, because the provider knows and the id only +// hints. +// +// A ChatGPT OAuth session was classified correctly for one reason: "gpt-5.5" +// happens to begin with "gpt". Nothing obliges the next id to — OpenAI has +// already shipped o1, o3 and o4 — and the prefix list has to be amended each +// time to keep a first-party provider working. Asking the provider does not. +func TestTheProviderFamilyWinsOverTheModelId(t *testing.T) { + // An id no prefix arm matches, from a provider that declares OpenAI. + if got := modelPromptAddendum("openai", "some-unreleased-id"); got != openAIPromptAddendum { + t.Error("a provider that declares openai did not get the openai addendum for an unrecognised id") + } + if got := modelPromptAddendum("gemini", "some-unreleased-id"); got != geminiPromptAddendum { + t.Error("a provider that declares gemini did not get the gemini addendum") + } + // Anthropic is aligned with the core prompt and takes no addendum, declared + // or guessed. Asserted so "no addendum" stays a decision, not an accident. + if got := modelPromptAddendum("anthropic", "some-unreleased-id"); got != "" { + t.Errorf("anthropic gained an addendum: %q", got) + } +} + +// A GATEWAY DECLARES NOTHING, and must fall back to the id — it is the only +// thing that distinguishes openai/gpt-4.1 from z-ai/glm-4.6 on one endpoint. +func TestAGatewayFallsBackToTheModelId(t *testing.T) { + for _, testCase := range []struct { + model string + want string + }{ + {"openai/gpt-4.1", openAIPromptAddendum}, + {"google/gemini-2.5-pro", geminiPromptAddendum}, + {"anthropic/claude-sonnet-4.5", ""}, + // Unclassified, and honestly so: no family addendum exists for it. + {"z-ai/glm-4.6", ""}, + {"qwen3-coder:480b", ""}, + } { + if got := modelPromptAddendum("", testCase.model); got != testCase.want { + t.Errorf("%s: addendum = %q, want %q", testCase.model, truncateForTest(got), truncateForTest(testCase.want)) + } + } +} + +// The wiring is not decoration: a family that reaches Options must reach the +// prompt. Asserted through the real assembly rather than the helper, because +// asserting the helper is how a value gets threaded to a layer that drops it. +func TestTheDeclaredFamilyReachesTheAssembledPrompt(t *testing.T) { + withProvider := BuildSystemPromptPreview(Options{ModelFamily: "openai", Model: "some-unreleased-id"}) + if !strings.Contains(withProvider, "Persist until the task is fully handled this turn") { + t.Error("the openai guidance did not reach the assembled prompt when the provider declared it") + } + withoutProvider := BuildSystemPromptPreview(Options{Model: "some-unreleased-id"}) + if strings.Contains(withoutProvider, "Persist until the task is fully handled this turn") { + t.Error("an unclassified model gained openai guidance from nowhere") + } +} + +func truncateForTest(text string) string { + if len(text) > 40 { + return text[:40] + "..." + } + return text +} 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/agent/posture_off_identity_test.go b/internal/agent/posture_off_identity_test.go new file mode 100644 index 000000000..bc3491f3d --- /dev/null +++ b/internal/agent/posture_off_identity_test.go @@ -0,0 +1,212 @@ +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 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 +// 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. +// +// MOVED AGAIN WHEN main's #867 RESHAPED read_file's SCHEMA (the canonical +// path/offset/limit contract) and reworded its description; before that, #838 +// reworded glob, grep, list_directory and read_file. Both are schema changes on +// main's side rather than posture leaks from this branch, and the distinction was +// proved rather than assumed: the first request body of a posture-off run was +// compared byte for byte against a binary built from that same main, across +// --auto low/medium/high/member and --use-spec, and all five were identical. +// The absolute byte counts moved with main's new wording (33549 -> 31851 on +// --auto low); the DIFFERENCE between the two binaries stayed zero, which is +// the thing this guard exists to hold. +const postureOffDefinitionsFingerprint = "ea7b4e64c1e651e610e7b9ab4a1dc901d786d11f119fa6c4f9c6bb12ec98ae1f" + +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/prompt_capability_test.go b/internal/agent/prompt_capability_test.go new file mode 100644 index 000000000..912a63ec5 --- /dev/null +++ b/internal/agent/prompt_capability_test.go @@ -0,0 +1,244 @@ +package agent + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/specialist" + "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 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{} + 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 +} + +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 2572370f4..d94fe181b 100644 --- a/internal/agent/system_prompt.go +++ b/internal/agent/system_prompt.go @@ -66,6 +66,72 @@ 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 { + // 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 + } + 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 @@ -95,7 +161,7 @@ func buildSystemPromptParts(options Options) systemPromptParts { core = fallbackSystemPrompt } sections := []string{core} - if addendum := modelPromptAddendum(options.Model); addendum != "" { + if addendum := modelPromptAddendum(options.ModelFamily, options.Model); addendum != "" { sections = append(sections, addendum) } if session := sessionRuntimeContext(options); session != "" { @@ -129,6 +195,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) } diff --git a/internal/agent/system_prompt_models.go b/internal/agent/system_prompt_models.go index 97c39aec6..7a55eeade 100644 --- a/internal/agent/system_prompt_models.go +++ b/internal/agent/system_prompt_models.go @@ -22,6 +22,13 @@ const ( // modelFamily classifies a model id into a prompt-tuning family, or "" when // unknown (in which case no addendum is added). +// +// A GUESS, AND THE FALLBACK ONLY. Matching id prefixes answers correctly for +// first-party ids and for the vendor-prefixed ones a gateway uses +// ("openai/gpt-4.1"), and answers nothing at all for most of the market: of the +// 37 providers in the catalog, 28 have a default model that matches none of +// these arms — including the recommended default. Where the PROVIDER knows the +// answer it is passed in instead; see modelPromptAddendum. func modelFamily(model string) string { m := strings.ToLower(strings.TrimSpace(model)) switch { @@ -41,8 +48,21 @@ func modelFamily(model string) string { // modelPromptAddendum returns the family-specific prompt block for a model id, // or "" when the family is unknown. -func modelPromptAddendum(model string) string { - switch modelFamily(model) { +// modelPromptAddendum returns the family-specific prompt block, preferring the +// family the PROVIDER declared over anything guessed from the model id. +// +// The provider is authoritative where it is single-family, and the id is not: a +// ChatGPT OAuth session was classified correctly only because "gpt-5.5" happens +// to begin with "gpt", which no future id is obliged to do. Gateways serve +// several families at once and declare none, so those still fall back to the id +// — which is the right answer for them, since only the id distinguishes +// "openai/gpt-4.1" from "z-ai/glm-4.6" on the same endpoint. +func modelPromptAddendum(providerFamily, model string) string { + family := strings.TrimSpace(providerFamily) + if family == "" { + family = modelFamily(model) + } + switch family { case familyOpenAI: return openAIPromptAddendum case familyGemini: diff --git a/internal/agent/system_prompt_models_test.go b/internal/agent/system_prompt_models_test.go index 8dbe10522..df019d505 100644 --- a/internal/agent/system_prompt_models_test.go +++ b/internal/agent/system_prompt_models_test.go @@ -39,7 +39,7 @@ func TestBuildSystemPromptAppendsModelAddendum(t *testing.T) { t.Fatalf("the claude prompt must not contain the OpenAI addendum") } // Unknown / unset model gets no family block. - if got := modelPromptAddendum(""); got != "" { + if got := modelPromptAddendum("", ""); got != "" { t.Fatalf("expected no addendum without a model, got %q", got) } if strings.Contains(buildSystemPrompt(Options{}), "") { diff --git a/internal/agent/token_budget_test.go b/internal/agent/token_budget_test.go new file mode 100644 index 000000000..56e688913 --- /dev/null +++ b/internal/agent/token_budget_test.go @@ -0,0 +1,169 @@ +package agent + +import ( + "context" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/tools" + "github.com/Gitlawb/zero/internal/zeroruntime" +) + +// spendingTurn is one provider turn that calls a tool and reports usage. +func spendingTurn(id string, tokens int) []zeroruntime.StreamEvent { + return []zeroruntime.StreamEvent{ + {Type: zeroruntime.StreamEventToolCallStart, ToolCallID: id, ToolName: "run_tests"}, + {Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: id, ArgumentsFragment: `{}`}, + {Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: id}, + {Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{PromptTokens: tokens, CompletionTokens: 0}}, + {Type: zeroruntime.StreamEventDone}, + } +} + +func budgetProvider(turns int, perTurn int, finalText string) *mockProvider { + streams := make([][]zeroruntime.StreamEvent, 0, turns+1) + for i := 0; i < turns; i++ { + streams = append(streams, spendingTurn("call-"+string(rune('a'+i)), perTurn)) + } + streams = append(streams, []zeroruntime.StreamEvent{ + {Type: zeroruntime.StreamEventText, Content: finalText}, + {Type: zeroruntime.StreamEventDone}, + }) + return &mockProvider{turns: streams} +} + +func budgetRegistry() *tools.Registry { + registry := tools.NewRegistry() + registry.Register(goTestTool{output: "ok\tpkg\t0.10s\n"}) + return registry +} + +// A RUN MUST BE BOUNDED BY WHAT IT SPENDS, not only by how many round trips it +// makes. +// +// MaxTurns is a proxy for cost that does not track cost: a measured heavy run +// reached its 320-turn limit having spent 35,781,390 tokens, where a cheap run +// reaches the same limit at roughly a tenth of that. The turn count bounds round +// trips; nothing bounded spend. +func TestARunStopsOnItsTokenBudget(t *testing.T) { + provider := budgetProvider(6, 1000, "done") + result, err := Run(context.Background(), "work", provider, Options{ + Cwd: t.TempDir(), Registry: budgetRegistry(), SessionID: "budget", + MaxTurns: 50, MaxTokens: 2500, + }) + if err != nil { + t.Fatal(err) + } + // 1000 per turn against 2500: turns 1-3 run (3000 spent), turn 4 is refused. + if len(provider.requests) > 5 { + t.Fatalf("the run made %d requests; the token budget did not bind", len(provider.requests)) + } + if strings.TrimSpace(result.FinalAnswer) == "" { + t.Error("a budget expiry produced no final answer; it must end with a written summary, not silence") + } +} + +// THE READER MUST BE TOLD WHICH BOUND FIRED. Reporting a turn limit for a spend +// stop sends someone to raise a number that was never what ran out. +func TestATokenStopSaysSoRatherThanBlamingTheTurnCount(t *testing.T) { + provider := budgetProvider(6, 1000, "done") + result, err := Run(context.Background(), "work", provider, Options{ + Cwd: t.TempDir(), Registry: budgetRegistry(), SessionID: "budget-reason", + MaxTurns: 50, MaxTokens: 2500, RequireCompletionSignal: true, + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(result.IncompleteReason, "token budget") { + t.Errorf("IncompleteReason = %q; a spend stop must name the token budget", result.IncompleteReason) + } + if strings.Contains(result.IncompleteReason, "max-turns") { + t.Errorf("a token stop was reported as a turn-limit stop: %q", result.IncompleteReason) + } + // ...and the model is told the same thing, so its summary is about the right + // constraint. + last := provider.requests[len(provider.requests)-1] + rendered := renderZeromaxingMessages(last.Messages) + if !strings.Contains(rendered, "token budget") { + t.Errorf("the final-answer prompt does not mention the token budget:\n%s", rendered[max(0, len(rendered)-400):]) + } +} + +// A TURN-COUNT STOP STILL READS AS ONE. The new branch must not relabel the old +// ending. +func TestATurnStopStillSaysMaxTurns(t *testing.T) { + provider := budgetProvider(6, 10, "done") + result, err := Run(context.Background(), "work", provider, Options{ + Cwd: t.TempDir(), Registry: budgetRegistry(), SessionID: "turn-reason", + MaxTurns: 3, RequireCompletionSignal: true, + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(result.IncompleteReason, "max-turns") { + t.Errorf("IncompleteReason = %q; a turn stop must still name the turn limit", result.IncompleteReason) + } +} + +// AN UNSET BUDGET IS UNBOUNDED, so every caller that never sets it behaves +// exactly as before. +func TestAnUnsetTokenBudgetBoundsNothing(t *testing.T) { + provider := budgetProvider(4, 1_000_000, "finished") + result, err := Run(context.Background(), "work", provider, Options{ + Cwd: t.TempDir(), Registry: budgetRegistry(), SessionID: "unbounded", + MaxTurns: 50, + }) + if err != nil { + t.Fatal(err) + } + if len(provider.requests) != 5 { + t.Fatalf("an unbounded run made %d requests, want all 5", len(provider.requests)) + } + if !strings.Contains(result.FinalAnswer, "finished") { + t.Errorf("final answer = %q", result.FinalAnswer) + } +} + +// THE BUDGET IS CHECKED AT A TURN BOUNDARY, so a turn whose tool calls have +// already run is not discarded. Stopping mid-turn would throw away work the run +// has already paid for. +func TestTheBudgetDoesNotDiscardAnAlreadyPaidTurn(t *testing.T) { + ran := 0 + registry := tools.NewRegistry() + registry.Register(countingTool{count: &ran}) + provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{ + // One turn that blows the entire budget in a single exchange. + { + {Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "counted"}, + {Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "c1", ArgumentsFragment: `{}`}, + {Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"}, + {Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{PromptTokens: 9999}}, + {Type: zeroruntime.StreamEventDone}, + }, + {{Type: zeroruntime.StreamEventText, Content: "summary"}, {Type: zeroruntime.StreamEventDone}}, + }} + if _, err := Run(context.Background(), "work", provider, Options{ + Cwd: t.TempDir(), Registry: registry, SessionID: "boundary", + MaxTurns: 50, MaxTokens: 100, + }); err != nil { + t.Fatal(err) + } + if ran != 1 { + t.Errorf("the tool ran %d times; the turn's already-issued call must still execute", ran) + } +} + +type countingTool struct{ count *int } + +func (countingTool) Name() string { return "counted" } +func (countingTool) Description() string { return "counts" } +func (countingTool) Parameters() tools.Schema { + return tools.Schema{Type: "object", AdditionalProperties: false} +} +func (countingTool) Safety() tools.Safety { + return tools.Safety{SideEffect: tools.SideEffectNone, Permission: tools.PermissionAllow, AdvertiseInAuto: true} +} +func (t countingTool) Run(context.Context, map[string]any) tools.Result { + *t.count++ + return tools.Result{Status: tools.StatusOK, Output: "ok"} +} diff --git a/internal/agent/types.go b/internal/agent/types.go index 7e6cab927..848b21b80 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -280,9 +280,20 @@ type Options struct { SessionTitle string ProviderName string Model string - ReasoningEffort string - Cwd string - SystemPrompt string + // ModelFamily is the family the PROVIDER declares it serves, from the + // provider catalog — "openai", "anthropic", "gemini", or "" when the provider + // is a gateway serving several and cannot answer. Empty falls back to + // classifying the model id, which is a guess that matches nothing for most of + // the catalog. Prompt tuning only; it selects the family addendum and nothing + // else. + ModelFamily string + ReasoningEffort string + Cwd string + SystemPrompt string + // userMessage is this turn's raw user text, set by Run from its prompt + // argument and forwarded to tools. Unexported: it is not a knob a caller + // sets, it is what the caller already passed as `prompt`. + userMessage string // ResponseStyle is the operator-selected reply style from the TUI /style // command (e.g. "concise", "explanatory", "review"). It is rendered into the // system prompt as a short directive. Empty or "balanced" adds nothing — the @@ -292,6 +303,19 @@ type Options struct { // nil for text-only runs (the seeded message then carries no images, exactly // as before). Images []zeroruntime.ImageBlock + // MaxTokens bounds what a whole run may SPEND, in provider-reported tokens. + // 0 is unbounded, which is every caller that does not set it. + // + // MaxTurns IS NOT A COST BOUND, and treating it as one is how a run ends at + // an arbitrary place. A cheap run reaches 320 turns having spent ~3M tokens; a + // measured heavy run reached the same 320 having spent 35,781,390. The turn + // count bounds round trips, not spend, and the two diverge by an order of + // magnitude on exactly the runs worth bounding. + // + // Crossing it ends the run the same way MaxTurns does — one final call asking + // for a summary — so a budget expiry produces a written report of what was + // done rather than a run that simply stops. + MaxTokens int // ContextWindow is the model's maximum input token budget. When > 0 the agent // loop compacts long conversations once the estimated size crosses a fraction // of this window. 0 DISABLES compaction entirely (every existing caller/test @@ -367,6 +391,24 @@ 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 + // 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. + 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 new file mode 100644 index 000000000..ea94e8e92 --- /dev/null +++ b/internal/agent/zeromaxing.go @@ -0,0 +1,167 @@ +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. 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 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. 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." + + // ZeromaxingEvidenceNotice states what counts as evidence. It fires on the + // FIRST TURN OF EVERY RUN while the posture is on — once per user message, + // which is when a claim arrives and when a report is written. Repeating it on + // every tool turn would cost hundreds of copies to say something that only + // bears on the boundaries of a run. + // + // EARNED BY A MEASURED RUN, three failure classes in one submission: + // + // A property was scored top marks in five places on the strength of "the + // test passes" — while the test consumed the lazy iterator it was meant to + // prove lazy, discarded the value whose absence was the bug, and reported + // avg × 2.5 as a p99. Passing was treated as proof without once asking what + // the test would still pass with. + // + // A table of benchmark timings was reported that no command in the session + // had produced. The same test read 0.86s in one paste and 4.20s in the + // next with nothing said about the difference, and the column summed to an + // exact total no real transcript lands on. That is the most serious of the + // three, because it is not an oversight. + // + // Told its code had a defect, it agreed — and the claim was wrong. It + // revised a correct answer down and wrote a proof that contradicted its own + // preceding step. An agent that folds to whoever asserts most confidently + // follows the user rather than the evidence, and is worth less the more it + // is trusted. + ZeromaxingEvidenceNotice = "Evidence discipline under zeromaxing. " + + "A passing test is not proof that a property holds: whenever you rest a claim on one, name the test AND say in one sentence what it would still pass with. " + + "Every measurement you report must come from a command you ran in this session — if a number differs from one you gave earlier, show both and account for the difference rather than quietly replacing it. " + + "And when someone tells you your code has a defect, prove or refute it from the code before you agree: agreeing without a line-level trace is a wrong answer even when the claim turns out to be right." + + // 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 and avoid launching heavy multi-step side tasks." +) + +// 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, orchestrateAvailable bool) []string { + if turn < 1 { + return nil + } + switch posture { + case ZeromaxingEntering: + if turn == 1 { + notices := []string{ZeromaxingEnterNotice, ZeromaxingBudgetNotice} + if orchestrateAvailable { + notices = append(notices, ZeromaxingOrchestrateNotice) + } + // LAST, so it is the nearest instruction to the user's message. + return append(notices, ZeromaxingEvidenceNotice) + } + return []string{ZeromaxingStillOnNotice} + case ZeromaxingActive: + // Already on when the run started: no enter notice, not even on turn 1. + // + // The evidence contract still repeats, because it is per-RUN rather than + // per-posture-change: turn 1 is where the user's message sits, so it is + // where a claim about the code arrives and where a report gets written. + // Announcing it only when the posture flipped would leave every later run + // of a long session without it — and the long sessions are the ones that + // produce reports. + if turn == 1 { + return []string{ZeromaxingStillOnNotice, ZeromaxingEvidenceNotice} + } + 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..460c29417 --- /dev/null +++ b/internal/agent/zeromaxing_test.go @@ -0,0 +1,225 @@ +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, states the budget and the evidence contract", ZeromaxingEntering, 1, + []string{ZeromaxingEnterNotice, ZeromaxingBudgetNotice, ZeromaxingEvidenceNotice}}, + // (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. + // The evidence contract is per-RUN, not per-posture-change: turn 1 is + // where the user's message sits, so it is where a claim about the code + // arrives and where a report gets written. + {"active turn 1 does not re-announce but restates the evidence contract", ZeromaxingActive, 1, + []string{ZeromaxingStillOnNotice, ZeromaxingEvidenceNotice}}, + {"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, false) + 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, false) { + 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, false) + for turn := 3; turn <= 40; turn++ { + if got := zeromaxingReminders(ZeromaxingEntering, turn, false); !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) + } + } + } +} + +// 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) + } + } +} + +// THE EVIDENCE CONTRACT FIRES ONCE PER RUN, on the turn the user's message sits +// on — not once per posture change, and not on every tool turn. +// +// Once per posture change would leave every later run of a long session without +// it, and the long sessions are the ones that produce reports. Every tool turn +// would pay hundreds of copies to say something that only bears on the +// boundaries of a run. +func TestTheEvidenceContractFiresOnceOnTheFirstTurnOfEveryRun(t *testing.T) { + for _, posture := range []Zeromaxing{ZeromaxingEntering, ZeromaxingActive} { + fired := 0 + for turn := 1; turn <= 50; turn++ { + for _, line := range zeromaxingReminders(posture, turn, false) { + if line == ZeromaxingEvidenceNotice { + fired++ + if turn != 1 { + t.Errorf("posture %v repeated the evidence contract on turn %d; it belongs on turn 1 only", posture, turn) + } + } + } + } + if fired != 1 { + t.Errorf("posture %v fired the evidence contract %d times across 50 turns, want exactly 1", posture, fired) + } + } + + // A posture that is OFF or on its way out says nothing: a contract about how + // to weigh evidence is part of the posture, and claiming it after the posture + // ended would be as wrong as the still-on notice would be. + for _, posture := range []Zeromaxing{ZeromaxingOff, ZeromaxingExiting} { + for turn := 1; turn <= 5; turn++ { + for _, line := range zeromaxingReminders(posture, turn, true) { + if line == ZeromaxingEvidenceNotice { + t.Errorf("posture %v emitted the evidence contract on turn %d", posture, turn) + } + } + } + } +} + +// The contract has to actually carry all three rules it exists for. Asserted on +// the substance, not the wording, so a rewrite is free but a DELETION is not: +// each of these went missing in a measured run and cost a different thing. +func TestTheEvidenceContractCoversAllThreeFailures(t *testing.T) { + for _, required := range []string{ + // A passing test treated as proof of the property. + "passing test is not proof", + "what it would still pass with", + // Numbers reported that no command in the session produced. + "must come from a command you ran in this session", + "show both", + // Folding to whoever asserts a defect most confidently. + "prove or refute it from the code before you agree", + "even when the claim turns out to be right", + } { + if !strings.Contains(ZeromaxingEvidenceNotice, required) { + t.Errorf("the evidence contract no longer says %q:\n%s", required, ZeromaxingEvidenceNotice) + } + } +} diff --git a/internal/cli/app.go b/internal/cli/app.go index 4d6b11aab..bde0a010d 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -17,10 +17,12 @@ 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" "github.com/Gitlawb/zero/internal/mcp" + "github.com/Gitlawb/zero/internal/memory" "github.com/Gitlawb/zero/internal/modelregistry" "github.com/Gitlawb/zero/internal/observability" "github.com/Gitlawb/zero/internal/plugins" @@ -688,6 +690,14 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a } registry := newCoreRegistryScoped(workspaceRoot, scope) + // OPT-IN, and the default matters: registering these changes the advertised + // tool set for EVERY run, which is exactly what this branch guarantees the + // posture does not do when it is off. Off by default keeps that guarantee + // true; a user who wants durable notes says so in their own config. + if resolved.Profiles.Memory { + registry.Register(tools.NewMemoryTool(memory.DefaultPaths(workspaceRoot))) + registry.Register(tools.NewMemoryWriteTool(memory.DefaultPaths(workspaceRoot))) + } registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl) executionRunner := execution.NewRunner(nil) sandboxStore, err := deps.newSandboxStore() @@ -709,11 +719,97 @@ 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{} + // 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() + // 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) + // 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 + // 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, + orchestrateWiring{ + DiscoverModels: planModelDiscoverer(workspaceRoot, resolved.Provider), + // Sizes each task's dependency briefing to the window of the model + // that will READ it. Wired here because only this side knows the + // provider profile and the session's own model. + ContextWindows: planContextWindows(resolved.Provider, resolved.Provider.Model), + // The LIVE scope, not a snapshot of it. A request_permissions grant + // lands mid-session and must reach the children dispatched after it. + // ExtraRoots, not Roots: this is what the run holds BEYOND its + // workspace, which is what the field means and what a child needs. + // Roots() also returns the workspace root itself, and for a plan task + // running in an isolated worktree that arrived as --add-dir — re-opening everything --cwd had just narrowed. A measured + // run wrote ten times into the user's real tree that way, each write + // allowed with the reason "workspace write is allowed", because by + // then it genuinely was inside the child's write roots. + ExtraWriteRoots: scope.ExtraRoots, + // The read counterpart: a request_permissions READ grant lands in the + // scope's readRoots, which ExtraRoots (write grants) does not return, so + // without this a plan auditing a granted path failed "outside the + // workspace" in every task. LIVE scope, read at dispatch, same as above. + ExtraReadRoots: scope.ExtraReadRoots, + ProbeModel: planModelProber(workspaceRoot, resolved.Provider, deps.newProvider), + ModelPrefs: planModelPreferences(resolved.Profiles.PlanModels), + // Off unless the user's own config asks for it: on by default would + // refuse a plan for everyone whose phrasing does not happen to match. + RequirePlanKeyword: resolved.Profiles.RequirePlanKeyword, + 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, + 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(), + 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, + // 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) } + // 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. @@ -811,7 +907,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, @@ -826,6 +922,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a FavoriteModels: resolved.Preferences.FavoriteModels, RecentModels: resolved.Preferences.RecentModels, RecapsEnabled: resolved.Preferences.RecapsEnabled(), + KeepFinishedAgents: resolved.Preferences.KeepsFinishedAgents(), Provider: provider, NewProvider: deps.newProvider, ProbeProviderHealth: deps.probeProviderHealth, @@ -840,6 +937,10 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a SessionStore: deps.newSessionStore(), SandboxStore: sandboxStore, MCPConfig: mcpConfig, + ZeromaxingDisabled: resolved.Profiles.DisableZeromaxing, + ZeromaxingGate: zeromaxingGate, + PlanProgress: planProgress, + PlanPaths: planPaths, MCPPermissionStore: mcpPermissionStore, MCPTokenStore: mcpTokenStore, MCPCommand: func(ctx context.Context, args []string) tui.MCPCommandResult { @@ -865,13 +966,22 @@ 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), + // 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 @@ -1058,12 +1168,118 @@ func (r *agentToolRuntime) specialistInfos() []agent.SpecialistInfo { return r.specialists } -func registerSpecialistTools(registry *tools.Registry, workspaceRoot string, maxTeamSize int) (*agentToolRuntime, error) { +// 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 { + // RequirePlanKeyword carries Profiles.RequirePlanKeyword to the tool. + RequirePlanKeyword bool + // 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 + // PlanContext supplies the run-invariant state a plan task inherits. + PlanContext specialist.PlanTaskContext + // ContextWindows sizes a task's dependency briefing to the model that will + // read it. nil keeps the fixed caps. + ContextWindows specialist.ContextWindowFunc + // 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 + // ExtraReadRoots reports the paths the run may READ beyond its workspace — its + // request_permissions grants — at DISPATCH time, so a plan's tasks can read a + // granted external path instead of failing "outside the workspace". The read + // counterpart of ExtraWriteRoots: a read grant lands in a separate scope list + // that ExtraWriteRoots does not cover. nil means workspace-only reads. + ExtraReadRoots 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. + 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 + // 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 + // 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. +// +// 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{} + // 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 + } + 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. +// +// 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 } - executor := specialist.Executor{Paths: paths} + // One budget for the whole session, shared by every run and every plan in it + // — which is the point: a per-run counter would reset on each message and + // bound nothing a conversation does over time. + sessionBudget := specialist.NewSessionBudget(specialist.DefaultSessionSubagents) + executor := specialist.Executor{ + SessionBudget: sessionBudget, + 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, + // So a delegated Task that names no model is routed to a role-appropriate + // pin under the zeromaxing posture, the same way a plan task is. Off unless + // the posture is active and auto-assign is configured. The discoverer is + // the same one the plan tool uses: a pin fires only when the provider's + // own listing carries it, so a stale pin after a provider switch degrades + // to inherit instead of a child dead at spawn. + ModelPrefs: wiring.ModelPrefs, + PostureActive: wiring.Gate.Active, + DiscoverModels: wiring.DiscoverModels, + ServeCache: &specialist.ModelServeCache{}, + } runtime, err := specialist.RegisterTools(registry, executor) if err != nil { return nil, err @@ -1082,6 +1298,40 @@ 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: recorder, + ParentTools: planParentTools(registry, enabledTools, disabledTools), + Depth: planContext.Depth, + Size: wiring.Size, + // Refuse a plan the user's own turn did not ask for, when this session + // asks to be protected that way. Off unless configured — see + // ProfilesConfig.RequirePlanKeyword. + RequirePlanKeyword: wiring.RequirePlanKeyword, + Plans: wiring.Plans, + Launch: wiring.Launch, + Isolate: wiring.Isolate, + DiscoverModels: wiring.DiscoverModels, + ContextWindows: wiring.ContextWindows, + ExtraReadRoots: wiring.ExtraReadRoots, + ProbeModel: wiring.ProbeModel, + ModelPrefs: wiring.ModelPrefs, + }) return &agentToolRuntime{specialist: runtime, swarm: sw, specialists: specialistSummaries(paths)}, nil } @@ -1422,3 +1672,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/child_exit_code_agreement_test.go b/internal/cli/child_exit_code_agreement_test.go new file mode 100644 index 000000000..522570817 --- /dev/null +++ b/internal/cli/child_exit_code_agreement_test.go @@ -0,0 +1,51 @@ +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) + } +} + +// THE SAME HAZARD, for the same reason, on the provider exit. +// +// specialist retries a plan task ONCE when the child died on the provider +// rather than on the work, and it recognises that from this exit code. If +// exitProvider moves and specialist's childExitProvider does not, a provider +// failure stops being recognised, the task silently loses its retry, and a +// transient 500 goes back to costing the task and everything downstream of it — +// with nothing failing anywhere to say so. +func TestTheChildProviderExitCodeAgreesWithWhatSpecialistExpects(t *testing.T) { + const specialistChildExitProvider = 3 + if exitProvider != specialistChildExitProvider { + t.Fatalf("exitProvider is %d but internal/specialist retries provider failures on %d — "+ + "a task killed by a provider error will no longer be retried", + exitProvider, specialistChildExitProvider) + } + // And the two codes must stay DISTINCT: one number meaning both would make + // every provider failure look like a decline (or the reverse), and the two + // retries have different safety rules — the provider one refuses write tasks. + if exitProvider == exitIncomplete { + t.Fatal("exitProvider and exitIncomplete collided; the two retries would be indistinguishable") + } +} diff --git a/internal/cli/exec.go b/internal/cli/exec.go index 2d1fe542a..c424b9e4f 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" @@ -134,6 +135,11 @@ type execOptions struct { // additional write roots for this run. Unioned with // config.SandboxConfig.AdditionalWriteRoots at scope construction time. addDirs []string + // readDirs holds directories passed via --add-read-dir: READ-ONLY roots added + // with scope.AddRead after construction. This is how a specialist child + // inherits the parent's request_permissions read grants without gaining write + // access to them — a plan auditing a granted external path reads it here. + readDirs []string // tracePath, when set, writes a per-turn NDJSON trace (agenteval-compatible) // to the given file path — or to stderr when the value is "-". Falls back to // the ZERO_TRACE env var when the flag is absent. Off by default: a run @@ -173,6 +179,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()) } @@ -180,6 +195,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()) @@ -217,17 +236,105 @@ 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{} + // 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 // 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 + // 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 - 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()) + // 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, planRecorder, orchestrateWiring{ + DiscoverModels: planModelDiscoverer(workspaceRoot, planProvider), + // The run's EXTRA write roots only — the grants beyond the + // workspace — exactly as the TUI wires them (scope.ExtraRoots). + // Passing execScope.Roots() here handed the child the PARENT + // WORKSPACE ROOT as an extra writable --add-dir, and for an + // ISOLATED plan that defeats the isolation outright: the + // worktree exists so a write-capable plan cannot touch the + // parent tree, and this line was handing the parent tree back. + // A child's own workspace comes from its --cwd; only explicit + // grants ride along. + // + // 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 { + return execChildWriteRoots(execScope) + }, + // The read counterpart: a request_permissions READ grant lands in a + // separate scope list the write roots above do not cover, so without + // this a plan auditing a granted path fails "outside the workspace" in + // every task. Same live-scope closure, same reason. + ExtraReadRoots: func() []string { + if execScope == nil { + return nil + } + return execScope.ExtraReadRoots() + }, + // 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{ + 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. + 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, + }, + 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), + // 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()) } @@ -287,12 +394,45 @@ 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() { + 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 + } + } 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()) } + // READ-ONLY roots from --add-read-dir, added after construction so they never + // become write roots: this is how a specialist child reads the parent's + // request_permissions grants without gaining write access. A bad path fails + // closed (the run stops) rather than silently dropping the grant. + for _, dir := range options.readDirs { + if _, err := execScope.AddRead(dir); err != nil { + return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", fmt.Sprintf("--add-read-dir %q: %v", dir, err)) + } + } for _, tool := range tools.CoreToolsScoped(workspaceRoot, execScope) { registry.Register(tool) } @@ -588,6 +728,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{ @@ -628,6 +769,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip) result, err := agent.Run(runCtx, agentPrompt, provider, agent.Options{ MaxTurns: resolved.MaxTurns, + MaxTokens: execProfile.MaxTokens, ContextWindow: resolveAgentContextWindow(runCtx, modelRegistry, resolved.Provider), DeferThreshold: effectiveDeferThreshold, Specialists: specialistRuntime.specialistInfos(), @@ -639,6 +781,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in Depth: options.depth, SessionTitle: sessionTitle, ProviderName: resolved.Provider.Name, + ModelFamily: providercatalog.ModelFamilyFor(resolved.Provider.CatalogID), Model: resolved.Provider.Model, ModelSwitcher: modelSwitcher, TurnSessionProvider: turnSessions, @@ -653,6 +796,13 @@ 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), + // 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 @@ -791,6 +941,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 == "" { @@ -1203,6 +1361,64 @@ 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 +} + +// 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 +// 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 +// 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 +1536,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 @@ -1450,3 +1676,26 @@ 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) +} + +// execChildWriteRoots is what a headless run's children may WRITE beyond their +// own workspace: the run's granted extra roots, and never the parent workspace +// root itself. It returned execScope.Roots() — workspace included — and for an +// ISOLATED plan that defeats the isolation outright: the worktree exists so a +// write-capable plan cannot touch the parent tree, and the wiring was handing +// the parent tree back as a writable --add-dir. The TUI has always passed only +// scope.ExtraRoots; the two surfaces now agree. +func execChildWriteRoots(scope *sandbox.Scope) []string { + if scope == nil { + return nil + } + return scope.ExtraRoots() +} diff --git a/internal/cli/exec_child_roots_test.go b/internal/cli/exec_child_roots_test.go new file mode 100644 index 000000000..ca0764ed7 --- /dev/null +++ b/internal/cli/exec_child_roots_test.go @@ -0,0 +1,48 @@ +package cli + +import ( + "path/filepath" + "testing" + + "github.com/Gitlawb/zero/internal/sandbox" +) + +// AN ISOLATED PLAN'S CHILD MUST NOT GET THE PARENT TREE BACK. The headless +// wiring handed children execScope.Roots() — the parent workspace root +// included — as extra WRITE roots. A worktree-isolated plan exists so a +// write-capable task cannot touch the parent tree, and that line handed the +// parent tree back as a writable --add-dir. Only the run's explicit extra +// grants may ride along; the child's own workspace comes from its --cwd. +func TestExecChildWriteRootsExcludeTheParentWorkspace(t *testing.T) { + // The scope stores symlink-resolved paths (/var → /private/var on macOS), + // so both sides of every comparison resolve first. + resolve := func(path string) string { + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + t.Fatalf("EvalSymlinks(%s): %v", path, err) + } + return resolved + } + workspace := resolve(t.TempDir()) + granted := resolve(t.TempDir()) + scope, err := sandbox.NewScope(workspace, []string{granted}) + if err != nil { + t.Fatalf("NewScope: %v", err) + } + roots := execChildWriteRoots(scope) + found := false + for _, root := range roots { + if root == workspace { + t.Fatalf("the parent workspace root leaked into a child's write roots: %q", roots) + } + if root == granted { + found = true + } + } + if !found { + t.Fatalf("the explicit grant must still ride along, got %q", roots) + } + if execChildWriteRoots(nil) != nil { + t.Fatal("a nil scope must yield no roots") + } +} diff --git a/internal/cli/exec_parse.go b/internal/cli/exec_parse.go index 8eb55223d..bd9d68857 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" ) @@ -116,6 +117,19 @@ func parseExecArgs(args []string) (execOptions, bool, error) { return options, false, err } options.addDirs = append(options.addDirs, value) + case arg == "--add-read-dir": + value, next, err := nextFlagValue(args, index, arg) + if err != nil { + return options, false, err + } + options.readDirs = append(options.readDirs, value) + index = next + case strings.HasPrefix(arg, "--add-read-dir="): + value, err := requiredInlineFlagValue(arg, "--add-read-dir") + if err != nil { + return options, false, err + } + options.readDirs = append(options.readDirs, value) case arg == "--mode": value, next, err := nextFlagValue(args, index, arg) if err != nil { @@ -517,9 +531,33 @@ 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": + 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_parse_add_read_dir_test.go b/internal/cli/exec_parse_add_read_dir_test.go new file mode 100644 index 000000000..b00d07882 --- /dev/null +++ b/internal/cli/exec_parse_add_read_dir_test.go @@ -0,0 +1,30 @@ +package cli + +import "testing" + +// --add-read-dir collects READ-ONLY roots, the read counterpart of --add-dir. +// A specialist child receives the parent's request_permissions read grants on +// this flag so it can audit a granted path without gaining write access. +func TestParseExecArgsCollectsAddReadDirs(t *testing.T) { + options, _, err := parseExecArgs([]string{ + "--prompt", "hi", + "--add-read-dir", "/one", + "--add-read-dir=/two", + }) + if err != nil { + t.Fatalf("parseExecArgs: %v", err) + } + if len(options.readDirs) != 2 || options.readDirs[0] != "/one" || options.readDirs[1] != "/two" { + t.Fatalf("readDirs=%v want [/one /two]", options.readDirs) + } + // It must NOT bleed into addDirs — that would emit it as a write root. + if len(options.addDirs) != 0 { + t.Fatalf("a read-only root leaked into addDirs (write roots): %v", options.addDirs) + } +} + +func TestParseExecArgsAddReadDirRequiresValue(t *testing.T) { + if _, _, err := parseExecArgs([]string{"--add-read-dir"}); err == nil { + t.Fatal("bare --add-read-dir must 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/exec_scope_test.go b/internal/cli/exec_scope_test.go index 4f002ec52..7414393e4 100644 --- a/internal/cli/exec_scope_test.go +++ b/internal/cli/exec_scope_test.go @@ -115,6 +115,26 @@ func runExecAddDirWriteProbe(t *testing.T, cwd string, args []string, target str // is known the scoped core tools are re-registered and Registry.Register // replaces the earlier instances BY NAME. The before/after write_file probes // prove both the overwrite and the scoped enforcement it ships. +// --add-read-dir grants READ, not write, at the process boundary: a write_file +// inside the read-only root is DENIED. This pins exec's readDirs loop to +// scope.AddRead — had it used scope.Add (write), or emitted --add-dir, this write +// would land, and a path the parent may only read would be writable in the child. +func TestAddReadDirGrantsReadOnlyAtTheProcessBoundary(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + cwd := t.TempDir() + readRoot := tempDirOutsideDefaultTemp(t) + target := filepath.Join(readRoot, "must-not-write.txt") + + args := []string{"exec", "--add-read-dir", readRoot, "--skip-permissions-unsafe", "write the note"} + exitCode, stderr := runExecAddDirWriteProbe(t, cwd, args, target) + if exitCode != exitSuccess { + t.Fatalf("exit = %d, want %d; stderr = %q", exitCode, exitSuccess, stderr) + } + if _, err := os.Stat(target); !os.IsNotExist(err) { + t.Fatalf("a write inside a --add-read-dir root landed — a read grant escalated to write (stat err=%v)", err) + } +} + func TestExecScopeReRegistrationSwapsCoreToolsByName(t *testing.T) { root := t.TempDir() extra := tempDirOutsideDefaultTemp(t) diff --git a/internal/cli/exec_spec.go b/internal/cli/exec_spec.go index fc22eed35..98db515e5 100644 --- a/internal/cli/exec_spec.go +++ b/internal/cli/exec_spec.go @@ -12,6 +12,7 @@ import ( "github.com/Gitlawb/zero/internal/execution" "github.com/Gitlawb/zero/internal/modelregistry" "github.com/Gitlawb/zero/internal/notify" + "github.com/Gitlawb/zero/internal/providercatalog" "github.com/Gitlawb/zero/internal/sandbox" "github.com/Gitlawb/zero/internal/sessions" "github.com/Gitlawb/zero/internal/specmode" @@ -123,6 +124,7 @@ func runExecSpecDraft(run execSpecDraftRun) int { SessionID: draftSession.SessionID, SessionTitle: run.sessionTitle, ProviderName: run.resolved.Provider.Name, + ModelFamily: providercatalog.ModelFamilyFor(run.resolved.Provider.CatalogID), Model: run.resolved.Provider.Model, ReasoningEffort: run.reasoningEffort, Profile: run.profilePolicy, 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 new file mode 100644 index 000000000..fdf4df8bc --- /dev/null +++ b/internal/cli/exec_zeromaxing_test.go @@ -0,0 +1,457 @@ +package cli + +import ( + "reflect" + "regexp" + "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/specialist" +) + +// (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 != 480 || displaced != 80 { + t.Fatalf("over resolved 80 = (%d, %d), want (480, 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 != 480 { + 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 != 480 { + t.Fatalf("effective budget = %d, want 480", 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. + 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) + } +} + +// 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) + } +} + +// 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(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) + } + 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) + } +} + +// (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+) → 480`) + match := transition.FindStringSubmatch(stderr) + if match == nil { + t.Fatalf("the delta must state a caller-relative budget transition:\n%s", stderr) + } + if match[1] == "480" { + 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) + } +} + +// 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(100_000)}, + }, specialist.Limits{MaxTasks: 5}) + 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/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/cli/orchestrate_read_roots_wiring_test.go b/internal/cli/orchestrate_read_roots_wiring_test.go new file mode 100644 index 000000000..9844c39e4 --- /dev/null +++ b/internal/cli/orchestrate_read_roots_wiring_test.go @@ -0,0 +1,28 @@ +package cli + +import ( + "os" + "strings" + "testing" +) + +// Both surfaces that launch plan tasks must wire ExtraReadRoots, or a +// request_permissions READ grant never reaches those tasks and a plan auditing a +// granted external path fails "outside the workspace" in every finder. +// +// This is the unwired-feature guard: an option that is built but never passed +// does nothing, and mutation testing cannot catch it because deleting an unused +// wiring breaks no test. So this asserts the wiring is present at the source, in +// both the TUI (app.go) and headless (exec.go) paths, beside the write grant it +// mirrors. +func TestBothPathsWireExtraReadRootsBesideWrite(t *testing.T) { + for _, file := range []string{"app.go", "exec.go"} { + src, err := os.ReadFile(file) + if err != nil { + t.Fatalf("read %s: %v", file, err) + } + if !strings.Contains(string(src), "ExtraReadRoots:") { + t.Fatalf("%s does not wire ExtraReadRoots — a plan there cannot read a granted path", file) + } + } +} 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/cli/plan_grant_test.go b/internal/cli/plan_grant_test.go new file mode 100644 index 000000000..6a0056c4d --- /dev/null +++ b/internal/cli/plan_grant_test.go @@ -0,0 +1,384 @@ +package cli + +import ( + "context" + "strings" + "testing" + + "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 — +// 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, 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, 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") + } + // 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) + } + } +} + +// --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 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.PlanGrantableToolNames() { + 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) + } + } +} + +// 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, 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) + } +} + +// 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) {} + +// 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) + } +} + +// 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") + } +} + +// 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)) + } +} + +// 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_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/cli/plan_live_provider_test.go b/internal/cli/plan_live_provider_test.go new file mode 100644 index 000000000..3d47f13ae --- /dev/null +++ b/internal/cli/plan_live_provider_test.go @@ -0,0 +1,131 @@ +package cli + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/config" +) + +// pointConfigHomeAtATempDir redirects config.UserConfigDir at a scratch directory +// and returns it. +// +// The variable that does the redirecting IS NOT THE SAME ON EVERY PLATFORM: +// os.UserConfigDir reads %AppData% on Windows and $XDG_CONFIG_HOME elsewhere, and +// config.UserConfigDir lets XDG win on macOS too. Setting only XDG_CONFIG_HOME +// left Windows resolving against the real %AppData%, where this file was never +// written — so every case below silently exercised the resolve-failed fallback +// and the switch-following test could not fail on Windows for the right reason. +// Mirrors internal/config/paths_test.go's setUserConfigRoot. +func pointConfigHomeAtATempDir(t *testing.T) string { + t.Helper() + root := t.TempDir() + switch runtime.GOOS { + case "windows": + t.Setenv("APPDATA", root) + default: + t.Setenv("XDG_CONFIG_HOME", root) + } + base, err := config.UserConfigDir() + if err != nil { + t.Fatalf("resolve user config dir: %v", err) + } + // The redirect must have LANDED. Without this, setting the wrong variable + // for the platform resolves back to the developer's real config directory: + // the helper writes a provider file into it and the tests pass for the + // wrong reason — which is exactly how this went unnoticed on Windows. + if !strings.HasPrefix(base, root) { + t.Fatalf("config home resolved to %q, outside the temp root %q: this test would read and write the real user config", base, root) + } + return base +} + +func writeTwoProviderConfig(t *testing.T, active string) { + t.Helper() + dir := filepath.Join(pointConfigHomeAtATempDir(t), "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) + } +} + +// 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", + } + // Empty reads as unset (applyEnv TrimSpaces it) and t.Setenv restores the + // real value afterwards — os.Unsetenv here would leak into sibling tests. + t.Setenv(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) { + // A config home with no zero/config.json in it: resolution finds nothing. + pointConfigHomeAtATempDir(t) + t.Setenv(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_catalog_filter_test.go b/internal/cli/plan_model_catalog_filter_test.go new file mode 100644 index 000000000..e68ea6d2f --- /dev/null +++ b/internal/cli/plan_model_catalog_filter_test.go @@ -0,0 +1,83 @@ +package cli + +import ( + "testing" + + "github.com/Gitlawb/zero/internal/providermodeldiscovery" +) + +// A PLAN MUST NOT BE OFFERED A MODEL ITS OWN PROVIDER WILL REFUSE. +// +// providers.New applies providermodelcatalog's per-provider allow-list at client +// creation (validateModelAllowedForProvider), and `providers models` applies it +// before printing. The plan discoverer did not, so on a scoped provider the +// router was handed candidates that could never run: assignment succeeded, the +// panel showed the model, and the task died at dispatch with "provider X does +// not allow model Y" — the same invisible-until-dispatch failure as being +// assigned from another provider's list altogether. +func TestPlanDiscoveryDropsModelsTheProviderWillRefuse(t *testing.T) { + // opencode-go-anthropic-compatible is scoped to Qwen and MiniMax families. + found := []providermodeldiscovery.Model{ + {ID: "qwen3.5-coder", ToolCall: true}, + {ID: "claude-sonnet-5", ToolCall: true}, + {ID: "minimax-m2", ToolCall: true}, + {ID: "gpt-5", ToolCall: true}, + } + + got := planModelsFromDiscovered("opencode-go-anthropic-compatible", found) + + ids := map[string]bool{} + for _, model := range got { + ids[model.ID] = true + } + for _, refused := range []string{"claude-sonnet-5", "gpt-5"} { + if ids[refused] { + t.Errorf("%q reached the plan; providers.New refuses it for this provider, so every task assigned it dies at dispatch", refused) + } + } + for _, allowed := range []string{"qwen3.5-coder", "minimax-m2"} { + if !ids[allowed] { + t.Errorf("%q was dropped, but this provider serves it", allowed) + } + } +} + +// An UNSCOPED provider keeps its whole list. The allow-list defaults to +// permitting everything, and a filter that quietly narrowed an ordinary provider +// would remove models a plan is entitled to use. +func TestPlanDiscoveryKeepsEveryModelOnAnUnscopedProvider(t *testing.T) { + found := []providermodeldiscovery.Model{ + {ID: "grok-4.5", ToolCall: true}, + {ID: "grok-code-fast", ToolCall: true}, + {ID: "claude-sonnet-5", ToolCall: true}, + } + for _, catalogID := range []string{"xai", "", " "} { + got := planModelsFromDiscovered(catalogID, found) + if len(got) != len(found) { + t.Errorf("catalogID %q narrowed an unscoped provider from %d models to %d", catalogID, len(found), len(got)) + } + } +} + +// The translation must carry every field the plan chooses between — a model that +// arrives with its costs zeroed is routed as if it were free. +func TestPlanDiscoveryCarriesTheFieldsRoutingReads(t *testing.T) { + got := planModelsFromDiscovered("xai", []providermodeldiscovery.Model{{ + ID: "grok-4.5", Description: "frontier", ToolCall: true, Reasoning: true, + InputCost: 3, OutputCost: 15, OutputModalities: []string{"text"}, + }}) + if len(got) != 1 { + t.Fatalf("got %d models, want 1", len(got)) + } + model := got[0] + switch { + case model.ID != "grok-4.5", model.Description != "frontier": + t.Errorf("identity lost: %+v", model) + case !model.ToolCall, !model.Reasoning: + t.Errorf("capabilities lost, so routing cannot tell this model apart: %+v", model) + case model.InputCost != 3, model.OutputCost != 15: + t.Errorf("costs lost, so the cheapest-model rule ranks this as free: %+v", model) + case len(model.OutputModalities) != 1: + t.Errorf("modalities lost: %+v", model) + } +} 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..386486eea --- /dev/null +++ b/internal/cli/plan_model_prefs_carry_test.go @@ -0,0 +1,63 @@ +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"}, + MinSize: 7, + } + 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/plan_recorder.go b/internal/cli/plan_recorder.go new file mode 100644 index 000000000..b213c9619 --- /dev/null +++ b/internal/cli/plan_recorder.go @@ -0,0 +1,85 @@ +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) +} + +// 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) { + bridge.append(specialist.PlanAdmittedEvent(plan)) +} + +func (bridge *planSessionRecorder) TaskDispatched(task specialist.Task) { + bridge.append(specialist.TaskDispatchedEvent(task)) +} + +func (bridge *planSessionRecorder) TaskCompleted(result specialist.TaskResult) { + bridge.append(specialist.TaskCompletedEvent(result)) +} + +func (bridge *planSessionRecorder) TaskFailed(result specialist.TaskResult) { + bridge.append(specialist.TaskFailedEvent(result)) +} + +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(specialist.PlanCompletedEvent(plan, report)) +} 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"]) + } +} diff --git a/internal/cli/provider_models.go b/internal/cli/provider_models.go index 542ad1207..ab17b9a21 100644 --- a/internal/cli/provider_models.go +++ b/internal/cli/provider_models.go @@ -6,16 +6,22 @@ import ( "io" "os" "strings" + "sync" "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/providercatalog" "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 +72,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 +110,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 +136,62 @@ 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 != "" { + // The size where the id names one, so the directory reads as light/medium/ + // heavy at a glance — the classification routing uses, made visible. Absent + // for a cloud id that carries none. + if label := specialist.ModelSizeLabel(model.ID); label != "" { + line += " [" + label + "]" + } + 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 +207,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 +284,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 +297,108 @@ 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) { + live := livePlanProvider(workspaceRoot, profile) + found, err := defaultDiscoverProviderModels(ctx, discoveryCredentialProfile(live)) + if err != nil { + return nil, err + } + // The LIVE profile's catalog id, not the captured one: discovery already + // follows an in-session provider switch, and filtering against the + // launch-time provider would reintroduce the disagreement + // livePlanProvider exists to remove. + return planModelsFromDiscovered(live.CatalogID, found), nil + } +} + +// planModelsFromDiscovered narrows a provider's list to the models a plan may +// actually be assigned, and translates them into specialist's own type. +// +// FILTERED THROUGH THE SAME ALLOW-LIST THE CLIENT WILL APPLY. +// defaultDiscoverProviderModels deliberately returns a provider's whole list — +// right for `providers models`, which exists to show what is there, and which +// applies this same filter itself before printing. A plan does not display these, +// it RUNS them, and providers.New refuses a model outside its provider's scope +// (validateModelAllowedForProvider). So the unfiltered list handed the router +// candidates that could never be dispatched, and every task assigned one died at +// client creation — the same shape as assigning from the wrong provider's list +// entirely, and just as invisible until dispatch. +func planModelsFromDiscovered(catalogID string, found []providermodeldiscovery.Model) []specialist.DiscoveredModel { + catalogID = strings.TrimSpace(catalogID) + out := make([]specialist.DiscoveredModel, 0, len(found)) + for _, model := range found { + if !providermodelcatalog.ModelIDAllowedForProvider(catalogID, model.ID) { + continue + } + 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 +} + +// 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, + MinSize: cfg.MinSize, + TopModels: cfg.TopModels, + } +} + +// planContextWindows reports the context window of the model a plan task will +// run on, 0 when unknown. +// +// FROM THE CATALOGUE, NOT FROM DISCOVERY. The window is static metadata about a +// model, so asking the provider for it over the network per task would pay a +// round trip for an answer that cannot change — and most of this catalogue's +// gateways do not report a window at all, in which case 0 is the honest answer +// and the plan keeps its fixed briefing caps. +// +// AN EMPTY MODEL ID MEANS "the model this run is using". A plan task that names +// no model inherits the parent's, and only this side of the seam knows what that +// is — internal/specialist deliberately does not. +func planContextWindows(profile config.ProviderProfile, sessionModel string) specialist.ContextWindowFunc { + var once sync.Once + windows := map[string]int{} + return func(modelID string) int { + once.Do(func() { + descriptor, err := providercatalog.Require(profile.CatalogID) + if err != nil { + return + } + for _, model := range providermodelcatalog.Models(descriptor) { + if model.ContextWindow > 0 { + windows[model.ID] = model.ContextWindow + } + } + }) + if strings.TrimSpace(modelID) == "" { + modelID = sessionModel + } + return windows[strings.TrimSpace(modelID)] + } +} diff --git a/internal/cli/provider_models_test.go b/internal/cli/provider_models_test.go index 405da62e3..a1088e9a4 100644 --- a/internal/cli/provider_models_test.go +++ b/internal/cli/provider_models_test.go @@ -42,6 +42,31 @@ func TestRunProvidersModelsListsDiscoveredModels(t *testing.T) { } } +// The directory shows each model's SIZE where the id names one, so a reader sees +// light/medium/heavy at a glance — the classification routing uses, made visible. +// A cloud id with no size gets no invented label. +func TestRunProvidersModelsShowsModelSize(t *testing.T) { + var stdout, stderr bytes.Buffer + deps := commandCenterDeps(t) + deps.discoverProviderModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) { + return []providermodeldiscovery.Model{ + {ID: "gpt-oss:20b"}, + {ID: "team/cloud-model"}, // no size in the id + }, nil + } + + if exitCode := runWithDeps([]string{"providers", "models"}, &stdout, &stderr, deps); exitCode != exitSuccess { + t.Fatalf("exit = %d: %s", exitCode, stderr.String()) + } + out := stdout.String() + if !strings.Contains(out, "gpt-oss:20b [20B]") { + t.Fatalf("the directory did not show the model size:\n%s", out) + } + if strings.Contains(out, "team/cloud-model [") { + t.Fatalf("a cloud model with no size got an invented size label:\n%s", out) + } +} + func TestRunProvidersModelsJSON(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer 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{ 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/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/plan_size.go b/internal/config/plan_size.go new file mode 100644 index 000000000..17bed3ed4 --- /dev/null +++ b/internal/config/plan_size.go @@ -0,0 +1,141 @@ +package config + +import ( + "fmt" + "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 480-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 +} + +// 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..6fc3da3b9 --- /dev/null +++ b/internal/config/plan_size_test.go @@ -0,0 +1,287 @@ +package config + +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. +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()) + } + } +} + +// 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 SETS NO PLAN MODELS AT ALL — not a pin, and not an exclusion. +// +// The pin half was always refused: a cloned repo pinning every role to the +// priciest model raises cost for whoever opens it, the same hazard the PlanSize +// tighten-only rule exists for. +// +// Exclusion was allowed on the premise that removing a candidate can only lower +// spend. It does the opposite just as easily — the selector picks per role from +// what survives, so excluding the cheap ids promotes the next model up rather +// than removing any work. A repo can drive all three roles onto the priciest +// model on the account while naming none of them to run. +func TestProjectConfigSetsNoPlanModelsAtAll(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) + } + // AN EXCLUSION RAISES SPEND JUST AS EASILY AS A PIN, which is why this used + // to assert the opposite. The selector chooses per role from whatever is + // left, so excluding the cheap candidates does not remove the work — it + // promotes the next model up. A repo can steer all three roles onto the + // priciest model on the account without naming a single id to run. + for _, name := range got.Exclude { + if name == "something-bad" { + t.Errorf("a project exclusion was applied: a repo can raise the user's spend by removing the cheap candidates, %+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/profile_flags_test.go b/internal/config/profile_flags_test.go new file mode 100644 index 000000000..04549e38c --- /dev/null +++ b/internal/config/profile_flags_test.go @@ -0,0 +1,82 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func writeConfigs(t *testing.T, user, project string) ResolvedConfig { + t.Helper() + dir := t.TempDir() + userPath := filepath.Join(dir, "user.json") + projectPath := filepath.Join(dir, "project.json") + if err := os.WriteFile(userPath, []byte(user), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(projectPath, []byte(project), 0o600); err != nil { + t.Fatal(err) + } + resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath}) + if err != nil { + t.Fatalf("resolve: %v", err) + } + return resolved +} + +const someProvider = `"providers":[{"name":"p","provider_kind":"openai-compatible","catalogID":"xai",` + + `"baseURL":"https://api.x.ai/v1","apiFormat":"chat-completions","model":"grok-4.5","apiKey":"k"}],"activeProvider":"p"` + +// BOTH FLAGS DEFAULT OFF, and that is what keeps a build carrying them +// byte-identical to one without them. +func TestTheNewProfileFlagsDefaultOff(t *testing.T) { + resolved := writeConfigs(t, `{`+someProvider+`}`, `{}`) + if resolved.Profiles.RequirePlanKeyword { + t.Error("RequirePlanKeyword defaulted on; it would refuse plans for everyone whose phrasing does not match") + } + if resolved.Profiles.Memory { + t.Error("Memory defaulted on; registering its tools changes the advertised tool set for every run") + } +} + +// USER CONFIG MAY SET EITHER. It is the higher-trust source. +func TestUserConfigMaySetBothFlags(t *testing.T) { + resolved := writeConfigs(t, + `{`+someProvider+`,"profiles":{"requirePlanKeyword":true,"memory":true}}`, `{}`) + if !resolved.Profiles.RequirePlanKeyword { + t.Error("user config could not enable the plan-keyword gate") + } + if !resolved.Profiles.Memory { + t.Error("user config could not enable memory") + } +} + +// A PROJECT MAY TIGHTEN A SAFETY GATE. Asking for one is not a privilege +// escalation — it is a repo asking to be treated more carefully. +func TestProjectConfigMayEnableThePlanKeywordGate(t *testing.T) { + resolved := writeConfigs(t, `{`+someProvider+`}`, `{"profiles":{"requirePlanKeyword":true}}`) + if !resolved.Profiles.RequirePlanKeyword { + t.Error("a project could not ask for the plan-keyword gate") + } +} + +// ...AND MAY NOT HAND ITSELF A WRITE PRIMITIVE. memory_write writes into the +// workspace, and project config is not trust-gated: a cloned repo must not be +// able to give the agent a new way to write into it. +func TestProjectConfigCannotEnableMemory(t *testing.T) { + resolved := writeConfigs(t, `{`+someProvider+`}`, `{"profiles":{"memory":true}}`) + if resolved.Profiles.Memory { + t.Error("a cloned repo enabled the memory write tool for whoever opened it") + } +} + +// A project cannot switch the gate back OFF either — that direction is a +// downgrade, and presence-only means an attempted false is simply not there. +func TestProjectConfigCannotDisableThePlanKeywordGate(t *testing.T) { + resolved := writeConfigs(t, + `{`+someProvider+`,"profiles":{"requirePlanKeyword":true}}`, + `{"profiles":{"requirePlanKeyword":false}}`) + if !resolved.Profiles.RequirePlanKeyword { + t.Error("a project turned the user's safety gate off") + } +} 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..afdda8637 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,64 @@ 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 + } + // User config may enable either of these freely; both default off so a caller + // that sets neither is byte-identical to a build without them. + if src.Profiles.RequirePlanKeyword { + dst.Profiles.RequirePlanKeyword = true + } + if src.Profiles.Memory { + dst.Profiles.Memory = 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) + } + // 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...) + } + // User config only, like the pins and guidance: a size floor decides which + // models do the work and therefore what a plan costs, so a cloned repo must + // not set it. A positive value overrides; 0 or negative is "unset". + if src.Profiles.PlanModels.TopModels > 0 { + dst.Profiles.PlanModels.TopModels = src.Profiles.PlanModels.TopModels + } + if src.Profiles.PlanModels.MinSize > 0 { + dst.Profiles.PlanModels.MinSize = src.Profiles.PlanModels.MinSize + } + // 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) } @@ -273,8 +332,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) @@ -325,6 +402,65 @@ 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 + } + // RequirePlanKeyword from project config may only ENABLE. Turning a safety + // gate ON is tightening and a repo may reasonably ask for it; turning it OFF + // is a downgrade, and presence-only means an attempted "false" is simply not + // there to act on — the same shape as DisableZeromaxing above. + if src.Profiles.RequirePlanKeyword { + dst.Profiles.RequirePlanKeyword = true + } + // Profiles.Memory is IGNORED here, deliberately. memory_write is a write + // primitive pointed at the workspace, and project config is not trust-gated: + // a cloned repo must not be able to hand the agent a new way to write into + // it. User config still enables it freely, which is where that decision + // belongs. + // + // 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. + // PlanModels from project config is IGNORED ENTIRELY — pins and exclusions + // alike. + // + // The pin half was always refused: a cloned repo pinning all three roles to + // the priciest model on the account raises cost for whoever opens it, the + // same hazard the PlanSize rule below exists to prevent. + // + // EXCLUSION WAS ALLOWED ON A FALSE PREMISE — that removing a candidate "can + // only ever lower what a plan spends". It does the opposite just as easily. + // The selector picks per role from what is left, so excluding the cheapest + // scan model does not remove the scan, it promotes the next model up. A repo + // listing every inexpensive id on the account steers all three roles onto + // the priciest survivor while naming no model to run at all — the pin's + // effect, spelled as its inverse, and it would have passed a reviewer reading + // the old comment. + // + // Ignored silently, like the network "allow" above: a project that does not + // hold this privilege is not a malformed project. USER config still sets both + // freely, which is where a genuine "this model cannot handle our codebase" + // belongs — the repo's maintainer writes it there, or says it in the README. + 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 543ac3c6c..40769fe1a 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -116,6 +116,17 @@ type PreferencesConfig struct { // the user turned idle recaps off. A *bool is its own tri-state, so no // custom unmarshal is needed (unlike ToolsConfig.DeferThreshold's int). Recaps *bool `json:"recaps,omitempty"` + // KeepFinishedAgents makes the AGENTS panel KEEP finished sub-agents instead + // of dropping them 1.5s after they complete. + // + // The drop is the default (PR #345, "agents never disappear"), and the panel + // has a click-to-toggle to override it per session — but the toggle resets + // every session. This is the standing preference: set true and finished + // agents stay for the whole session, every session, without clicking. + // + // A *bool tri-state like Recaps: nil is unset (the drop-after-linger + // default), true keeps them, false is the explicit drop. + KeepFinishedAgents *bool `json:"keepFinishedAgents,omitempty"` } // RecentModelEntry is one provider-qualified model selection recorded in @@ -135,6 +146,15 @@ func (p PreferencesConfig) RecapsEnabled() bool { return p.Recaps == nil || *p.Recaps } +// KeepsFinishedAgents reports the standing preference for the AGENTS panel. +// +// Defaults FALSE, unlike RecapsEnabled which defaults true: dropping finished +// agents after a linger is the established product default (PR #345), so an +// unset preference keeps it. Only an explicit true makes them stay. +func (p PreferencesConfig) KeepsFinishedAgents() bool { + return p.KeepFinishedAgents != nil && *p.KeepFinishedAgents +} + // KeyBindingDef defines one key binding string (e.g. "ctrl+o") that the TUI // can remap. An empty string means "use the built-in default" for that action. type KeyBindingDef string @@ -322,6 +342,129 @@ type SwarmConfig struct { MaxTeamSize int `json:"maxTeamSize,omitempty"` } +// 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"` + // MinSize is the smallest model, in BILLIONS of parameters, worth routing a + // task to. A provider that lists tiny models (a 135M or 1B toy) alongside + // decent ones would otherwise see the toy chosen as the "cheapest" tier for a + // scan; this floor drops any model KNOWN to be smaller, so a task lands on a + // decent model instead. Size is read from the model id ("qwen3-coder:480b"). + // + // A model whose id names no size is NOT dropped — an unknown size is not + // evidence of a small one, and most cloud ids carry none. And if the floor + // would leave a provider with no models at all, it is ignored: a plan running + // on a small model beats a plan that cannot run. 0 means no floor. + MinSize float64 `json:"minSize,omitempty"` + // TopModels caps how many of the most capable discovered models a plan may + // route to, after exclusions and the size floor. A provider listing twenty + // models otherwise puts its SMALLEST on the cheap tier and hands the router + // twenty candidates; keeping the best ten narrows both to models worth a + // sub-agent. Unset (0) is the built-in default of 10; raise it to widen the + // field. A pin outside the list still applies — pins are validated against + // what the provider serves, not against this shortlist. + TopModels int `json:"topModels,omitempty"` +} + +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"` + // 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"` + // RequirePlanKeyword makes the orchestrate tool refuse a plan unless the + // turn's own user text asks for one ("run a plan for ...", "fan out ..."). + // + // Off by default, and that is a real trade. Once the posture is on the tool + // exists for the rest of the session, and everything the model reads shares + // context with the user's instructions — file contents, PR comments, MCP + // output. An imperative sentence in any of them reads like an instruction to + // the tool that spends the most. On by default would refuse a plan for every + // user whose phrasing happens not to match, which is the call this codebase + // already made for auto_assign. + // + // Project config may only ENABLE it, never switch it back off: turning a + // safety gate off is a downgrade a cloned repo must not be able to make for + // whoever opens it. See mergeProjectConfig. + RequirePlanKeyword bool `json:"requirePlanKeyword,omitempty"` + // Memory enables the durable note store: the `memory` and `memory_write` + // tools, reading and writing .zero/memory. + // + // USER CONFIG ONLY. memory_write is a write primitive pointed at the + // workspace, and project config is not trust-gated — a cloned repo must not + // be able to hand the agent a new way to write into it. mergeProjectConfig + // ignores this field entirely, the same answer PlanModels gets. + Memory bool `json:"memory,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 { type rawTools struct { DeferThreshold *int `json:"deferThreshold"` @@ -348,6 +491,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 +508,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 +523,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 +573,7 @@ type ResolvedConfig struct { Notify NotifyConfig Tools ToolsConfig Swarm SwarmConfig + Profiles ProfilesConfig Preferences PreferencesConfig KeyBindings KeyBindingsConfig LocalControl LocalControlConfig @@ -488,6 +635,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 +664,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/config/writer.go b/internal/config/writer.go index 7c54cc63a..a998abf82 100644 --- a/internal/config/writer.go +++ b/internal/config/writer.go @@ -548,6 +548,27 @@ func SetRecentModels(path string, entries []RecentModelEntry) (FileConfig, error // SetRecapsEnabled persists the idle recap preference, mirroring // SetFavoriteModels (read-modify-atomic-write). +func SetKeepFinishedAgents(path string, keep bool) (FileConfig, error) { + path = strings.TrimSpace(path) + if path == "" { + return FileConfig{}, fmt.Errorf("config path is required") + } + cfg := FileConfig{} + if data, err := os.ReadFile(path); err == nil { + if err := json.Unmarshal(data, &cfg); err != nil { + return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err) + } + } else if !os.IsNotExist(err) { + return FileConfig{}, fmt.Errorf("read config %s: %w", path, err) + } + v := keep + cfg.Preferences.KeepFinishedAgents = &v + if err := writeConfigFile(path, cfg); err != nil { + return FileConfig{}, err + } + return cfg, nil +} + func SetRecapsEnabled(path string, enabled bool) (FileConfig, error) { path = strings.TrimSpace(path) if path == "" { 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 +} diff --git a/internal/execprofile/profile.go b/internal/execprofile/profile.go index c162553c4..d56b5789a 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" @@ -34,6 +35,14 @@ type Profile struct { // user did not pass an explicit --max-turns. The displaced resolved value // becomes the escalation target (see Policy). MaxTurns int + // MaxTokens bounds what a run may SPEND, in provider-reported tokens. 0 is + // unbounded, which is every profile that does not set it. + // + // A TURN COUNT IS NOT A COST BOUND. A measured heavy run reached the 320-turn + // ceiling having spent 35,781,390 tokens; a lighter one reached it at a tenth + // of that. Raising the turn budget without this would double the worst case + // while still ending runs at an arbitrary place, so the two move together. + MaxTokens int // ReasoningEffort fills the run's effort when the user left it unset. It // still flows through the model-supported gating at the call site, so an // unsupported level degrades exactly like an explicit flag would. @@ -94,12 +103,202 @@ var ( ReasoningEffort: "high", SelfCorrect: true, } + + // Zeromaxing is the top posture: thorough's knobs with TRIPLE the turn + // budget, plus the loop-posture reminders the agent injects while it is + // active (see agent.Zeromaxing). + // + // It was double, and 320 turns was cutting real work short — a measured run + // spent every one of them on legitimate sequential work and was forced into a + // summary before it could report what it had found. The multiple stays exact + // so the relationship remains something a reader can check. + // + // 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", + // 480, raised from 320 because 320 was cutting real work short: a measured + // run spent all of it on legitimate sequential edit-verify work and was + // forced into a summary before it could write up what it had found. + // + // NOT HIGHER, and the reason is the token curve rather than caution. In + // that run the prompt grew monotonically from 35k to 186k tokens per turn + // and had not flattened, so turns near the end cost five times what early + // ones did. 640 would land around 360k per turn and push the run into + // compaction — which is where "paste the actual terminal output" quietly + // becomes a summary of the output, the exact failure the evidence contract + // exists to prevent. + MaxTurns: 480, + // The bound that is meant to actually fire. Anchored on measurement, not + // preference: the largest observed legitimate run spent 35.8M and needed + // roughly 45M to finish, so this lets that work complete while stopping a + // run that has stopped making progress — which at 480 turns and a growing + // prompt would otherwise reach well over 100M. + // + // TWO RUNS IS A THIN BASIS. Revisit it once more heavy runs have been + // measured; the mechanism is what matters here, and the number is the part + // most likely to be wrong. + MaxTokens: 50_000_000, + 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, +} + +// 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. +// +// 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: 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. + SelfCorrectOverridden +) + +// selfCorrectLine renders the transition using /selfcorrect's own vocabulary +// (lsp / tests), so it 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" + } +} + +// 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 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: 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() + "." +} + +// 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..635892011 --- /dev/null +++ b/internal/execprofile/zeromaxing_test.go @@ -0,0 +1,249 @@ +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 != 480 { + t.Fatalf("Zeromaxing.MaxTurns = %d, want 480", Zeromaxing.MaxTurns) + } + if Zeromaxing.MaxTurns != Thorough.MaxTurns*3 { + t.Fatalf("budget %d is not triple 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) + } + // The budget clause states the CALLER's transition, not another profile's. + got := Delta(DeltaState{CurrentMaxTurns: 80}) + for _, want := range []string{"turn budget: 80 → 480", "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 -> 480" would be wrong. +func TestDeltaBudgetLineIsCallerRelative(t *testing.T) { + cases := []struct { + name string + current int + want string + absent string + }{ + {"balanced default", 80, "turn budget: 80 → 480", "160"}, + {"already at the posture budget", 480, "turn budget: unchanged (480)", "→"}, + {"unknown current budget", 0, "turn budget: 480", "→"}, + {"a pinned lower budget", 30, "turn budget: 30 → 480", "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, +// 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(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) + } + // 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, "turn budget:") || !strings.Contains(got, "reasoning effort:") { + t.Fatalf("Delta(%v) dropped a 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(DeltaState{CurrentMaxTurns: 80, SelfCorrect: 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) { + 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) + } + } +} + +// THE SPEND BUDGET AND THE TURN BUDGET MOVE TOGETHER. +// +// Raising the turn ceiling without bounding spend doubles the worst case while +// still ending runs at an arbitrary place: the measured run that forced this +// change spent 35,781,390 tokens reaching 320 turns, and 480 turns on the same +// growing prompt would reach well over 100M. The number is anchored on that +// measurement — above what the largest legitimate run needed, below a runaway. +func TestZeromaxingBoundsSpendNotOnlyTurns(t *testing.T) { + if Zeromaxing.MaxTokens <= 0 { + t.Fatal("the posture raises the turn ceiling and bounds no spend at all") + } + if Zeromaxing.MaxTokens != 50_000_000 { + t.Fatalf("Zeromaxing.MaxTokens = %d, want 50000000", Zeromaxing.MaxTokens) + } + // Every other profile leaves it unbounded, so nothing outside the posture + // gains a ceiling nobody chose. + for _, profile := range []Profile{Balanced, Thorough, Fast} { + if profile.MaxTokens != 0 { + t.Errorf("%s bounds spend at %d; only the posture should", profile.Name, profile.MaxTokens) + } + } +} diff --git a/internal/execution/completed_session_test.go b/internal/execution/completed_session_test.go new file mode 100644 index 000000000..269b4d63f --- /dev/null +++ b/internal/execution/completed_session_test.go @@ -0,0 +1,170 @@ +package execution + +import ( + "context" + "os/exec" + "runtime" + "strings" + "testing" + "time" +) + +// THE TWO NUMBERS MUST NOT CONTRADICT EACH OTHER. +// +// Retention was 30 seconds while maxEmptyPollYield allowed a five-minute poll, +// so a caller Zero itself invites to wait five minutes could arrive to find the +// answer already forgotten. Pinned as a RELATIONSHIP, not a value, so changing +// the poll bound cannot silently reintroduce the gap. +func TestCompletedRetentionCoversTheLongestPoll(t *testing.T) { + if defaultCompletedRetention < maxEmptyPollYield { + t.Fatalf("a finished session is forgotten after %s while a poll may wait %s: "+ + "a caller that waits the full poll can arrive after the answer is gone", + defaultCompletedRetention, maxEmptyPollYield) + } +} + +// A LATE POLL GETS THE RESULT, NOT AN ACCUSATION. +// +// The measured failure: a 60-second test was started, the caller polled with +// yield_time_ms 40000, and the id was already gone — so the same test was run a +// second time to recover a result the first had produced. The reply told the +// caller not to probe session ids, about an id this manager had issued and +// instructed it to poll. +func TestALatePollOnAFinishedSessionGetsItsResult(t *testing.T) { + manager := NewProcessManager(ProcessManagerOptions{}) + manager.remember(ProcessResult{ + ProcessID: 1004, + CommandText: "go test -run=TestDifferentialFuzz60s ./diff/", + Output: "--- PASS: TestDifferentialFuzz60s (60.00s)\nok \tmini/diff\t60.330s\n", + Exited: true, + ExitCode: 0, + }) + // The process itself is long gone; only the record remains. + manager.Remove(1004) + + result, err := manager.Continue(t.Context(), ProcessContinue{ProcessID: 1004}) + if err != nil { + t.Fatalf("a session that ran and finished was refused: %v", err) + } + if !result.Exited || result.ExitCode != 0 { + t.Errorf("the finished result was not carried back: %+v", result) + } + if !strings.Contains(result.Output, "TestDifferentialFuzz60s") { + t.Errorf("the output was lost, so the work has to be done again:\n%s", result.Output) + } +} + +// AN ID THIS MANAGER NEVER ISSUED IS STILL REFUSED. The accusation exists for a +// real probe, and softening it for everything would remove the signal the +// repeated-failure guard keys on. +func TestAnIdThatNeverRanIsStillNotFound(t *testing.T) { + manager := NewProcessManager(ProcessManagerOptions{}) + if _, err := manager.Continue(t.Context(), ProcessContinue{ProcessID: 9999}); err != ErrProcessNotFound { + t.Fatalf("a never-issued id returned %v, want ErrProcessNotFound", err) + } +} + +// The record is bounded: a long session cannot accumulate them without limit, +// and the OLDEST is dropped first so the most recent work stays answerable. +func TestRememberedCompletionsAreBoundedOldestFirst(t *testing.T) { + manager := NewProcessManager(ProcessManagerOptions{}) + total := maxRememberedCompletions + 10 + for i := 0; i < total; i++ { + manager.remember(ProcessResult{ProcessID: 1000 + i, Exited: true, Output: "x"}) + } + if got := len(manager.completed); got != maxRememberedCompletions { + t.Fatalf("kept %d records, want the bound of %d", got, maxRememberedCompletions) + } + if _, ok := manager.Completed(1000); ok { + t.Error("the oldest record survived the bound") + } + if _, ok := manager.Completed(1000 + total - 1); !ok { + t.Error("the newest record was dropped; recent work is what a late poll asks about") + } +} + +// Only a FINISHED session is remembered — a still-running one is answered by the +// live process, and recording it would hand back a result it has not reached. +func TestOnlyFinishedSessionsAreRemembered(t *testing.T) { + manager := NewProcessManager(ProcessManagerOptions{}) + manager.remember(ProcessResult{ProcessID: 1001, Exited: false, Output: "partial"}) + if _, ok := manager.Completed(1001); ok { + t.Error("a still-running session was recorded as finished") + } +} + +// A long output keeps its TAIL: the exit summary and the failure live at the +// end, the noise at the start. +func TestARememberedOutputKeepsItsTail(t *testing.T) { + manager := NewProcessManager(ProcessManagerOptions{}) + long := strings.Repeat("noise\n", recentOutputBytes) + "FINAL: ok" + manager.remember(ProcessResult{ProcessID: 1002, Exited: true, Output: long}) + got, ok := manager.Completed(1002) + if !ok { + t.Fatal("not remembered") + } + if !strings.Contains(got.Output, "FINAL: ok") { + t.Error("the tail was discarded, which is where the result is") + } + if len(got.Output) > recentOutputBytes { + t.Errorf("kept %d bytes, want at most %d", len(got.Output), recentOutputBytes) + } +} + +// THE CASE THAT WAS ACTUALLY MEASURED: nobody polls while it runs. +// +// A caller starts a long command, gets a session id, goes away to do other work, +// and comes back after it finished. Recording the result only on the polling +// paths leaves exactly this case with nothing to hand back — and it is the +// common one, because the reason to background a command is to do something else +// meanwhile. In the measured run this cost a 60-second test being run twice. +func TestASessionNobodyPolledIsStillAnswerable(t *testing.T) { + root := t.TempDir() + manager := NewProcessManager(ProcessManagerOptions{}) + // The behaviour under test — a finished background process staying + // answerable — is OS-agnostic, so unlike this package's POSIX-shell tests + // this one runs everywhere rather than skipping on Windows. + shell, flag := "/bin/sh", "-c" + script := "echo FINAL-RESULT; exit 3" + if runtime.GOOS == "windows" { + shell, flag = "cmd", "/c" + script = "echo FINAL-RESULT& exit 3" + } + command := exec.Command(shell, flag, script) + request := Request{ + Origin: OriginInteractiveCommand, Mode: ModeCaptured, + Command: Command{Name: shell, Args: []string{flag, script}}, + WorkingDirectory: root, WorkspaceRoots: []string{root}, + } + // A wait of zero: Start returns while it is still running, exactly as it does + // for a command that outlives its inline wait. + started, err := manager.Start(context.Background(), ProcessStart{ + Prepared: PreparedCommand{Command: command}, Request: request, + }, 0) + if err != nil { + t.Fatalf("Start: %v", err) + } + + // Nobody polls. Wait for the process to finish on its own, the way a caller + // off doing other work would. + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if _, remembered := manager.Completed(started.ProcessID); remembered { + break + } + time.Sleep(10 * time.Millisecond) + } + + // Now the late poll. It must get the result, not ErrProcessNotFound. + manager.Remove(started.ProcessID) + result, err := manager.Continue(context.Background(), ProcessContinue{ProcessID: started.ProcessID}) + if err != nil { + t.Fatalf("a session nobody polled was refused after it finished: %v", err) + } + if !strings.Contains(result.Output, "FINAL-RESULT") { + t.Errorf("the output was lost, so the command has to be run again:\n%q", result.Output) + } + if result.ExitCode != 3 { + t.Errorf("exit code = %d, want 3", result.ExitCode) + } +} diff --git a/internal/execution/process_manager.go b/internal/execution/process_manager.go index 7e4005fc8..816e1d66c 100644 --- a/internal/execution/process_manager.go +++ b/internal/execution/process_manager.go @@ -12,13 +12,33 @@ import ( ) const ( - defaultCompletedRetention = 30 * time.Second - defaultMaxProcesses = 64 - maxPendingOutputBytes = 2 * 1024 * 1024 - recentOutputBytes = 4096 - processStopTimeout = 3 * time.Second - maxInteractiveYield = 30 * time.Second - maxEmptyPollYield = 5 * time.Minute + defaultMaxProcesses = 64 + maxPendingOutputBytes = 2 * 1024 * 1024 + recentOutputBytes = 4096 + processStopTimeout = 3 * time.Second + maxInteractiveYield = 30 * time.Second + maxEmptyPollYield = 5 * time.Minute + + // defaultCompletedRetention is how long a finished process stays addressable. + // + // DERIVED FROM THE POLL BOUND, never chosen beside it. It was 30 seconds + // while maxEmptyPollYield allowed a five-minute poll, and those two numbers + // contradict each other: a caller Zero itself invites to wait five minutes + // could arrive to find the answer already forgotten. In a measured run a + // 60-second test was started, polled with yield_time_ms 40000, and the id was + // gone — so the same 60-second test was run a second time to recover a result + // the first one had already produced. + // + // Doubled so a caller that waits the full poll still has as long again to come + // back with what it learned. TestCompletedRetentionCoversTheLongestPoll pins + // the relationship rather than the number. + defaultCompletedRetention = 2 * maxEmptyPollYield + + // maxRememberedCompletions bounds the finished-session records kept after a + // process is evicted. They hold an exit code and a short output tail, so the + // cost is bytes; the bound exists so a long session cannot accumulate them + // without limit. + maxRememberedCompletions = 64 ) var ( @@ -33,6 +53,22 @@ type ProcessManagerOptions struct { // ProcessManager owns retained interactive-process identity, transport, // bounded output, continuation, cancellation, completion, and cleanup. +// completedProcess is what a finished session leaves behind once its process is +// gone: enough to answer a late poll honestly. +// +// A LATE POLL IS NOT A GUESS. Without this, an id that ran and finished is +// indistinguishable from one a model invented, and both are answered by +// UnknownExecSessionError — which tells the caller not to probe ids, having just +// handed it that id and instructed it to poll. The result is a re-run of work +// already done. +type completedProcess struct { + id int + command string + output string + exitCode int + exited bool +} + type ProcessManager struct { mu sync.Mutex nextID int @@ -40,6 +76,10 @@ type ProcessManager struct { completedRetention time.Duration maxProcesses int startTransport processTransportStarter + // completed remembers finished sessions after their process is evicted, in + // arrival order so the oldest is dropped first. + completed map[int]completedProcess + completedOrder []int } type ProcessStart struct { @@ -104,6 +144,7 @@ func NewProcessManager(options ProcessManagerOptions) *ProcessManager { return &ProcessManager{ nextID: 1000, processes: make(map[int]*managedProcess), + completed: make(map[int]completedProcess), completedRetention: retention, maxProcesses: maxProcesses, startTransport: startProcessTransport, @@ -196,6 +237,7 @@ func (manager *ProcessManager) Start(ctx context.Context, input ProcessStart, wa result.Changes = more.Changes } if result.Exited { + manager.remember(result) manager.Remove(process.id) } return result, nil @@ -204,6 +246,14 @@ func (manager *ProcessManager) Start(ctx context.Context, input ProcessStart, wa func (manager *ProcessManager) Continue(ctx context.Context, input ProcessContinue) (ProcessResult, error) { process, ok := manager.get(input.ProcessID) if !ok { + // A SESSION THAT RAN AND FINISHED IS NOT A GUESSED ID. Answering both with + // ErrProcessNotFound told a caller "do not probe session ids" about an id + // this manager had issued and instructed it to poll — and threw away the + // result, so the work was done again. An id never issued still falls + // through to the error, so a real probe is still refused. + if finished, remembered := manager.Completed(input.ProcessID); remembered { + return finished, nil + } return ProcessResult{}, ErrProcessNotFound } process.touch() @@ -219,6 +269,7 @@ func (manager *ProcessManager) Continue(ctx context.Context, input ProcessContin } result := process.collectResult(ctx, clampContinuationWait(input.Wait, len(input.Input) == 0), input.Interrupt) if result.Exited { + manager.remember(result) manager.Remove(process.id) } return result, nil @@ -294,6 +345,59 @@ func (manager *ProcessManager) Remove(id int) { manager.mu.Unlock() } +// remember records a finished session so a late poll gets its result rather than +// an error telling it not to probe ids. +func (manager *ProcessManager) remember(result ProcessResult) { + if !result.Exited || result.ProcessID == 0 { + return + } + manager.mu.Lock() + defer manager.mu.Unlock() + if _, seen := manager.completed[result.ProcessID]; !seen { + manager.completedOrder = append(manager.completedOrder, result.ProcessID) + for len(manager.completedOrder) > maxRememberedCompletions { + delete(manager.completed, manager.completedOrder[0]) + manager.completedOrder = manager.completedOrder[1:] + } + } + manager.completed[result.ProcessID] = completedProcess{ + id: result.ProcessID, + command: result.CommandText, + output: tailProcessOutput(result.Output), + exitCode: result.ExitCode, + exited: true, + } +} + +// Completed reports a finished session's result when the id ran in this process +// and has since been evicted. A never-issued id is not found, so a genuine probe +// is still refused. +func (manager *ProcessManager) Completed(id int) (ProcessResult, bool) { + manager.mu.Lock() + defer manager.mu.Unlock() + record, ok := manager.completed[id] + if !ok { + return ProcessResult{}, false + } + return ProcessResult{ + ProcessID: record.id, + CommandText: record.command, + Output: record.output, + Exited: record.exited, + ExitCode: record.exitCode, + }, true +} + +// tailProcessOutput keeps the END of a finished session's output: the tail is +// where a test result, a build error and an exit summary live, and the head is +// where the noise is. +func tailProcessOutput(output string) string { + if len(output) <= recentOutputBytes { + return output + } + return output[len(output)-recentOutputBytes:] +} + func (manager *ProcessManager) Len() int { manager.mu.Lock() defer manager.mu.Unlock() @@ -353,6 +457,12 @@ func (manager *ProcessManager) processToPruneLocked() *managedProcess { func (manager *ProcessManager) removeCompletedLater(process *managedProcess) { go func() { <-process.done + // CAPTURED BEFORE THE WAIT, because the common case is that NOBODY polled + // while it ran: a caller starts a long command, goes away to do other + // work, and comes back after it finished. Recording only on the polling + // paths would leave exactly that case with nothing to hand back — which is + // the case that was measured, and it cost a 60-second test being run twice. + manager.remember(process.collectResult(context.Background(), 0, false)) if manager.completedRetention > 0 { timer := time.NewTimer(manager.completedRetention) <-timer.C diff --git a/internal/measurements/measurements.go b/internal/measurements/measurements.go new file mode 100644 index 000000000..d9913e433 --- /dev/null +++ b/internal/measurements/measurements.go @@ -0,0 +1,240 @@ +// Package measurements keeps a run's own timings so a report cannot invent them. +// +// THE FAILURE THIS EXISTS FOR. A measured run finished a benchmark and reported +// a table of test timings that no command in the session had produced. The same +// test read 0.86s in one paste and 4.20s in the next with nothing said about the +// difference, a -race overhead moved from +3.7% to +133% between two tellings of +// the same result, and the column summed to an exact total no real transcript +// lands on. A prompt rule — "re-run every command before you paste it" — is the +// obvious answer and the weak one: a model that is willing to write numbers it +// did not measure is equally willing to say it re-ran them. +// +// The harness is not. Every command's output passed through this process and was +// written to the session log, so the run's real numbers are already here. This +// package reads them back and compares them against what the answer claims, +// which is the one check a model cannot satisfy by asserting harder. +// +// DELIBERATELY LOOSE. Timings vary between runs for honest reasons — a loaded +// machine, a warm cache, a different -count. The tolerance below is a 50% band, +// which lets ordinary variation through and catches 0.86s reported as 4.20s. The +// cost of a false positive is high (a tripwire that cries wolf is turned off, and +// then it catches nothing), and the cost of a false negative is one uncaught +// number, so this errs firmly toward silence. +package measurements + +import ( + "math" + "regexp" + "sort" + "strconv" + "strings" + "sync" +) + +// Measurement is one timing a command reported: what was measured, and how long +// it took in seconds. +type Measurement struct { + Name string + Seconds float64 +} + +// Conflict is a number an answer states that the session never recorded. +type Conflict struct { + Name string + // Claimed is the value the answer gave, in seconds. + Claimed float64 + // Recorded is every value this session actually observed for Name, sorted. + Recorded []float64 +} + +var ( + // `ok github.com/x/y 8.337s` and its FAIL twin. Not anchored at the end: + // a coverage or cached suffix may follow. + goTestPackageLine = regexp.MustCompile(`(?m)^(?:ok|FAIL)\s+(\S+)\s+([0-9]+(?:\.[0-9]+)?)s(?:\s|$)`) + // `--- PASS: TestFoo (0.30s)`, at any indentation, including subtests. + goTestCaseLine = regexp.MustCompile(`(?m)^\s*--- (?:PASS|FAIL|SKIP):\s+(\S+)\s+\(([0-9]+(?:\.[0-9]+)?)s\)`) + // A duration as an answer would write it, in seconds or milliseconds. + claimedDuration = regexp.MustCompile(`([0-9]+(?:\.[0-9]+)?)\s*(ms|s)\b`) +) + +// ParseGoTest pulls every timing out of `go test` output. +// +// Two shapes only — the per-package result line and the per-case `--- PASS` +// line. Benchmarks report ns/op rather than a duration and are NOT read here: +// guessing at a unit would put wrong numbers in the ledger, and a ledger that is +// itself unreliable is worse than none. +func ParseGoTest(text string) []Measurement { + if strings.TrimSpace(text) == "" { + return nil + } + var out []Measurement + for _, pattern := range []*regexp.Regexp{goTestPackageLine, goTestCaseLine} { + for _, match := range pattern.FindAllStringSubmatch(text, -1) { + seconds, err := strconv.ParseFloat(match[2], 64) + if err != nil { + continue + } + name := strings.TrimSpace(match[1]) + if name == "" { + continue + } + out = append(out, Measurement{Name: name, Seconds: seconds}) + } + } + return out +} + +// Ledger is every timing this run observed, and which conflicts it has already +// raised. +// +// A nil Ledger is a working no-op, so a caller that does not want the check +// holds nil and still calls every method unconditionally. +type Ledger struct { + mu sync.Mutex + observed map[string][]float64 + raised map[string]bool +} + +func NewLedger() *Ledger { + return &Ledger{observed: map[string][]float64{}, raised: map[string]bool{}} +} + +// Record reads any timings out of a command's output and remembers them. +// Returns how many it took, which is what a test asserts on. +func (l *Ledger) Record(text string) int { + if l == nil { + return 0 + } + found := ParseGoTest(text) + if len(found) == 0 { + return 0 + } + l.mu.Lock() + defer l.mu.Unlock() + for _, m := range found { + l.observed[m.Name] = append(l.observed[m.Name], m.Seconds) + } + return len(found) +} + +// tolerance reports whether two timings are close enough to be the same result +// measured twice. A 50% band with a small absolute floor: ordinary variation and +// sub-centisecond jitter pass, a fivefold difference does not. +func tolerance(a, b float64) bool { + spread := math.Max(math.Abs(a), math.Abs(b)) * 0.5 + if spread < 0.05 { + spread = 0.05 + } + return math.Abs(a-b) <= spread +} + +// Conflicts reports numbers in claim that contradict what this session recorded. +// +// ONLY NAMES THE LEDGER ALREADY KNOWS are considered, and only when the claim +// puts a duration next to one on the same line. An answer that mentions a test +// without timing it, or that reports something never measured here, produces +// nothing — this check exists to catch a number that DISAGREES with the +// transcript, not to demand that every number have one. +// +// Each name is reported at most once per Ledger. A second pass over the same +// answer is silent, so the caller can feed a correction back to the model +// without the possibility of a loop. +func (l *Ledger) Conflicts(claim string) []Conflict { + if l == nil || strings.TrimSpace(claim) == "" { + return nil + } + l.mu.Lock() + defer l.mu.Unlock() + + var out []Conflict + for name, recorded := range l.observed { + if l.raised[name] || len(recorded) == 0 { + continue + } + claimed, ok := claimedSecondsFor(claim, name) + if !ok { + continue + } + agrees := false + for _, seen := range recorded { + if tolerance(claimed, seen) { + agrees = true + break + } + } + if agrees { + continue + } + values := append([]float64(nil), recorded...) + sort.Float64s(values) + out = append(out, Conflict{Name: name, Claimed: claimed, Recorded: values}) + } + // Deterministic order: this text reaches a model, and a set that reshuffles + // between identical runs is a diff nobody can read. + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + for _, conflict := range out { + l.raised[conflict.Name] = true + } + return out +} + +// claimedSecondsFor finds the duration an answer puts beside a name, searching +// the remainder of each line the name appears on. Same line only: a number three +// paragraphs away is not this name's timing, and pairing them would invent a +// disagreement rather than find one. +func claimedSecondsFor(claim, name string) (float64, bool) { + for _, line := range strings.Split(claim, "\n") { + index := strings.Index(line, name) + if index < 0 { + continue + } + match := claimedDuration.FindStringSubmatch(line[index+len(name):]) + if match == nil { + continue + } + value, err := strconv.ParseFloat(match[1], 64) + if err != nil { + continue + } + if match[2] == "ms" { + value /= 1000 + } + return value, true + } + return 0, false +} + +// Nudge renders conflicts as the correction a model is asked to act on. Empty +// when there is nothing to say, so the caller can test the string itself. +func Nudge(conflicts []Conflict) string { + if len(conflicts) == 0 { + return "" + } + var b strings.Builder + if len(conflicts) == 1 { + b.WriteString("One number in your answer does not match what this session recorded.\n") + } else { + b.WriteString("Some numbers in your answer do not match what this session recorded.\n") + } + for _, conflict := range conflicts { + b.WriteString(" - ") + b.WriteString(conflict.Name) + b.WriteString(": your answer says ") + b.WriteString(formatSeconds(conflict.Claimed)) + b.WriteString("; the commands actually run in this session reported ") + for i, value := range conflict.Recorded { + if i > 0 { + b.WriteString(", ") + } + b.WriteString(formatSeconds(value)) + } + b.WriteString(".\n") + } + b.WriteString("Re-run the command and report what it prints. If both numbers are real, give both and say what changed between them — " + + "do not replace one with the other silently. If the number was not measured in this session, say so plainly instead of stating it as a result.") + return b.String() +} + +func formatSeconds(value float64) string { + return strconv.FormatFloat(value, 'f', -1, 64) + "s" +} diff --git a/internal/measurements/measurements_test.go b/internal/measurements/measurements_test.go new file mode 100644 index 000000000..cb0d93ee8 --- /dev/null +++ b/internal/measurements/measurements_test.go @@ -0,0 +1,174 @@ +package measurements + +import ( + "strings" + "sync" + "testing" +) + +const goTestOutput = ` +ok github.com/Gitlawb/zero/internal/specialist 8.337s +FAIL github.com/Gitlawb/zero/internal/cli 34.249s +ok github.com/Gitlawb/zero/internal/config 20.047s coverage: 61.2% of statements +ok github.com/Gitlawb/zero/internal/minify (cached) +--- PASS: TestChattyChild (0.86s) +--- FAIL: TestWallBackstop (1.00s) + --- PASS: TestNested/subcase (0.02s) +` + +func TestParseGoTestReadsBothLineShapes(t *testing.T) { + got := ParseGoTest(goTestOutput) + byName := map[string]float64{} + for _, m := range got { + byName[m.Name] = m.Seconds + } + + for name, want := range map[string]float64{ + "github.com/Gitlawb/zero/internal/specialist": 8.337, + "github.com/Gitlawb/zero/internal/cli": 34.249, + // A trailing coverage suffix must not stop the line being read. + "github.com/Gitlawb/zero/internal/config": 20.047, + "TestChattyChild": 0.86, + "TestWallBackstop": 1.00, + // Indented subtests count: they are what a per-test table is built from. + "TestNested/subcase": 0.02, + } { + if byName[name] != want { + t.Errorf("%s = %v, want %v", name, byName[name], want) + } + } + // A cached package reports no duration, so there is nothing to record — and + // inventing a zero for it would make every later claim look like a conflict. + if _, present := byName["github.com/Gitlawb/zero/internal/minify"]; present { + t.Error("a (cached) package was recorded with a duration it never reported") + } +} + +// THE FAILURE THIS WAS BUILT FOR: the same test reported at 0.86s in one paste +// and 4.20s in the next, with nothing said about the difference. +func TestAClaimThatContradictsTheTranscriptIsCaught(t *testing.T) { + ledger := NewLedger() + if n := ledger.Record(goTestOutput); n == 0 { + t.Fatal("nothing was recorded, so no conflict could ever be found") + } + + conflicts := ledger.Conflicts("| TestChattyChild | 4.20s | passes |") + if len(conflicts) != 1 { + t.Fatalf("got %d conflicts, want 1: %+v", len(conflicts), conflicts) + } + if conflicts[0].Name != "TestChattyChild" || conflicts[0].Claimed != 4.20 { + t.Fatalf("wrong conflict: %+v", conflicts[0]) + } + if len(conflicts[0].Recorded) != 1 || conflicts[0].Recorded[0] != 0.86 { + t.Fatalf("the recorded value is not carried back to the reader: %+v", conflicts[0]) + } +} + +// ...and the honest cases stay silent. A tripwire that fires on ordinary +// variation gets switched off, and then it catches nothing at all. +func TestHonestReportingProducesNoConflict(t *testing.T) { + ledger := NewLedger() + ledger.Record(goTestOutput) + + for name, claim := range map[string]string{ + "the number as recorded": "TestChattyChild took 0.86s.", + "ordinary run-to-run variation": "TestChattyChild took 0.91s.", + "a package line restated": "ok github.com/Gitlawb/zero/internal/specialist 8.4s", + "sub-centisecond jitter": "TestNested/subcase (0.03s)", + "named without a timing": "TestChattyChild passes.", + "a name this session never ran": "TestSomethingElse took 99.0s.", + "the same value in milliseconds": "TestChattyChild took 860ms.", + } { + fresh := NewLedger() + fresh.Record(goTestOutput) + if got := fresh.Conflicts(claim); len(got) != 0 { + t.Errorf("%s produced a false conflict: %+v", name, got) + } + } +} + +// A NUMBER THREE PARAGRAPHS AWAY IS NOT THIS NAME'S TIMING. Pairing across lines +// would invent disagreements rather than find them. +func TestADurationOnAnotherLineIsNotPairedWithTheName(t *testing.T) { + ledger := NewLedger() + ledger.Record(goTestOutput) + + claim := "TestChattyChild is the one to look at.\n\nSeparately, the whole suite took 4.20s." + if got := ledger.Conflicts(claim); len(got) != 0 { + t.Errorf("a duration from an unrelated line was attributed to the test: %+v", got) + } +} + +// EACH NAME IS RAISED ONCE. The caller feeds this back to the model, so a second +// pass over an uncorrected answer has to be silent or the loop never ends. +func TestAConflictIsRaisedOnlyOnce(t *testing.T) { + ledger := NewLedger() + ledger.Record(goTestOutput) + claim := "TestChattyChild took 4.20s." + + if got := ledger.Conflicts(claim); len(got) != 1 { + t.Fatalf("first pass found %d conflicts, want 1", len(got)) + } + if got := ledger.Conflicts(claim); len(got) != 0 { + t.Fatalf("the same conflict was raised twice, so an unchanged answer would loop: %+v", got) + } +} + +// A test run twice legitimately has two timings, and matching EITHER is honest. +func TestMatchingAnyRecordedValueIsEnough(t *testing.T) { + ledger := NewLedger() + ledger.Record("--- PASS: TestFlaky (0.10s)\n") + ledger.Record("--- PASS: TestFlaky (9.90s)\n") + + if got := ledger.Conflicts("TestFlaky took 9.90s."); len(got) != 0 { + t.Errorf("matching the second of two recorded runs was called a conflict: %+v", got) + } + if got := ledger.Conflicts("TestFlaky took 45.0s."); len(got) != 1 { + t.Errorf("a value matching neither run was not caught: %+v", got) + } +} + +// The nudge has to name the number, the recorded value, and what to do — a +// warning a model cannot act on is a warning it will not act on. +func TestTheNudgeNamesBothNumbersAndTheRemedy(t *testing.T) { + nudge := Nudge([]Conflict{{Name: "TestChattyChild", Claimed: 4.2, Recorded: []float64{0.86}}}) + for _, required := range []string{"TestChattyChild", "4.2s", "0.86s", "Re-run the command", "give both"} { + if !strings.Contains(nudge, required) { + t.Errorf("the nudge does not contain %q:\n%s", required, nudge) + } + } + if Nudge(nil) != "" { + t.Error("an empty conflict set must render nothing") + } +} + +// A nil Ledger is a working no-op: the loop calls these unconditionally and only +// holds a real ledger under the posture. +func TestANilLedgerIsSafe(t *testing.T) { + var ledger *Ledger + if got := ledger.Record(goTestOutput); got != 0 { + t.Errorf("Record on a nil ledger returned %d", got) + } + if got := ledger.Conflicts("TestChattyChild took 4.20s."); got != nil { + t.Errorf("Conflicts on a nil ledger returned %+v", got) + } +} + +// Tool results arrive from concurrently executed tool calls, so recording races +// against recording and against the final check. +func TestTheLedgerIsSafeUnderConcurrentRecording(t *testing.T) { + ledger := NewLedger() + var wait sync.WaitGroup + for i := 0; i < 16; i++ { + wait.Add(1) + go func() { + defer wait.Done() + ledger.Record(goTestOutput) + ledger.Conflicts("nothing to see") + }() + } + wait.Wait() + if got := ledger.Conflicts("TestChattyChild took 4.20s."); len(got) != 1 { + t.Fatalf("got %d conflicts after concurrent recording, want 1", len(got)) + } +} diff --git a/internal/memory/memory.go b/internal/memory/memory.go new file mode 100644 index 000000000..56dd4d545 --- /dev/null +++ b/internal/memory/memory.go @@ -0,0 +1,300 @@ +// Package memory is a durable, scoped note store that survives a session. +// +// WHAT IT IS FOR. A session ends and everything it worked out goes with it — +// the convention this repo actually follows, the decision behind a strange- +// looking guard, the finding an audit already confirmed. AGENTS.md holds what +// someone sat down and wrote; this holds what accumulates, and it is scoped so +// the two do not become one file nobody prunes. +// +// NOT A SECOND PLAN STORE. The "no new store" invariant is about PLAN STATE — +// "plan state is session events", ARCHITECTURE.md — because two stores for one +// fact eventually disagree. Notes are not derivable from any event log, so there +// is nothing here for a second store to contradict. +// +// THE SAFETY RULES ARE PLAN_STORE'S, deliberately reused rather than rewritten: +// an allow-list name that cannot spell a traversal component, symlink refusal on +// the directory and on the file, and an O_EXCL temp file renamed into place. A +// note store is a write primitive pointed at a path the model chooses, which is +// the same shape as "save my plan" and needs the same answers. +package memory + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// Scope is where a note lives, and who else sees it. +type Scope string + +const ( + // ScopeProject is checked in beside the repo: shared with everyone who clones + // it, and therefore reviewed like any other file in the tree. + ScopeProject Scope = "project" + // ScopeLocal is this machine only. The natural home for anything specific to + // one checkout, one operator, or one afternoon. + ScopeLocal Scope = "local" +) + +// fileExt is the stored extension. Markdown with frontmatter, because a note is +// meant to be readable by the person whose repo it is sitting in. +const fileExt = ".md" + +// maxNoteBytes bounds one note. Generous for prose, small enough that a runaway +// write cannot quietly fill a repo. +const maxNoteBytes = 64 << 10 + +// namePattern is an ALLOW-LIST, the same rule plan names use: enumerate what is +// permitted rather than forbidding traversal, because every deny-list in this +// repo has leaked at least once. +var namePattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + +var ( + ErrBadName = errors.New("a memory name may use only letters, digits, hyphen and underscore, and be at most 64 characters") + ErrNoStore = errors.New("memory is not available in this run") + ErrTooLarge = fmt.Errorf("a memory note may be at most %d bytes", maxNoteBytes) + ErrNotFound = errors.New("no such memory") + ErrBadScope = errors.New(`scope must be "project" or "local"`) + ErrIsSymlink = errors.New("refusing to write through a symlink") +) + +// Paths locates the two scopes. An empty directory means that scope is simply +// unavailable, and a write to it is refused with a reason rather than silently +// written somewhere else. +type Paths struct { + ProjectDir string + LocalDir string +} + +// DefaultPaths puts project memory beside the repo and local memory under it, so +// one .gitignore line separates shared from private. +func DefaultPaths(workspaceRoot string) Paths { + if strings.TrimSpace(workspaceRoot) == "" { + return Paths{} + } + base := filepath.Join(workspaceRoot, ".zero", "memory") + return Paths{ProjectDir: base, LocalDir: filepath.Join(base, "local")} +} + +func (paths Paths) dirFor(scope Scope) (string, error) { + switch scope { + case ScopeProject: + if paths.ProjectDir == "" { + return "", ErrNoStore + } + return paths.ProjectDir, nil + case ScopeLocal: + if paths.LocalDir == "" { + return "", ErrNoStore + } + return paths.LocalDir, nil + default: + return "", ErrBadScope + } +} + +// Note is one stored memory. +type Note struct { + Name string + // Description is the one-line summary from frontmatter. It is what a listing + // shows, so a reader can decide what to open WITHOUT reading everything — + // which is the whole reason notes carry frontmatter at all. + Description string + Scope Scope + Body string +} + +// ValidName reports whether a name is storable. +func ValidName(name string) bool { + return name != "" && len(name) <= 64 && namePattern.MatchString(name) +} + +// List returns every note in both scopes, project first, each sorted by name. +// +// LOCAL SHADOWS NOTHING. Unlike saved plans, where project shadows user because +// a repo's own plan is what its contributors should get, both scopes are listed: +// they hold different KINDS of thing, and hiding one behind the other would lose +// a note rather than resolve a conflict. +func List(paths Paths) []Note { + var out []Note + for _, scope := range []Scope{ScopeProject, ScopeLocal} { + dir, err := paths.dirFor(scope) + if err != nil { + continue + } + entries, err := os.ReadDir(dir) + if err != nil { + continue + } + var scoped []Note + for _, entry := range entries { + if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), fileExt) { + continue + } + name := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())) + note, err := Read(paths, scope, name) + if err != nil { + continue + } + scoped = append(scoped, note) + } + sort.Slice(scoped, func(i, j int) bool { return scoped[i].Name < scoped[j].Name }) + out = append(out, scoped...) + } + return out +} + +// Read returns one note. +func Read(paths Paths, scope Scope, name string) (Note, error) { + if !ValidName(name) { + return Note{}, ErrBadName + } + dir, err := paths.dirFor(scope) + if err != nil { + return Note{}, err + } + body, err := os.ReadFile(filepath.Join(dir, name+fileExt)) + if err != nil { + if os.IsNotExist(err) { + return Note{}, ErrNotFound + } + return Note{}, err + } + description, text := splitFrontmatter(string(body)) + return Note{Name: name, Description: description, Scope: scope, Body: text}, nil +} + +// Write stores a note, replacing any note of the same name in the same scope. +// +// The write path is plan_store's: refuse a symlinked directory or file, create +// an O_EXCL temp file with an unpredictable name, rename into place. An edit is +// therefore atomic, and a crash mid-write leaves the previous note rather than a +// half-written one. +func Write(paths Paths, scope Scope, name, description, body string) (string, error) { + if !ValidName(name) { + return "", ErrBadName + } + dir, err := paths.dirFor(scope) + if err != nil { + return "", err + } + content := renderNote(name, description, body) + if len(content) > maxNoteBytes { + return "", ErrTooLarge + } + 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+fileExt) + if err := refuseSymlink(path); err != nil { + return "", err + } + file, err := os.CreateTemp(dir, name+".*.tmp") + if err != nil { + return "", fmt.Errorf("create a temporary file in %s: %w", dir, err) + } + temp := file.Name() + writeErr := func() error { + if _, err := file.WriteString(content); err != nil { + return err + } + return file.Close() + }() + if writeErr != nil { + _ = file.Close() + _ = os.Remove(temp) + return "", fmt.Errorf("write %s: %w", path, writeErr) + } + if err := os.Chmod(temp, 0o600); err != nil { + _ = os.Remove(temp) + return "", err + } + if err := os.Rename(temp, path); err != nil { + _ = os.Remove(temp) + return "", fmt.Errorf("save %s: %w", path, err) + } + return path, nil +} + +// Forget removes a note. Missing is not an error: the caller asked for it to be +// gone, and it is. +func Forget(paths Paths, scope Scope, name string) error { + if !ValidName(name) { + return ErrBadName + } + dir, err := paths.dirFor(scope) + if err != nil { + return err + } + path := filepath.Join(dir, name+fileExt) + if err := refuseSymlink(path); err != nil { + return err + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +// refuseSymlink mirrors plan_store's: Lstat, never Stat, because Stat follows +// the link and reports 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 { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("inspect %s: %w", path, err) + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("%s: %w", path, ErrIsSymlink) + } + return nil +} + +func renderNote(name, description, body string) string { + var b strings.Builder + b.WriteString("---\nname: ") + b.WriteString(name) + if trimmed := strings.TrimSpace(description); trimmed != "" { + b.WriteString("\ndescription: ") + b.WriteString(singleLine(trimmed)) + } + b.WriteString("\n---\n\n") + b.WriteString(strings.TrimRight(body, "\n")) + b.WriteString("\n") + return b.String() +} + +// splitFrontmatter returns the description and the body. A note without +// frontmatter is not an error — it is a file someone wrote by hand, and losing +// it because it lacks a header would be the store punishing the reader it exists +// to serve. +func splitFrontmatter(content string) (description string, body string) { + if !strings.HasPrefix(content, "---\n") { + return "", content + } + rest := content[len("---\n"):] + end := strings.Index(rest, "\n---\n") + if end < 0 { + return "", content + } + for _, line := range strings.Split(rest[:end], "\n") { + if value, ok := strings.CutPrefix(strings.TrimSpace(line), "description:"); ok { + description = strings.TrimSpace(value) + } + } + return description, strings.TrimLeft(rest[end+len("\n---\n"):], "\n") +} + +func singleLine(text string) string { + return strings.Join(strings.Fields(text), " ") +} diff --git a/internal/memory/memory_test.go b/internal/memory/memory_test.go new file mode 100644 index 000000000..2f496e8c1 --- /dev/null +++ b/internal/memory/memory_test.go @@ -0,0 +1,158 @@ +package memory + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func testPaths(t *testing.T) Paths { + t.Helper() + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + return DefaultPaths(root) +} + +func TestANoteRoundTripsThroughItsScope(t *testing.T) { + paths := testPaths(t) + for _, scope := range []Scope{ScopeProject, ScopeLocal} { + if _, err := Write(paths, scope, "conventions", "how this repo does errors", "Wrap with %w.\n"); err != nil { + t.Fatalf("%s: write: %v", scope, err) + } + note, err := Read(paths, scope, "conventions") + if err != nil { + t.Fatalf("%s: read: %v", scope, err) + } + if note.Description != "how this repo does errors" { + t.Errorf("%s: description = %q", scope, note.Description) + } + if !strings.Contains(note.Body, "Wrap with %w.") { + t.Errorf("%s: body = %q", scope, note.Body) + } + if note.Scope != scope { + t.Errorf("scope = %q, want %q", note.Scope, scope) + } + } +} + +// BOTH SCOPES ARE LISTED. Unlike saved plans, where project shadows user, these +// hold different KINDS of thing — hiding one behind the other would lose a note +// rather than resolve a conflict. +func TestListingShowsBothScopesWithoutShadowing(t *testing.T) { + paths := testPaths(t) + if _, err := Write(paths, ScopeProject, "shared", "team convention", "x"); err != nil { + t.Fatal(err) + } + if _, err := Write(paths, ScopeLocal, "shared", "my own note", "y"); err != nil { + t.Fatal(err) + } + notes := List(paths) + if len(notes) != 2 { + t.Fatalf("listed %d notes, want both scopes: %+v", len(notes), notes) + } + if notes[0].Scope != ScopeProject || notes[1].Scope != ScopeLocal { + t.Errorf("project must be listed first: %+v", notes) + } +} + +// THE NAME IS THE PATH GUARD, an allow-list for the same reason plan names use +// one: every deny-list in this repo has leaked at least once. +func TestNamesCannotTraverse(t *testing.T) { + paths := testPaths(t) + for _, name := range []string{ + "../escape", "..", ".", "a/b", `a\b`, "a b", "", strings.Repeat("x", 65), + "~/evil", "a;b", "a\x00b", "note.md", + } { + if _, err := Write(paths, ScopeProject, name, "d", "b"); !errors.Is(err, ErrBadName) { + t.Errorf("Write accepted %q: %v", name, err) + } + if _, err := Read(paths, ScopeProject, name); !errors.Is(err, ErrBadName) { + t.Errorf("Read accepted %q: %v", name, err) + } + } + for _, name := range []string{"conventions", "decision-2", "audit_findings", "A1"} { + if _, err := Write(paths, ScopeProject, name, "d", "b"); err != nil { + t.Errorf("Write rejected %q: %v", name, err) + } + } +} + +// A SYMLINK IS REFUSED. A note store is a write primitive pointed at a path the +// model chooses, which is the same shape as "save my plan" and needs the same +// answer. +func TestWritingRefusesToFollowASymlink(t *testing.T) { + paths := testPaths(t) + base := filepath.Dir(paths.ProjectDir) + if err := os.MkdirAll(paths.ProjectDir, 0o700); err != nil { + t.Fatal(err) + } + target := filepath.Join(base, "precious") + if err := os.WriteFile(target, []byte("do not clobber"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(paths.ProjectDir, "evil"+fileExt)); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + if _, err := Write(paths, ScopeProject, "evil", "d", "b"); !errors.Is(err, ErrIsSymlink) { + t.Fatalf("Write followed a symlink: %v", err) + } + 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) + } +} + +// An unavailable scope is refused with a reason rather than written elsewhere. +func TestAnUnavailableScopeIsRefused(t *testing.T) { + if _, err := Write(Paths{}, ScopeProject, "x", "d", "b"); !errors.Is(err, ErrNoStore) { + t.Errorf("a write with no store configured returned %v", err) + } + if _, err := Write(testPaths(t), Scope("elsewhere"), "x", "d", "b"); !errors.Is(err, ErrBadScope) { + t.Errorf("an unknown scope returned %v", err) + } +} + +// A note written by hand, without frontmatter, must still be readable — losing +// it would be the store punishing the reader it exists to serve. +func TestAHandWrittenNoteWithoutFrontmatterStillReads(t *testing.T) { + paths := testPaths(t) + if err := os.MkdirAll(paths.ProjectDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(paths.ProjectDir, "manual"+fileExt), []byte("just some prose\n"), 0o600); err != nil { + t.Fatal(err) + } + note, err := Read(paths, ScopeProject, "manual") + if err != nil { + t.Fatalf("read: %v", err) + } + if !strings.Contains(note.Body, "just some prose") { + t.Errorf("body = %q", note.Body) + } +} + +func TestOversizedNotesAreRefusedAndForgetIsIdempotent(t *testing.T) { + paths := testPaths(t) + if _, err := Write(paths, ScopeProject, "big", "d", strings.Repeat("x", maxNoteBytes+1)); !errors.Is(err, ErrTooLarge) { + t.Errorf("an oversized note was accepted: %v", err) + } + if err := Forget(paths, ScopeProject, "never-existed"); err != nil { + t.Errorf("forgetting a missing note errored: %v", err) + } + if _, err := Write(paths, ScopeLocal, "temp", "d", "b"); err != nil { + t.Fatal(err) + } + if err := Forget(paths, ScopeLocal, "temp"); err != nil { + t.Fatalf("forget: %v", err) + } + if _, err := Read(paths, ScopeLocal, "temp"); !errors.Is(err, ErrNotFound) { + t.Errorf("a forgotten note is still readable: %v", err) + } +} diff --git a/internal/providercatalog/catalog.go b/internal/providercatalog/catalog.go index a29f330a5..995229c18 100644 --- a/internal/providercatalog/catalog.go +++ b/internal/providercatalog/catalog.go @@ -52,6 +52,22 @@ type Descriptor struct { SupportedAPIFormats []APIFormat Aliases []string + // ModelFamily names the model family this provider serves, for prompt tuning + // only. EMPTY MEANS MIXED OR UNKNOWN, which is most of this catalog: a + // gateway like OpenRouter or Ollama Cloud serves several families at once, so + // the provider cannot answer the question and the model id has to. + // + // Set ONLY where the provider is first-party and single-family. It is NOT the + // wire format: ollama-cloud speaks openai-compatible and serves Qwen, and + // treating that as "OpenAI" would hand GPT-shaped guidance to a model that + // never asked for it. + // + // Why it exists: prompt tuning classified by model-id PREFIX, so a ChatGPT + // OAuth session was recognised only because "gpt-5.5" happens to begin with + // "gpt" — luck no future id is obliged to repeat — while most providers here + // matched nothing and silently received no guidance at all. + ModelFamily string + // Custom marks the "bring your own endpoint" catalog entries // (custom-openai-compatible, custom-anthropic-compatible). Unlike every other // descriptor, RequiresAuth here is just a template default for the credential @@ -132,6 +148,9 @@ var descriptors = []Descriptor{ // needs an interactive login before a request will succeed. func() Descriptor { d := openAICompat("chatgpt", "ChatGPT", "https://chatgpt.com/backend-api/codex", "gpt-5.5", nil) + // First-party OpenAI behind a subscription endpoint: the PROVIDER answers + // the family question, so a future model id need not start with "gpt". + d.ModelFamily = FamilyOpenAI d.RequiresAuth = true return oauthProvider(d, false, false) }(), @@ -167,7 +186,7 @@ var descriptors = []Descriptor{ // at a local proxy that holds the OAuth session and exposes an OpenAI-compatible // endpoint. Local (no API key — the proxy authenticates); override the base URL // for your proxy's port. See docs/oauth-subscriptions.md. - localOpenAI("chatgpt-proxy", "ChatGPT (local OAuth proxy)", "http://localhost:10531/v1", "gpt-5", "chatgpt"), + familyOf(localOpenAI("chatgpt-proxy", "ChatGPT (local OAuth proxy)", "http://localhost:10531/v1", "gpt-5", "chatgpt"), FamilyOpenAI), func() Descriptor { d := openAICompat("custom-openai-compatible", "Custom OpenAI-compatible", "https://example.invalid/v1", "custom-model", []string{"OPENAI_API_KEY"}, "custom openai compatible") d.Custom = true @@ -203,6 +222,32 @@ func Get(id string) (Descriptor, bool) { return Descriptor{}, false } +// The model families prompt tuning distinguishes. Deliberately few: a family +// exists here only when the system prompt has something family-specific to say, +// and inventing one per vendor would imply guidance that does not exist. +const ( + FamilyOpenAI = "openai" + FamilyAnthropic = "anthropic" + FamilyGemini = "gemini" +) + +// ModelFamilyFor returns the model family a catalogued provider serves, or "" +// when the provider is unknown, is a gateway, or otherwise serves more than one. +// "" is the honest answer for most of this catalog and means "ask the model id". +func ModelFamilyFor(catalogID string) string { + descriptor, ok := Get(catalogID) + if !ok { + return "" + } + return descriptor.ModelFamily +} + +// familyOf stamps a model family onto a descriptor built by a generic helper. +func familyOf(descriptor Descriptor, family string) Descriptor { + descriptor.ModelFamily = family + return descriptor +} + func Require(id string) (Descriptor, error) { normalized := NormalizeID(id) descriptor, ok := Get(normalized) @@ -241,6 +286,7 @@ func openAI(id string, name string, baseURL string, model string, env []string, RequiresAuth: true, SupportedAPIFormats: []APIFormat{APIFormatOpenAIResponses, APIFormatOpenAIChatCompletions}, Aliases: aliases, + ModelFamily: FamilyOpenAI, } } @@ -255,6 +301,7 @@ func anthropic(id string, name string, baseURL string, model string, env []strin RequiresAuth: true, SupportedAPIFormats: []APIFormat{APIFormatAnthropicMessages}, Aliases: aliases, + ModelFamily: FamilyAnthropic, } } @@ -269,6 +316,7 @@ func google(id string, name string, baseURL string, model string, env []string, RequiresAuth: true, SupportedAPIFormats: []APIFormat{APIFormatGoogleGenerateContent}, Aliases: aliases, + ModelFamily: FamilyGemini, } } diff --git a/internal/providercatalog/model_family_test.go b/internal/providercatalog/model_family_test.go new file mode 100644 index 000000000..07442d348 --- /dev/null +++ b/internal/providercatalog/model_family_test.go @@ -0,0 +1,70 @@ +package providercatalog + +import "testing" + +// EVERY FIRST-PARTY SINGLE-FAMILY PROVIDER DECLARES ITS FAMILY, and every +// gateway declares none. +// +// This is the fact prompt tuning reads. Before it existed, tuning guessed from +// the model id: of this catalog's providers, only a handful had a default model +// matching any prefix arm, so the rest silently received no family guidance — +// including the recommended default, and including OpenAI's own OAuth entry, +// which worked only because "gpt-5.5" begins with "gpt". +func TestFirstPartyProvidersDeclareTheirFamily(t *testing.T) { + for id, want := range map[string]string{ + "openai": FamilyOpenAI, + "chatgpt": FamilyOpenAI, // OAuth, subscription endpoint + "chatgpt-proxy": FamilyOpenAI, + "anthropic": FamilyAnthropic, + "google": FamilyGemini, + } { + if got := ModelFamilyFor(id); got != want { + t.Errorf("%s declares family %q, want %q", id, got, want) + } + } +} + +// A GATEWAY MUST DECLARE NOTHING. Ollama Cloud speaks openai-compatible and +// serves Qwen; calling that "openai" would hand GPT-shaped guidance to a model +// that never asked for it, which is worse than the silence it replaces. +func TestGatewaysAndMultiFamilyProvidersDeclareNothing(t *testing.T) { + for _, id := range []string{ + "ollama-cloud", "openrouter", "huggingface", "groq", "together", + "fireworks", "gitlawb-opengateway", "xai", "deepseek", "zai", + "bedrock", "vertex", "custom-openai-compatible", + } { + if got := ModelFamilyFor(id); got != "" { + t.Errorf("%s declares family %q; it serves more than one, so the model id must decide", id, got) + } + } +} + +// An unknown provider is not an error and not a guess. +func TestAnUnknownProviderDeclaresNothing(t *testing.T) { + if got := ModelFamilyFor("no-such-provider"); got != "" { + t.Errorf("an unknown provider returned %q", got) + } + if got := ModelFamilyFor(""); got != "" { + t.Errorf("an empty catalog id returned %q", got) + } +} + +// A DECLARED FAMILY MUST BE ONE THE PROMPT LAYER KNOWS. A typo here would be +// invisible: it reads as "unknown" and silently degrades to id-guessing, which +// is exactly the state this field was added to fix. +func TestEveryDeclaredFamilyIsAKnownOne(t *testing.T) { + known := map[string]bool{FamilyOpenAI: true, FamilyAnthropic: true, FamilyGemini: true} + declared := 0 + for _, descriptor := range All() { + if descriptor.ModelFamily == "" { + continue + } + declared++ + if !known[descriptor.ModelFamily] { + t.Errorf("%s declares unknown family %q", descriptor.ID, descriptor.ModelFamily) + } + } + if declared == 0 { + t.Fatal("no provider declares a family, so the whole mechanism is inert") + } +} 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) + } +} diff --git a/internal/sandbox/scope.go b/internal/sandbox/scope.go index af9b364ec..2749541ab 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 @@ -48,6 +59,19 @@ func (s *Scope) WorkspaceRoot() string { } // Roots returns the workspace root first, then the extra roots, as a copy. +// ExtraRoots returns ONLY the roots granted beyond the workspace, as a copy. +// +// Roots() includes the workspace root as well, which is right for a caller +// asking "everything this run may write". It is wrong for a caller asking "what +// does this run hold BEYOND its workspace" — and one such caller launches child +// agents in an isolated worktree, where handing back the parent's workspace root +// re-opens the very tree the worktree exists to protect. +func (s *Scope) ExtraRoots() []string { + s.mu.RLock() + defer s.mu.RUnlock() + return append([]string(nil), s.extraRoots...) +} + func (s *Scope) Roots() []string { s.mu.RLock() defer s.mu.RUnlock() @@ -70,6 +94,27 @@ func (s *Scope) ReadRoots() []string { return dedupeScopeRoots(roots) } +// ExtraReadRoots returns every path the run may READ BEYOND its workspace — the +// write grants AND the read-only grants, deduped, as a copy. It is ReadRoots() +// without the workspace root. +// +// It exists for the same caller ExtraRoots() does: a child agent dispatched into +// its own workspace, which the parent must be able to hand its beyond-workspace +// access without also handing back the parent workspace root (see ExtraRoots). +// ExtraRoots() alone is not enough for that child, because a read grant from +// request_permissions lands in readRoots, not extraRoots — so a child given only +// ExtraRoots() can read what the parent may WRITE beyond its workspace but not +// what it was granted to READ, and a read-only audit of a granted path fails +// "outside the workspace" for want of exactly this list. +func (s *Scope) ExtraReadRoots() []string { + s.mu.RLock() + defer s.mu.RUnlock() + roots := make([]string, 0, len(s.extraRoots)+len(s.readRoots)) + roots = append(roots, s.extraRoots...) + roots = append(roots, s.readRoots...) + return dedupeScopeRoots(roots) +} + // Add grants write access under path. The path must be an existing directory; // it is home-expanded, made absolute, and symlink-resolved before being // trusted, and the filesystem root is rejected outright. Adding a path already @@ -119,15 +164,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 +200,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,16 +230,55 @@ func (s *Scope) writeRootCoversLocked(root string) bool { return false } -func (s *Scope) removeReadRoot(root string) { +// 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() - defer s.mu.Unlock() + remaining, tracked := s.tempReads[root] + if !tracked { + s.mu.Unlock() + return + } + remaining-- + if remaining > 0 { + s.tempReads[root] = remaining + 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() } -func (s *Scope) removeWriteRoot(root string) { +// 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() - defer s.mu.Unlock() + remaining, tracked := s.tempWrites[root] + if !tracked { + s.mu.Unlock() + return + } + remaining-- + if remaining > 0 { + s.tempWrites[root] = remaining + s.mu.Unlock() + return + } + // Same single-hold rule as releaseTemporaryRead above. + delete(s.tempWrites, root) s.extraRoots = removeScopeRoot(s.extraRoots, root) + s.mu.Unlock() } func removeScopeRoot(roots []string, root string) []string { diff --git a/internal/sandbox/scope_extra_read_test.go b/internal/sandbox/scope_extra_read_test.go new file mode 100644 index 000000000..e6fdeba43 --- /dev/null +++ b/internal/sandbox/scope_extra_read_test.go @@ -0,0 +1,83 @@ +package sandbox + +import ( + "os" + "path/filepath" + "slices" + "testing" +) + +// THE ANTI-ESCALATION PROPERTY. A read grant (AddRead, where --add-read-dir lands) +// is READABLE but NOT WRITABLE. This is what makes propagating a parent's +// request_permissions read grant to a child safe: the child can audit the granted +// path but cannot modify it. Emitting the grant as a WRITE root (--add-dir) would +// break exactly this. +func TestAReadGrantIsReadableButNotWritable(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Skip("no home directory to place a non-temp grant under") + } + granted, err := os.MkdirTemp(home, "zero-scope-ro-") + if err != nil { + t.Fatalf("mkdir under home: %v", err) + } + defer os.RemoveAll(granted) + + scope, err := NewScope(t.TempDir(), nil) + if err != nil { + t.Fatalf("NewScope: %v", err) + } + root, err := scope.AddRead(granted) + if err != nil { + t.Fatalf("AddRead: %v", err) + } + target := filepath.Join(root, "audit-me.go") + + if block := scope.validateRead(target); block != nil { + t.Fatalf("a read grant did not allow READING %q: %v — the audit would fail 'outside the workspace'", target, block) + } + if block := scope.validate(target); block == nil { + t.Fatalf("a read grant ALLOWED WRITING %q — a read grant escalated to write", target) + } +} + +// ExtraReadRoots carries a read grant that ExtraRoots() omits, and never the +// workspace root. This is the bug: a request_permissions READ grant lands in +// readRoots, which ExtraRoots() (write grants only) does not return — so a child +// handed only ExtraRoots() cannot read a path the parent was granted. +// +// The grant is created OUTSIDE the default temp roots (/tmp, $TMPDIR), which +// NewScope seeds as write roots: a t.TempDir() grant would collapse into them and +// prove nothing. +func TestExtraReadRootsCarriesAReadGrantThatExtraRootsOmits(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Skip("no home directory to place a non-temp grant under") + } + readGrant, err := os.MkdirTemp(home, "zero-scope-read-") + if err != nil { + t.Fatalf("mkdir under home: %v", err) + } + defer os.RemoveAll(readGrant) + + scope, err := NewScope(t.TempDir(), nil) + if err != nil { + t.Fatalf("NewScope: %v", err) + } + readRoot, err := scope.AddRead(readGrant) // request_permissions read grant -> readRoots + if err != nil { + t.Fatalf("AddRead: %v", err) + } + + if slices.Contains(scope.ExtraRoots(), readRoot) { + t.Fatalf("ExtraRoots carried the read grant %q — then there would be no bug to fix", readRoot) + } + if !slices.Contains(scope.ExtraReadRoots(), readRoot) { + t.Fatalf("ExtraReadRoots omitted the read grant %q: %v — a read-only child cannot audit a granted path", + readRoot, scope.ExtraReadRoots()) + } + if slices.Contains(scope.ExtraReadRoots(), scope.WorkspaceRoot()) { + t.Fatalf("ExtraReadRoots included the workspace root %q — a worktree child would re-open the parent tree", + scope.WorkspaceRoot()) + } +} 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]) + } +} 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") + } +} diff --git a/internal/sessions/store.go b/internal/sessions/store.go index 464940081..723a14e0c 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/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..02d8ef58b --- /dev/null +++ b/internal/specialist/budget_enforcement_test.go @@ -0,0 +1,482 @@ +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, downstreamTasks: 1, totalTasks: 4} + 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.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: the meter still +// counts, and no pool exists to cross. +func TestAnUnboundedPlanIsNeverStoppedByTheMeter(t *testing.T) { + spend := &planSpend{limit: 0, downstreamTasks: 1, totalTasks: 4} + for round := 0; round < 100; round++ { + 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 +// 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") { + 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(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"}, + 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: 900_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. + // 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) + } +} + +// 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(600_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/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/child_scope_test.go b/internal/specialist/child_scope_test.go new file mode 100644 index 000000000..0ad1e8a89 --- /dev/null +++ b/internal/specialist/child_scope_test.go @@ -0,0 +1,258 @@ +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, " ")) + } +} + +// A READ GRANT REACHES THE CHILD AS --add-read-dir, NEVER --add-dir. Routing a +// path the parent may only READ through the write flag would make it writable in +// the child — a read grant escalated to write. This is the launch-side half of +// the anti-escalation guarantee (the scope-side half is in internal/sandbox). +func TestAReadOnlyRootIsLaunchedAsReadNotWrite(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: "audit the repo", + Cwd: "/Users/kratos/zero-p7-demo", + ReadOnlyRoots: []string{"/Users/kratos/dev/zero"}, + }) + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + if !argsContainPair(built.Args, "--add-read-dir", "/Users/kratos/dev/zero") { + t.Fatalf("the read grant never reached the child as --add-read-dir:\n%s", strings.Join(built.Args, " ")) + } + if argsContainPair(built.Args, "--add-dir", "/Users/kratos/dev/zero") { + t.Fatalf("the read grant was emitted as --add-dir (write) — a read grant escalated to write:\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) + } + // THREE: the plan's own two, plus the verification task the posture appends + // to a multi-task plan that names no verifier (plan_verify_stage.go). The + // property under test is that EVERY dispatched task carries the originating + // call id — an appended one included, since it is dispatched by the same + // runner — so the count is asserted as "the plan's tasks plus the verifier" + // rather than pinned at the number this plan happens to declare. + if len(seen) != 3 { + t.Fatalf("expected the two plan tasks plus the appended verifier, 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 8eb63b2de..a572f0d69 100644 --- a/internal/specialist/exec.go +++ b/internal/specialist/exec.go @@ -15,6 +15,8 @@ import ( "path/filepath" "strconv" "strings" + "sync" + "time" "github.com/Gitlawb/zero/internal/background" "github.com/Gitlawb/zero/internal/sessions" @@ -38,18 +40,113 @@ type LaunchBackgroundFunc func(binaryPath string, args []string, outputFile stri type BackgroundManagerFunc func() (*background.Manager, error) type Executor struct { - NewSessionID NewSessionIDFunc - WritePromptFile WritePromptFileFunc - PromptFileMaxSize int - Load LoadFunc - RunChild RunChildFunc - LaunchBackground LaunchBackgroundFunc - BinaryPath string - Paths Paths - SessionStore *sessions.Store + NewSessionID NewSessionIDFunc + WritePromptFile WritePromptFileFunc + PromptFileMaxSize int + Load LoadFunc + RunChild RunChildFunc + LaunchBackground LaunchBackgroundFunc + BinaryPath string + Paths Paths + SessionStore *sessions.Store + // SessionBudget bounds how many sub-agents this SESSION may start, across + // every run and every plan in it. nil is unbounded, which is every caller + // that does not wire one. + SessionBudget *SessionBudget 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 + // ModelPrefs carries the user's per-role model pins and the auto-assign + // default. A Task that names no model can then be routed to a role-appropriate + // pin — exactly as the same work would be inside a plan — instead of always + // inheriting the parent's model. The zero value assigns nothing. + ModelPrefs ModelPreferences + // PostureActive reports the zeromaxing gate, read at every spawn because the + // posture can flip mid-session — a captured bool would carry the value the run + // started with. Per-task model auto-assignment is off unless this returns true + // AND ModelPrefs.AutoAssign is configured, so a posture-off spawn never + // consults a pin and is byte-identical to before this existed. nil is off. + PostureActive func() bool + // DiscoverModels lists what the active provider can serve, for checking a + // role pin before it is applied to a spawn. A pin the provider does not + // serve is not a pin, it is a leftover — applied blind, three real children + // died at spawn with `"not-found": The model kimi-k2.6 does not exist` + // because the session had moved to xai while the pins named Ollama models. + // nil disables auto-assignment entirely (the honest default for a headless + // executor), exactly as it does on the plan tool. + DiscoverModels ModelDiscoverer + // ServeCache memoizes DiscoverModels for a short window, shared across the + // copies of this value-typed Executor. Per-spawn discovery would add a + // provider round-trip to every delegation in a fan-out; a short TTL keeps + // that to one. Safe across a mid-session provider switch because the + // session-model guard in autoTaskModel rejects a list the current session's + // model is not on, whether the list is stale or fresh. nil discovers on + // every call. + ServeCache *ModelServeCache +} + +// ModelServeCache memoizes one provider model listing for a short window. +type ModelServeCache struct { + mu sync.Mutex + at time.Time + served map[string]bool + valid bool +} + +// modelServeCacheTTL bounds staleness. Short: a pin check is advisory routing, +// and two minutes is long enough to cover a five-agent fan-out with one probe. +const modelServeCacheTTL = 2 * time.Minute + +// servedModelSet answers "what does the provider serve right now", through the +// cache when one is wired. ok is false when discovery is unavailable or failed +// — the caller inherits rather than guessing. +func (executor Executor) servedModelSet(ctx context.Context) (map[string]bool, bool) { + if executor.DiscoverModels == nil { + return nil, false + } + cache := executor.ServeCache + if cache != nil { + cache.mu.Lock() + if cache.valid && time.Since(cache.at) < modelServeCacheTTL { + served := cache.served + cache.mu.Unlock() + return served, true + } + cache.mu.Unlock() + } + models, err := executor.DiscoverModels(ctx) + if err != nil { + return nil, false + } + served := servedModels(models) + if cache != nil { + cache.mu.Lock() + cache.at, cache.served, cache.valid = time.Now(), served, true + cache.mu.Unlock() + } + return served, true } type BuildArgsInput struct { @@ -73,6 +170,13 @@ type BuildArgsInput struct { // read-only "--auto low". Off by default, so the Task tool's specialists are // unchanged. The sandbox still confines writes to the workspace root. MemberAutonomy bool + // ExtraReadRoots are directories this child may read beyond its workspace, + // emitted as --add-dir. A plan task's scratchpad arrives here. + ExtraReadRoots []string + // ReadOnlyRoots are directories this child may READ but not write, emitted as + // --add-read-dir (scope.AddRead). The parent's request_permissions read grants + // arrive here — separate from ExtraReadRoots because --add-dir grants write. + ReadOnlyRoots []string } type BuildResumeArgsInput struct { @@ -82,6 +186,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 { @@ -97,6 +211,13 @@ type TaskParameters struct { Description string RunInBackground bool Resume string + // Model, when set, runs THIS sub-agent on a specific model rather than the + // parent's. It is the standalone-Task equivalent of a plan task's per-task + // model: it lets a caller send one worker to a cheap model and another to a + // strong one without an orchestrate plan. Resolved through the registry + // (aliases and deprecated ids redirected); a provider is the final authority + // on whether it will run. + Model string // Manifest, when non-nil, supplies the specialist definition inline instead // of resolving Name against the specialist registry. It is validated before // use. The swarm launcher sets this so a swarm member can run from its own @@ -121,6 +242,12 @@ type TaskRunOptions struct { // sandboxed shell in the workspace (see BuildArgsInput.MemberAutonomy). Off // for Task-tool specialists. MemberAutonomy bool + // ExtraReadRoots are directories this child may read beyond its workspace. + // Emitted as --add-dir, the same flag the run's own extra roots use. + ExtraReadRoots []string + // ReadOnlyRoots are directories this child may READ but not write, emitted as + // --add-read-dir. The parent's request_permissions read grants arrive here. + ReadOnlyRoots []string // Progress, when set, is called with each stream-json event emitted by the // child process while it runs. nil is a no-op. Progress func(streamjson.Event) @@ -174,6 +301,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 @@ -202,6 +337,35 @@ 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 + // 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 + // Model is what the child ACTUALLY ran on — the manifest's model when it + // names one, the parent's otherwise (resolvedChildModel). + // + // CARRIED BACK, because the caller cannot recompute it: only this side has + // the resolved manifest. The AGENTS sidebar already renders "on " for + // a plan task and showed nothing for a Task sub-agent, because nothing ever + // told it which model the child used. + Model string + // Signal describes the signal that terminated the child, empty when it exited + // normally. + // + // STRUCTURAL, because the caller has to BRANCH on it. BuildFinalResult already + // writes the signal into the result prose, and a caller that needed the fact + // had to match that prose — invariant 9, the rule this package keeps + // relearning. The plan executor needs it to tell "the plan killed this task" + // from "something outside killed it", and those call for opposite reports. + Signal string } type ChildRunResult struct { @@ -236,8 +400,19 @@ func (executor Executor) Run(ctx context.Context, params TaskParameters, options if params.RunInBackground { return ExecResult{}, fmt.Errorf("specialist resume cannot run in background") } + // NOT CHARGED. The budget bounds how many sub-agents this session may + // START; a resume continues one that was already counted. Charging it + // meant iterating on a single sub-agent's answer thirty times consumed + // thirty of the session's slots while spawning nothing. return executor.runResume(ctx, params, options) } + // COUNTED HERE: after the checks that can refuse this call outright, and on + // the FRESH path only, because this is the only path that starts a child. + // Charging before them spent a slot on a malformed call that never ran. + // Still ahead of every side effect — no prompt file, no session, no process. + if err := executor.SessionBudget.admit(); err != nil { + return ExecResult{}, err + } return executor.runFresh(ctx, params, options) } @@ -294,9 +469,70 @@ 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) + // PER-CALL ROOTS, after the run's own. A plan task's scratchpad belongs to + // ONE plan, so it cannot come from executor.extraWriteRoots — that is read at + // every launch and would leak one plan's directory into every other child the + // session starts. + args = appendExtraWriteRootArgs(args, input.ExtraReadRoots, input.Cwd) + // READ-ONLY roots on their own flag: the parent's request_permissions read + // grants, so a plan can audit a granted external path. --add-read-dir, never + // --add-dir, so the child can read but not write — a read grant must not + // become a write. + args = appendReadOnlyRootArgs(args, input.ReadOnlyRoots, 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 +} + +// appendReadOnlyRootArgs emits one --add-read-dir per root — READ access only, the +// read counterpart of appendExtraWriteRootArgs. Same skips (the child's own +// workspace, blank entries) for the same reasons. +func appendReadOnlyRootArgs(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-read-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 == "" { @@ -327,17 +563,40 @@ 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) } + // 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 } 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 } + // A Task that named no model inherits the parent's — unless the zeromaxing + // posture is on and auto-assignment is configured, in which case it is routed + // to the pin for its role, just as a plan task would be. An explicit model on + // the call always wins, and a specialist that DECLARES its own model keeps it: + // autoTaskModel is consulted only when neither the call nor the manifest named + // one, so it never overrides a choice already made. + requested := params.Model + if strings.TrimSpace(requested) == "" && strings.TrimSpace(manifest.Metadata.Model) == "" { + requested = executor.autoTaskModel(ctx, manifest, params.Prompt, options.ParentModel) + } + if err := applyTaskModel(&manifest, requested, options.ParentReasoningEffort); err != nil { + return ExecResult{}, err + } built, err := executor.BuildArgs(BuildArgsInput{ Manifest: manifest, Prompt: params.Prompt, @@ -350,6 +609,8 @@ func (executor Executor) runFresh(ctx context.Context, params TaskParameters, op Cwd: options.Cwd, PermissionMode: options.PermissionMode, MemberAutonomy: options.MemberAutonomy, + ExtraReadRoots: options.ExtraReadRoots, + ReadOnlyRoots: options.ReadOnlyRoots, }) if err != nil { return ExecResult{}, err @@ -360,11 +621,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 { @@ -387,17 +659,46 @@ 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 } + // THE MODEL THE SESSION ACTUALLY RAN ON. BuildResumeArgs falls back to the + // parent's model when the manifest names none, so a task launched on an + // explicit or auto-assigned model came back from a resume on a different one + // — the drift BuildResumeArgsInput's own comment records, reintroduced one + // layer up. The child recorded its resolved model in the session store at + // launch; prefer it when neither the resume call nor the manifest names one. + // + // An explicit model on the resume call wins, exactly as on a fresh launch — + // it was silently ignored here before, and the two doors must not disagree. + // Auto-assignment is deliberately NOT re-run: a resume prompt is a follow-up + // ("now verify it"), and re-classifying it would flip a session's model + // midway through its own conversation. + requested := params.Model + if strings.TrimSpace(requested) == "" && strings.TrimSpace(manifest.Metadata.Model) == "" { + requested = executor.resumeModelStillServed(ctx, session.ModelID) + } + if err := applyTaskModel(&manifest, requested, options.ParentReasoningEffort); err != nil { + 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 @@ -405,6 +706,36 @@ func (executor Executor) runResume(ctx context.Context, params TaskParameters, o return executor.runBuiltArgs(ctx, built, manifest, params, options, "resume", options.Progress) } +// resumeModelStillServed returns the model a session ran on, unless discovery +// says this provider no longer serves it. +// +// REJECTED ON EVIDENCE, not on the absence of proof — the opposite direction to +// autoTaskModel, and deliberately so. autoTaskModel ADDS a model the caller did +// not ask for, so it applies one only when the provider's list confirms it. +// This RESTORES the model the session demonstrably ran on, which is the correct +// answer in every case except one: the user switched provider since. So the +// recorded model stands unless the current provider's own listing is available +// AND does not contain it — at which point applying it would spawn a child that +// dies with "not-found", the failure this whole family of guard exists for. +// +// No discoverer, a failed listing, or an empty one all leave the recovery in +// place: without evidence, continuing on the model the session actually used +// beats drifting to a different one mid-conversation. +func (executor Executor) resumeModelStillServed(ctx context.Context, recorded string) string { + recorded = strings.TrimSpace(recorded) + if recorded == "" { + return "" + } + served, ok := executor.servedModelSet(ctx) + if !ok || len(served) == 0 { + return recorded + } + if !servedContains(served, recorded) { + return "" + } + return recorded +} + func (executor Executor) runBackground(ctx context.Context, built BuildArgsResult, manifest Manifest, params TaskParameters, options TaskRunOptions) (ExecResult, error) { if err := ctx.Err(); err != nil { if built.PromptFile != "" { @@ -459,6 +790,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", @@ -570,6 +902,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, @@ -580,18 +913,59 @@ 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. + // 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, + Signal: run.Signal, + Model: resolvedChildModel(manifest, options.ParentModel), + }, err } summary := SummarizeStream(run.Events, run.ExitCode) 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(), + ExitCode: summary.ExitCode, + Signal: run.Signal, + Model: resolvedChildModel(manifest, options.ParentModel), }, nil } @@ -693,11 +1067,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) } @@ -712,7 +1097,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 } @@ -880,3 +1275,103 @@ func launchBackgroundProcess(binaryPath string, args []string, outputFile string }() return pid, nil } + +// applyTaskModel sets a per-Task model on the manifest it will run under. +// +// A MANIFEST MODEL OVERRIDES THE PARENT'S — resolvedChildModel prefers it — so +// setting it here is all it takes for a standalone Task to run on a different +// model. An empty request changes nothing, keeping every existing spawn on the +// inherited model exactly as before. +// +// AND IT FORWARDS THE PARENT'S EFFORT. appendModelArgs inherits the parent's +// reasoning effort ONLY when no model is named, so naming a model would +// otherwise drop the zeromaxing posture's raised effort. Setting the effort +// explicitly here keeps it — the child re-clamps against the named model, so +// forwarding a tier it does not support is safe. A manifest that already names +// its own effort is left alone. +// autoTaskModel picks a per-role model for a Task that named none, but only +// under the zeromaxing posture with auto-assignment configured. +// +// It reuses the plan path's own classifier and role pins, so a delegated +// sub-agent is routed EXACTLY as the same work would be inside a plan: the grant +// outranks the prose (a write-capable Task is "implement"), and an unclassifiable +// task, or one whose role has no pin, returns "" and inherits the parent's model. +// +// OFF UNLESS ASKED FOR. Both the posture and the configured auto-assign default +// must be set; otherwise this returns "" before touching the classifier, so a +// posture-off spawn never reaches resolveTaskModel and stays byte-identical to +// before this existed. The classifier reads the tools the child will actually +// hold (resolvedToolAllowlist), and a resolution error there degrades to "" — +// a routing hint is never worth failing a spawn over. +func (executor Executor) autoTaskModel(ctx context.Context, manifest Manifest, prompt, parentModel string) string { + if executor.PostureActive == nil || !executor.PostureActive() || !executor.ModelPrefs.AutoAssign { + return "" + } + // A PLAN-AUTHORED MANIFEST ALREADY WENT THROUGH ASSIGNMENT. The plan tool + // runs the router, the pins, the served-check and the probes, reports every + // decision in its notes, and honours a per-plan auto_assign override — so a + // plan task arriving here with no model is a DECISION (inherit), not an + // absence. Second-guessing it would contradict the plan's own report, defeat + // an explicit auto_assign:false, and re-apply a pin the plan level passed + // over as not served. Provenance is already on the manifest; derived, never + // carried as a second flag. + if manifest.FilePath == planManifestFilePath { + return "" + } + granted, err := resolvedToolAllowlist(manifest) + if err != nil { + return "" + } + pin := executor.ModelPrefs.pinned(classifyTaskRole(Task{Tools: granted, Prompt: prompt})) + if pin == "" { + return "" + } + // PROVED SERVED BEFORE IT IS APPLIED, fail-safe in every other case. The + // plan path checks pins against discovery when discovery answers; this path + // applied them blind, and a session that had moved to xai spawned three + // children onto Ollama pins — each died with `"not-found"`. So the pin fires + // only when the provider's own list carries BOTH the pin and the session's + // model; the second check is the plan path's provider-mismatch guard, which + // catches discovery answering for a different provider than the session is + // actually on (including a stale cache across a provider switch). Anything + // short of that — no discoverer, a failed listing, a mismatched list — is + // an inherit, never a guess: a sub-agent on the session's model is slower + // routing; a sub-agent dead at spawn is a failed task. + served, ok := executor.servedModelSet(ctx) + if !ok || len(served) == 0 { + return "" + } + if parent := strings.TrimSpace(parentModel); parent != "" && !servedContains(served, parent) { + return "" + } + if !servedContains(served, pin) { + return "" + } + return pin +} + +func applyTaskModel(manifest *Manifest, requested, parentEffort string) error { + requested = strings.TrimSpace(requested) + if requested == "" { + return nil + } + resolved, err := resolveTaskModel(requested) + if err != nil { + return err + } + manifest.Metadata.Model = resolved + // ONLY FOR MODELS THE REGISTRY CAN VOUCH FOR — the same gate the plan path + // applies (planTaskReasoningEffort). The child clamps a forwarded effort + // only for models it can look up; for anything else it passes the value to + // the provider untouched, and a provider that does not take the parameter + // rejects the whole request. Under zeromaxing the parent's effort is always + // raised, so forwarding it unconditionally made EVERY Task that named an + // uncurated model on such a provider die at spawn — a real orchestrator hit + // exactly that, gave up, and ran all five sub-agents on the session's model. + // An unknown model runs at the provider's default effort instead, which is + // what it did before per-task models existed. + if strings.TrimSpace(manifest.Metadata.ReasoningEffort) == "" && modelTakesExplicitEffort(resolved) { + manifest.Metadata.ReasoningEffort = strings.TrimSpace(parentEffort) + } + return nil +} diff --git a/internal/specialist/export_test.go b/internal/specialist/export_test.go index ec3441620..17e921e65 100644 --- a/internal/specialist/export_test.go +++ b/internal/specialist/export_test.go @@ -3,6 +3,7 @@ package specialist import ( "bufio" + "context" "encoding/json" "fmt" "io" @@ -10,6 +11,7 @@ import ( "github.com/Gitlawb/zero/internal/background" "github.com/Gitlawb/zero/internal/streamjson" + "github.com/Gitlawb/zero/internal/tools" ) func NewOutputTool(manager *background.Manager) *OutputTool { @@ -53,3 +55,19 @@ func ParseStream(reader io.Reader) ([]streamjson.Event, error) { } return events, nil } + +// Moved from the production file: test-only convenience seam (deadcode gate). +func ExecutePlan(ctx context.Context, plan Plan, parentTools []string, run PlanRunner, recorder PlanRecorder, opts ...ExecOption) PlanReport { + return ExecutePlanIn(ctx, plan, PlanWorkspace{}, parentTools, run, recorder, opts...) +} + +// Moved from the production file: test-only convenience seam (deadcode gate). +func withDependencyBriefing(task Task, results map[string]TaskResult) string { + return withDependencyBriefingBudget(task, results, dependencyBriefingPerTask, dependencyBriefingTotal) +} + +// Moved from the production file: test-only convenience seam (deadcode gate). +func (tool *OrchestrateTool) autoAssignModels(ctx context.Context, args map[string]any, options tools.RunOptions) ([]string, error) { + notes, _, err := tool.autoAssignModelsCosting(ctx, args, options) + return notes, err +} diff --git a/internal/specialist/manifest.go b/internal/specialist/manifest.go index e40a263dc..342f7d2f4 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" @@ -33,13 +32,31 @@ type Metadata struct { } type Manifest struct { - Metadata Metadata `json:"metadata"` - SystemPrompt string `json:"systemPrompt"` - ResolvedTools []string `json:"resolvedTools,omitempty"` - Location Location `json:"location"` - FilePath string `json:"filePath"` - LastModified time.Time `json:"lastModified,omitempty"` - Warnings []string `json:"warnings,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"` + Warnings []string `json:"warnings,omitempty"` } type Summary struct { @@ -253,11 +270,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) @@ -271,6 +293,7 @@ func Validate(manifest *Manifest) error { return fmt.Errorf("specialist %q: %w", manifest.Metadata.Name, err) } manifest.ResolvedTools = resolved + manifest.ToolsResolved = true return nil } @@ -497,9 +520,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/output_tool.go b/internal/specialist/output_tool.go index 3c4dd4667..a79ba7ba8 100644 --- a/internal/specialist/output_tool.go +++ b/internal/specialist/output_tool.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "os" + "strconv" "strings" "time" @@ -145,13 +146,27 @@ func (tool *OutputTool) readOutput(task background.Task) tools.Result { if task.Status != background.StatusRunning { Executor{SessionStore: tool.SessionStore}.recordBackgroundTaskAccounting(task, summary) } + meta := map[string]string{ + "task_id": string(task.ID), + "status": string(task.Status), + } + // THE CHILD'S SPEND, so the AGENTS panel can show it for a BACKGROUND agent. + // + // A background child is detached — it never streams via OnToolProgress, so + // the live token/tool bridge never sees it, and its row sat at "0 tok · 0 + // tools" while the real numbers (this summary; also a provider_usage event + // in the store) went unshown. TaskOutput is the one channel a background + // child has to the parent, so it carries them. + if tokens := summary.Usage.EffectiveTotalTokens(); tokens > 0 { + meta["tokens"] = strconv.Itoa(tokens) + } + if n := len(summary.Tools); n > 0 { + meta["tools"] = strconv.Itoa(n) + } return tools.Result{ Status: tools.StatusOK, Output: formatTaskOutputSummary(task, summary, rawLines), - Meta: map[string]string{ - "task_id": string(task.ID), - "status": string(task.Status), - }, + Meta: meta, } } diff --git a/internal/specialist/output_tool_test.go b/internal/specialist/output_tool_test.go index 8070ba84b..124a691b2 100644 --- a/internal/specialist/output_tool_test.go +++ b/internal/specialist/output_tool_test.go @@ -162,3 +162,25 @@ func TestSummarizeTaskDataCollectsErrors(t *testing.T) { t.Fatalf("raw = %#v", raw) } } + +// A TERMINAL TASKOUTPUT POLL EXPOSES THE CHILD'S SPEND in Meta, so the AGENTS +// panel can show it for a detached background child that never streamed. +func TestTaskOutputMetaCarriesTokensAndToolCount(t *testing.T) { + // A stream-json blob a background child would have written: usage + tools. + data := strings.Join([]string{ + `{"schemaVersion":2,"type":"run_start","runId":"r","sessionId":"child"}`, + `{"schemaVersion":2,"type":"tool_call","runId":"r","id":"c1","name":"grep"}`, + `{"schemaVersion":2,"type":"tool_call","runId":"r","id":"c2","name":"read_file"}`, + `{"schemaVersion":2,"type":"usage","runId":"r","totalTokens":1355600}`, + `{"schemaVersion":2,"type":"final","runId":"r","text":"done"}`, + `{"schemaVersion":2,"type":"run_end","runId":"r","status":"success","exitCode":0}`, + }, "\n") + + summary, _ := summarizeTaskData(data, 0) + if got := summary.Usage.EffectiveTotalTokens(); got != 1355600 { + t.Fatalf("summary tokens = %d, want 1355600", got) + } + if len(summary.Tools) != 2 { + t.Fatalf("summary tool count = %d, want 2", len(summary.Tools)) + } +} 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/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 new file mode 100644 index 000000000..4747eae86 --- /dev/null +++ b/internal/specialist/plan.go @@ -0,0 +1,994 @@ +package specialist + +import ( + "fmt" + "math" + "regexp" + "sort" + "strings" + "time" + + "github.com/Gitlawb/zero/internal/config" +) + +// ZeroMaxing plan capture: a validated dependency graph, executed CONCURRENTLY. +// +// A plan declares a dependency graph and the executor runs it in topological +// order, up to budget.max_workers tasks at a time (maxPlanWorkers = 16). Tasks +// may be granted write tools by name, in which case the plan runs in an isolated +// git worktree — see RequiresIsolation and resolvePlanWorkspace. +// +// THE MEASUREMENT SURVIVED THE GATE IT WAS BUILT FOR. This header used to read +// "executed SEQUENTIALLY … one task at a time … if the recorded max_speedup says +// fan-out would not have paid for itself, Phase 3 is not built". That was a +// genuine evidence gate, and it was PASSED: a five-task diamond measured 40.0s +// at one worker against 27.0s at four (4643666), so concurrency was built — +// the gate did its job and opened. PlanReport.MaxSpeedup is still +// recorded and still reported — the number that justified fan-out is the same +// number that now says whether any given plan was worth fanning out — but it is +// no longer deciding whether to build it. +// +// 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 + // 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 +// MaxTokens is enforced again at dispatch. +type Budget struct { + // 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 + // 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 + // 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. +type Limits struct { + // 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 + // 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. + 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, + "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 +// 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)) + 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 +} + +// 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) } + +// 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 + } + // 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{ + "max_workers": p.budget.MaxWorkers, + "max_retries": p.budget.MaxRetries, + } + 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()) + } + 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. +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{}, planTooLargeError(len(rawTasks), limits) + } + + // 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 + } + 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 +} + +// 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 + +// 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. +// +// 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. +// +// 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 { + // 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, + // 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) + } + } + 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 { + names = append(names, name) + } + sort.Strings(names) + 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 +// 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 { + return Budget{}, fmt.Errorf("plan requires a budget object with max_workers") + } + budget := Budget{ + 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 + // 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 err := refuseUnrepresentableSeconds("max_wall_seconds", seconds); err != nil { + return Budget{}, err + } + if seconds > 0 { + budget.MaxWall = time.Duration(seconds) * time.Second + } + } + 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) + } + if err := refuseUnrepresentableSeconds("max_stall_seconds", seconds); err != nil { + return Budget{}, err + } + stall := time.Duration(seconds) * time.Second + 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())) + } + 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 + // 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 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 480-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 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. + // + // 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) + } + 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 +} + +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) + } + // 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: dependsOn, + Tools: 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, 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 == "" { + 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) +} + +// 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 { + 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 { + 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. +// 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: + return int(value), true + case int: + return value, true + default: + 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 +} + +// maxRepresentableSeconds is the largest whole number of seconds that survives +// conversion to a time.Duration, which counts NANOSECONDS in an int64. +const maxRepresentableSeconds = int64(math.MaxInt64) / int64(time.Second) + +// refuseUnrepresentableSeconds rejects a timeout too large to express as a +// time.Duration. +// +// SILENTLY WRAPPING IS THE HAZARD, and it fails OPEN. seconds * time.Second +// overflows int64 past ~292 years and comes back NEGATIVE, and a negative wall +// budget reads as "no bound at all" at the dispatch check — so a plan asking for +// an absurdly long timeout got an unlimited one instead, which is the opposite +// of what every other bound in this file does with a value it cannot honour. +// The negative and the zero cases are already refused above; this closes the +// third way a number stops meaning what it says. +func refuseUnrepresentableSeconds(field string, seconds int) error { + if int64(seconds) > maxRepresentableSeconds { + return fmt.Errorf( + "budget.%s must be at most %d seconds; %d cannot be represented as a duration and would wrap to a negative timeout, which reads as no timeout at all", + field, maxRepresentableSeconds, seconds) + } + return nil +} diff --git a/internal/specialist/plan_briefing_budget_test.go b/internal/specialist/plan_briefing_budget_test.go new file mode 100644 index 000000000..d95022e7f --- /dev/null +++ b/internal/specialist/plan_briefing_budget_test.go @@ -0,0 +1,250 @@ +package specialist + +import ( + "context" + "strings" + "testing" +) + +// THE UNKNOWN WINDOW IS THE MAJORITY CASE, so it is the one that must not move. +// Most of this catalogue reports no window at all, and every one of those runs +// must behave byte-identically to before this existed. +func TestAnUnknownWindowKeepsTheExactBudgetsFromBefore(t *testing.T) { + perTask, total := dependencyBriefingBudget(0) + if perTask != 4000 || total != 12000 { + t.Fatalf("unknown window changed the budget to %d/%d, want 4000/12000", perTask, total) + } + // A provider reporting a nonsensical window is unknown, not tiny. + if p, tt := dependencyBriefingBudget(-1); p != 4000 || tt != 12000 { + t.Fatalf("a negative window produced %d/%d", p, tt) + } +} + +// The ratio the original constants chose is DERIVED, not re-picked, so a model +// near the size those constants implied lands on exactly today's numbers. +func TestAWindowMatchingTodaysConstantsReproducesThem(t *testing.T) { + // 30_000 tokens * 4 chars * 0.10 == 12_000, the existing total. + perTask, total := dependencyBriefingBudget(30_000) + if total != 12000 || perTask != 4000 { + t.Fatalf("the anchor window produced %d/%d, want 4000/12000 — the fraction silently re-tuned every plan", perTask, total) + } +} + +// A large window carries a real report whole. The measured case: a code-review +// child produced 18,349 characters, and at the fixed cap a dependent saw 4,000. +func TestALargeWindowCarriesAWholeReport(t *testing.T) { + const measuredReportChars = 18_349 + perTask, _ := dependencyBriefingBudget(204_800) + if perTask <= measuredReportChars { + t.Fatalf("per-dependency budget %d still truncates the measured %d-character report", perTask, measuredReportChars) + } + if fixed, _ := dependencyBriefingBudget(0); perTask <= fixed { + t.Fatalf("a 204k-context reader got %d, no more than the fixed %d", perTask, fixed) + } +} + +// Bounded at both ends: a tiny model still gets something usable, and a 1M model +// does not inherit an entire plan — the reason the caps existed at all. +func TestTheBudgetIsBoundedAtBothEnds(t *testing.T) { + if _, total := dependencyBriefingBudget(1_000); total < briefingFloorTotal { + t.Fatalf("a tiny window produced %d, below the floor %d: the briefing stops being worth reading", total, briefingFloorTotal) + } + if _, total := dependencyBriefingBudget(1_000_000); total > briefingCeilingTotal { + t.Fatalf("a 1M window produced %d, above the ceiling %d: a deep chain accumulates the whole plan", total, briefingCeilingTotal) + } + // Monotonic: a bigger reader never gets a smaller budget. + prev := 0 + for _, window := range []int{0, 8_000, 32_768, 128_000, 204_800, 1_000_000} { + if window == 0 { + continue + } + _, total := dependencyBriefingBudget(window) + if total < prev { + t.Fatalf("window %d got %d, less than the smaller window's %d", window, total, prev) + } + prev = total + } +} + +// THE BUDGET BELONGS TO THE READER. With per-task models a large-context +// synthesiser routinely depends on a small-context finder; sizing by the +// producer would starve the reader for no reason. +func TestTheBudgetFollowsTheReadingTaskNotTheWritingOne(t *testing.T) { + windows := map[string]int{"small-finder": 8_000, "big-synth": 204_800} + options := execOptions{contextWindow: func(model string) int { return windows[model] }} + + finder := Task{ID: "find", Model: "small-finder"} + synth := Task{ID: "synth", Model: "big-synth", DependsOn: []string{"find"}} + + _, finderTotal := dependencyBriefingBudget(options.windowFor(finder)) + _, synthTotal := dependencyBriefingBudget(options.windowFor(synth)) + if synthTotal <= finderTotal { + t.Fatalf("the synthesiser (204k) got %d while its 8k dependency got %d: sized by the wrong task", synthTotal, finderTotal) + } +} + +// A caller that wires nothing keeps the fixed caps — the nil path every existing +// call site takes. +func TestACallerThatWiresNoWindowsKeepsTheFixedCaps(t *testing.T) { + var options execOptions + if window := options.windowFor(Task{Model: "anything"}); window != 0 { + t.Fatalf("an unwired option reported window %d", window) + } + // And a nil option in the variadic list must be skipped, not dereferenced. + if got := dependencyBriefingBudgetTotalFor(nil); got != 12000 { + t.Fatalf("a nil option produced total %d", got) + } +} + +// END TO END through the real briefing, because a budget function that nothing +// consults is the defect this branch has already produced twice. +func TestTheExecutorAppliesTheWindowBudgetToTheRealBriefing(t *testing.T) { + long := strings.Repeat("F", 30_000) + results := map[string]TaskResult{ + "find": {ID: "find", Outcome: TaskSucceeded, Output: long}, + } + task := Task{ID: "synth", DependsOn: []string{"find"}} + + fixedPer, fixedTotal := dependencyBriefingBudget(0) + fixed := withDependencyBriefingBudget(task, results, fixedPer, fixedTotal) + + widePer, wideTotal := dependencyBriefingBudget(204_800) + wide := withDependencyBriefingBudget(task, results, widePer, wideTotal) + + if len(wide) <= len(fixed) { + t.Fatalf("a 204k reader got a briefing of %d chars, no more than the fixed %d", len(wide), len(fixed)) + } + if !strings.Contains(fixed, "find") || !strings.Contains(wide, "find") { + t.Fatal("the briefing no longer names its dependency") + } + // The wide one must actually carry more of the OUTPUT, not just more heading. + if strings.Count(wide, "F") <= strings.Count(fixed, "F") { + t.Fatal("the larger budget carried no more of the dependency's answer") + } +} + +// THE PLUMBING, asserted on what the dependent task ACTUALLY RECEIVES. +// +// An earlier version of this test asserted only that the hook was called, and a +// mutation that orphaned the computed budget at the call site — computing it and +// then briefing at the fixed caps anyway — passed it cleanly. That is the defect +// class this branch has now produced three times: proving the helper works while +// nothing proves the caller consults it. So this reads req.Task.Prompt, which is +// the briefed prompt the child is handed. +func TestExecutePlanInHonoursTheContextWindowOption(t *testing.T) { + planArgs := map[string]any{ + "name": "p", + "tasks": []any{ + map[string]any{"id": "find", "prompt": "look"}, + map[string]any{"id": "synth", "prompt": "combine", "depends_on": []any{"find"}}, + }, + "budget": map[string]any{"max_workers": float64(1)}, + } + limits := Limits{MaxTasks: 20, ParentTools: PlanReadOnlyToolNames()} + + briefedSynthPrompt := func(opts ...ExecOption) string { + var got string + run := func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + if req.Task.ID == "synth" { + got = req.Task.Prompt + } + return TaskResult{ID: req.Task.ID, Outcome: TaskSucceeded, Output: strings.Repeat("F", 30_000)}, nil + } + report := ExecutePlan(context.Background(), mustParsePlan(t, planArgs, limits), + PlanReadOnlyToolNames(), run, nil, opts...) + if report.Failed != 0 { + t.Fatalf("the plan failed: %+v", report) + } + if got == "" { + t.Fatal("the dependent task never ran, so nothing was measured") + } + return got + } + + fixed := briefedSynthPrompt() + wide := briefedSynthPrompt(WithContextWindows(func(string) int { return 204_800 })) + + if strings.Count(wide, "F") <= strings.Count(fixed, "F") { + t.Fatalf("the dependent task received %d characters of its dependency's answer with a 204k window "+ + "and %d without one: the computed budget never reached the briefing", + strings.Count(wide, "F"), strings.Count(fixed, "F")) + } + // And an unwired run must be byte-identical to before the option existed. + if want := strings.Count(fixed, "F"); want != dependencyBriefingPerTask { + t.Fatalf("an unwired run briefed %d characters, not the fixed %d", want, dependencyBriefingPerTask) + } +} + +// dependencyBriefingBudgetTotalFor runs the option list exactly as ExecutePlanIn +// does, so the nil-tolerance above is asserted against the real loop. +func dependencyBriefingBudgetTotalFor(opts []ExecOption) int { + var options execOptions + for _, opt := range opts { + if opt != nil { + opt(&options) + } + } + _, total := dependencyBriefingBudget(options.windowFor(Task{})) + return total +} + +// THE OPTIONS MUST REACH BOTH EXECUTION PATHS, and must be built in ONE place. +// +// THE AUDIT FINDING THIS PINS. WithContextWindows and WithScratchpad were built, +// tested and mutation-checked — and had exactly one production reference each: +// their own definitions. Nothing ever passed them, so every real plan kept the +// fixed 4000/12000 caps and no plan ever got a scratchpad. Mutation testing +// cannot catch an unwired feature, because deleting it breaks nothing. +func TestTheToolPassesItsExecOptionsToBothPaths(t *testing.T) { + source := readFileForTest(t, "plan_tool.go") + calls := strings.Count(source, "ExecutePlanIn(") + built := strings.Count(source, "tool.execOptionsFor(plan, routerTokens)...") + if calls != built { + t.Fatalf("plan_tool.go has %d ExecutePlanIn call(s) but builds options for %d of them", calls, built) + } +} + +// And the builder must actually include each option when its input is present. +func TestExecOptionsCarryTheWindowAndTheScratchpad(t *testing.T) { + withDeps := mustParsePlan(t, map[string]any{ + "name": "p", + "tasks": []any{ + map[string]any{"id": "a", "prompt": "look"}, + map[string]any{"id": "b", "prompt": "combine", "depends_on": []any{"a"}}, + }, + "budget": map[string]any{"max_workers": float64(1)}, + }, Limits{MaxTasks: 20, ParentTools: PlanReadOnlyToolNames()}) + + tool := &OrchestrateTool{ContextWindows: func(string) int { return 204_800 }} + var applied execOptions + for _, opt := range tool.execOptionsFor(withDeps, 1000) { + opt(&applied) + } + if applied.preSpent != 1000 { + t.Fatalf("router spend not carried: %d", applied.preSpent) + } + if applied.contextWindow == nil { + t.Fatal("a wired ContextWindows hook never reached the executor: briefings keep the fixed caps") + } + if !applied.scratchpad { + t.Fatal("a plan WITH dependencies got no scratchpad, so a truncated briefing stays unreachable") + } + + // A plan whose tasks depend on nothing writes no briefing, so a scratchpad + // would be created, populated and deleted for no reader. + noDeps := mustParsePlan(t, map[string]any{ + "name": "p", + "tasks": []any{map[string]any{"id": "a", "prompt": "look"}}, + "budget": map[string]any{"max_workers": float64(1)}, + }, Limits{MaxTasks: 20, ParentTools: PlanReadOnlyToolNames()}) + var bare execOptions + for _, opt := range (&OrchestrateTool{}).execOptionsFor(noDeps, 0) { + opt(&bare) + } + if bare.scratchpad { + t.Fatal("a dependency-free plan got a scratchpad nothing can read") + } + if bare.contextWindow != nil { + t.Fatal("an unwired hook produced a non-nil window func") + } +} diff --git a/internal/specialist/plan_budget_negative_test.go b/internal/specialist/plan_budget_negative_test.go new file mode 100644 index 000000000..552fab7be --- /dev/null +++ b/internal/specialist/plan_budget_negative_test.go @@ -0,0 +1,112 @@ +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, and it must not turn it into a bound either. +// +// Asserting only that ParsePlan returned no error would pass just as happily if +// admission started assigning some non-zero timeout to a plan that asked for +// none: the plan would be accepted and then killed by a clock nobody set. Both +// spellings of "no timeout" have to survive as zero, which is what every reader +// downstream treats as unbounded. +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) { + plan, 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"}}) + if err != nil { + t.Fatalf("%s timeouts should parse: %v", name, err) + } + if got := plan.Budget().MaxWall; got != 0 { + t.Errorf("MaxWall = %v for %s timeouts; unset must stay unset or the plan is bounded by a clock nobody asked for", got, name) + } + if got := plan.Budget().MaxStall; got != 0 { + t.Errorf("MaxStall = %v for %s timeouts; unset must stay unset", got, name) + } + }) + } +} + +// A TIMEOUT TOO LARGE TO EXPRESS MUST BE REFUSED, because wrapping fails OPEN. +// +// seconds * time.Second is int64 nanoseconds, so past roughly 292 years the +// multiply wraps and comes back NEGATIVE. A negative wall budget does not read +// as "a very long time" anywhere — planWallBudget's caller only acts when the +// value is positive, so the plan that asked for the longest possible timeout was +// given no timeout at all. Every other unusable value in this file is rejected; +// this one was accepted and inverted. +func TestABudgetTimeoutTooLargeToRepresentIsRefused(t *testing.T) { + for _, field := range []string{"max_wall_seconds", "max_stall_seconds"} { + t.Run(field, func(t *testing.T) { + // Comfortably past maxRepresentableSeconds, and the shape a model + // writes when it means "do not stop me". + budget := okBudget() + budget[field] = float64(1e18) + + _, err := ParsePlan(map[string]any{ + "tasks": []any{task("a", "x")}, + "budget": budget, + }, readOnlyLimits()) + if err == nil { + t.Fatalf("%s = 1e18 was admitted; it wraps to a negative duration, which reads as no bound", field) + } + if !strings.Contains(err.Error(), field) { + t.Fatalf("the refusal does not name the field the caller has to fix: %v", err) + } + }) + } +} + +// ...and the largest value that DOES fit is still accepted, so the guard bounds +// only what it must. +func TestTheLargestRepresentableWallBudgetIsStillAccepted(t *testing.T) { + budget := okBudget() + budget["max_wall_seconds"] = float64(maxRepresentableSeconds) + + plan, err := ParsePlan(map[string]any{ + "tasks": []any{task("a", "x")}, + "budget": budget, + }, readOnlyLimits()) + if err != nil { + t.Fatalf("the largest representable wall budget was refused: %v", err) + } + if got := plan.Budget().MaxWall; got <= 0 { + t.Fatalf("MaxWall = %v; a value that fits must stay positive", got) + } +} diff --git a/internal/specialist/plan_budget_schema_test.go b/internal/specialist/plan_budget_schema_test.go new file mode 100644 index 000000000..7ee289f79 --- /dev/null +++ b/internal/specialist/plan_budget_schema_test.go @@ -0,0 +1,109 @@ +package specialist + +import ( + "fmt" + "testing" + + "github.com/Gitlawb/zero/internal/tools" +) + +// THE SCHEMA MUST CARRY THE BOUNDS IT ENFORCES. +// +// A manual run emitted budget.max_tokens_per_task: 5000. Admission refused it +// for sitting below minimumPlausibleTaskTokens, correctly — and the model spent +// a turn and a call discovering a rule that was written down only in prose, in a +// description it had already read. A declared bound is checked by the provider +// before the request is ever made. +// +// EACH ASSERTION PAIRS THE SCHEMA WITH ITS ENFORCEMENT CONSTANT rather than with +// a literal, so a bound that changes in one place and not the other fails here +// instead of shipping as a schema that lies about the rules. +func budgetProperties(t *testing.T) map[string]tools.PropertySchema { + t.Helper() + tool := &OrchestrateTool{} + budget, ok := tool.Parameters().Properties["budget"] + if !ok { + t.Fatal("the orchestrate schema no longer declares a budget") + } + if len(budget.Properties) == 0 { + t.Fatal("budget declares no fields, so a model composing one must invent the names") + } + return budget.Properties +} + +func TestTheBudgetSchemaDeclaresTheBoundsItEnforces(t *testing.T) { + props := budgetProperties(t) + + for _, want := range []struct { + field string + minimum *int + maximum *int + }{ + // The one that actually cost a retry. + {field: "max_tokens_per_task", minimum: planSchemaBound(minimumPlausibleTaskTokens)}, + // A whole-plan budget below the floor cannot buy its first task either. + {field: "max_tokens", minimum: planSchemaBound(minimumPlausibleTaskTokens)}, + {field: "max_workers", minimum: planSchemaBound(1), maximum: planSchemaBound(maxPlanWorkers)}, + {field: "max_retries", minimum: planSchemaBound(0), maximum: planSchemaBound(maxPlanRetries)}, + {field: "max_stall_seconds", minimum: planSchemaBound(int(minStallTimeout.Seconds()))}, + {field: "max_wall_seconds", minimum: planSchemaBound(1)}, + } { + field, ok := props[want.field] + if !ok { + t.Errorf("budget no longer declares %s", want.field) + continue + } + if got := boundText(field.Minimum); got != boundText(want.minimum) { + t.Errorf("%s declares minimum %s, enforces %s", want.field, got, boundText(want.minimum)) + } + if got := boundText(field.Maximum); got != boundText(want.maximum) { + t.Errorf("%s declares maximum %s, enforces %s", want.field, got, boundText(want.maximum)) + } + } +} + +// The bound and the enforcement must agree on the SAME VALUE, proved by feeding +// the schema's own minimum minus one to the real admission path and requiring a +// refusal — and the minimum itself and requiring acceptance. A declared bound +// that is merely present but wrong is worse than none: it teaches the model a +// rule the code does not hold. +func TestTheDeclaredPerTaskFloorIsTheOneAdmissionEnforces(t *testing.T) { + props := budgetProperties(t) + field := props["max_tokens_per_task"] + if field.Minimum == nil { + t.Fatal("max_tokens_per_task declares no minimum") + } + floor := *field.Minimum + + args := func(perTask int) map[string]any { + return map[string]any{ + "name": "p", + "tasks": []any{map[string]any{"id": "a", "prompt": "look"}}, + "budget": map[string]any{"max_workers": float64(1), "max_tokens_per_task": float64(perTask)}, + } + } + limits := Limits{MaxTasks: 20, ParentTools: PlanReadOnlyToolNames()} + + if _, err := ParsePlan(args(floor-1), limits); err == nil { + t.Fatalf("one below the declared minimum (%d) was admitted: the schema promises a bound nothing enforces", floor) + } + if _, err := ParsePlan(args(floor), limits); err != nil { + t.Fatalf("the declared minimum (%d) was refused: the schema forbids a value that is actually legal: %v", floor, err) + } +} + +// The value from the real run, against the real path. +func TestTheRejectedRunValueIsNowOutsideTheDeclaredRange(t *testing.T) { + props := budgetProperties(t) + field := props["max_tokens_per_task"] + if field.Minimum == nil || *field.Minimum <= 5000 { + t.Fatalf("max_tokens_per_task: 5000 is still inside the declared range, so the model can still emit it") + } +} + +func boundText(bound *int) string { + if bound == nil { + return "none" + } + return fmt.Sprintf("%d", *bound) +} 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_builtin_params_test.go b/internal/specialist/plan_builtin_params_test.go new file mode 100644 index 000000000..ae596b808 --- /dev/null +++ b/internal/specialist/plan_builtin_params_test.go @@ -0,0 +1,140 @@ +package specialist + +import ( + "strings" + "testing" +) + +// A BUNDLED PLAN MUST NOT CARRY A STAND-IN FOR ITS OWN SUBJECT. +// +// research.json read "Find where THE SUBJECT is DEFINED" — prose describing a +// hole rather than a parameter that declares one, so nothing refused it and +// nothing filled it. Run from the documented path it dispatched five child +// agents to search the repository for the literal words "THE SUBJECT". A +// placeholder that is not a ${parameter} is invisible to expandPlanParams, which +// is the one thing in this package that would otherwise have caught it. +// +// Checked over EVERY builtin, not just research: the next bundled plan gets the +// same guarantee without anyone remembering to ask for it. +func TestNoBuiltinPlanUsesAProsePlaceholder(t *testing.T) { + // Shapes a human writes when they mean "fill this in" and a model reads as an + // instruction about something that exists. + standIns := []string{ + "THE SUBJECT", "THE TOPIC", "THE TARGET", "THE QUESTION", + "", "", "", "TODO", "FIXME", "XXX", + } + for _, plan := range builtinPlans() { + haystack := strings.ToUpper(planParamSearchText(plan.Args)) + for _, standIn := range standIns { + if strings.Contains(haystack, strings.ToUpper(standIn)) { + t.Errorf("builtin plan %q contains the stand-in %q: it must be a ${parameter} so a run without one is refused, "+ + "not prose that sends every task searching for the literal words", plan.Name, standIn) + } + } + } +} + +// The research plan declares exactly one parameter, and it reaches every task +// that needs it. +func TestTheResearchPlanTakesItsSubjectAsAParameter(t *testing.T) { + var research SavedPlan + for _, plan := range builtinPlans() { + if plan.Name == "research" { + research = plan + } + } + if research.Name == "" { + t.Fatal("the research plan is no longer bundled") + } + + params := PlanParams(research.Args) + if len(params) != 1 || params[0] != "subject" { + t.Fatalf("research must take exactly the subject, takes %v", params) + } + + // EVERY SEARCH TASK, not just one. The three searches are deliberately + // independent angles on the same subject; one left un-parameterised would + // search for nothing in particular while the other two worked. + tasks, ok := research.Args["tasks"].([]any) + if !ok { + t.Fatal("research has no tasks") + } + searchers := 0 + for _, raw := range tasks { + entry, ok := raw.(map[string]any) + if !ok { + continue + } + if planString(entry, "phase") != "search" { + continue + } + searchers++ + if !strings.Contains(planString(entry, "prompt"), "${subject}") { + t.Errorf("search task %q never mentions the subject, so it searches for nothing in particular", + planString(entry, "id")) + } + } + if searchers == 0 { + t.Fatal("research has no search tasks: the assertion above proved nothing") + } +} + +// Filled in, it must still be a runnable plan. A parameter that produced a plan +// failing admission would have moved the failure rather than fixed it. +func TestTheResearchPlanAdmitsOnceItsSubjectIsSupplied(t *testing.T) { + var research SavedPlan + for _, plan := range builtinPlans() { + if plan.Name == "research" { + research = plan + } + } + if research.Name == "" { + t.Fatal("the research plan is no longer bundled") + } + + expanded, err := expandPlanParams(research.Args, map[string]string{"subject": "the retry watchdog"}) + if err != nil { + t.Fatalf("supplying the subject must expand: %v", err) + } + plan, err := ParsePlan(expanded, Limits{MaxTasks: 20, ParentTools: PlanReadOnlyToolNames()}) + if err != nil { + t.Fatalf("the expanded research plan does not admit: %v", err) + } + if plan.TaskCount() == 0 { + t.Fatal("the expanded plan has no tasks") + } + // And the subject actually reached the prompts, rather than admitting with + // the placeholder still in them. + for _, task := range plan.Tasks() { + if strings.Contains(task.Prompt, "${") { + t.Fatalf("task %q still carries an unfilled placeholder: %s", task.ID, task.Prompt) + } + } + // Unsupplied, it must REFUSE rather than run five agents on a placeholder. + if _, err := expandPlanParams(research.Args, nil); err == nil { + t.Fatal("research ran without a subject") + } +} + +// planParamSearchText gathers the prose a stand-in could hide in: the same +// fields expandPlanParams substitutes into, plus the description. +func planParamSearchText(args map[string]any) string { + var b strings.Builder + b.WriteString(planString(args, "description")) + b.WriteString("\n") + tasks, ok := args["tasks"].([]any) + if !ok { + return b.String() + } + for _, raw := range tasks { + entry, ok := raw.(map[string]any) + if !ok { + continue + } + for _, field := range planParamTaskFields { + b.WriteString(planString(entry, field)) + b.WriteString("\n") + } + } + return b.String() +} diff --git a/internal/specialist/plan_cancel_relabel_test.go b/internal/specialist/plan_cancel_relabel_test.go new file mode 100644 index 000000000..be3acbb95 --- /dev/null +++ b/internal/specialist/plan_cancel_relabel_test.go @@ -0,0 +1,133 @@ +package specialist + +import ( + "context" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +// pausingRecorder parks the scheduler at a chosen task boundary so a test can +// decide exactly when a completed task is harvested. +type pausingRecorder struct { + gate chan struct{} + once sync.Once + boundary int32 + boundries atomic.Int32 +} + +func (r *pausingRecorder) TaskDispatched(Task) {} +func (r *pausingRecorder) TaskCompleted(TaskResult) {} +func (r *pausingRecorder) TaskFailed(TaskResult) {} +func (r *pausingRecorder) PlanRunning(context.CancelFunc) {} + +func (r *pausingRecorder) WaitWhilePaused(ctx context.Context) { + // The first boundary must pass through or nothing is ever dispatched. + if r.boundries.Add(1) < r.boundary { + return + } + r.once.Do(func() { + select { + case <-r.gate: + case <-ctx.Done(): + } + }) +} + +// A TASK THAT FAILED ON ITS OWN MERITS STAYS FAILED, even when the plan is +// stopped before its result is harvested. +// +// The harvest relabels a cut-short task as cancelled so that stopping a +// twenty-task plan does not read as twenty defects. That check was `ctx.Err() != +// nil` alone, which is true of EVERY task harvested after the stop — including +// one that had already finished failing for a reason of its own. A genuine +// compile error became "cancelled: the run was stopped while this task was +// running", the plan reported as stopped rather than broken, and the single +// result worth reading was overwritten with a sentence about the user. +func TestARealFailureSurvivesACancelThatArrivesAfterIt(t *testing.T) { + plan := mustPlan(t, []any{ + task("boom", "fails for real"), + task("later", "never runs"), + }, map[string]any{"max_workers": float64(2)}, readOnlyLimits()) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + // Park at the SECOND boundary: after "boom" has been dispatched, before its + // completion is harvested. + recorder := &pausingRecorder{gate: make(chan struct{}), boundary: 2} + + 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, complete and self-reported: err is nil, + // exactly as the runner returns when a child ran to the end + // and its task did not succeed. + return TaskResult{Outcome: TaskFailed, Err: "compile error in main.go"}, nil + } + return TaskResult{Outcome: TaskSucceeded}, nil + }, recorder) + }() + + <-failed + // Let the completion land in the scheduler's buffer before the stop, so it is + // harvested with ctx already cancelled — the exact ordering that mislabelled it. + time.Sleep(50 * time.Millisecond) + cancel() + close(recorder.gate) + + report := <-done + var boom TaskResult + for _, result := range report.Tasks { + if result.ID == "boom" { + boom = result + } + } + if boom.ID == "" { + t.Fatalf("the failing task is missing from the report: %+v", report.Tasks) + } + if boom.Outcome != TaskFailed { + t.Errorf("outcome = %q, want %q: a task that failed on its own merits was relabelled because the plan was stopped afterwards", + boom.Outcome, TaskFailed) + } + if !strings.Contains(boom.Err, "compile error in main.go") { + t.Errorf("the failure's own reason was overwritten: %q", boom.Err) + } + if report.Failed == 0 { + t.Errorf("report.Failed = 0 with a genuinely broken task: the plan reads as merely stopped") + } +} + +// ...and the property that check exists for still holds: a task the cancellation +// actually cut short is CANCELLED, not a defect. Stopping a plan must never read +// as a plan full of failures. +func TestATaskTheCancelActuallyCutShortIsStillCancelled(t *testing.T) { + plan := mustPlan(t, []any{task("wedged", "runs until stopped")}, okBudget(), readOnlyLimits()) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + report := ExecutePlan(ctx, plan, []string{"read_file"}, + func(taskCtx context.Context, _ PlanTaskRequest) (TaskResult, error) { + cancel() + <-taskCtx.Done() + // What the runner returns for a task the context killed: the + // process-level error travels back with the result. + return TaskResult{Outcome: TaskFailed, Err: taskCtx.Err().Error()}, taskCtx.Err() + }, nil) + + if len(report.Tasks) != 1 { + t.Fatalf("expected one task in the report, got %d", len(report.Tasks)) + } + if got := report.Tasks[0].Outcome; got != TaskCancelled { + t.Fatalf("outcome = %q, want %q: a task the stop actually killed must not read as a defect", got, TaskCancelled) + } + if report.Failed != 0 { + t.Errorf("report.Failed = %d for a plan that was merely stopped", report.Failed) + } +} diff --git a/internal/specialist/plan_concurrent_test.go b/internal/specialist/plan_concurrent_test.go new file mode 100644 index 000000000..1bd67fca7 --- /dev/null +++ b/internal/specialist/plan_concurrent_test.go @@ -0,0 +1,556 @@ +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 +// 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. +// +// 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{})} + 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 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 >= want { + break + } + select { + case <-deadline: + 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): + } + } + 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) + } + } +} + +// 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) + } +} + +// 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_events.go b/internal/specialist/plan_events.go new file mode 100644 index 000000000..a06cb5d59 --- /dev/null +++ b/internal/specialist/plan_events.go @@ -0,0 +1,134 @@ +package specialist + +import ( + "strings" + + "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) { + payload := map[string]any{ + "id": result.ID, + "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, + } + // A BOUNDED OUTPUT, so a RESUMED dependent can be briefed on what this task + // found even in a NEW process. + // + // The whole output is deliberately NOT stored — a large plan's on-disk size + // would multiply, and the live transcript already carries it. But at resume + // the transcript's in-memory results are gone, the scratchpad has been + // released, and RemainingPlan strips the completed dependency entirely — so + // a dependent that resumes loses the finding it was supposed to build on. + // Storing the HEAD of the output, capped at the same per-task briefing + // budget a dependent would ever see, closes that with a bounded cost. + if brief := boundedResumeOutput(result.Output); brief != "" { + payload["output"] = brief + } + // The task's fingerprint, so a resume can tell this completed task apart from + // an edited one with the same id. Absent on pre-identity events, which resume + // treats as "unknown identity" — matched by id alone, exactly as before. + if result.Identity != "" { + payload["identity"] = result.Identity + } + return sessions.EventTaskCompleted, payload +} + +// resumeOutputCap bounds the output stored per completed task for resume. Equal +// to the per-task briefing budget: a dependent never sees more than this of any +// one dependency, so storing more would be paid for and never read. +const resumeOutputCap = dependencyBriefingPerTask + +// boundedResumeOutput is the head of a task's output, capped for durable +// storage. Empty in, empty out — a task that produced nothing stores nothing. +func boundedResumeOutput(output string) string { + trimmed := strings.TrimSpace(output) + if len(trimmed) <= resumeOutputCap { + return trimmed + } + return trimmed[:resumeOutputCap] +} + +// 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, + // 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, + } +} + +// 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/specialist/plan_exec.go b/internal/specialist/plan_exec.go new file mode 100644 index 000000000..de755245e --- /dev/null +++ b/internal/specialist/plan_exec.go @@ -0,0 +1,1999 @@ +package specialist + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/Gitlawb/zero/internal/streamjson" +) + +// 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" + // 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. +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" + // 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. +type TaskResult struct { + ID string + Outcome TaskOutcome + Duration time.Duration + // Identity is the fingerprint of the task that produced this result — see + // taskIdentity. The executor stamps it on a succeeded result so a resume can + // tell a completed task that is UNCHANGED (skip it) from one whose prompt, + // model, tools or dependencies were edited since (re-run it, and its + // dependents with it). Empty on a result that did not complete, and on any + // event recorded before resume became identity-aware. + Identity string + // 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 + // 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 + // MeasurementConflicts are numbers this task reported that its OWN commands + // contradict — a timing stated as 4.20s where every `go test` it ran printed + // 0.86s. Empty for the overwhelming majority of tasks. + // + // THE PARENT'S TRIPWIRE CANNOT SEE THIS. It checks the parent's answer against + // the parent's tool output, and a plan task's commands run in the child's own + // session — so a number a task invents was caught by neither. The child's + // tool results do stream to the parent, which is what makes the check + // possible here at all. + // + // REPORTED, NOT RETRIED. The parent's version sends an answer back for one + // more turn; doing that here would spend another whole task to re-ask a + // question the report can simply carry the answer to. A reader told "this + // figure disagrees with the command that produced it" can weigh it; a reader + // told nothing cannot. + MeasurementConflicts []string + // Signal describes the signal that killed this task's child, empty when it + // exited normally. + // + // A FLAG, NOT A MESSAGE MATCH, for the same reason Stalled is one. A child + // killed by its own context looks identical in prose to one killed by the OS, + // and the executor has to tell them apart: it cancelled the first and knows + // why, and knows nothing at all about the second. + Signal string + // ScratchpadPath is where the executor persisted this task's FULL output, so + // a dependent handed a truncated excerpt can read the rest. Empty when the + // plan kept no scratchpad, which is the pre-existing behaviour. + // + // WRITTEN BY THE EXECUTOR, never by the task. A read-only plan task has no + // write tool at all, and granting it one would flip RequiresIsolation() and + // demand a git worktree for every plan that used this. + ScratchpadPath string + // 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 + // ProviderFailed reports that the child died on the PROVIDER rather than on + // the work: an HTTP failure the transport would not replay. + // + // A CODE, NOT A MESSAGE, for the same reason Declined is one: the child exits + // childExitProvider for exactly this. + // + // It exists because the transport deliberately does not retry 500/502/504 — + // unlike 429/503/529 those do not guarantee the request had no effect, so + // REPLAYING THE SAME POST risks paying for the same completion twice + // (providerio.ShouldRetryStatus). That is the right call for a replay and the + // wrong outcome for a plan: a measured ten-task run lost one task to a bare + // "Internal Server Error" after 6 tool calls and 42,524 tokens, and with it + // anything that depended on it. A fresh child is not a replay of that POST — + // it is a new request that the provider is very likely to answer. + ProviderFailed 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. +type PlanReport struct { + Status PlanStatus + Tasks []TaskResult + 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 + // 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 + // 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 + // 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. +// +// 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 + // ReadRoots are directories this task may read beyond its workspace — the + // plan's scratchpad, when it has one. Empty for every plan that does not, + // which is the pre-existing behaviour. Emitted as --add-dir (grants write). + ReadRoots []string + // ReadOnlyRoots are directories this task may READ but not write — the + // parent's request_permissions grants, so a plan can audit a granted external + // path. Emitted as --add-read-dir (scope.AddRead), never the write channel. + ReadOnlyRoots []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) + // 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 + // 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 + // 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. + StallTimeout time.Duration + // StallPoll is how often the watchdog checks for silence. Zero means the + // production rule — a sixth of StallTimeout, floored at one second. + // + // TESTS ONLY, and it exists because without it they cannot reach the + // watchdog at all: the floor means a 60ms StallTimeout still polls once a + // second, so a child living 300ms gets ZERO ticks and a test asserting "the + // chatty child survived" passes identically whether or not its events are + // wired to the clock. That test was the only cover for the wiring, and it + // could not fail. The alternative was a child that runs for several seconds + // in every suite run, to observe something that takes milliseconds. + StallPoll 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 + // 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. + // + // 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 +// Executor directly so the budget, ordering and failure semantics are testable +// without launching child processes. +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, +// 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. + +// ExecOption tunes one plan execution. +// +// VARIADIC because ExecutePlanIn has seventy-five call sites, seventy-three of +// them tests. A seventh positional parameter would rewrite every one of them to +// pass a value they do not care about, and a test that had to be edited to keep +// compiling is a test nobody re-read. An option they omit means "as before", +// which is exactly what those tests are asserting. +type ExecOption func(*execOptions) + +// WithScratchpad keeps each task's FULL output on disk for the life of the plan +// and points truncated excerpts at it. +// +// OPT-IN. It writes files and adds a read root to every task, and neither should +// happen to a caller that did not ask. +func WithScratchpad() ExecOption { + return func(o *execOptions) { o.scratchpad = true } +} + +// WithReadRoots grants every task READ access to directories the parent holds +// BEYOND its workspace — the paths a request_permissions grant added to the +// parent's scope. Without it a plan that audits a granted external path fails +// "outside the workspace" in every task, because a task inherits its workspace +// but not the parent's dynamic read grants. +// +// READ ONLY, and merged with the scratchpad grant rather than replacing it: these +// are the parent's already-approved paths, handed down unchanged, never widened. +func WithReadRoots(roots []string) ExecOption { + return func(o *execOptions) { + o.parentReadRoots = append(o.parentReadRoots, roots...) + } +} + +// WithPreSpentTokens charges work the PLAN caused but no task performed. +// +// Today that is exactly one thing: the routing call auto_assign makes before any +// task runs. It happens outside the executor, so its tokens reached neither the +// budget nor the report — and the report is what a person reads to decide +// whether a plan was worth what it cost. +func WithPreSpentTokens(tokens int) ExecOption { + return func(o *execOptions) { + if tokens > 0 { + o.preSpent = tokens + } + } +} + +type execOptions struct { + // scratchpad asks for a per-plan directory of task outputs. + scratchpad bool + // preSpent is tokens the plan owes before its first task starts. + preSpent int + // contextWindow reports the window of the model a TASK will run on, 0 when + // unknown. Called with the task's own model id, or "" when the task named + // none — the supplier knows what the run itself is using, and this package + // deliberately does not. + contextWindow ContextWindowFunc + // parentReadRoots are directories the parent may read beyond its workspace — + // its request_permissions grants — handed to every task so a plan can audit a + // granted external path. Empty for a run that wired none, which is every + // existing caller. + parentReadRoots []string +} + +// ContextWindowFunc reports a model's context window in tokens, 0 when unknown. +// +// A HOOK, not an import: this package runs on the child-execution path and must +// not drag the provider stack in behind it. The surface that owns the catalogue +// supplies it — see planContextWindows in internal/cli — and nil means no window +// is knowable here, which is the honest default for a run that never wired one. +// +// 0 MEANS UNKNOWN, the same spelling and the same meaning as +// agent.ContextMeasurement.ContextWindow, so the two cannot drift into +// disagreeing about what 0 means. Unknown disables the adjustment rather than +// guessing at it. +// +// AN EMPTY MODEL ID means "whatever this run is using". A plan task that names +// no model inherits the parent's, and only the supplying side knows what that is. +type ContextWindowFunc func(modelID string) int + +// WithContextWindows sizes each task's dependency briefing to the context window +// of the model that will READ it. Omitted, or supplied nil, every briefing keeps +// the fixed caps it had before. +func WithContextWindows(window ContextWindowFunc) ExecOption { + return func(o *execOptions) { o.contextWindow = window } +} + +// windowFor is the resolved window for one task, and the nil-safe path that +// makes every existing caller behave as it did. +func (o execOptions) windowFor(task Task) int { + if o.contextWindow == nil { + return 0 + } + return o.contextWindow(task.Model) +} + +// 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, opts ...ExecOption) PlanReport { + var options execOptions + for _, opt := range opts { + if opt != nil { + opt(&options) + } + } + // THE PLAN'S SCRATCHPAD. Off unless asked for, so every existing caller — + // and the additivity guarantee — is untouched: with no scratchpad, Record + // returns "", scratchpadPointer renders nothing, and the briefing is the + // sentence it always was. + // + // A scratchpad that cannot be created is NOT a reason to refuse the plan. + // It only makes truncated excerpts reachable; without it they are exactly as + // reachable as they were before, which is to say the plan still works. + var scratchpad *Scratchpad + if options.scratchpad { + if pad, err := NewScratchpad(plan.Name()); err == nil { + scratchpad = pad + defer scratchpad.Release() + } + } + // 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() + // 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. + // ONE SOURCE FOR THE WALL: this clock, read by the backstop that cancels + // children and by the walk that skips tasks not yet dispatched. Two sites + // reading the budget differently is how they drift — a plan whose backstop + // reached only one of them would kill work in flight while still dispatching + // more, or the reverse. + // + // A CLOCK RATHER THAN context.WithTimeout, because the budget has to be able + // to move: a plan the user paused is not spending it. See planWallClock. + wallClock := newPlanWallClock(planWallBudget(plan.Budget()), nil) + if wallClock != nil { + var cancelWall context.CancelFunc + ctx, cancelWall = context.WithCancel(ctx) + defer cancelWall() + wallClock.watch(ctx, cancelWall) + } + planRunning(recorder, cancelPlan) + + 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 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 + // 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: planSpendLimit(plan.Budget().MaxTokens, options.preSpent)} + // One budget for the whole plan, so a systemic provider failure costs two + // extra children rather than one per task. See planProviderRetryBudget. + providerRetries := &planProviderRetryBudget{remaining: maxPlanProviderRetries} + // 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() + + // One stall timeout for the whole plan, resolved once. + 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 + report.TokenLimit = plan.Budget().MaxTokens + // SEEDED, NOT ADDED AT THE END. report.TokensUsed accumulates per task from + // here on, and every early return between here and the end reports whatever + // it holds — so a total corrected only on the success path would be wrong on + // exactly the runs a reader most wants the truth about. + report.TokensUsed = options.preSpent + 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 + // PERSISTED HERE, at the one point every completion passes through, + // whatever its outcome. A cancelled task's partial findings are evidence + // — plan_exec already says so where it briefs dependents — so they are + // worth keeping for the same reason a successful task's are, and putting + // this at the single chokepoint means no later outcome branch can forget. + // + // A FAILURE TO RECORD IS NOT A FAILURE OF THE TASK. The scratchpad is an + // optimisation over the excerpt the dependent gets anyway; losing the + // disk copy must never turn a task that ran into a task that failed. + if path, recordErr := scratchpad.Record(id, strings.TrimSpace(result.Output)); recordErr == nil { + result.ScratchpadPath = path + } + result.ID = id + if result.Duration == 0 { + result.Duration = time.Since(completion.started) + } + 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 { + // 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. + // + // IT MUST BE THIS TASK THAT WAS CUT SHORT, not merely this task being + // harvested after someone stopped the plan. A bare ctx.Err() check + // relabelled a task that had already failed on its own — a real + // compile error, reported and complete — the moment the user pressed + // stop, erasing the one result worth reading and reporting the plan as + // "stopped" when something in it was genuinely broken. + // + // THE TASK DID NOT CONCLUDE is the thing being detected, and two + // structural signals say so — never the message text, invariant 9: + // + // err != nil. The executor returns the process-level error + // alongside its result (exec.go's post-start failure path), so a + // task the context killed always carries one. + // + // result.Stalled. The watchdog's own path returns err nil + // deliberately, because a stall is a decision this side of the + // boundary made. A stall that coincides with a Ctrl-C is still a + // cancellation, which is what TestACancelledRunIsNotRetried pins. + // + // Neither is true of a child that ran to the end and reported a + // failure of its own — err nil, not stalled, its reason in + // result.Err — and that is the case a bare ctx.Err() swallowed. + // result.Signal. A child killed by its own context is SIGKILLed — + // osexec.CommandContext does that with no WaitDelay — and the + // executor returns that as a StatusError result with a NIL error. + // So "concluded" was true for a task the plan had just killed, and + // two tasks stopped at exactly 300.0s were reported as ordinary + // FAILURES carrying a guess list: "Common causes: an out-of-memory + // kill, a timeout, or cancellation; check the signal to tell + // which." The plan knew which. It shrugged, and a reader + // reasonably concluded their machine had run out of memory and + // went to raise limits that were never involved. + concluded := err == nil && !result.Stalled && result.Signal == "" + cutShort := errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || + (ctx.Err() != nil && !concluded) + if cutShort { + cancelled = true + result.Outcome = TaskCancelled + // NAMED, NOT GUESSED. The prose the child came back with is the + // signal diagnostic, which for a task the plan itself stopped is + // a guess about our own decision — so it is replaced here rather + // than preserved. A signal is still reported, as a detail of a + // reason we can state, not in place of one. + if result.Err == "" || result.Signal != "" { + // 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 wallClock.wallExpired() || 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" + } + if result.Signal != "" { + result.Err += " (the child was terminated by " + result.Signal + ")" + } + } + 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 + } + // 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 + // STAMPED HERE, ON THE ONE SUCCESS PATH BOTH SURFACES FLOW THROUGH. + // recordCompleted feeds the CLI recorder and the TUI bridge the same + // result, and both write it verbatim into task_completed — so setting the + // identity once, before recording, is what puts it in the log for resume + // to match against. Only a succeeded task is matched by identity; a failed + // one re-runs regardless, so its result carries none. + result.Identity = taskIdentity(tasks[id]) + 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 + // turns it into a cancellation exactly as if the plan had been running. + // + // AND THE TIME IS GIVEN BACK. max_wall_seconds bounds what a plan spends, + // and a paused plan spends nothing: without this, pausing for twenty + // minutes took twenty minutes off a thirty-minute budget, and pausing for + // longer than the budget meant the plan was already dead when the user + // resumed it — stopped, and told its wall budget elapsed, by time in + // which no task ran. + pausedAt := time.Now() + waitWhilePaused(recorder, ctx) + wallClock.addPaused(time.Since(pausedAt)) + + // 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 + // 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 wallClock.wallExpired() { + reason = "cancelled: the plan's max_wall_seconds elapsed before this task ran" + } + result := TaskResult{ + ID: id, + Outcome: TaskCancelled, + Err: reason, + } + results[id] = result + failed[id] = true + report.Cancelled++ + recordFailed(recorder, result) + continue + } + + if missing, skip := unusableDependencies(task, results); skip { + result := TaskResult{ + ID: id, + Outcome: TaskSkippedDependency, + 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 + 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. + // 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 || wallClock.exhausted() { + if downstream { + downstreamExhausted = true + } else { + upstreamExhausted = 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 + } + + // 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. 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 + } + + // WHAT ITS DEPENDENCIES FOUND, handed to it rather than left for it to + // rediscover. See dependencyBriefing. + perTask, totalBrief := dependencyBriefingBudget(options.windowFor(task)) + task.Prompt = withDependencyBriefingBudget(task, results, perTask, totalBrief) + // 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, + spend: spend, + maxTaskTokens: plan.Budget().MaxTokensPerTask, + waitsOnOthers: len(task.DependsOn) > 0, + // readRoots is emitted to the child as --add-dir, which grants WRITE, so + // only the scratchpad (a dir the executor owns) travels it. The parent's + // request_permissions READ grants must NOT: routing a read grant through + // the write channel would escalate it. They travel readOnlyRoots, emitted + // as --add-read-dir (scope.AddRead) so the child can read but not write. + readRoots: scratchpadReadRoots(scratchpad), + readOnlyRoots: options.parentReadRoots, + maxRetries: plan.Budget().MaxRetries, + providerRetries: providerRetries, + wallClock: wallClock, + } + 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() { + 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 +} + +// 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 + cwd string + stallTimeout time.Duration + maxRetries int + spend *planSpend + maxTaskTokens int + waitsOnOthers bool + // readRoots are directories a task may READ beyond its workspace — today + // only the plan's scratchpad. Never write roots: the executor is the sole + // writer there, which is what makes contention impossible rather than + // merely unlikely. + readRoots []string + // readOnlyRoots are the parent's request_permissions READ grants, handed to + // the task READ-ONLY (emitted as --add-read-dir, applied via scope.AddRead) so + // a plan can audit a granted external path without the task gaining write + // access to it. Kept SEPARATE from readRoots because readRoots is emitted as + // --add-dir, which grants write. + readOnlyRoots []string + // wallClock is the plan's running-time budget, nil when unbounded. Read + // rather than a fixed deadline so time the user spent paused does not + // count against it. + wallClock *planWallClock + // providerRetries is the PLAN-WIDE budget for provider-failure retries, + // shared by every task in the plan and nil when there is none. + // + // PLAN-WIDE, not per task, because the exit code cannot tell a transient + // 500 from a permanent one. internal/cli returns exitProvider for every + // agent-run failure — an expired key, an unknown model, exhausted quota — + // so a per-task retry meant a dead API key cost a ten-task plan twenty + // spawns to produce the same ten "authentication failed" errors. A shared + // budget keeps the value for the case this exists for (one task catching a + // bad moment) and bounds the waste when the failure is systemic: the first + // couple of tasks pay for the discovery, the rest do not. + providerRetries *planProviderRetryBudget +} + +// planProviderRetryBudget bounds provider-failure retries across a whole plan. +// Concurrent: tasks are dispatched in parallel, so the counter is mutex-guarded +// like planSpend rather than a plain int. +type planProviderRetryBudget struct { + mu sync.Mutex + remaining int +} + +// maxPlanProviderRetries is how many provider-failure retries one plan may +// spend in total. Two: enough for a genuine blip to be re-tried and for a +// second, independent one later in the same plan, and few enough that a +// systemic failure costs two extra children rather than one per task. +const maxPlanProviderRetries = 2 + +// take reports whether a retry is available and consumes it when so. A nil +// budget yields none, which is the honest answer for a caller that wired one +// deliberately absent. +func (budget *planProviderRetryBudget) take() bool { + if budget == nil { + return false + } + budget.mu.Lock() + defer budget.mu.Unlock() + if budget.remaining <= 0 { + return false + } + budget.remaining-- + return true +} + +// 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 + +// childExitProvider is the child's exit code for "the provider failed", as +// opposed to the task failing. Duplicated from internal/cli's exitProvider for +// the same reason childExitIncomplete is, and pinned by the same agreement test. +const childExitProvider = 3 + +// 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 + + // 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 + // retriedAfterProviderFailure bounds the provider retry at exactly one, for + // the same reason: a provider failing twice in a row is having an outage, not + // a bad moment, and a second retry spends another child to learn that. + retriedAfterProviderFailure := 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, + Spend: policy.spend, + MaxTaskTokens: policy.maxTaskTokens, + WaitsOnOtherTasks: policy.waitsOnOthers, + ReadRoots: policy.readRoots, + ReadOnlyRoots: policy.readOnlyRoots, + }) + if result.Duration == 0 { + result.Duration = time.Since(started) + } + totalDuration += result.Duration + totalTokens += result.Tokens + 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.wallClock.exhausted() { + 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.wallClock.exhausted() { + 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.ProviderFailed && !retriedAfterProviderFailure && !grantsPlanWriteTool(policy.tools): + // THE PROVIDER FAILED, WHICH IS NOT AN ANSWER. + // + // The branch below is right about a task that read the code and + // concluded wrongly: running it again buys the same report. It is not + // right about an HTTP 500 — that is a coin flip, and a measured + // ten-task run lost one task to a bare "Internal Server Error" after 6 + // tool calls and 42,524 tokens, taking its dependents with it. The + // transport will not replay the POST itself, deliberately: 500/502/504 + // do not guarantee the request had no effect, so a replay could pay + // for the same completion twice (providerio.ShouldRetryStatus). A + // fresh child is not that replay — it is a new request. + // + // READ-ONLY TASKS ONLY, and that bound is the whole safety argument. A + // write task may have applied part of its change before the provider + // died; re-running it could apply that change twice, and no exit code + // can tell us how far it got. A read-only task re-reads, which costs + // tokens and nothing else. The same asymmetry the no-tool-call check + // below rests on: for writes the inference has to be airtight. + // + // 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.wallClock.exhausted() { + return result, err + } + // THE PLAN-WIDE BUDGET, checked last so it is only spent on a retry + // that is actually going to happen. + if !policy.providerRetries.take() { + return result, err + } + retriedAfterProviderFailure = true + 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. + 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.wallClock.exhausted(): + // 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 { + 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 && 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 + } +} + +// 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 { + // 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. +// 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 + } + return spend.limit * int64(spend.downstreamTasks) / int64(spend.totalTasks) +} + +// 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 +) + +// SIZED TO THE READER'S CONTEXT WINDOW when one is known. +// +// The constants above are provider-blind, and this catalogue is not: its models +// run from an 8k local Ollama to a 1M-context gateway. 12,000 characters is +// most of a small model's context and a rounding error in a large one's. A +// measured case: a code-review child produced 18,349 characters and a dependent +// task would have seen 4,000 of them — 78% discarded — whether it had room for +// the rest or not. +// +// THE BUDGET BELONGS TO THE TASK THAT READS THE BRIEFING, never the one that +// wrote it. With per-task models a 1M-context synthesiser routinely depends on a +// 32k finder, and sizing by the producer would starve the reader for no reason. +// +// UNKNOWN KEEPS TODAY'S NUMBERS EXACTLY. A window of 0 means nothing reported +// one — the majority case in this catalogue — and returns the two constants +// unchanged, so every provider that describes nothing behaves byte-identically +// to before this existed. Same contract as agent.ContextMeasurement: unknown +// disables the adjustment rather than guessing at it. +const ( + // briefingWindowFraction is how much of a reader's context the inherited + // findings may occupy. A tenth leaves the task its own prompt, its tools and + // room to actually work — the briefing is an input to the job, not the job. + briefingWindowFraction = 0.10 + // briefingCharsPerToken converts the window (tokens) to the budget + // (characters). Four is the usual English approximation and it only has to be + // the right order of magnitude: this decides a budget, not an invoice. + briefingCharsPerToken = 4 + // briefingFloorTotal keeps a briefing worth reading on a very small model. + // Below this a dependent learns nothing and re-derives everything, which + // costs more than the characters saved. + briefingFloorTotal = 4000 + // briefingCeilingTotal stops a 1M-context model from inheriting an entire + // plan. The bound the original constants existed for does not go away just + // because the window is large: a twenty-task chain still must not accumulate. + briefingCeilingTotal = 96_000 + // briefingPerTaskShare is the total divided among dependencies. Three, + // because 12,000/4,000 is exactly the ratio the constants above already + // chose; deriving it keeps a 30k-window model on today's numbers rather than + // silently re-tuning every existing plan. + briefingPerTaskShare = 3 +) + +// dependencyBriefingBudget returns the per-dependency and whole-briefing caps +// for a reader with the given context window, 0 meaning unknown. +func dependencyBriefingBudget(contextWindow int) (perTask, total int) { + if contextWindow <= 0 { + return dependencyBriefingPerTask, dependencyBriefingTotal + } + total = int(float64(contextWindow) * briefingCharsPerToken * briefingWindowFraction) + if total < briefingFloorTotal { + total = briefingFloorTotal + } + if total > briefingCeilingTotal { + total = briefingCeilingTotal + } + return total / briefingPerTaskShare, total +} + +// 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 dependency that produced nothing skips its +// dependents entirely (unusableDependencies), so anything reaching here has +// something to say; 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. +// withDependencyBriefing briefs a task at the FIXED caps. +// +// Kept as the two-argument function every existing caller and test already +// uses. Editing six test call sites to pass budgets they do not care about +// would have been six tests changed to keep compiling — and a test edited for +// that reason is one nobody re-reads. They assert briefing CONTENT, which the +// fixed caps still produce exactly as before. + +// withDependencyBriefingBudget is the same briefing sized to the reading task. +func withDependencyBriefingBudget(task Task, results map[string]TaskResult, perTaskBudget, totalBudget int) string { + if len(task.DependsOn) == 0 { + return task.Prompt + } + var brief strings.Builder + var unfinished []string + remaining := totalBudget + for _, id := range task.DependsOn { + result, ok := results[id] + 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 := perTaskBudget + if budget > remaining { + budget = remaining + } + truncated := false + if len(output) > budget { + output, truncated = output[:budget], true + } + remaining -= len(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. + // + // AND NOW REACHABLE. When the plan kept a scratchpad, the whole answer + // is on disk and the dependent is told where — so a truncated excerpt + // stops being a loss and becomes a summary with the rest one read_file + // behind it. Without a scratchpad this is exactly the old sentence. + if pointer := scratchpadPointer(result.ScratchpadPath, len(strings.TrimSpace(result.Output))); pointer != "" { + brief.WriteString(pointer + "\n") + } else { + brief.WriteString("[truncated — re-read the files named above if you need more]\n") + } + } + brief.WriteString("\n") + } + if brief.Len() == 0 { + return 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 +// 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 +// 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) +} + +// 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. +// +// 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 { + // 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) + } + } + } else { + for _, name := range task.Tools { + // 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, + // 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) + } + } + 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, 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 +// 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", report.Status, report.Succeeded, report.Failed, report.Skipped) + if report.Cancelled > 0 { + 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. + // 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") + } + // A NUMBER A TASK REPORTED THAT ITS OWN COMMANDS CONTRADICT. + // + // Above the fold, because this is the one kind of finding a reader cannot + // check for themselves: the command ran inside a child's session and its + // output is not in this report. A measured submission stated a table of + // timings no command had produced, the same test reading 0.86s in one place + // and 4.20s in another; nothing in the harness noticed, and nothing said so. + // + // Silent when there are none, which is almost every plan. + for _, task := range report.Tasks { + for _, conflict := range task.MeasurementConflicts { + fmt.Fprintf(&b, "unverified figure in %s — %s\n", task.ID, conflict) + } + } + // 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" — + // 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) + } + if task.Err != "" { + b.WriteString("\n error:\n" + task.Err) + } + } + 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 +} + +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) + } +} + +// 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) + } +} + +// 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 +// 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) +} + +// 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. +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 { + 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) + } +} + +// scratchpadReadRoots is the read grant a task needs to open its dependencies' +// full outputs, and nothing else. +func scratchpadReadRoots(pad *Scratchpad) []string { + if root := pad.Root(); root != "" { + return []string{root} + } + return nil +} + +// planSpendLimit reduces a plan's budget by what it already owes. +// +// A POSITIVE BUDGET NEVER BECOMES UNBOUNDED. Throughout planSpend, limit <= 0 +// means "no bound at all" — ceilingFor returns 0 and overPool never fires. So a +// pre-spend at or above the whole budget must floor at 1, not at 0: a plan that +// has already overspent needs its very next task refused, and turning its bound +// off would do the exact opposite. +// +// An UNBOUNDED plan stays unbounded. Subtracting from 0 would invent a ceiling +// the caller never asked for, and max_tokens is optional precisely because +// guessing it low is how a plan spends everything and returns nothing. +func planSpendLimit(maxTokens, preSpent int) int64 { + if maxTokens <= 0 { + return 0 + } + if preSpent <= 0 { + return int64(maxTokens) + } + if remaining := int64(maxTokens) - int64(preSpent); remaining > 0 { + return remaining + } + return 1 +} diff --git a/internal/specialist/plan_exec_test.go b/internal/specialist/plan_exec_test.go new file mode 100644 index 000000000..2fe35baa3 --- /dev/null +++ b/internal/specialist/plan_exec_test.go @@ -0,0 +1,533 @@ +package specialist + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + "time" + + "github.com/Gitlawb/zero/internal/streamjson" +) + +// 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, req PlanTaskRequest) (TaskResult, error) { + task := req.Task + 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, req PlanTaskRequest) (TaskResult, error) { + task := req.Task + 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, PlanTaskRequest) (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) { + // 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(150_000) + 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, req PlanTaskRequest) (TaskResult, error) { + task := req.Task + dispatched = append(dispatched, task.ID) + return TaskResult{Outcome: TaskSucceeded, Tokens: 90_000, Output: "ok"}, nil + }, nil) + + // 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) + } + 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, req PlanTaskRequest) (TaskResult, error) { + tools := req.Tools + 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}) + var granted []string + ExecutePlan(context.Background(), plan, []string{"read_file", "grep", "write_file", "bash"}, + func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + tools := req.Tools + 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, req PlanTaskRequest) (TaskResult, error) { + task := req.Task + 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, 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()) + 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, req PlanTaskRequest) (TaskResult, error) { + task := req.Task + 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, req PlanTaskRequest) (TaskResult, error) { + task := req.Task + 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, PlanTaskRequest) (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) + } +} + +// 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"}, + // 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 + }, + } + runner := NewPlanRunner(PlanTaskContext{Executor: executor, Cwd: t.TempDir(), SpecialistName: "explorer"}) + + budget := okBudget() + // 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) + + // 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) + } + // 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_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_grant_test.go b/internal/specialist/plan_grant_test.go new file mode 100644 index 000000000..b32c8ea65 --- /dev/null +++ b/internal/specialist/plan_grant_test.go @@ -0,0 +1,330 @@ +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": 500_000}, + } +} + +// 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, 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, ParentTools: []string{"grep"}}, + ) + dispatched := 0 + report := ExecutePlan(context.Background(), plan, nil, + func(context.Context, PlanTaskRequest) (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) + } +} + +// 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 { + // 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; "+ + "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 +} + +// 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") + } +} + +// 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()) + } +} diff --git a/internal/specialist/plan_handoff_test.go b/internal/specialist/plan_handoff_test.go new file mode 100644 index 000000000..90c4a0dd7 --- /dev/null +++ b/internal/specialist/plan_handoff_test.go @@ -0,0 +1,109 @@ +package specialist + +import ( + "context" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/streamjson" +) + +// overspendingChild emits the shape of a task killed mid-edit-loop: tool results +// that changed files, and NOT ONE line of prose — which is exactly why the +// measured run handed on nothing. +func overspendingChild(t *testing.T, perEvent int, files ...string) PlanRunner { + t.Helper() + 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) { + for _, file := range files { + if progress != nil { + progress(streamjson.Event{Type: streamjson.EventToolCall, Name: "edit_file"}) + progress(streamjson.Event{Type: streamjson.EventToolResult, ChangedFiles: []string{file}}) + progress(streamjson.Event{Type: streamjson.EventUsage, TotalTokens: &perEvent}) + } + } + <-ctx.Done() + return ChildRunResult{Started: true, ExitCode: -1}, ctx.Err() + }, + } + return NewPlanRunner(PlanTaskContext{Executor: executor, Cwd: t.TempDir(), SpecialistName: "explorer"}) +} + +// A TASK STOPPED BY ITS BUDGET MUST HAND SOMETHING ON. +// +// A measured plan lost 858,231 tokens of real work this way: m6-bytecode-vm was +// stopped at its per-task cap having edited vm.go nine times, and returned ZERO +// characters — because a task killed mid-edit-loop has written no prose. The +// briefing that carries a cut-short task's output to its dependents worked +// correctly on an empty input. +func TestABudgetCancelledTaskHandsOnWhatItChanged(t *testing.T) { + run := overspendingChild(t, 600, "vm/vm.go", "bytecode/bytecode.go") + result, _ := run(context.Background(), PlanTaskRequest{ + Task: Task{ID: "m6", Prompt: "build the VM"}, + Tools: []string{"edit_file"}, + MaxTaskTokens: 1000, + }) + + if result.Outcome != TaskCancelled { + t.Fatalf("outcome = %q, want %q", result.Outcome, TaskCancelled) + } + if strings.TrimSpace(result.Output) == "" { + t.Fatal("a cut-short task handed on nothing; a follow-up starts blind and must re-derive the state from a tree that may not compile") + } + for _, file := range []string{"vm/vm.go", "bytecode/bytecode.go"} { + if !strings.Contains(result.Output, file) { + t.Errorf("the handoff does not name %q:\n%s", file, result.Output) + } + } + // It must warn that the files are mid-change — a follow-up that assumes they + // compile will misread a broken tree as the task's considered output. + if !strings.Contains(result.Output, "need not compile") { + t.Errorf("the handoff does not warn the files may be mid-change:\n%s", result.Output) + } + // The reason the task stopped is unchanged and still structural. + if !strings.Contains(result.Err, "max_tokens_per_task") { + t.Errorf("the cancellation reason was lost: %q", result.Err) + } +} + +// THE HANDOFF REACHES A FOLLOW-UP TASK, through the briefing that already exists +// for cut-short work. Asserting on the runner alone would prove a string was +// built, not that anyone receives it. +func TestTheHandoffReachesADependentTask(t *testing.T) { + briefing := withDependencyBriefing( + Task{ID: "m6-continue", Prompt: "finish the VM", DependsOn: []string{"m6"}}, + map[string]TaskResult{"m6": { + ID: "m6", + Outcome: TaskCancelled, + Output: planTaskHandoff(map[string]bool{"vm/vm.go": true}, ""), + }}, + ) + if !strings.Contains(briefing, "vm/vm.go") { + t.Fatalf("a follow-up task is not told which files the cut-short task changed:\n%s", briefing) + } + if !strings.Contains(briefing, "INCOMPLETE") { + t.Errorf("the briefing does not mark the work as unfinished:\n%s", briefing) + } +} + +// PROSE IS KEPT, never replaced: a task that did say something before it was +// stopped has already said something worth having. +func TestTheHandoffIsAppendedToWhatTheTaskSaid(t *testing.T) { + got := planTaskHandoff(map[string]bool{"a.go": true}, "I finished the parser.") + if !strings.Contains(got, "I finished the parser.") { + t.Errorf("the task's own words were dropped:\n%s", got) + } + if strings.Index(got, "I finished the parser.") > strings.Index(got, "a.go") { + t.Errorf("the file list displaced the task's own words:\n%s", got) + } + // A task that changed nothing gains no invented handoff. + if got := planTaskHandoff(nil, "nothing to do here"); got != "nothing to do here" { + t.Errorf("a task that changed no files gained a handoff: %q", got) + } + if got := planTaskHandoff(nil, ""); got != "" { + t.Errorf("an empty task produced %q", got) + } +} diff --git a/internal/specialist/plan_identity.go b/internal/specialist/plan_identity.go new file mode 100644 index 000000000..65a5fcc40 --- /dev/null +++ b/internal/specialist/plan_identity.go @@ -0,0 +1,66 @@ +package specialist + +import ( + "crypto/sha256" + "encoding/hex" + "sort" + "strconv" +) + +// taskIdentity is a stable fingerprint of everything about a task that changes +// what it does or how it runs. Two tasks with the same identity are the same +// unit of work — so a completed one can be resumed as done — and a task whose +// identity differs from the one recorded when it ran is a DIFFERENT unit that +// must run again, along with everything that depends on it. +// +// WHAT IS IN, AND WHY EACH — this is invariant 10 (a key that omits a field that +// varies serves one result for many): a fingerprint that dropped any field below +// would let an edited task resume a stale result. +// - Prompt: the instruction. Editing it is the whole reason to re-run. +// - Model: a different model may answer differently; a resume must not replay +// one model's answer for another's. +// - Tools: the authority grant bounds what the task may do, so it bounds the +// result. Sorted — the grant is a set, not a sequence. +// - DependsOn: the edge set is part of the task's input, because it decides +// which briefings the task receives. Adding or removing a dependency changes +// the task even when the dependencies themselves did not re-run. Sorted, for +// the same reason as Tools. +// +// WHAT IS OUT, AND WHY: +// - ID is the MATCH KEY, not part of the fingerprint: identity answers "is the +// same-id task still the same work?", so folding id in would make every task +// match only itself and defeat edit detection. +// - Phase is a display and ordering label with no execution semantics (see +// Task.Phase) — relabelling it must not force a re-run. +// - Per-task reasoning effort is not a Task field today. If one is ever added +// it MUST join this list, or a raised-effort resume replays the lower-effort +// result. This comment is the tripwire for that. +// +// The encoding is LENGTH-DELIMITED so field values cannot alias into one +// another: {"ab","c"} and {"a","bc"} must not collapse to one fingerprint. +func taskIdentity(task Task) string { + hash := sha256.New() + write := func(tag, value string) { + hash.Write([]byte(tag)) + hash.Write([]byte{0}) + hash.Write([]byte(strconv.Itoa(len(value)))) + hash.Write([]byte{0}) + hash.Write([]byte(value)) + hash.Write([]byte{0}) + } + write("prompt", task.Prompt) + write("model", task.Model) + + tools := append([]string(nil), task.Tools...) + sort.Strings(tools) + for _, tool := range tools { + write("tool", tool) + } + + deps := append([]string(nil), task.DependsOn...) + sort.Strings(deps) + for _, dep := range deps { + write("dep", dep) + } + return hex.EncodeToString(hash.Sum(nil)) +} diff --git a/internal/specialist/plan_identity_record_test.go b/internal/specialist/plan_identity_record_test.go new file mode 100644 index 000000000..15a7e3ced --- /dev/null +++ b/internal/specialist/plan_identity_record_test.go @@ -0,0 +1,75 @@ +package specialist + +import ( + "context" + "encoding/json" + "testing" +) + +// identityCaptureRecorder turns the executor's completion callback into the SAME +// event payload both real surfaces write (TaskCompletedEvent), so a test can +// assert what actually lands in the log — not just that a helper works. +type identityCaptureRecorder struct { + completed map[string]map[string]any +} + +func (r *identityCaptureRecorder) TaskDispatched(Task) {} +func (r *identityCaptureRecorder) TaskCompleted(result TaskResult) { + if r.completed == nil { + r.completed = map[string]map[string]any{} + } + _, payload := TaskCompletedEvent(result) + r.completed[result.ID] = payload +} +func (r *identityCaptureRecorder) TaskFailed(TaskResult) {} + +// A completed task records its identity, and it reaches the task_completed event +// — end to end through the real executor, because a stamp that never reaches the +// event is precisely the defect this pins. +func TestACompletedTaskRecordsItsIdentity(t *testing.T) { + plan := mustParsePlan(t, map[string]any{ + "name": "p", + "tasks": []any{map[string]any{"id": "find", "prompt": "look", "model": "m1"}}, + "budget": map[string]any{"max_workers": float64(1)}, + }, Limits{MaxTasks: 20, ParentTools: PlanReadOnlyToolNames()}) + + run := func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + return TaskResult{ID: req.Task.ID, Outcome: TaskSucceeded, Output: "done"}, nil + } + rec := &identityCaptureRecorder{} + report := ExecutePlan(context.Background(), plan, PlanReadOnlyToolNames(), run, rec) + if report.Succeeded != 1 || report.Failed != 0 { + t.Fatalf("plan did not succeed cleanly: %+v", report) + } + + payload := rec.completed["find"] + if payload == nil { + t.Fatal("no task_completed event captured for find") + } + got, _ := payload["identity"].(string) + if got == "" { + t.Fatal("the completed task recorded no identity — a resume cannot detect an edit") + } + if want := taskIdentity(plan.Tasks()[0]); got != want { + t.Fatalf("recorded identity %q != the task's identity %q", got, want) + } +} + +// The identity survives the JSON the store persists — it is not lost to the key +// name or serialization. +func TestTheRecordedIdentitySurvivesJSON(t *testing.T) { + _, payload := TaskCompletedEvent(TaskResult{ID: "a", Outcome: TaskSucceeded, Identity: "abc123"}) + raw, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + var back struct { + Identity string `json:"identity"` + } + if err := json.Unmarshal(raw, &back); err != nil { + t.Fatal(err) + } + if back.Identity != "abc123" { + t.Fatalf("identity did not survive JSON: %q", back.Identity) + } +} diff --git a/internal/specialist/plan_identity_test.go b/internal/specialist/plan_identity_test.go new file mode 100644 index 000000000..d0cf3592d --- /dev/null +++ b/internal/specialist/plan_identity_test.go @@ -0,0 +1,63 @@ +package specialist + +import "testing" + +// Identity is stable, order-independent for the set-valued fields, and moves +// with every field that changes what the task does. +func TestTaskIdentityIsStableAndCoversWhatVaries(t *testing.T) { + base := Task{ID: "a", Prompt: "do it", Model: "m1", + Tools: []string{"read_file", "grep"}, DependsOn: []string{"x", "y"}} + + // Tool and dependency ORDER must not matter — both are sets. + reordered := Task{ID: "a", Prompt: "do it", Model: "m1", + Tools: []string{"grep", "read_file"}, DependsOn: []string{"y", "x"}} + if taskIdentity(base) != taskIdentity(reordered) { + t.Fatal("identity changed when only tool/dep order changed — the grant is a set") + } + + // Every field that changes execution changes identity. + for name, mutate := range map[string]func(Task) Task{ + "prompt": func(k Task) Task { k.Prompt = "do it differently"; return k }, + "model": func(k Task) Task { k.Model = "m2"; return k }, + "tools": func(k Task) Task { k.Tools = []string{"read_file"}; return k }, + "deps": func(k Task) Task { k.DependsOn = []string{"x"}; return k }, + } { + if taskIdentity(mutate(base)) == taskIdentity(base) { + t.Fatalf("changing %s did not change identity — a resume would replay a stale result", name) + } + } + + // Fields with no execution meaning must NOT change identity, or an unrelated + // relabel forces the whole plan to re-run. + idChanged := base + idChanged.ID = "b" + phaseChanged := base + phaseChanged.Phase = "later" + if taskIdentity(idChanged) != taskIdentity(base) { + t.Fatal("changing the id changed identity — id is the match key, not the fingerprint") + } + if taskIdentity(phaseChanged) != taskIdentity(base) { + t.Fatal("changing the phase changed identity — phase has no execution semantics") + } +} + +// Length delimiting must stop adjacent field values from aliasing into one +// fingerprint. The per-field tags are not enough on their own: a value can end +// with the NEXT field's tag and shift a character across the boundary. Here +// prompt "xmodel"+model "y" and prompt "x"+model "modely" write the identical +// tag-and-value byte stream unless each value is length-prefixed. +func TestTaskIdentityDoesNotAliasAcrossFieldBoundaries(t *testing.T) { + shifted := taskIdentity(Task{Prompt: "xmodel", Model: "y"}) + other := taskIdentity(Task{Prompt: "x", Model: "modely"}) + if shifted == other { + t.Fatal("a character shifted across the prompt/model boundary produced one identity — values are not length-delimited") + } + // The same hazard within the repeated tool tag: "atool"+"b" vs "a"+"toolb". + if taskIdentity(Task{Tools: []string{"atool", "b"}}) == taskIdentity(Task{Tools: []string{"a", "toolb"}}) { + t.Fatal("two different tool sets produced one identity — tool values are not length-delimited") + } + // And a tool named like a dependency must not let the two fields trade values. + if taskIdentity(Task{Tools: []string{"x"}}) == taskIdentity(Task{DependsOn: []string{"x"}}) { + t.Fatal("a tool and a dependency with the same name produced one identity") + } +} diff --git a/internal/specialist/plan_isolation_test.go b/internal/specialist/plan_isolation_test.go new file mode 100644 index 000000000..728e426eb --- /dev/null +++ b/internal/specialist/plan_isolation_test.go @@ -0,0 +1,147 @@ +package specialist + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/sandbox" +) + +// argValues returns every value passed for a repeated flag. +func argValues(args []string, flag string) []string { + var out []string + for i, arg := range args { + if arg == flag && i+1 < len(args) { + out = append(out, args[i+1]) + } + } + return out +} + +// AN ISOLATED PLAN TASK MUST NOT BE HANDED THE PARENT'S TREE BACK. +// +// The worktree exists so a write-capable plan has "somewhere to write that is +// not the user's tree" (plan_worktree.go) — one branch, one diff, a discard +// path. --cwd narrows the child to it. If the parent's own workspace root then +// arrives as --add-dir, the very next flag re-opens everything the worktree was +// protecting, and the isolation is decoration. +// +// This is not hypothetical. In a measured run the task's cwd was the worktree +// and ten writes landed in /Users/kratos/mini, each allowed with the reason +// "workspace write is allowed" — because by then it genuinely was inside the +// child's write roots. +func TestAnIsolatedPlanTaskIsNotHandedTheParentTree(t *testing.T) { + parentWorkspace, worktree, granted := isolationDirs(t) + + executor := Executor{ + BinaryPath: "/bin/true", + NewSessionID: func() (string, error) { return "specialist_00000000000000000000000a", nil }, + // Exactly what app.go wires: the run's scope, asked for what it holds + // BEYOND its workspace. Wiring scope.Roots here instead — which also + // returns the workspace root — is the defect this test exists for. + ExtraWriteRoots: mustScope(t, parentWorkspace, granted).ExtraRoots, + } + + built, err := executor.BuildArgs(BuildArgsInput{ + Manifest: Manifest{Metadata: Metadata{Name: "explorer"}}, + Prompt: "do the work", + Cwd: worktree, + CurrentDepth: 0, + }) + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + + added := argValues(built.Args, "--add-dir") + for _, root := range added { + if root == parentWorkspace { + t.Errorf("the child was handed the parent's workspace %q as a write root, "+ + "so the worktree at %q isolates nothing: --add-dir %v", + parentWorkspace, worktree, added) + } + } + // A genuine mid-session grant must still reach the child — a child confined + // MORE tightly than its parent is its own bug, and the reason this list + // exists at all. + if !containsRoot(added, granted) { + t.Errorf("a granted root was withheld from the child: --add-dir %v", added) + } + // And the worktree itself is covered by --cwd, never repeated. + if containsRoot(added, worktree) { + t.Errorf("the child's own workspace was repeated as an extra root: %v", added) + } + if cwds := argValues(built.Args, "--cwd"); len(cwds) != 1 || cwds[0] != worktree { + t.Errorf("--cwd = %v, want exactly the worktree", cwds) + } +} + +// An ORDINARY sub-agent, whose cwd is the parent's workspace, is unaffected: the +// workspace root was already skipped for matching its cwd. This pins that the +// fix changes only the isolated case. +func TestAnOrdinarySubAgentKeepsEveryRootItHadBefore(t *testing.T) { + parentWorkspace, _, granted := isolationDirs(t) + + executor := Executor{ + BinaryPath: "/bin/true", + NewSessionID: func() (string, error) { return "specialist_00000000000000000000000a", nil }, + ExtraWriteRoots: mustScope(t, parentWorkspace, granted).ExtraRoots, + } + built, err := executor.BuildArgs(BuildArgsInput{ + Manifest: Manifest{Metadata: Metadata{Name: "explorer"}}, + Prompt: "do the work", + Cwd: parentWorkspace, + CurrentDepth: 0, + }) + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + added := argValues(built.Args, "--add-dir") + if !containsRoot(added, granted) { + t.Errorf("a granted root was withheld: %v", added) + } + if containsRoot(added, parentWorkspace) { + t.Errorf("the child's own workspace was repeated as an extra root: %v", added) + } +} + +// isolationDirs makes the three real directories a plan run involves: the +// parent's workspace, the worktree a write-capable plan is isolated into, and a +// root granted mid-session beyond the workspace. +func isolationDirs(t *testing.T) (parentWorkspace, worktree, granted string) { + t.Helper() + base, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + parentWorkspace = filepath.Join(base, "project") + worktree = filepath.Join(base, "worktrees", "plan-x") + granted = filepath.Join(base, "granted-elsewhere") + for _, dir := range []string{parentWorkspace, worktree, granted} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + return parentWorkspace, worktree, granted +} + +// mustScope builds the same sandbox scope a session holds: a workspace root plus +// anything granted beyond it. +func mustScope(t *testing.T, workspaceRoot string, extras ...string) *sandbox.Scope { + t.Helper() + scope, err := sandbox.NewScope(workspaceRoot, extras) + if err != nil { + t.Fatalf("NewScope: %v", err) + } + return scope +} + +func containsRoot(roots []string, want string) bool { + for _, root := range roots { + if strings.TrimSpace(root) == want { + return true + } + } + return false +} diff --git a/internal/specialist/plan_keyword.go b/internal/specialist/plan_keyword.go new file mode 100644 index 000000000..7d0ac6251 --- /dev/null +++ b/internal/specialist/plan_keyword.go @@ -0,0 +1,77 @@ +package specialist + +import ( + "fmt" + "strings" +) + +// The heavy path may be required to have been ASKED FOR, in the user's own words. +// +// THE THREAT. Once the posture is on, orchestrate exists for the rest of the +// session — and everything the model reads sits in the same context as the +// user's instructions: file contents, a PR comment, a web result, MCP server +// output, a scheduled task's payload. Imperative language in any of them reads +// to a model exactly like an instruction, and this is the tool that spends real +// money and real time. The posture gate stops orchestrate EXISTING while the +// posture is off; it does nothing once it is on. +// +// WHY A HARD CHECK RATHER THAN A PROMPT RULE. A prompt rule is advice to the +// same model the injected text is talking to. This is an admission check: it +// holds regardless of what the model was told or believed. +// +// DEFAULT OFF, and that is a real trade rather than an oversight. On by default +// would refuse a plan for every existing user whose phrasing does not happen to +// match — including a benchmark prompt that says "you are being measured, one +// session, one pass" and means it. This codebase already made that call once, +// for auto_assign: "On means every existing plan silently starts running on +// models the user never chose." A gate that silently starts refusing work is the +// same shape. So it is opt-in, and the honest cost of that is that it protects +// only the sessions that enable it. + +// planKeywords are the phrases that count as asking for a plan. Matched as +// substrings of the lowercased message, deliberately: a user who writes "please +// run a plan for this" or "can you fan out over the packages" has asked, and +// demanding an exact form would make the gate a password prompt. +var planKeywords = []string{ + "run a plan", + "use a plan", + "a plan for", + "fan out", + "fan-out", + "in parallel", + "use workflow", + "use a workflow", + "run the workflow", + "orchestrate", + "multi-agent", + "sub-agents", + "subagents", +} + +// planRequestedByUser reports whether the turn's own user text asks for a plan. +// +// THE RAW MESSAGE ONLY, never the accumulated context — the whole point is to +// distinguish what the user said from what the model read. An empty message is +// NOT a request: a caller that supplies no user text cannot demonstrate one, and +// a gate that treated absence as consent would be off in exactly the headless +// and scheduled paths most exposed to untrusted payloads. +func planRequestedByUser(message string) bool { + lowered := strings.ToLower(message) + for _, keyword := range planKeywords { + if strings.Contains(lowered, keyword) { + return true + } + } + return false +} + +// planKeywordRefusal is what the model is told, and it names the remedy: the +// point is a user who wants a plan gets one by saying so, not that plans become +// unavailable. +func planKeywordRefusal() error { + return fmt.Errorf( + "a multi-agent plan has to be asked for in your own words — say something like "+ + "%q or %q and it will run. This session requires it because plans cost real time and money, "+ + "and instructions can arrive from files, comments and tool output as easily as from you", + "run a plan for ...", "fan out and investigate ...") +} diff --git a/internal/specialist/plan_keyword_test.go b/internal/specialist/plan_keyword_test.go new file mode 100644 index 000000000..c30c39296 --- /dev/null +++ b/internal/specialist/plan_keyword_test.go @@ -0,0 +1,128 @@ +package specialist + +import ( + "context" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/tools" +) + +func keywordTool(t *testing.T, require bool) *OrchestrateTool { + t.Helper() + return &OrchestrateTool{ + PostureActive: func() bool { return true }, + RequirePlanKeyword: require, + ParentTools: []string{"read_file"}, + RunTask: func(context.Context, PlanTaskRequest) (TaskResult, error) { + return TaskResult{Outcome: TaskSucceeded, Output: "done"}, nil + }, + } +} + +func keywordArgs() map[string]any { + return map[string]any{"tasks": []any{ + map[string]any{"id": "a", "prompt": "look at the tree"}, + }} +} + +// A PLAN THE USER DID NOT ASK FOR IS REFUSED, when the session requires asking. +// +// Once the posture is on, orchestrate exists for the rest of the session, and +// everything the model reads shares context with the user's instructions — file +// contents, PR comments, MCP output, a scheduled payload. An imperative sentence +// in any of them reads like an instruction to the tool that spends the most. +func TestAPlanIsRefusedWhenTheUserDidNotAskForOne(t *testing.T) { + tool := keywordTool(t, true) + result := tool.RunWithOptions(context.Background(), keywordArgs(), tools.RunOptions{ + UserMessage: "have a look at the auth code and tell me what you think", + }) + if result.Status != tools.StatusError { + t.Fatalf("a plan ran without the user asking: %+v", result) + } + // The refusal must name the remedy, or it reads as "plans are broken". + for _, required := range []string{"run a plan for", "your own words"} { + if !strings.Contains(result.Output, required) { + t.Errorf("the refusal does not tell the user how to ask: %q", result.Output) + } + } +} + +// ...and one the user DID ask for runs. A gate that blocks the honest case is a +// gate that gets turned off. +func TestAPlanTheUserAskedForStillRuns(t *testing.T) { + for _, message := range []string{ + "run a plan for the auth packages", + "fan out and investigate the sandbox", + "can you orchestrate this across the three packages", + "look at these in parallel please", + "use a workflow for this", + } { + t.Run(message, func(t *testing.T) { + tool := keywordTool(t, true) + result := tool.RunWithOptions(context.Background(), keywordArgs(), tools.RunOptions{UserMessage: message}) + if result.Status == tools.StatusError && strings.Contains(result.Output, "your own words") { + t.Errorf("a plan the user asked for was refused: %q", result.Output) + } + }) + } +} + +// AN EMPTY MESSAGE IS NOT CONSENT. A caller that supplies no user text cannot +// demonstrate a request, and treating absence as permission would leave the gate +// off in exactly the headless and scheduled paths most exposed to untrusted +// payloads. +func TestAnAbsentUserMessageIsNotTakenAsARequest(t *testing.T) { + tool := keywordTool(t, true) + result := tool.RunWithOptions(context.Background(), keywordArgs(), tools.RunOptions{UserMessage: ""}) + if result.Status != tools.StatusError { + t.Fatal("an empty user message was treated as having asked for a plan") + } +} + +// DEFAULT OFF, and unchanged for everyone who does not enable it — the same call +// this codebase made for auto_assign, and for the same reason. +func TestTheGateIsOffUnlessTheSessionAsksForIt(t *testing.T) { + tool := keywordTool(t, false) + result := tool.RunWithOptions(context.Background(), keywordArgs(), tools.RunOptions{ + UserMessage: "have a look at the auth code", + }) + if result.Status == tools.StatusError && strings.Contains(result.Output, "your own words") { + t.Errorf("the gate fired with RequirePlanKeyword false: %q", result.Output) + } +} + +// The matcher itself, at its edges. +func TestPlanRequestedByUserMatching(t *testing.T) { + for message, want := range map[string]bool{ + "RUN A PLAN for this": true, // case-insensitive + "please fan-out over the packages": true, // hyphenated + "audit this repo thoroughly": false, + "": false, + "the TODO says to fan out the tests": true, // a false POSITIVE the gate accepts + "read the file and summarise it": false, + } { + if got := planRequestedByUser(message); got != want { + t.Errorf("planRequestedByUser(%q) = %v, want %v", message, got, want) + } + } +} + +// PLAN TASKS REACH NEITHER MEMORY TOOL, and that is a decision rather than an +// oversight. +// +// A note is read back in every future session and believed. Letting a task READ +// one means a stale note can steer a whole fan-out; letting a task WRITE one +// means twenty tasks race to describe the same finding and the last wins — which +// is precisely why update_plan was taken out of this grant. Granting either is a +// one-line change here, made deliberately. +func TestPlanTasksMayNotReachMemory(t *testing.T) { + for _, name := range []string{tools.MemoryToolName, tools.MemoryWriteToolName} { + if planReadOnlyTools[name] { + t.Errorf("%q is grantable to a plan task as read-only", name) + } + if planWriteTools[name] { + t.Errorf("%q is grantable to a plan task as a write tool", name) + } + } +} diff --git a/internal/specialist/plan_measurement_test.go b/internal/specialist/plan_measurement_test.go new file mode 100644 index 000000000..3316e644b --- /dev/null +++ b/internal/specialist/plan_measurement_test.go @@ -0,0 +1,116 @@ +package specialist + +import ( + "context" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/streamjson" +) + +// childRunner returns an Executor whose child emits the given tool output and +// then answers with the given text — the shape of a task that runs a command +// and then reports a number about it. +func childRunner(t *testing.T, toolOutput, answer string) PlanRunner { + t.Helper() + 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) { + // A real child does BOTH: streams each event as it happens (which is + // what the ledger reads) and returns them for SummarizeStream to fold + // into the task's output. A fixture that only did one would test a + // path production never takes. + events := []streamjson.Event{ + {Type: streamjson.EventToolCall, Name: "exec_command"}, + {Type: streamjson.EventToolResult, Output: toolOutput}, + {Type: streamjson.EventFinal, Text: answer}, + } + for _, event := range events { + if progress != nil { + progress(event) + } + } + return ChildRunResult{Started: true, Events: events}, nil + }, + } + return NewPlanRunner(PlanTaskContext{Executor: executor, Cwd: t.TempDir(), SpecialistName: "explorer"}) +} + +const taskSuiteOutput = "ok \tgithub.com/x/y\t0.86s\n--- PASS: TestChattyChild (0.86s)\n" + +// A PLAN TASK'S OWN COMMANDS ARE THE CHECK ON ITS OWN NUMBERS. +// +// The parent's tripwire compares the parent's answer against the parent's tool +// output. A plan task's commands run in the CHILD's session, so a figure a task +// invents was caught by neither — which is the gap this closes. The child's tool +// results do stream to the parent, which is what makes the check possible. +func TestATaskReportingAFigureItsCommandsContradictIsFlagged(t *testing.T) { + run := childRunner(t, taskSuiteOutput, "TestChattyChild took 4.20s, well within budget.") + result, err := run(context.Background(), PlanTaskRequest{ + Task: Task{ID: "bench", Prompt: "measure it"}, + Tools: []string{"read_file"}, + }) + if err != nil { + t.Fatalf("run: %v", err) + } + if len(result.MeasurementConflicts) != 1 { + t.Fatalf("got %d conflicts, want 1: %+v", len(result.MeasurementConflicts), result.MeasurementConflicts) + } + got := result.MeasurementConflicts[0] + for _, required := range []string{"TestChattyChild", "4.2s", "0.86s"} { + if !strings.Contains(got, required) { + t.Errorf("the conflict does not mention %q: %s", required, got) + } + } +} + +// AN HONEST TASK CARRIES NOTHING. A check that fires on a correct report is +// worse than no check: it gets ignored, and then it catches nothing. +func TestATaskReportingWhatItMeasuredIsNotFlagged(t *testing.T) { + for name, answer := range map[string]string{ + "the figure as measured": "TestChattyChild took 0.86s.", + "ordinary variation": "TestChattyChild took 0.91s.", + "no figure at all": "TestChattyChild passes.", + "a name never measured": "TestSomethingElse took 99.0s.", + } { + t.Run(name, func(t *testing.T) { + run := childRunner(t, taskSuiteOutput, answer) + result, err := run(context.Background(), PlanTaskRequest{ + Task: Task{ID: "bench", Prompt: "measure it"}, Tools: []string{"read_file"}, + }) + if err != nil { + t.Fatalf("run: %v", err) + } + if len(result.MeasurementConflicts) != 0 { + t.Errorf("an honest report was flagged: %+v", result.MeasurementConflicts) + } + }) + } +} + +// THE READER MUST SEE IT. A conflict recorded on a struct nobody renders is a +// conflict nobody acts on — and this is the one finding a reader cannot check +// for themselves, because the command ran inside a child's session. +func TestTheReportNamesAnUnverifiedFigure(t *testing.T) { + report := PlanReport{ + Status: PlanCompleted, Succeeded: 1, TokensUsed: 10, + Tasks: []TaskResult{{ + ID: "bench", Outcome: TaskSucceeded, + MeasurementConflicts: []string{"TestChattyChild: reported 4.2s, but this task's own commands printed 0.86s"}, + }}, + } + summary := report.Summary() + for _, required := range []string{"unverified figure", "bench", "TestChattyChild", "4.2s", "0.86s"} { + if !strings.Contains(summary, required) { + t.Errorf("the summary does not carry %q:\n%s", required, summary) + } + } + // ...and stays silent for an ordinary plan. + clean := PlanReport{Status: PlanCompleted, Succeeded: 1, TokensUsed: 10, + Tasks: []TaskResult{{ID: "bench", Outcome: TaskSucceeded}}} + if strings.Contains(clean.Summary(), "unverified figure") { + t.Errorf("a plan with no conflicts announced one:\n%s", clean.Summary()) + } +} 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..9baa25d3b --- /dev/null +++ b/internal/specialist/plan_model_assign.go @@ -0,0 +1,442 @@ +package specialist + +import ( + "context" + "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 + // MinSize is the smallest model, in billions of parameters, worth routing a + // task to. A model KNOWN to be smaller (its id names a size below this) is + // dropped from every tier, so a task lands on a decent model rather than a + // toy. A model of unknown size is kept; 0 is no floor. Named to match the + // config field it carries from (see the prefs-carry guard test). + MinSize float64 + // TopModels is how many of the MOST CAPABLE models a plan may route to. A + // provider can list far more than a plan can sensibly use, and ranking the + // whole field makes the cheap tier the smallest thing it happens to serve. + // 0 means the default (defaultTopRankedModels); a provider offering fewer + // keeps all of them. Pins are checked against the served set, not this list, + // so naming a model outside the top N still routes to it. Named to match the + // config field it carries from (see the prefs-carry guard test). + TopModels int +} + +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) + } + // Drop models KNOWN to be below the decency floor, so a task lands on a decent + // model rather than a toy. Fail-open: never leaves a plan with no models. + eligible = applyMinSizeFloor(eligible, prefs.MinSize) + + // Least-to-most capable, so buildModelTiers reads cheap/balanced/strong off + // the ends. Priced providers rank by cost exactly as before; a free provider + // ranks by the size in the id instead of falling through to alphabetical. + sortModelsByCapability(eligible) + + // THEN THE SHORTLIST. Ranking the whole field is what makes the cheap tier + // the smallest thing the provider happens to serve — on a provider listing + // twenty models, that is a toy doing a scan while nineteen better ones sit + // unused, and the router is handed all twenty to choose between. Keeping the + // most capable N narrows both, so the tiers span the good models rather than + // everything. A PIN IS UNAFFECTED: pins are validated against the provider's + // served set, never against this list, so naming a model outside the top N + // still routes to it — the user's explicit instruction outranks a heuristic. + return applyTopRank(eligible, prefs.TopModels) +} + +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); pin { + case "": + case 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..244635d3e --- /dev/null +++ b/internal/specialist/plan_model_probe.go @@ -0,0 +1,253 @@ +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 +) + +// maxProbesInFlight caps how many models are probed at once. +// +// Sized for the round trip, not for the catalogue: eight requests overlap enough +// that proving a typical nineteen-model list still costs about three round trips +// rather than nineteen, while a provider advertising hundreds of models no longer +// sees a burst it answers with 429. A 429 classifies as ProbeUnknown, so an +// unbounded fan-out was the worst of both — it paid for every request, learned +// nothing from any of them, and left the plan's own tasks throttled behind it. +const maxProbesInFlight = 8 + +// 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", + // BILLED SEPARATELY AND UNAFFORDABLE IS ALSO A REFUSAL. Ollama lists + // models that its plan does not cover and answers a request for one with + // "this model uses extra usage only (not included plan usage) and your + // extra usage balance is empty". The model exists, discovery reports it, + // and it ranks HIGHEST because ranking is by price — so auto-assign picks + // it and every task assigned to it dies in seconds having done nothing. + // + // Measured on one account: kimi-k3 was picked on four separate days + // across eight plan tasks, each dying in ~7 seconds with 0 tokens and 0 + // tool calls, and each costing the plan a dispatch and a retry cycle. + // + // "EXTRA USAGE ONLY", not bare "extra usage": the same words appear in + // "add extra usage" inside this very message and would appear in any + // balance notice that is not a refusal — and this function's rule is that + // being wrong here silently removes a model the user paid for. + // + // SELF-CORRECTING. This is "you cannot afford it right now", not "it does + // not exist", and the probe cache lives only for the process — so adding + // balance makes the next session probe again and keep the model. + "extra usage only", + "not included plan usage", + "usage balance is empty", + } { + 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, in width as well as in time. The point is to pay one +// round trip instead of nineteen, not to open one connection per model: a +// provider listing hundreds of models answered a burst of hundreds of +// simultaneous requests on the session's own key with 429s, which classify as +// ProbeUnknown — so the probe paid for the burst, learned nothing, and left the +// plan's real tasks throttled behind it. maxProbesInFlight is the cap; the +// caller's context still bounds the time, and 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 + // A counting semaphore, not a worker pool: the number of models is usually + // under the cap, and this way that common case still starts every probe at + // once with no queue to hand work through. + inFlight := make(chan struct{}, maxProbesInFlight) + 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() }() + select { + case inFlight <- struct{}{}: + defer func() { <-inFlight }() + case <-ctx.Done(): + // Queued behind the cap when the caller gave up. Unknown keeps + // the model, which is what every other giving-up path does. + verdicts[index] = ModelProbeResult{Verdict: ProbeUnknown, Reason: ctx.Err().Error()} + return + } + 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..5160a2d50 --- /dev/null +++ b/internal/specialist/plan_model_probe_test.go @@ -0,0 +1,405 @@ +package specialist + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "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) + } +} + +// THE FAN-OUT IS BOUNDED IN WIDTH, not only in time. +// +// The comment said "CONCURRENT AND BOUNDED" while the loop started one goroutine +// and one provider request per unproven model with no limit. A provider listing +// hundreds of models answered the burst with 429s, which classify as +// ProbeUnknown — so the probe paid for every request, learned nothing, and left +// the plan's real tasks throttled behind it. +func TestProbingManyModelsStaysUnderTheConcurrencyCap(t *testing.T) { + models := make([]DiscoveredModel, 200) + for i := range models { + models[i] = DiscoveredModel{ID: fmt.Sprintf("model-%03d", i)} + } + + var mu sync.Mutex + inFlight, peak := 0, 0 + probe := func(context.Context, string) ModelProbeResult { + mu.Lock() + inFlight++ + if inFlight > peak { + peak = inFlight + } + mu.Unlock() + // Long enough that a genuinely unbounded fan-out overlaps here. + time.Sleep(2 * time.Millisecond) + mu.Lock() + inFlight-- + mu.Unlock() + return ModelProbeResult{Verdict: ProbeServes} + } + + kept, notes := proveModels(context.Background(), models, probe, &probeCache{}) + + mu.Lock() + got := peak + mu.Unlock() + if got > maxProbesInFlight { + t.Fatalf("peak concurrent probes = %d, cap is %d: %d models produced a burst on the session's own key", got, maxProbesInFlight, len(models)) + } + if got < 2 { + t.Fatalf("peak concurrent probes = %d: the probe serialised, so proving a catalogue costs one round trip per model", got) + } + if len(kept) != len(models) { + t.Fatalf("kept %d of %d models that all answered", len(kept), len(models)) + } + if len(notes) != 0 { + t.Fatalf("models that answered produced removal notes: %v", notes) + } +} + +// A MODEL BILLED SEPARATELY AND UNAFFORDABLE IS A REFUSAL TOO. +// +// The message is verbatim from a real session (2026-08-03, ollama-cloud). The +// model EXISTS and discovery reports it, so nothing structural distinguishes it +// — and because ranking is by price it ranks highest, so auto-assign chose it on +// four separate days across eight plan tasks. Each died in about seven seconds +// with zero tokens and zero tool calls, costing the plan a dispatch and a retry +// cycle every time. +// +// Classified here, before assignment, the model is dropped instead of assigned. +func TestAModelTheAccountCannotAffordIsRefusedBeforeItIsAssigned(t *testing.T) { + const verbatim = `provider request error: {"error":"this model uses extra usage only (not included plan usage) and your extra usage balance is empty, add extra usage or turn on auto reload at https://ollama.com/settings (ref: d62a7217-60fb-48ad-b9c1-766b717ec5eb)"}` + + got := ClassifyProbeError(errors.New(verbatim)) + if got.Verdict != ProbeRefuses { + t.Fatalf("the billing refusal was not recognised: %v", got.Verdict) + } + // The reason must survive: a model vanishing from routing with no + // explanation is indistinguishable from a bug, which is why proveModels + // reports one per removal. + if !strings.Contains(got.Reason, "extra usage") { + t.Fatalf("the refusal kept no usable reason: %q", got.Reason) + } + + // Each marker on its own, so a provider rewording part of the sentence still + // trips at least one. + for _, message := range []string{ + "this model uses extra usage only", + "not included plan usage", + "your extra usage balance is empty", + } { + if verdict := ClassifyProbeError(errors.New(message)).Verdict; verdict != ProbeRefuses { + t.Errorf("marker not matched: %q -> %v", message, verdict) + } + } + + // STILL FAILING TOWARD KEEPING THE MODEL. A balance NOTICE is not a refusal: + // the request succeeded and the model works, so removing it would drop a + // model the user is paying for. This is why the marker is "extra usage only" + // rather than a bare "extra usage". + for _, message := range []string{ + "you have $5.00 of extra usage remaining", + "add extra usage to raise your limit", + "extra usage enabled for this account", + } { + if verdict := ClassifyProbeError(errors.New(message)).Verdict; verdict != ProbeUnknown { + t.Errorf("a balance notice was read as a refusal: %q -> %v", message, verdict) + } + } +} + +// AND THE REFUSAL MUST REACH THE ASSIGNER, not merely the classifier. A verdict +// nothing consults leaves the model in the ranking and the plan assigns it +// anyway — the "layer B does not carry it" defect this package keeps producing. +func TestAnUnaffordableModelIsRemovedFromTheCandidatesEntirely(t *testing.T) { + const billing = `{"error":"this model uses extra usage only (not included plan usage) and your extra usage balance is empty"}` + models := []DiscoveredModel{ + // Ranked highest by price, which is exactly why auto-assign reached for it. + {ID: "kimi-k3", ToolCall: true, InputCost: 90, OutputCost: 900, OutputModalities: []string{"text"}}, + {ID: "glm-5.2", ToolCall: true, InputCost: 1, OutputCost: 3, OutputModalities: []string{"text"}}, + } + probe := func(_ context.Context, id string) ModelProbeResult { + if id == "kimi-k3" { + return ClassifyProbeError(errors.New(billing)) + } + return ModelProbeResult{Verdict: ProbeServes} + } + + survivors, notes := proveModels(context.Background(), models, probe, &probeCache{}) + for _, model := range survivors { + if model.ID == "kimi-k3" { + t.Fatal("the unaffordable model survived probing and is still assignable") + } + } + if len(survivors) != 1 || survivors[0].ID != "glm-5.2" { + t.Fatalf("probing removed the wrong models: %+v", survivors) + } + if len(notes) == 0 { + t.Fatal("a model vanished from routing with no note: indistinguishable from a bug") + } + if !strings.Contains(strings.Join(notes, " "), "kimi-k3") { + t.Fatalf("the note does not name the model that was removed: %v", notes) + } +} diff --git a/internal/specialist/plan_model_router.go b/internal/specialist/plan_model_router.go new file mode 100644 index 000000000..b575526df --- /dev/null +++ b/internal/specialist/plan_model_router.go @@ -0,0 +1,308 @@ +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 CAPABILITY ORDER (least to most capable) rather than +// prices: what matters is which model is stronger than which, and a router given +// raw prices starts optimising cost against a budget it cannot see. Size is shown +// where the id names it — "20B" vs "120B" is a capability signal the router +// should weigh, not a budget one — but prices are still withheld for that reason. +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, least to most capable:\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) + } + // The size when the id names one: a router can read "20B" vs "120B" and + // match the light model to a scan, the heavy one to a judgement. Absent + // for a cloud id that carries no size — never invented. + if label := ModelSizeLabel(model.ID); label != "" { + fmt.Fprintf(&b, " [%s]", label) + } + 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..e6c219286 --- /dev/null +++ b/internal/specialist/plan_model_router_test.go @@ -0,0 +1,409 @@ +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 router is shown each model's SIZE when the id names one, so it can match a +// light model to a scan and a heavy one to a judgement rather than infer +// capability from list position alone. A cloud id that names no size gets no +// invented label. +func TestRouterPromptShowsModelSizeWhenNamed(t *testing.T) { + candidates := []DiscoveredModel{ + {ID: "gpt-oss:20b"}, + {ID: "qwen3.5:397b", Reasoning: true}, + {ID: "gpt-4o"}, // cloud: no size in the id + } + prompt := routerPrompt(routerTasks(), candidates, "") + if !strings.Contains(prompt, "gpt-oss:20b [20B]") { + t.Errorf("the router prompt did not label the 20B model:\n%s", prompt) + } + if !strings.Contains(prompt, "qwen3.5:397b [397B]") { + t.Errorf("the router prompt did not label the 397B model:\n%s", prompt) + } + if strings.Contains(prompt, "gpt-4o [") { + t.Errorf("the router prompt invented a size for a cloud model:\n%s", prompt) + } + if !strings.Contains(prompt, "least to most capable") { + t.Errorf("the candidate header no longer states capability order:\n%s", prompt) + } +} + +// 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_size.go b/internal/specialist/plan_model_size.go new file mode 100644 index 000000000..c7fc9d42c --- /dev/null +++ b/internal/specialist/plan_model_size.go @@ -0,0 +1,201 @@ +package specialist + +import ( + "regexp" + "sort" + "strconv" + "strings" +) + +// modelSizeBillions estimates a model's parameter count in BILLIONS from its id, +// 0 when the id names no size. +// +// THIS IS THE CROSS-PROVIDER SIZE SIGNAL. A local/free provider prices every +// model at zero, so ranking it by price ranks it by nothing — but its ids name +// their size ("qwen3-coder:480b", "gpt-oss:20b", "phi3:3.8b", "smollm:135m"), +// and that is the capability signal price cannot give. Cloud ids ("gpt-4o", +// "claude-opus-4") carry no size and return 0; those providers are priced, so +// size is not the signal they rank by anyway. +// +// The LARGEST size-shaped token wins: an id can carry a version that looks +// numeric ("qwen3.5:397b" — 3.5 is a version, 397b is the size), but a version +// is not suffixed b/m, so only the real size matches; taking the largest is a +// second guard against an incidental small number. +// TRILLIONS TOO. The suffix set was [bm], so "kimi-k2:1t" parsed as size 0 — +// unknown — and on a free provider sorted BELOW gpt-oss:20b: the largest model +// on the account became the cheap tier every scan task was routed to, and the +// first thing the shortlist discarded. +// +// THE BOUNDARY EXCLUDES DIGITS, and that is not cosmetic. It was [^a-z], which +// a DIGIT satisfies — so "deepseek-r1t2-chimera" matched "1t" followed by "2" +// and reported a TRILLION-parameter model, and "r1b2-x" reported 1B. Version +// text is not a parameter count. The b/m suffixes always had this hole; adding +// t is what made it reachable on a real id, so the boundary is fixed for all +// three rather than for the one that was reported. +var modelSizeToken = regexp.MustCompile(`(?i)(\d+(?:\.\d+)?)([bmt])(?:[^a-z0-9]|$)`) + +func modelSizeBillions(id string) float64 { + var largest float64 + for _, match := range modelSizeToken.FindAllStringSubmatch(id, -1) { + value, err := strconv.ParseFloat(match[1], 64) + if err != nil || value <= 0 { + continue + } + switch { + case strings.EqualFold(match[2], "m"): + value /= 1000 // millions -> billions + case strings.EqualFold(match[2], "t"): + value *= 1000 // trillions -> billions + } + if value > largest { + largest = value + } + } + return largest +} + +// ModelSizeLabel renders a model's size for a human (or a router) to read — +// "20B", "135M" — or "" when the id names no size. A router shown "20b" vs "120b" +// can match a light model to a scan and a heavy one to a judgement directly, +// instead of inferring capability from list position alone. +func ModelSizeLabel(id string) string { + size := modelSizeBillions(id) + if size <= 0 { + return "" + } + if size < 1 { + return strconv.FormatFloat(size*1000, 'g', -1, 64) + "M" + } + return strconv.FormatFloat(size, 'g', -1, 64) + "B" +} + +// applyMinSizeFloor drops models KNOWN to be smaller than the floor — a task +// then lands on a decent model rather than a toy the provider happened to list +// cheapest. +// +// A model whose id names NO size is kept: an unknown size is not evidence of a +// small one, and most cloud ids carry none — filtering them would silently empty +// a cloud provider's list. FAIL-OPEN at the end: if the floor leaves nothing, it +// is ignored, because a plan on a small model beats a plan with no model to run. +func applyMinSizeFloor(models []DiscoveredModel, floor float64) []DiscoveredModel { + if floor <= 0 { + return models + } + kept := make([]DiscoveredModel, 0, len(models)) + for _, model := range models { + if size := modelSizeBillions(model.ID); size > 0 && size < floor { + continue + } + kept = append(kept, model) + } + if len(kept) == 0 { + return models + } + return kept +} + +// sortModelsByCapability orders models from LEAST to MOST capable, so the tier +// builder can read cheap/balanced/strong off the ends. +// +// PRICED PROVIDERS ARE UNCHANGED: cost is their capability proxy, and the order +// is exactly what it was — cost, then cost, then id. A FREE provider (every cost +// zero) instead ranks by the size parsed from the id, because the cost sort there +// fell through to alphabetical and tiered by nothing. The size branch is skipped +// the moment any model reports a price, so a mixed or priced catalogue never +// changes. +// catalogueIsPriced reports whether ANY model in the set carries a price, which +// is what decides whether the ranking orders by cost or by size. +func catalogueIsPriced(models []DiscoveredModel) bool { + for _, model := range models { + if model.InputCost > 0 || model.OutputCost > 0 { + return true + } + } + return false +} + +func sortModelsByCapability(eligible []DiscoveredModel) { + priced := catalogueIsPriced(eligible) + sort.SliceStable(eligible, func(i, j int) bool { + a, b := eligible[i], eligible[j] + if !priced { + if as, bs := modelSizeBillions(a.ID), modelSizeBillions(b.ID); as != bs { + return as < bs + } + } + if a.InputCost != b.InputCost { + return a.InputCost < b.InputCost + } + if a.OutputCost != b.OutputCost { + return a.OutputCost < b.OutputCost + } + return a.ID < b.ID + }) +} + +// defaultTopRankedModels is how many of the most capable models a plan may +// route to, when nothing is configured. +// +// A provider can list far more than a plan can sensibly use. ollama-cloud +// serves around twenty, and the ranking below spans the whole field — so the +// cheap tier reads the SMALLEST thing the provider offers and the router is +// handed twenty candidates to choose between, most of which no task should get. +// Keeping the top ten narrows both to the models actually worth a sub-agent: +// the tiers then span "smallest of the good ten" to "best", rather than +// "smallest of everything" to "best". +// +// Ten rather than three: the tiers need spread (cheap/balanced/strong are read +// off the ends and the middle), and the router's whole value is having real +// choices — a shortlist of three is a tier table with extra steps. +const defaultTopRankedModels = 10 + +// applyTopRank keeps only the most capable N, from a list already sorted +// least-to-most capable. +// +// FAIL-OPEN, like every other narrowing here: a provider offering fewer than N +// keeps all of them, and a non-positive N means "the default" rather than +// "none" — a zero value must never be the thing that leaves a plan with no +// models to assign. +// +// The tail, not the head: the list is ascending, so the most capable models are +// at the end. Taking the head would keep exactly the toys this exists to drop. +func applyTopRank(models []DiscoveredModel, top int) []DiscoveredModel { + if top <= 0 { + top = defaultTopRankedModels + } + if len(models) <= top { + return models + } + // BOTH ENDS OF THE RANKING, and a hard cap in every case. + // + // Two corrections to earlier versions live here. Taking a plain TAIL was + // wrong because "capable" means SIZE on a free provider and COST on a priced + // one, so the tail kept the ten DEAREST models and moved the cheap tier from + // the cheapest in the catalogue to the tenth-dearest. Exempting priced + // catalogues entirely was worse: catalogueIsPriced is an ANY test, so one + // priced model among three hundred disabled the cut for all three hundred, + // planModels.topModels went inert against its own documentation, and the + // router prompt lost its only bound — measured at 2,194 -> 22,985 characters + // on a 300-model catalogue, paid once per plan on the strongest model. + // + // Keeping both ends satisfies every reader of this list at once: the cheap + // tier reads eligible[0], the strong tier reads the last, and the router gets + // a bounded shortlist that still spans the real range. A third from the cheap + // end is deliberate rather than half — the tiers need one cheap candidate and + // several capable ones, not a even split. + // + // It also preserves the unsized models without a special case. An id whose + // size cannot be read sorts as 0 — the least-capable end — so those models + // live in the cheap third and survive, which is what the previous fix was + // for: dropping kimi-k2.6 and glm-5.2 because their names carry no parameter + // count was a parsing accident, not a capability judgement. + low := top / 3 + if low < 1 { + low = 1 + } + high := top - low + kept := make([]DiscoveredModel, 0, top) + kept = append(kept, models[:low]...) + kept = append(kept, models[len(models)-high:]...) + return kept +} diff --git a/internal/specialist/plan_model_size_test.go b/internal/specialist/plan_model_size_test.go new file mode 100644 index 000000000..b15801239 --- /dev/null +++ b/internal/specialist/plan_model_size_test.go @@ -0,0 +1,328 @@ +package specialist + +import ( + "strconv" + "testing" +) + +// The size parser handles real ids from both kinds of provider: local ids name +// their size, cloud ids do not (and are priced anyway). +func TestModelSizeBillionsParsesRealIds(t *testing.T) { + cases := map[string]float64{ + "gpt-oss:120b": 120, + "gpt-oss:20b": 20, + "qwen3.5:397b": 397, // 3.5 is a version; 397b is the size + "mistral-large-3:675b": 675, // -3 is a version; 675b is the size + "deepseek-coder-v2:16b": 16, // v2 is a version; 16b is the size + "phi3:3.8b": 3.8, // fractional billions + "codellama:13b": 13, + "smollm2:135m": 0.135, // millions -> billions + "mixtral:8x7b": 7, // MoE: the b-suffixed token + "gpt-4o": 0, // cloud, no size in the id + "claude-opus-4": 0, + } + for id, want := range cases { + if got := modelSizeBillions(id); got != want { + t.Errorf("modelSizeBillions(%q) = %v, want %v", id, got, want) + } + } +} + +// The human/router-facing size label: billions above 1, millions below, empty +// when the id names no size. +func TestModelSizeLabel(t *testing.T) { + cases := map[string]string{ + "gpt-oss:20b": "20B", + "phi3:3.8b": "3.8B", + "smollm2:135m": "135M", + "gpt-4o": "", + } + for id, want := range cases { + if got := ModelSizeLabel(id); got != want { + t.Errorf("ModelSizeLabel(%q) = %q, want %q", id, got, want) + } + } +} + +// The decency floor drops models KNOWN to be small (a task should not land on a +// toy), keeps decent ones, and keeps models of UNKNOWN size — an unknown size is +// not evidence of a small one, and cloud ids carry none. +func TestMinSizeFloorDropsToysButKeepsDecentAndUnknown(t *testing.T) { + models := []DiscoveredModel{ + {ID: "toy:1b"}, {ID: "tiny:135m"}, {ID: "decent:7b"}, {ID: "big:70b"}, {ID: "gpt-4o"}, + } + kept := map[string]bool{} + for _, m := range applyMinSizeFloor(models, 7) { + kept[m.ID] = true + } + if kept["toy:1b"] || kept["tiny:135m"] { + t.Fatalf("a sub-floor toy survived the decency floor: %v", kept) + } + if !kept["decent:7b"] || !kept["big:70b"] { + t.Fatalf("a decent model was dropped: %v", kept) + } + if !kept["gpt-4o"] { + t.Fatal("a model of unknown size was dropped — unknown is not evidence of small") + } +} + +// If the floor would leave NO models, it yields: a plan on a small model beats a +// plan with nothing to run. +func TestMinSizeFloorFailsOpenWhenItWouldEmptyThePool(t *testing.T) { + models := []DiscoveredModel{{ID: "small-a:1b"}, {ID: "small-b:2b"}} + if kept := applyMinSizeFloor(models, 70); len(kept) != 2 { + t.Fatalf("the floor emptied the pool instead of failing open: kept %d", len(kept)) + } +} + +// Floor 0 (unset) changes nothing — the default for every user who has not asked +// for one. +func TestMinSizeFloorOffKeepsEverything(t *testing.T) { + models := []DiscoveredModel{{ID: "toy:1b"}, {ID: "big:70b"}} + if kept := applyMinSizeFloor(models, 0); len(kept) != 2 { + t.Fatalf("floor 0 (off) dropped models: kept %d", len(kept)) + } +} + +// A FREE provider tiers by SIZE, not alphabet. The ids are arranged so the +// alphabetical order (zzz > mmm > aaa) disagrees with the size order +// (1b < 7b < 70b) — a passing test proves size won. +func TestFreeProviderTiersBySizeNotAlphabet(t *testing.T) { + models := []DiscoveredModel{ + {ID: "zzz:1b"}, + {ID: "aaa:70b"}, + {ID: "mmm:7b"}, + } + sortModelsByCapability(models) + got := []string{models[0].ID, models[1].ID, models[2].ID} + want := []string{"zzz:1b", "mmm:7b", "aaa:70b"} // light -> heavy + for i := range want { + if got[i] != want[i] { + t.Fatalf("free-provider order = %v, want %v (by size, not alphabet)", got, want) + } + } +} + +// A PRICED provider is unchanged: cost is its capability proxy, and the size +// branch must not leak in. The costs and sizes are arranged to DISAGREE, so if +// size influenced a priced sort this fails. +func TestPricedProviderTiersByCostUnchanged(t *testing.T) { + models := []DiscoveredModel{ + {ID: "big-but-cheap:70b", InputCost: 1}, + {ID: "small-but-pricey:1b", InputCost: 5}, + {ID: "mid:7b", InputCost: 3}, + } + sortModelsByCapability(models) + got := []string{models[0].ID, models[1].ID, models[2].ID} + want := []string{"big-but-cheap:70b", "mid:7b", "small-but-pricey:1b"} // cost asc + for i := range want { + if got[i] != want[i] { + t.Fatalf("priced order = %v, want cost order %v (size must not leak in)", got, want) + } + } +} + +// THE SHORTLIST. A provider can list far more than a plan can sensibly use, and +// ranking the whole field is what put the SMALLEST model the provider serves on +// the cheap tier — a toy doing a scan while nineteen better ones sat unused — +// and handed the router twenty candidates to choose between. +func TestOnlyTheTopRankedModelsAreOffered(t *testing.T) { + // Twelve free models, sized in the id: the ranking is by size, ascending. + sizes := []string{"1b", "3b", "7b", "8b", "14b", "20b", "27b", "32b", "70b", "120b", "235b", "397b"} + models := make([]DiscoveredModel, 0, len(sizes)) + for _, size := range sizes { + models = append(models, DiscoveredModel{ID: "m:" + size, ToolCall: true}) + } + ranked := rankedEligibleModels(models, ModelPreferences{}) + if len(ranked) != defaultTopRankedModels { + t.Fatalf("offered %d models, want the top %d", len(ranked), defaultTopRankedModels) + } + // BOTH ENDS are kept, and the middle is what goes. The tiers read + // cheap/balanced/strong off this list, so a cut that kept only the capable + // end moved the cheap tier up the catalogue — on a priced provider, by an + // order of magnitude of real money. + if ranked[0].ID != "m:1b" || ranked[len(ranked)-1].ID != "m:397b" { + t.Fatalf("kept %q..%q, want both ends of the ranking", ranked[0].ID, ranked[len(ranked)-1].ID) + } + // The middle is thinned: something between the ends must be gone, or this + // is not a cut at all. + for _, model := range ranked { + if model.ID == "m:8b" || model.ID == "m:14b" { + t.Fatalf("%s survived; the cut must thin the middle, not the ends", model.ID) + } + } + if tiers := buildModelTiers(models, ModelPreferences{}); tiers.cheap != "m:1b" { + t.Fatalf("cheap tier = %q, want the cheapest end of the ranking", tiers.cheap) + } +} + +// FAIL-OPEN, like every other narrowing here: a provider offering fewer than the +// cap keeps all of them, and a configured cap is honoured. +func TestTheShortlistNeverEmptiesTheField(t *testing.T) { + three := []DiscoveredModel{{ID: "m:7b"}, {ID: "m:70b"}, {ID: "m:120b"}} + if got := rankedEligibleModels(three, ModelPreferences{}); len(got) != 3 { + t.Fatalf("a provider with three models kept %d", len(got)) + } + if got := applyTopRank(three, 0); len(got) != 3 { + t.Fatal("a zero cap must mean the default, never none") + } + if got := applyTopRank(three, -5); len(got) != 3 { + t.Fatal("a negative cap must mean the default, never none") + } + // A cap of two keeps one from each end, never two from the same end: the + // tiers need a cheap candidate and a capable one. + if got := applyTopRank(three, 2); len(got) != 2 || got[0].ID != "m:7b" || got[1].ID != "m:120b" { + t.Fatalf("a configured cap of 2 kept %v, want one model from each end", got) + } + if got := rankedEligibleModels(nil, ModelPreferences{}); len(got) != 0 { + t.Fatalf("no models in, %d out", len(got)) + } +} + +// A PIN OUTSIDE THE SHORTLIST STILL APPLIES. The user naming a model is an +// instruction, not a suggestion, and pins are validated against what the +// provider SERVES rather than against this heuristic list. +func TestAPinOutsideTheShortlistStillRoutes(t *testing.T) { + sizes := []string{"1b", "3b", "7b", "8b", "14b", "20b", "27b", "32b", "70b", "120b", "235b", "397b"} + models := make([]DiscoveredModel, 0, len(sizes)) + served := map[string]bool{} + for _, size := range sizes { + models = append(models, DiscoveredModel{ID: "m:" + size, ToolCall: true}) + served["m:"+size] = true + } + prefs := ModelPreferences{Scan: "m:1b"} // deliberately the smallest, off the list + tiers := buildModelTiers(models, prefs) + if got := tiers.modelForRoleWith(TaskRoleScan, prefs, served); got != "m:1b" { + t.Fatalf("scan resolved to %q; a pin the provider serves must win over the shortlist", got) + } +} + +// A PRICED CATALOGUE KEEPS BOTH ENDS, AND STILL HAS A CAP. +// +// Two regressions met here. Taking a plain TAIL kept the ten DEAREST models, +// because a priced catalogue ranks by cost — the cheap tier moved from the +// cheapest model to the tenth-dearest. Exempting priced catalogues entirely was +// worse: catalogueIsPriced is an ANY test, so ONE priced model among three +// hundred disabled the cut for all three hundred, planModels.topModels went +// inert, and the router prompt lost its only bound. +func TestAPricedCatalogueKeepsBothEndsAndStaysCapped(t *testing.T) { + var priced []DiscoveredModel + for i := 1; i <= 300; i++ { + priced = append(priced, DiscoveredModel{ID: "p" + strconv.Itoa(i), ToolCall: true, InputCost: float64(i)}) + } + ranked := rankedEligibleModels(priced, ModelPreferences{TopModels: 10}) + if len(ranked) > 10 { + t.Fatalf("300 priced models with TopModels=10 kept %d; the cap must be real", len(ranked)) + } + tiers := buildModelTiers(priced, ModelPreferences{TopModels: 10}) + if tiers.cheap != "p1" { + t.Fatalf("cheap tier = %q, want the cheapest model in the catalogue", tiers.cheap) + } + if tiers.strong != "p300" { + t.Fatalf("strong tier = %q, want the most capable model in the catalogue", tiers.strong) + } + // ONE priced model must not disable the cut for a mostly-free catalogue. + mixed := make([]DiscoveredModel, 0, 300) + for i := 1; i <= 300; i++ { + mixed = append(mixed, DiscoveredModel{ID: "m" + strconv.Itoa(i) + ":" + strconv.Itoa(i) + "b", ToolCall: true}) + } + mixed[0].InputCost = 1 + if got := rankedEligibleModels(mixed, ModelPreferences{TopModels: 10}); len(got) > 10 { + t.Fatalf("one priced model among 300 disabled the cap: kept %d", len(got)) + } +} + +// TRILLIONS PARSE. "kimi-k2:1t" read as size 0 — unknown — and on a free +// provider sorted BELOW gpt-oss:20b, so the largest model on the account became +// the cheap tier every scan task was routed to, and the first thing the +// shortlist discarded. +func TestATrillionParameterModelIsNotSizeZero(t *testing.T) { + if got := modelSizeBillions("kimi-k2:1t"); got != 1000 { + t.Fatalf("modelSizeBillions(kimi-k2:1t) = %v, want 1000", got) + } + free := []DiscoveredModel{ + {ID: "gpt-oss:20b", ToolCall: true}, + {ID: "kimi-k2:1t", ToolCall: true}, + {ID: "qwen3-coder:480b", ToolCall: true}, + } + tiers := buildModelTiers(free, ModelPreferences{}) + if tiers.cheap == "kimi-k2:1t" { + t.Fatal("the trillion-parameter model became the cheap tier") + } + if tiers.strong != "kimi-k2:1t" { + t.Fatalf("strong tier = %q, want the largest model", tiers.strong) + } +} + +// A MODEL WHOSE SIZE CANNOT BE READ IS NEVER DROPPED FOR BEING SMALL. +// +// The ranking sorts an unparseable id as size 0 — the least-capable end — so +// the first version of the shortlist deleted exactly the models whose names do +// not state a parameter count. On a real ollama-cloud account that removed +// kimi-k2.6, glm-5.2 and deepseek-v4-flash from routing while gpt-oss:20b +// survived, and the operator's own router guidance called the first two the +// strongest reasoners on the machine. Dropping a model because its name is +// uninformative is a parsing accident, not a capability judgement — and +// applyMinSizeFloor already follows exactly this fail-open rule. +func TestTheShortlistNeverDropsAnUnsizedModel(t *testing.T) { + ids := []string{ + "gpt-oss:20b", "gpt-oss:120b", "deepseek-v3.1:671b", "qwen3-coder:480b", + "qwen3.5:397b", "kimi-k2.6", "glm-5.2", "deepseek-v4-flash", + "llama4:400b", "mistral-large:123b", "gemma3:27b", "qwen3:32b", + "phi4:14b", "llama3.3:70b", + } + models := make([]DiscoveredModel, 0, len(ids)) + for _, id := range ids { + models = append(models, DiscoveredModel{ID: id, ToolCall: true}) + } + kept := map[string]bool{} + for _, model := range rankedEligibleModels(models, ModelPreferences{}) { + kept[model.ID] = true + } + for _, unsized := range []string{"kimi-k2.6", "glm-5.2", "deepseek-v4-flash"} { + if !kept[unsized] { + t.Errorf("%s was dropped for having no size in its id", unsized) + } + } + // The cut still applies to models we CAN compare: the smallest sized one goes. + if kept["phi4:14b"] { + t.Error("phi4:14b survived; the shortlist must still cut the smallest sized models") + } + // And the cap is still real: the catalogue is larger than it. + if len(kept) > defaultTopRankedModels { + t.Fatalf("kept %d models, want at most the cap of %d", len(kept), defaultTopRankedModels) + } +} + +// VERSION TEXT IS NOT A PARAMETER COUNT. +// +// The suffix boundary was [^a-z], which a DIGIT satisfies — so +// "deepseek-r1t2-chimera" matched "1t" followed by "2" and reported a +// TRILLION-parameter model, which on a free provider would have made it the +// strong tier every judgement task was routed to. The b/m suffixes always had +// the same hole ("r1b2-x" read as 1B); adding t is what made it reachable on a +// real id, so the boundary is fixed for all three. +func TestVersionTextIsNotReadAsASize(t *testing.T) { + for _, id := range []string{ + "deepseek-r1t2-chimera", // the reported case + "r1b2-x", // the same hole on b + "m3m4-preview", // and on m + "qwen2t5-experimental", + } { + if got := modelSizeBillions(id); got != 0 { + t.Errorf("modelSizeBillions(%q) = %v, want 0 — that is version text, not a size", id, got) + } + } + // Real ids still parse, including at a boundary and mid-id. + for id, want := range map[string]float64{ + "kimi-k2:1t": 1000, + "gpt-oss:20b": 20, + "qwen3.5:397b": 397, + "gpt-oss:120b-cloud": 120, + "x:350m": 0.35, + } { + if got := modelSizeBillions(id); got != want { + t.Errorf("modelSizeBillions(%q) = %v, want %v", id, got, want) + } + } +} 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_params.go b/internal/specialist/plan_params.go new file mode 100644 index 000000000..42dee0a75 --- /dev/null +++ b/internal/specialist/plan_params.go @@ -0,0 +1,220 @@ +package specialist + +import ( + "fmt" + "regexp" + "sort" + "strings" +) + +// Parameters for a saved plan: the difference between a plan you re-run and one +// you re-author. +// +// A saved plan is stored args and re-admitted through ParsePlan, which makes it +// exactly repeatable and completely fixed. "Audit internal/tui" cannot be run +// against internal/cli without opening the file and editing the prompt of every +// task — so in practice a plan gets copied per target and the copies drift. +// +// WHAT MAY BE SUBSTITUTED IS DELIBERATELY NARROW. Only prose the model reads: +// each task's prompt, and the plan's description. Not `tools`, which is the +// authority grant; not `id` or `depends_on`, which are the graph; not `model` or +// the budget. A parameter that could reach the grant would turn "run the sweep +// plan with scope=x" into a way to widen what the plan may do, and a parameter +// that could reach an id would let one argument silently detach a dependency +// edge. Those are fixed when the plan is saved and reviewed, and they stay fixed. +// +// Expansion happens BEFORE ParsePlan, so validation, tool-grant narrowing, depth +// and budget checks all run against the plan that will actually execute rather +// than against a template of it. + +// planParamPattern matches ${name} with no nesting and no expressions. A +// template language here would be a second thing to validate and a second place +// for authority to leak; substitution is all this needs to be. +var planParamPattern = regexp.MustCompile(`\$\{([a-zA-Z][a-zA-Z0-9_-]*)\}`) + +// planParamFields are the keys whose values a parameter may reach. Everything +// absent from this list is structure or authority — see the file comment. +var planParamTaskFields = []string{"prompt"} + +// expandPlanParams substitutes params into a saved plan's args. +// +// FAILS CLOSED IN BOTH DIRECTIONS. A placeholder with no argument is an error +// rather than a prompt containing a literal "${scope}", which reads to the model +// as an instruction about a directory that does not exist. An argument with no +// placeholder is an error too: it is almost always a typo, and silently ignoring +// it runs the plan against the wrong target while reporting success. +func expandPlanParams(args map[string]any, params map[string]string) (map[string]any, error) { + declared := planParamPlaceholders(args) + if len(declared) == 0 && len(params) == 0 { + return args, nil + } + + var missing []string + for _, name := range declared { + if _, ok := params[name]; !ok { + missing = append(missing, name) + } + } + if len(missing) > 0 { + return nil, fmt.Errorf("this plan needs %s: supply %s in params", + pluralParams(missing), strings.Join(quoteAll(missing), ", ")) + } + + known := map[string]bool{} + for _, name := range declared { + known[name] = true + } + var unused []string + for name := range params { + if !known[name] { + unused = append(unused, name) + } + } + if len(unused) > 0 { + sort.Strings(unused) + return nil, fmt.Errorf("this plan has no %s: %s appears nowhere in it%s", + pluralParams(unused), strings.Join(quoteAll(unused), ", "), declaredHint(declared)) + } + + return substitutePlanParams(args, params), nil +} + +// PlanParams lists the parameters a stored plan declares. +// +// Exported so a caller that runs a saved plan BY NAME — the /plans command — can +// ask what it needs before dispatching a turn. Without it the only way to learn +// a plan takes a subject is to run it: expandPlanParams refuses at admission, +// which is correct but arrives one turn and one model call too late, and the +// bundled research plan otherwise spends five child agents researching the +// literal placeholder. +func PlanParams(args map[string]any) []string { + return planParamPlaceholders(args) +} + +// planParamPlaceholders lists every ${name} the plan uses, deduplicated and +// sorted so an error message reads the same way twice. +// +// Scanned ONLY where substitution is allowed. A ${name} inside a tool grant is +// not a parameter this plan takes; it is a literal that will fail tool +// validation, which is the correct outcome and a clearer error than anything +// this could say about it. +func planParamPlaceholders(args map[string]any) []string { + found := map[string]bool{} + collect := func(text string) { + for _, match := range planParamPattern.FindAllStringSubmatch(text, -1) { + found[match[1]] = true + } + } + collect(planString(args, "description")) + if tasks, ok := args["tasks"].([]any); ok { + for _, raw := range tasks { + entry, ok := raw.(map[string]any) + if !ok { + continue + } + for _, field := range planParamTaskFields { + collect(planString(entry, field)) + } + } + } + out := make([]string, 0, len(found)) + for name := range found { + out = append(out, name) + } + sort.Strings(out) + return out +} + +// substitutePlanParams returns a COPY with the substitutions applied. The stored +// plan is never mutated: LoadPlans caches by path, and rewriting a cached plan +// would leave the next run of it carrying the previous run's arguments. +func substitutePlanParams(args map[string]any, params map[string]string) map[string]any { + replace := func(text string) string { + return planParamPattern.ReplaceAllStringFunc(text, func(match string) string { + name := planParamPattern.FindStringSubmatch(match)[1] + if value, ok := params[name]; ok { + return value + } + return match + }) + } + + out := make(map[string]any, len(args)) + for key, value := range args { + out[key] = value + } + if description := planString(args, "description"); description != "" { + out["description"] = replace(description) + } + tasks, ok := args["tasks"].([]any) + if !ok { + return out + } + expanded := make([]any, 0, len(tasks)) + for _, raw := range tasks { + entry, ok := raw.(map[string]any) + if !ok { + expanded = append(expanded, raw) + continue + } + copied := make(map[string]any, len(entry)) + for key, value := range entry { + copied[key] = value + } + for _, field := range planParamTaskFields { + if text := planString(entry, field); text != "" { + copied[field] = replace(text) + } + } + expanded = append(expanded, copied) + } + out["tasks"] = expanded + return out +} + +// planParamsFromArgs reads the caller's params map, rejecting a shape that would +// otherwise substitute something like "map[]" into a prompt. +func planParamsFromArgs(args map[string]any) (map[string]string, error) { + raw, present := args["params"] + if !present { + return nil, nil + } + entries, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("params must be an object of name/value pairs") + } + out := make(map[string]string, len(entries)) + for name, value := range entries { + text, ok := value.(string) + if !ok { + return nil, fmt.Errorf("params[%q] must be a string; a plan parameter is substituted into a prompt as written", name) + } + if strings.TrimSpace(name) == "" { + return nil, fmt.Errorf("a plan parameter must be named") + } + out[name] = text + } + return out, nil +} + +func quoteAll(names []string) []string { + out := make([]string, 0, len(names)) + for _, name := range names { + out = append(out, `"`+name+`"`) + } + return out +} + +func pluralParams(names []string) string { + if len(names) == 1 { + return "parameter" + } + return "parameters" +} + +func declaredHint(declared []string) string { + if len(declared) == 0 { + return " (it takes none)" + } + return " (it takes " + strings.Join(quoteAll(declared), ", ") + ")" +} diff --git a/internal/specialist/plan_params_test.go b/internal/specialist/plan_params_test.go new file mode 100644 index 000000000..48950c810 --- /dev/null +++ b/internal/specialist/plan_params_test.go @@ -0,0 +1,208 @@ +package specialist + +import ( + "strings" + "testing" +) + +func paramPlan(t *testing.T, dir string) { + t.Helper() + plan := mustPlan(t, []any{ + map[string]any{"id": "trace", "prompt": "Trace every caller in ${scope} and quote file:line.", + "tools": []any{"grep"}}, + map[string]any{"id": "judge", "prompt": "Judge whether ${scope} holds the ${property} guarantee.", + "depends_on": []any{"trace"}}, + }, okBudget(), readOnlyLimits()) + if _, err := SavePlan(dir, "sweep", plan); err != nil { + t.Fatalf("SavePlan: %v", err) + } +} + +func resolveSaved(t *testing.T, dir string, args map[string]any) (map[string]any, error) { + t.Helper() + args["saved"] = "sweep" + return (&OrchestrateTool{Plans: PlanPaths{UserDir: dir}}).resolveSavedPlan(args) +} + +// ONE REVIEWED PLAN, MANY TARGETS. Without this a saved plan is fixed at the +// prose its author typed, so "audit internal/tui" gets copied and edited to +// audit anything else — and the copies drift from the reviewed original. +func TestASavedPlanIsFilledInFromParams(t *testing.T) { + dir := t.TempDir() + paramPlan(t, dir) + + resolved, err := resolveSaved(t, dir, map[string]any{ + "params": map[string]any{"scope": "internal/cli", "property": "additivity"}, + }) + if err != nil { + t.Fatalf("resolveSavedPlan: %v", err) + } + tasks, _ := resolved["tasks"].([]any) + if len(tasks) != 2 { + t.Fatalf("got %d tasks", len(tasks)) + } + first := planString(tasks[0].(map[string]any), "prompt") + second := planString(tasks[1].(map[string]any), "prompt") + if !strings.Contains(first, "internal/cli") || strings.Contains(first, "${") { + t.Errorf("task 1 not substituted: %q", first) + } + if !strings.Contains(second, "internal/cli") || !strings.Contains(second, "additivity") { + t.Errorf("task 2 not substituted: %q", second) + } +} + +// THE EXPANDED PLAN MUST STILL BE ADMITTED. Substituting after validation would +// mean the plan that ran was never the plan that was checked. +func TestAnExpandedPlanStillGoesThroughParsePlan(t *testing.T) { + dir := t.TempDir() + paramPlan(t, dir) + resolved, err := resolveSaved(t, dir, map[string]any{ + "params": map[string]any{"scope": "internal/cli", "property": "additivity"}, + }) + if err != nil { + t.Fatalf("resolveSavedPlan: %v", err) + } + plan, err := ParsePlan(resolved, readOnlyLimits()) + if err != nil { + t.Fatalf("the expanded plan did not re-admit: %v", err) + } + for _, task := range plan.Tasks() { + if strings.Contains(task.Prompt, "${") { + t.Errorf("task %q reached admission still holding a placeholder: %q", task.ID, task.Prompt) + } + } +} + +// FAILS CLOSED BOTH WAYS. A missing value would leave a literal "${scope}" in a +// prompt, which reads to the model as a directory that does not exist; a value +// matching nothing is almost always a typo, and ignoring it runs the plan +// against the wrong target while reporting success. +func TestParamMismatchesAreRefused(t *testing.T) { + dir := t.TempDir() + paramPlan(t, dir) + + t.Run("a placeholder with no value", func(t *testing.T) { + _, err := resolveSaved(t, dir, map[string]any{"params": map[string]any{"scope": "internal/cli"}}) + if err == nil { + t.Fatal("a plan ran with an unfilled placeholder") + } + if !strings.Contains(err.Error(), "property") { + t.Errorf("the error does not name what is missing: %v", err) + } + }) + t.Run("a value with no placeholder", func(t *testing.T) { + _, err := resolveSaved(t, dir, map[string]any{"params": map[string]any{ + "scope": "internal/cli", "property": "additivity", "scpoe": "typo"}}) + if err == nil { + t.Fatal("a misspelled parameter was accepted, so the plan would run against the wrong target") + } + if !strings.Contains(err.Error(), "scpoe") { + t.Errorf("the error does not name the typo: %v", err) + } + }) + t.Run("no params at all for a plan that needs them", func(t *testing.T) { + if _, err := resolveSaved(t, dir, map[string]any{}); err == nil { + t.Fatal("a parameterised plan ran with no parameters") + } + }) + t.Run("params for a plan that takes none", func(t *testing.T) { + plain := t.TempDir() + if _, err := SavePlan(plain, "sweep", savedPlanFixture(t)); err != nil { + t.Fatal(err) + } + _, err := resolveSaved(t, plain, map[string]any{"params": map[string]any{"scope": "x"}}) + if err == nil { + t.Fatal("a plan with no placeholders accepted a parameter") + } + }) +} + +// A PARAMETER MUST NOT REACH AUTHORITY OR THE GRAPH. Substituting into tools +// would make "run the sweep plan with scope=x" a way to widen what the plan may +// do; substituting into an id or depends_on would let one argument silently +// detach a dependency edge. Both are fixed when the plan is saved and reviewed. +func TestParamsCannotReachToolsOrTheGraph(t *testing.T) { + args := map[string]any{ + "tasks": []any{ + map[string]any{"id": "${scope}", "prompt": "look at ${scope}", + "tools": []any{"${scope}"}, "depends_on": []any{"${scope}"}, "model": "${scope}"}, + }, + } + // Only "prompt" is scanned, so the placeholders in tools/id/depends_on/model + // are not parameters this plan takes — supplying scope fills the prompt alone. + expanded, err := expandPlanParams(args, map[string]string{"scope": "bash"}) + if err != nil { + t.Fatalf("expandPlanParams: %v", err) + } + task := expanded["tasks"].([]any)[0].(map[string]any) + if got := planString(task, "prompt"); strings.Contains(got, "${") { + t.Errorf("prompt not substituted: %q", got) + } + for _, field := range []string{"id", "model"} { + if got := planString(task, field); got != "${scope}" { + t.Errorf("%s was substituted to %q; structure and authority are fixed at save time", field, got) + } + } + if got := task["tools"].([]any)[0].(string); got != "${scope}" { + t.Errorf("a parameter reached the TOOL GRANT: %q — this is an authority-widening path", got) + } + if got := task["depends_on"].([]any)[0].(string); got != "${scope}" { + t.Errorf("a parameter reached depends_on: %q", got) + } +} + +// THE STORED PLAN IS NOT MUTATED. LoadPlans caches by path, so rewriting the +// stored map would leave the next run carrying the previous run's arguments. +func TestExpandingDoesNotMutateTheStoredPlan(t *testing.T) { + args := map[string]any{ + "description": "audit ${scope}", + "tasks": []any{map[string]any{"id": "a", "prompt": "read ${scope}"}}, + } + if _, err := expandPlanParams(args, map[string]string{"scope": "internal/cli"}); err != nil { + t.Fatalf("expandPlanParams: %v", err) + } + if got := planString(args, "description"); got != "audit ${scope}" { + t.Errorf("the source description was rewritten to %q", got) + } + original := args["tasks"].([]any)[0].(map[string]any) + if got := planString(original, "prompt"); got != "read ${scope}" { + t.Errorf("the source task was rewritten to %q", got) + } +} + +// A non-string parameter would substitute something like "map[]" into a prompt. +func TestParamValuesMustBeStrings(t *testing.T) { + for name, value := range map[string]any{ + "a number": float64(7), + "an object": map[string]any{"a": "b"}, + "a list": []any{"a"}, + } { + t.Run(name, func(t *testing.T) { + if _, err := planParamsFromArgs(map[string]any{"params": map[string]any{"scope": value}}); err == nil { + t.Error("a non-string parameter was accepted") + } + }) + } + if _, err := planParamsFromArgs(map[string]any{"params": "not-an-object"}); err == nil { + t.Error("a non-object params was accepted") + } + if got, err := planParamsFromArgs(map[string]any{}); err != nil || got != nil { + t.Errorf("absent params should be absent, got %v %v", got, err) + } +} + +// An ordinary plan with no placeholders is untouched — the whole feature is +// inert for every plan saved before it existed. +func TestAPlanWithoutPlaceholdersIsUnchanged(t *testing.T) { + dir := t.TempDir() + if _, err := SavePlan(dir, "sweep", savedPlanFixture(t)); err != nil { + t.Fatal(err) + } + resolved, err := resolveSaved(t, dir, map[string]any{}) + if err != nil { + t.Fatalf("resolveSavedPlan: %v", err) + } + if tasks, _ := resolved["tasks"].([]any); len(tasks) != 3 { + t.Fatalf("got %d tasks, want the stored 3", len(tasks)) + } +} diff --git a/internal/specialist/plan_progress_test.go b/internal/specialist/plan_progress_test.go new file mode 100644 index 000000000..c2b726dd7 --- /dev/null +++ b/internal/specialist/plan_progress_test.go @@ -0,0 +1,215 @@ +package specialist + +import ( + "context" + "strings" + "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 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", + 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 + // 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 + }, + } + 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("the executor got no callback, so the watchdog cannot see the child is alive") + } +} + +// 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_provider_mismatch_test.go b/internal/specialist/plan_provider_mismatch_test.go new file mode 100644 index 000000000..aac765a6d --- /dev/null +++ b/internal/specialist/plan_provider_mismatch_test.go @@ -0,0 +1,176 @@ +package specialist + +import ( + "context" + "strings" + "sync" + "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}, + } + // WIRED, or the assertion below tests nothing: with no RunTask, runnerForCall + // returns nil and routeTaskModels leaves on its first guard, so `dispatched` + // stayed false however badly the provider check behaved. It has to be + // possible for the router to run before "the router did not run" means + // anything. + var mu sync.Mutex + dispatched := false + tool := &OrchestrateTool{ + DiscoverModels: func(context.Context) ([]DiscoveredModel, error) { return elsewhere, nil }, + ModelPrefs: ModelPreferences{AutoAssign: true}, + ParentTools: []string{"read_file"}, + RunTask: func(context.Context, PlanTaskRequest) (TaskResult, error) { + mu.Lock() + dispatched = true + mu.Unlock() + return TaskResult{Outcome: TaskSucceeded, Output: "{}"}, nil + }, + } + 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) + } + mu.Lock() + ran := dispatched + mu.Unlock() + if ran { + 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_provider_retry_test.go b/internal/specialist/plan_provider_retry_test.go new file mode 100644 index 000000000..114375c47 --- /dev/null +++ b/internal/specialist/plan_provider_retry_test.go @@ -0,0 +1,232 @@ +package specialist + +import ( + "context" + "testing" + + "github.com/Gitlawb/zero/internal/streamjson" +) + +// A PROVIDER FAILURE IS NOT AN ANSWER. +// +// The transport deliberately does not replay a 500/502/504 — unlike 429/503/529 +// those do not guarantee the request had no effect, so a replay could pay for +// the same completion twice. That is right for a replay and wrong for a plan: a +// measured ten-task run lost one task to a bare "Internal Server Error" after 6 +// tool calls and 42,524 tokens, and everything depending on it went with it. A +// fresh child is a new request, not that replay. + +// providerFailingRunner fails on the provider for the first `failures` attempts +// of each task, then succeeds — counting attempts per id. +func providerFailingRunner(failures 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] <= failures[id] { + return TaskResult{ + ID: id, Outcome: TaskFailed, ProviderFailed: true, + Err: "provider error: Internal Server Error (ref: 4bab40ea)", Tokens: 42524, + }, nil + } + return TaskResult{ID: id, Outcome: TaskSucceeded, Tokens: 7}, nil + } +} + +func TestAProviderFailureIsRetriedOnce(t *testing.T) { + plan := mustPlan(t, []any{task("a", "find every worktree creation site")}, okBudget(), readOnlyLimits()) + counts := map[string]int{} + report := ExecutePlan(context.Background(), plan, []string{"read_file"}, + providerFailingRunner(map[string]int{"a": 1}, counts), nil) + + if counts["a"] != 2 { + t.Fatalf("the task ran %d times; a provider failure is worth exactly one more attempt", counts["a"]) + } + if report.Succeeded != 1 { + t.Fatalf("report = %+v; the second attempt succeeded and the plan must say so", report) + } + // The spend of BOTH attempts is reported: a retry that hides its cost is how + // a plan's reported spend stops being its real spend. + if got := report.Tasks[0].Tokens; got != 42524+7 { + t.Fatalf("Tokens = %d, want both attempts counted (%d)", got, 42524+7) + } + if got := report.Tasks[0].Attempts; got != 2 { + t.Fatalf("Attempts = %d, want 2", got) + } +} + +// BOUNDED AT ONE. A provider failing twice in a row is having an outage, not a +// bad moment, and a second retry spends another child to learn that. +func TestAProviderFailureIsRetriedOnlyOnce(t *testing.T) { + plan := mustPlan(t, []any{task("a", "find every worktree creation site")}, okBudget(), readOnlyLimits()) + counts := map[string]int{} + report := ExecutePlan(context.Background(), plan, []string{"read_file"}, + providerFailingRunner(map[string]int{"a": 99}, counts), nil) + + if counts["a"] != 2 { + t.Fatalf("the task ran %d times; the provider retry is bounded at one", counts["a"]) + } + if report.Failed != 1 { + t.Fatalf("report = %+v; a provider that keeps failing must still fail the task", report) + } +} + +// THE SAFETY BOUND, and the whole reason this is not a blanket retry: a WRITE +// task may have applied part of its change before the provider died, and no +// exit code says how far it got. Re-running it could apply that change twice. +// A read-only task re-reads, which costs tokens and nothing else. +func TestAWriteTaskIsNotRetriedOnAProviderFailure(t *testing.T) { + writeTask := task("a", "add the missing guard") + writeTask["tools"] = []any{"read_file", "write_file"} + limits := readOnlyLimits() + limits.ParentTools = []string{"read_file", "write_file"} + plan := mustPlan(t, []any{writeTask}, okBudget(), limits) + + counts := map[string]int{} + report := ExecutePlan(context.Background(), plan, []string{"read_file", "write_file"}, + providerFailingRunner(map[string]int{"a": 1}, counts), nil) + + if counts["a"] != 1 { + t.Fatalf("a write task ran %d times; re-running one that may have half-applied its change "+ + "could apply it twice", counts["a"]) + } + if report.Failed != 1 { + t.Fatalf("report = %+v; the write task must fail rather than silently retry", report) + } +} + +// An ordinary failure is still an answer: the task read the code and concluded +// wrongly, and running it again buys the same report. Only the provider flag +// earns the extra attempt. +func TestAnOrdinaryFailureIsStillNotRetried(t *testing.T) { + plan := mustPlan(t, []any{task("a", "find every worktree creation site")}, okBudget(), readOnlyLimits()) + counts := map[string]int{} + run := func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + counts[req.Task.ID]++ + return TaskResult{ID: req.Task.ID, Outcome: TaskFailed, Err: "could not find it", Tokens: 5}, nil + } + ExecutePlan(context.Background(), plan, []string{"read_file"}, run, nil) + + if counts["a"] != 1 { + t.Fatalf("an ordinary failure ran %d times; only a provider failure earns a retry", counts["a"]) + } +} + +// THE JOIN, and the reason this test exists separately from the four above. +// +// Those drive the executor with a fake runner that SETS ProviderFailed, so they +// prove the retry logic and nothing about where the flag comes from. Gutting the +// classification in plan_runner.go left every one of them green — the exact +// shape of defect this package keeps relearning: a value written at one layer, +// consumed at another, with nothing asserting the join. This drives the REAL +// runner and reads the flag off its result. +func TestTheChildsProviderExitCodeBecomesTheFlag(t *testing.T) { + for _, tc := range []struct { + name string + exitCode int + want bool + }{ + {"provider failure", childExitProvider, true}, + {"declined", childExitIncomplete, false}, + {"ordinary failure", 1, false}, + } { + t.Run(tc.name, func(t *testing.T) { + exit := tc.exitCode + 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, emit func(streamjson.Event)) (ChildRunResult, error) { + events := []streamjson.Event{ + {Type: streamjson.EventRunStart, RunID: "run_1", SessionID: "specialist_00000000000000000000000a"}, + {Type: streamjson.EventRunEnd, RunID: "run_1", Status: "error", ExitCode: &exit}, + } + for _, event := range events { + if emit != nil { + emit(event) + } + } + return ChildRunResult{Events: events, ExitCode: exit, Started: true}, nil + }, + } + run := NewPlanRunner(PlanTaskContext{Executor: executor, Cwd: t.TempDir(), SpecialistName: "explorer"}) + result, err := run(context.Background(), PlanTaskRequest{ + Task: Task{ID: "a", Prompt: "find every worktree creation site"}, + Tools: []string{"read_file"}, + }) + if err != nil { + t.Fatalf("run: %v", err) + } + if result.ProviderFailed != tc.want { + t.Fatalf("exit %d produced ProviderFailed=%v, want %v — the retry keys on this flag", + tc.exitCode, result.ProviderFailed, tc.want) + } + }) + } +} + +// THE OTHER HALF OF THE AGREEMENT. internal/cli asserts its exitProvider equals +// the literal 3; this asserts THIS package's copy does too. Both sides pinned +// against the same literal is what makes the pair actually catch a drift — +// pinning only one side leaves the other free to move silently, which is what a +// mutation of this constant proved. +func TestTheProviderExitCodeThisPackageRetriesOnIsPinned(t *testing.T) { + if childExitProvider != 3 { + t.Fatalf("childExitProvider is %d; internal/cli exits 3 for a provider failure, so a task "+ + "killed by one would no longer be recognised or retried", childExitProvider) + } +} + +// THE PLAN-WIDE BOUND. exit code 3 means "the provider failed", but internal/cli +// returns it for EVERY agent-run error — an expired key, an unknown model, a +// quota wall — so a per-task retry meant a dead API key cost a ten-task plan +// twenty spawns to produce the same ten authentication errors. The budget is +// shared: the first couple of tasks pay for the discovery, the rest do not. +func TestProviderRetriesAreBoundedAcrossTheWholePlan(t *testing.T) { + tasks := []any{} + for _, id := range []string{"a", "b", "c", "d", "e", "f"} { + tasks = append(tasks, task(id, "find the "+id+" call sites")) + } + plan := mustPlan(t, tasks, okBudget(), readOnlyLimits()) + counts := map[string]int{} + always := map[string]int{"a": 99, "b": 99, "c": 99, "d": 99, "e": 99, "f": 99} + report := ExecutePlan(context.Background(), plan, []string{"read_file"}, + providerFailingRunner(always, counts), nil) + + total := 0 + for _, n := range counts { + total += n + } + // Six tasks, each attempted once, plus at most maxPlanProviderRetries extra. + if want := 6 + maxPlanProviderRetries; total != want { + t.Fatalf("the plan spent %d child spawns for 6 tasks; a systemic provider failure must cost "+ + "at most %d (one per task plus %d shared retries)", total, want, maxPlanProviderRetries) + } + if report.Failed != 6 { + t.Fatalf("report = %+v; every task must still fail", report) + } +} + +// A RESUME MUST NOT BE CHARGED A SPAWN SLOT. The budget bounds how many +// sub-agents a session may START; a resume continues one already counted, so +// iterating on one agent's answer thirty times used to consume thirty slots +// while spawning nothing. A malformed call must not be charged either. +func TestTheSessionBudgetChargesOnlyFreshSpawns(t *testing.T) { + budget := &SessionBudget{max: 3} + executor := Executor{SessionBudget: budget} + // A malformed call: refused before anything is charged. + if _, err := executor.Run(context.Background(), TaskParameters{Name: "explorer"}, + TaskRunOptions{Cwd: t.TempDir()}); err == nil { + t.Fatal("an empty prompt must be refused") + } + if got := budget.Started(); got != 0 { + t.Fatalf("a refused call charged %d slot(s)", got) + } + // A resume: continues an existing child, so it is not a new spawn. It fails + // for want of a session store, which is fine — the charge is what matters. + _, _ = executor.Run(context.Background(), + TaskParameters{Name: "explorer", Prompt: "carry on", Resume: "specialist_00000000000000000000000a"}, + TaskRunOptions{Cwd: t.TempDir()}) + if got := budget.Started(); got != 0 { + t.Fatalf("a resume charged %d slot(s); the budget counts spawns", got) + } +} diff --git a/internal/specialist/plan_read_roots_test.go b/internal/specialist/plan_read_roots_test.go new file mode 100644 index 000000000..8646cece5 --- /dev/null +++ b/internal/specialist/plan_read_roots_test.go @@ -0,0 +1,80 @@ +package specialist + +import ( + "context" + "slices" + "testing" +) + +// A plan task receives the parent's granted read roots, so a plan can audit a +// path the parent was granted (request_permissions) instead of failing "outside +// the workspace" in every task. Driven end to end through ExecutePlan, reading +// what the runner actually receives — a hook that nothing consults is the defect +// this branch has produced before. +func TestExecutePlanGivesTasksTheParentReadRoots(t *testing.T) { + plan := mustParsePlan(t, map[string]any{ + "name": "p", + "tasks": []any{map[string]any{"id": "a", "prompt": "look"}}, + "budget": map[string]any{"max_workers": float64(1)}, + }, Limits{MaxTasks: 20, ParentTools: PlanReadOnlyToolNames()}) + + type roots struct{ readOnly, read []string } + capture := func(into *roots) PlanRunner { + return func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + into.readOnly, into.read = req.ReadOnlyRoots, req.ReadRoots + return TaskResult{ID: req.Task.ID, Outcome: TaskSucceeded}, nil + } + } + + var granted roots + if report := ExecutePlan(context.Background(), plan, PlanReadOnlyToolNames(), + capture(&granted), nil, WithReadRoots([]string{"/granted/path"})); report.Failed != 0 { + t.Fatalf("plan failed: %+v", report) + } + // The grant reaches the task READ-ONLY, never the write channel — routing a + // read grant through ReadRoots (--add-dir) would escalate it to write. + if !slices.Contains(granted.readOnly, "/granted/path") { + t.Fatalf("ReadOnlyRoots %v did not include the parent grant — a granted audit path is unreachable", granted.readOnly) + } + if slices.Contains(granted.read, "/granted/path") { + t.Fatalf("the parent READ grant leaked into ReadRoots %v, which is emitted as --add-dir (write) — a read grant became writable", granted.read) + } + + // Without the option, a dependency-free task gets nothing beyond its + // workspace — the wiring is the only source of the extra root. + var bare roots + ExecutePlan(context.Background(), plan, PlanReadOnlyToolNames(), capture(&bare), nil) + if len(bare.readOnly) != 0 { + t.Fatalf("a task with no grant and no dependencies received read-only roots %v", bare.readOnly) + } +} + +// The tool builds the read-roots option from its ExtraReadRoots hook at dispatch, +// and only when the hook returns something — an unwired or empty hook adds +// nothing, so every existing plan is byte-identical. +func TestExecOptionsCarryTheParentReadRoots(t *testing.T) { + plan := mustParsePlan(t, map[string]any{ + "name": "p", + "tasks": []any{map[string]any{"id": "a", "prompt": "look"}}, + "budget": map[string]any{"max_workers": float64(1)}, + }, Limits{MaxTasks: 20, ParentTools: PlanReadOnlyToolNames()}) + + applyOptions := func(tool *OrchestrateTool) execOptions { + var applied execOptions + for _, opt := range tool.execOptionsFor(plan, 0) { + opt(&applied) + } + return applied + } + + wired := applyOptions(&OrchestrateTool{ExtraReadRoots: func() []string { return []string{"/granted/path"} }}) + if !slices.Contains(wired.parentReadRoots, "/granted/path") { + t.Fatalf("a wired ExtraReadRoots hook never reached the executor: %v", wired.parentReadRoots) + } + if got := applyOptions(&OrchestrateTool{}); len(got.parentReadRoots) != 0 { + t.Fatalf("an unwired tool produced read roots %v", got.parentReadRoots) + } + if got := applyOptions(&OrchestrateTool{ExtraReadRoots: func() []string { return nil }}); len(got.parentReadRoots) != 0 { + t.Fatalf("an empty hook still added read roots %v", got.parentReadRoots) + } +} diff --git a/internal/specialist/plan_resume.go b/internal/specialist/plan_resume.go new file mode 100644 index 000000000..a769f4a06 --- /dev/null +++ b/internal/specialist/plan_resume.go @@ -0,0 +1,353 @@ +package specialist + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "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 + // Outputs holds the BOUNDED output each succeeded task recorded, so a + // resumed dependent can be briefed on what its completed dependency found — + // see RemainingPlan. Empty for a task that produced nothing, or one recorded + // before outputs were stored. + Outputs map[string]string + // Identities holds the fingerprint each succeeded task recorded when it ran, + // so RemainingPlan can tell a completed task that is UNCHANGED from one whose + // prompt, model, tools or dependencies were edited since. Absent for a task + // recorded before resume became identity-aware — which RemainingPlan treats + // as "unknown", matching by id alone exactly as it did before. + Identities map[string]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 +} + +// 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) + if out := planEventOutput(event); out != "" { + if progress.Outputs == nil { + progress.Outputs = map[string]string{} + } + progress.Outputs[id] = out + } + if identity := planEventIdentity(event); identity != "" { + if progress.Identities == nil { + progress.Identities = map[string]string{} + } + progress.Identities[id] = identity + } + } + 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 +} + +// planEventOutput reads the bounded task output stored on a task_completed +// event, empty when absent (an older event, or a task that produced nothing). +func planEventOutput(event sessions.Event) string { + var payload struct { + Output string `json:"output"` + } + _ = json.Unmarshal(event.Payload, &payload) + return payload.Output +} + +// planEventIdentity reads the task fingerprint stored on a task_completed event, +// empty when absent — an event recorded before resume became identity-aware. +func planEventIdentity(event sessions.Event) string { + var payload struct { + Identity string `json:"identity"` + } + _ = json.Unmarshal(event.Payload, &payload) + return payload.Identity +} + +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 still needs to run. +// +// A task is DONE — removed, and every reference to it removed with it — only +// when it SUCCEEDED and its fingerprint is UNCHANGED since (planDoneSet). A task +// whose prompt, model, tools or dependencies were edited since it ran has a +// different fingerprint, so it is NOT done: it stays in the plan and runs again, +// and so does everything that depends on it, because their input changed. That +// is the difference between resuming and replaying stale work. +// +// Leaving a done task's edge behind would produce a plan whose dependency names +// nothing — ParsePlan refuses that, correctly — so a done dependency is dropped +// and its finding folded into the dependent below. A dependency that is NOT done +// (it re-runs) keeps its edge, so the dependent still waits on the fresh result. +// +// 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) { + done := planDoneSet(plan, progress) + + 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 done[id] { + continue + } + if deps, ok := entry["depends_on"].([]any); ok { + remaining := make([]any, 0, len(deps)) + var completed []string + for _, dep := range deps { + name, _ := dep.(string) + if done[name] { + // A DEPENDENCY THAT IS DONE is not dropped silently: its finding + // is what this task was meant to build on, and after a resume the + // live results are gone. Its output is folded into this task's + // prompt below so the work survives the interruption instead of + // being invisible to the task that needed it. A dependency that + // is NOT done stays in the edge list — it re-runs, and this task + // must wait for its fresh result, not a stale briefing. + completed = append(completed, name) + continue + } + remaining = append(remaining, dep) + } + if len(remaining) == 0 { + delete(entry, "depends_on") + } else { + entry["depends_on"] = remaining + } + if brief := resumeDependencyBrief(completed, progress.Outputs); brief != "" { + entry["prompt"] = brief + planString(entry, "prompt") + } + } + 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) +} + +// planDoneSet reports which tasks need not run again: those that SUCCEEDED with a +// fingerprint UNCHANGED since they ran, AND whose dependencies are all likewise +// done. The dependency clause is what makes an edit cascade — a task that is +// itself unchanged but depends on an edited one is NOT done, because the edited +// dependency will produce a different result to build on. +// +// Computed over plan.Order(), which is topological: a task is visited only after +// its dependencies, so their done-ness is settled before this one is decided. A +// single forward pass is therefore enough to close "done" under the graph. +func planDoneSet(plan Plan, progress PlanProgress) map[string]bool { + succeeded := map[string]bool{} + for _, id := range progress.Succeeded { + succeeded[id] = true + } + byID := map[string]Task{} + for _, task := range plan.Tasks() { + byID[task.ID] = task + } + + done := map[string]bool{} + for _, id := range plan.Order() { + task, ok := byID[id] + if !ok { + continue + } + if !succeeded[id] || !taskUnchanged(task, progress) { + continue // never finished, or edited since — must run + } + allDepsDone := true + for _, dep := range task.DependsOn { + if !done[dep] { + allDepsDone = false + break + } + } + if allDepsDone { + done[id] = true + } + } + return done +} + +// ResumeChangedTasks returns the ids of tasks that SUCCEEDED in the prior run but +// will run again on resume because they — or a task they depend on — were edited +// since. It is empty for an ordinary resume; a resume notice uses it to explain +// why a task the user believes finished is back in the plan. +// +// Returned in execution order, so the explanation reads the way the plan runs. +func ResumeChangedTasks(plan Plan, progress PlanProgress) []string { + done := planDoneSet(plan, progress) + succeeded := map[string]bool{} + for _, id := range progress.Succeeded { + succeeded[id] = true + } + var changed []string + for _, id := range plan.Order() { + if succeeded[id] && !done[id] { + changed = append(changed, id) + } + } + return changed +} + +// taskUnchanged reports whether a task's current fingerprint matches the one it +// recorded when it ran. +// +// A task with NO recorded identity — one completed before resume became +// identity-aware — is treated as UNCHANGED, matched by id alone exactly as +// resume behaved before this existed. Treating an absent identity as a mismatch +// would make the first resume after upgrading re-run every completed task, which +// is the opposite of what a resume is for. +func taskUnchanged(task Task, progress PlanProgress) bool { + recorded, ok := progress.Identities[task.ID] + if !ok { + return true + } + return recorded == taskIdentity(task) +} + +// resumeDependencyBrief prefixes a resumed task with what its ALREADY-COMPLETED +// dependencies found, so a plan cut short mid-run does not lose the findings the +// remaining tasks were meant to build on. +// +// Deterministic order (the depends_on order the plan declared), so a resumed +// plan produces the same prompt twice. A completed dependency with no stored +// output — an older event, or a task that produced nothing — contributes a +// heading noting it completed, never a silent gap that reads as "it found +// nothing". +func resumeDependencyBrief(completed []string, outputs map[string]string) string { + if len(completed) == 0 { + return "" + } + var b strings.Builder + b.WriteString("## What the tasks you depend on found in an earlier run\n\n") + for _, id := range completed { + fmt.Fprintf(&b, "### Result of task %q (completed before this run)\n", id) + if out := strings.TrimSpace(outputs[id]); out != "" { + b.WriteString(out) + b.WriteString("\n") + } else { + b.WriteString("(completed, but its output was not recorded for resume)\n") + } + b.WriteString("\n") + } + b.WriteString("Use this instead of rediscovering it. Verify anything you rely on — a result above is a previous run's conclusion, not established fact.\n\n## Your task\n\n") + return b.String() +} diff --git a/internal/specialist/plan_resume_identity_test.go b/internal/specialist/plan_resume_identity_test.go new file mode 100644 index 000000000..bdf03402e --- /dev/null +++ b/internal/specialist/plan_resume_identity_test.go @@ -0,0 +1,160 @@ +package specialist + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/sessions" +) + +// chainPlan builds a → b → c with the given per-task prompts, so a test can +// "edit" a task by rebuilding the chain with one prompt changed. +func chainPlan(t *testing.T, prompts map[string]string) Plan { + t.Helper() + mk := func(id, dep string) map[string]any { + entry := map[string]any{"id": id, "prompt": prompts[id]} + if dep != "" { + entry["depends_on"] = []any{dep} + } + return entry + } + return mustParsePlan(t, map[string]any{ + "name": "chain", + "tasks": []any{mk("a", ""), mk("b", "a"), mk("c", "b")}, + "budget": map[string]any{"max_workers": float64(1)}, + }, Limits{MaxTasks: 20, ParentTools: PlanReadOnlyToolNames()}) +} + +// succeededProgress records every task of a plan as completed, with the identity +// and output it would have written when it ran — the state a resume reduces to +// after a full run. +func succeededProgress(plan Plan) PlanProgress { + progress := PlanProgress{Name: plan.Name(), Order: plan.Order(), + Identities: map[string]string{}, Outputs: map[string]string{}} + for _, task := range plan.Tasks() { + progress.Succeeded = append(progress.Succeeded, task.ID) + progress.Identities[task.ID] = taskIdentity(task) + progress.Outputs[task.ID] = "output of " + task.ID + } + return progress +} + +func taskByID(plan Plan) map[string]Task { + byID := map[string]Task{} + for _, task := range plan.Tasks() { + byID[task.ID] = task + } + return byID +} + +func chainLimits() Limits { return Limits{MaxTasks: 20, ParentTools: PlanReadOnlyToolNames()} } + +// C4, positive: an UNEDITED fully-succeeded plan has nothing to resume. This is +// what proves identity MATCHING works — a broken match would fail to recognise +// the unchanged tasks and stage the whole plan. +func TestResumeWithNoEditsHasNothingLeft(t *testing.T) { + plan := chainPlan(t, map[string]string{"a": "A", "b": "B", "c": "C"}) + if _, err := RemainingPlan(plan, succeededProgress(plan), chainLimits()); err == nil { + t.Fatal("an unedited, fully-succeeded plan staged a remainder — unchanged tasks are not recognised as done") + } +} + +// C4: editing the LEAF re-runs only the leaf; its done dependencies are dropped +// and folded in as briefings. +func TestResumeReRunsOnlyAnEditedLeaf(t *testing.T) { + orig := chainPlan(t, map[string]string{"a": "A", "b": "B", "c": "C"}) + progress := succeededProgress(orig) + + edited := chainPlan(t, map[string]string{"a": "A", "b": "B", "c": "C EDITED"}) + remaining, err := RemainingPlan(edited, progress, chainLimits()) + if err != nil { + t.Fatal(err) + } + if remaining.TaskCount() != 1 || remaining.Order()[0] != "c" { + t.Fatalf("editing only the leaf re-ran %v, want just c", remaining.Order()) + } + c := remaining.Tasks()[0] + if len(c.DependsOn) != 0 { + t.Fatalf("c still names its done dependency b: %v", c.DependsOn) + } + if !strings.Contains(c.Prompt, "output of b") { + t.Fatalf("the re-running leaf was not briefed on its done dependency:\n%s", c.Prompt) + } +} + +// C5: ResumeChangedTasks names exactly the edited task and everything downstream +// of it — the recovery diagnostic a resume notice reads. Editing the MIDDLE task +// reports b and c, never the untouched root a. +func TestResumeChangedTasksNamesEditedAndCascaded(t *testing.T) { + orig := chainPlan(t, map[string]string{"a": "A", "b": "B", "c": "C"}) + progress := succeededProgress(orig) + + edited := chainPlan(t, map[string]string{"a": "A", "b": "B EDITED", "c": "C"}) + changed := ResumeChangedTasks(edited, progress) + if len(changed) != 2 || changed[0] != "b" || changed[1] != "c" { + t.Fatalf("changed = %v, want [b c] — the edited task and its dependent, in order", changed) + } + // An unedited resume reports nothing changed. + if got := ResumeChangedTasks(orig, progress); len(got) != 0 { + t.Fatalf("an unedited resume reported %v as changed", got) + } +} + +// C4: editing the ROOT cascades — the whole chain re-runs, edges intact, and no +// re-running dependency is briefed as if it were done. +func TestResumeReRunsAnEditedRootAndEverythingDownstream(t *testing.T) { + orig := chainPlan(t, map[string]string{"a": "A", "b": "B", "c": "C"}) + progress := succeededProgress(orig) + + edited := chainPlan(t, map[string]string{"a": "A EDITED", "b": "B", "c": "C"}) + remaining, err := RemainingPlan(edited, progress, chainLimits()) + if err != nil { + t.Fatal(err) + } + if remaining.TaskCount() != 3 { + t.Fatalf("editing the root re-ran %d task(s), want the whole chain of 3: %v", remaining.TaskCount(), remaining.Order()) + } + byID := taskByID(remaining) + if got := byID["b"].DependsOn; len(got) != 1 || got[0] != "a" { + t.Fatalf("b lost its edge to the re-running root a: %v", got) + } + if got := byID["c"].DependsOn; len(got) != 1 || got[0] != "b" { + t.Fatalf("c lost its edge to b: %v", got) + } + if strings.Contains(byID["b"].Prompt, "output of a") { + t.Fatal("b was briefed on a's stale output even though a re-runs — the fresh result must come from the edge, not a briefing") + } +} + +// completedWithIdentity is completedEvent plus the recorded fingerprint, so a +// reduction test can drive the real event both surfaces write. +func completedWithIdentity(t *testing.T, seq int, id, output, identity string) sessions.Event { + t.Helper() + typ, payload := TaskCompletedEvent(TaskResult{ID: id, Outcome: TaskSucceeded, Output: output, Identity: identity}) + raw, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + return sessions.Event{Type: typ, Sequence: seq, Payload: raw} +} + +// C3: reduction captures each succeeded task's recorded identity, and a +// pre-identity event leaves NO entry (absent, not empty) so RemainingPlan can +// tell "unknown identity" from a real one. +func TestReduceCapturesEachTasksRecordedIdentity(t *testing.T) { + progress, ok := ReducePlanEvents([]sessions.Event{ + admittedEvent(t, 1, "p", []string{"find", "old"}), + completedWithIdentity(t, 2, "find", "out", "id-find"), + completedEvent(t, 3, "old", "out"), // recorded before identity existed + }) + if !ok { + t.Fatal("no plan reduced") + } + if progress.Identities["find"] != "id-find" { + t.Fatalf("identity not captured for find: %q", progress.Identities["find"]) + } + if _, present := progress.Identities["old"]; present { + t.Fatal("a pre-identity completion recorded an identity — it must be absent, so resume matches it by id alone") + } +} diff --git a/internal/specialist/plan_resume_output_test.go b/internal/specialist/plan_resume_output_test.go new file mode 100644 index 000000000..8d511aaf7 --- /dev/null +++ b/internal/specialist/plan_resume_output_test.go @@ -0,0 +1,135 @@ +package specialist + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/sessions" +) + +func completedEvent(t *testing.T, seq int, id, output string) sessions.Event { + t.Helper() + typ, payload := TaskCompletedEvent(TaskResult{ID: id, Outcome: TaskSucceeded, Output: output}) + raw, _ := json.Marshal(payload) + return sessions.Event{Type: typ, Sequence: seq, Payload: raw} +} + +func admittedEvent(t *testing.T, seq int, name string, order []string) sessions.Event { + t.Helper() + raw, _ := json.Marshal(map[string]any{"name": name, "order": order}) + return sessions.Event{Type: sessions.EventPlanAdmitted, Sequence: seq, Payload: raw} +} + +// A RESUMED DEPENDENT IS BRIEFED ON WHAT ITS COMPLETED DEPENDENCY FOUND. +// +// Before: TaskCompletedEvent stored no output and RemainingPlan stripped the +// completed dependency, so a plan cut short mid-run lost the finding the +// remaining task was meant to build on. Now the bounded output rides the event, +// the reducer captures it, and RemainingPlan folds it into the dependent's +// prompt. +func TestAResumedDependentSeesItsCompletedDependencysFinding(t *testing.T) { + const finding = "The retry watchdog resets on every event (plan_watchdog.go:66)." + events := []sessions.Event{ + admittedEvent(t, 1, "audit", []string{"find", "synth"}), + completedEvent(t, 2, "find", finding), + } + progress, ok := ReducePlanEvents(events) + if !ok { + t.Fatal("reduce found no plan") + } + if progress.Outputs["find"] != finding { + t.Fatalf("the reducer lost the output: %q", progress.Outputs["find"]) + } + + plan := mustParsePlan(t, map[string]any{ + "name": "audit", + "tasks": []any{ + map[string]any{"id": "find", "prompt": "find it"}, + map[string]any{"id": "synth", "prompt": "combine the findings", "depends_on": []any{"find"}}, + }, + "budget": map[string]any{"max_workers": float64(1)}, + }, Limits{MaxTasks: 20, ParentTools: PlanReadOnlyToolNames()}) + + remaining, err := RemainingPlan(plan, progress, Limits{MaxTasks: 20, ParentTools: PlanReadOnlyToolNames()}) + if err != nil { + t.Fatalf("RemainingPlan: %v", err) + } + if remaining.TaskCount() != 1 { + t.Fatalf("expected only synth to remain, got %d tasks", remaining.TaskCount()) + } + synth := remaining.Tasks()[0] + if synth.ID != "synth" { + t.Fatalf("the wrong task remained: %s", synth.ID) + } + if !strings.Contains(synth.Prompt, finding) { + t.Fatalf("the resumed task was not briefed on its dependency's finding:\n%s", synth.Prompt) + } + if !strings.Contains(synth.Prompt, "combine the findings") { + t.Fatal("the task's own prompt was lost") + } +} + +// The stored output is BOUNDED, so a large plan's log does not balloon. +func TestTheStoredResumeOutputIsCapped(t *testing.T) { + huge := strings.Repeat("F", resumeOutputCap*3) + _, payload := TaskCompletedEvent(TaskResult{ID: "t", Outcome: TaskSucceeded, Output: huge}) + stored, _ := payload["output"].(string) + if len(stored) != resumeOutputCap { + t.Fatalf("stored %d chars, want the cap %d", len(stored), resumeOutputCap) + } + // A task that produced nothing stores nothing — no empty key. + _, empty := TaskCompletedEvent(TaskResult{ID: "t", Outcome: TaskSucceeded, Output: " "}) + if _, present := empty["output"]; present { + t.Fatal("an empty output was stored") + } +} + +// A COMPLETED DEPENDENCY WITH NO STORED OUTPUT still gets a heading, never a +// silent gap that reads as "it found nothing". +func TestAResumedDependencyWithoutOutputIsStillNamed(t *testing.T) { + // An older event: completed, but no output field. + old := sessions.Event{Type: sessions.EventTaskCompleted, Sequence: 2} + rawOld, _ := json.Marshal(map[string]any{"id": "find"}) + old.Payload = rawOld + + progress, _ := ReducePlanEvents([]sessions.Event{ + admittedEvent(t, 1, "audit", []string{"find", "synth"}), + old, + }) + plan := mustParsePlan(t, map[string]any{ + "name": "audit", + "tasks": []any{ + map[string]any{"id": "find", "prompt": "find"}, + map[string]any{"id": "synth", "prompt": "combine", "depends_on": []any{"find"}}, + }, + "budget": map[string]any{"max_workers": float64(1)}, + }, Limits{MaxTasks: 20, ParentTools: PlanReadOnlyToolNames()}) + remaining, _ := RemainingPlan(plan, progress, Limits{MaxTasks: 20, ParentTools: PlanReadOnlyToolNames()}) + synth := remaining.Tasks()[0] + if !strings.Contains(synth.Prompt, "find") || !strings.Contains(synth.Prompt, "not recorded") { + t.Fatalf("a completed-but-unrecorded dependency was not named:\n%s", synth.Prompt) + } +} + +// A plan with no completed dependencies is unchanged — the brief only appears +// when there is completed work to carry. +func TestAResumeWithNoCompletedDepsAddsNoBrief(t *testing.T) { + progress, _ := ReducePlanEvents([]sessions.Event{ + admittedEvent(t, 1, "p", []string{"a", "b"}), + }) + plan := mustParsePlan(t, map[string]any{ + "name": "p", + "tasks": []any{ + map[string]any{"id": "a", "prompt": "alpha"}, + map[string]any{"id": "b", "prompt": "beta", "depends_on": []any{"a"}}, + }, + "budget": map[string]any{"max_workers": float64(1)}, + }, Limits{MaxTasks: 20, ParentTools: PlanReadOnlyToolNames()}) + remaining, _ := RemainingPlan(plan, progress, Limits{MaxTasks: 20, ParentTools: PlanReadOnlyToolNames()}) + for _, task := range remaining.Tasks() { + if strings.Contains(task.Prompt, "earlier run") { + t.Fatalf("a brief was added with no completed deps:\n%s", task.Prompt) + } + } +} diff --git a/internal/specialist/plan_resume_test.go b/internal/specialist/plan_resume_test.go new file mode 100644 index 000000000..3ee4e0649 --- /dev/null +++ b/internal/specialist/plan_resume_test.go @@ -0,0 +1,231 @@ +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") + } +} + +// 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) + } + // The resume narrowing re-includes a failed task — asserted through the real + // gate (RemainingPlan), not a query helper the resume path never calls. + remaining, err := RemainingPlan(plan, progress, readOnlyLimits()) + if err != nil { + t.Fatal(err) + } + var found bool + for _, task := range remaining.Tasks() { + if task.ID == "a" { + found = true + } + } + if !found { + t.Fatalf("a failed task must be re-run on resume") + } +} + +// 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()} + _, 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/specialist/plan_retry_test.go b/internal/specialist/plan_retry_test.go new file mode 100644 index 000000000..c67ff8e4b --- /dev/null +++ b/internal/specialist/plan_retry_test.go @@ -0,0 +1,354 @@ +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"]) + } +} + +// 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/specialist/plan_router_spend_test.go b/internal/specialist/plan_router_spend_test.go new file mode 100644 index 000000000..7363808ef --- /dev/null +++ b/internal/specialist/plan_router_spend_test.go @@ -0,0 +1,218 @@ +package specialist + +import ( + "context" + "os" + "strings" + "testing" +) + +// ROUTER SPEND IS THE PLAN'S SPEND. +// +// auto_assign makes a frontier-model call to decide which model runs each task, +// BEFORE any task starts and outside the executor entirely — plan_tool routes at +// its own call site and only then reaches ExecutePlanIn. Its tokens therefore +// reached neither the budget nor the report, and the code said so in as many +// words: "the plan's reported spend is under its real spend by exactly this +// much, every run, invisibly." +// +// Measured across 17 real sessions: 7,860 to 10,513 tokens per plan. +const measuredRouterTokens = 10_513 + +func routerSpendPlan(t *testing.T) Plan { + t.Helper() + return mustParsePlan(t, map[string]any{ + "name": "p", + "tasks": []any{map[string]any{"id": "a", "prompt": "look"}}, + "budget": map[string]any{"max_workers": float64(1)}, + }, Limits{MaxTasks: 20, ParentTools: PlanReadOnlyToolNames()}) +} + +func succeedingRun(tokens int) PlanRunner { + return func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + return TaskResult{ID: req.Task.ID, Outcome: TaskSucceeded, Output: "done", Tokens: tokens}, nil + } +} + +// THE REPORTED TOTAL MUST INCLUDE IT. This is the number a person reads to +// decide whether a plan was worth what it cost. +func TestTheReportedSpendIncludesWhatRoutingCost(t *testing.T) { + const taskTokens = 500_000 + + without := ExecutePlan(context.Background(), routerSpendPlan(t), + PlanReadOnlyToolNames(), succeedingRun(taskTokens), nil) + if without.TokensUsed != taskTokens { + t.Fatalf("an unrouted plan reported %d, want %d", without.TokensUsed, taskTokens) + } + + with := ExecutePlan(context.Background(), routerSpendPlan(t), + PlanReadOnlyToolNames(), succeedingRun(taskTokens), nil, + WithPreSpentTokens(measuredRouterTokens)) + if want := taskTokens + measuredRouterTokens; with.TokensUsed != want { + t.Fatalf("a routed plan reported %d, want %d — routing is still invisible", + with.TokensUsed, want) + } +} + +// A PLAN THAT NEVER ROUTED IS UNCHANGED, byte for byte. auto_assign is off by +// default and most plans never route at all. +func TestAPlanThatDidNotRouteIsUnaffected(t *testing.T) { + for _, tokens := range []int{0, -1} { + report := ExecutePlan(context.Background(), routerSpendPlan(t), + PlanReadOnlyToolNames(), succeedingRun(1000), nil, WithPreSpentTokens(tokens)) + if report.TokensUsed != 1000 { + t.Fatalf("WithPreSpentTokens(%d) changed an unrouted plan's total to %d", tokens, report.TokensUsed) + } + } +} + +// THE BUDGET SEES THE SAME NUMBER THE REPORT DOES. If the report counted router +// spend and the limit did not, "budget exhausted at N/M" would print an N +// counting something never charged against M — two spellings of one quantity, +// free to drift. +func TestTheBudgetIsReducedByWhatRoutingAlreadySpent(t *testing.T) { + for _, tc := range []struct { + name string + budget int + preSpent int + want int64 + }{ + {"ordinary", 1_000_000, 10_513, 989_487}, + {"unbounded stays unbounded", 0, 10_513, 0}, + {"nothing pre-spent", 1_000_000, 0, 1_000_000}, + // A POSITIVE BUDGET NEVER BECOMES UNBOUNDED: limit <= 0 means "no bound" + // everywhere in planSpend, so a plan that has already overspent must + // floor at 1 and refuse its next task, not have its bound switched off. + {"pre-spend equals the budget", 10_513, 10_513, 1}, + {"pre-spend exceeds the budget", 5_000, 10_513, 1}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := planSpendLimit(tc.budget, tc.preSpent); got != tc.want { + t.Fatalf("planSpendLimit(%d, %d) = %d, want %d", tc.budget, tc.preSpent, got, tc.want) + } + }) + } +} + +// The floor is not cosmetic: at limit 1 the very next task must be refused. +// Switching the bound off instead would do the exact opposite of what an +// already-overspent plan needs. +func TestAnAlreadyOverspentPlanStillRefusesItsNextTask(t *testing.T) { + overspent := &planSpend{limit: planSpendLimit(5_000, 10_513)} + if !overspent.overPool(overspent.add(1_000, false), false) { + t.Fatal("a plan that had already overspent its budget accepted more work") + } + // And an unbounded plan is still unbounded after the same treatment. + unbounded := &planSpend{limit: planSpendLimit(0, 10_513)} + if unbounded.overPool(unbounded.add(9_000_000, false), false) { + t.Fatal("an unbounded plan grew a ceiling nobody asked for") + } +} + +// SEEDED, NOT ADDED AT THE END. Every early return between the seed and the end +// reports whatever TokensUsed holds, so a total corrected only on the success +// path would be wrong on exactly the runs a reader most wants the truth about. +func TestACancelledPlanStillReportsWhatRoutingCost(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + run := func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + cancel() + return TaskResult{ID: req.Task.ID, Outcome: TaskCancelled, Output: "stopped"}, context.Canceled + } + report := ExecutePlan(ctx, routerSpendPlan(t), PlanReadOnlyToolNames(), run, nil, + WithPreSpentTokens(measuredRouterTokens)) + if report.TokensUsed < measuredRouterTokens { + t.Fatalf("a cancelled plan reported %d, losing the %d routing already cost", + report.TokensUsed, measuredRouterTokens) + } +} + +// BOTH EXECUTION PATHS. plan_tool runs a plan in the foreground and in the +// background from two different call sites; wiring one and not the other would +// make the reported total correct only for whichever the user happened to pick. +func TestBothExecutionPathsChargeRouterSpend(t *testing.T) { + // Both paths now build their options through ONE builder, so the check is + // that the builder is what they use — and that it always charges the spend. + // (It previously counted a literal WithPreSpentTokens at each call site; + // the shared builder made that string appear once, and the test failed while + // the behaviour was correct.) + source := readFileForTest(t, "plan_tool.go") + calls := strings.Count(source, "ExecutePlanIn(") + built := strings.Count(source, "tool.execOptionsFor(plan, routerTokens)...") + if calls != built { + t.Fatalf("plan_tool.go has %d ExecutePlanIn call(s) but builds options for %d of them", calls, built) + } + + var applied execOptions + for _, opt := range (&OrchestrateTool{}).execOptionsFor(routerSpendPlan(t), measuredRouterTokens) { + opt(&applied) + } + if applied.preSpent != measuredRouterTokens { + t.Fatalf("the shared builder does not charge router spend: preSpent=%d", applied.preSpent) + } +} + +// readFileForTest reads a source file in this package. +// +// Used only by the both-paths check above, which asserts on the CALL SITES +// rather than on behaviour: the two paths are foreground and background, and +// exercising the background one needs a launcher and a context that outlives the +// turn. A source-level check is weaker than a behavioural one and is chosen +// deliberately over asserting nothing about the second path at all. +func readFileForTest(t *testing.T, name string) string { + t.Helper() + body, err := os.ReadFile(name) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + return string(body) +} + +// AND THE EXECUTOR MUST USE IT. The table above asserts planSpendLimit's +// arithmetic and proves nothing about whether ExecutePlanIn consults it — a +// mutation that built the meter from the raw budget passed it cleanly. +// +// So this runs a real two-task plan against a real budget: the first task spends +// most of it, and the second must be SKIPPED once routing has already taken its +// share, while the identical plan without a pre-spend still affords it. +func TestThePreSpendActuallyChangesWhatTheExecutorAffords(t *testing.T) { + const budget = 200_000 + const firstTask = 150_000 + const routed = 60_000 + + plan := func() Plan { + return mustParsePlan(t, map[string]any{ + "name": "p", + "tasks": []any{ + map[string]any{"id": "a", "prompt": "first"}, + map[string]any{"id": "b", "prompt": "second"}, + }, + // max_workers 1 so "a" has finished and been counted before "b" is + // considered; at 2 they dispatch together and neither is gated. + "budget": map[string]any{"max_workers": float64(1), "max_tokens": float64(budget)}, + }, Limits{MaxTasks: 20, ParentTools: PlanReadOnlyToolNames()}) + } + run := func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + spent := firstTask + if req.Task.ID == "b" { + spent = 1_000 + } + // Report through the plan's own meter, exactly as the real runner does. + if req.Spend != nil { + req.Spend.add(spent, req.WaitsOnOtherTasks) + } + return TaskResult{ID: req.Task.ID, Outcome: TaskSucceeded, Output: "done", Tokens: spent}, nil + } + + affordable := ExecutePlan(context.Background(), plan(), PlanReadOnlyToolNames(), run, nil) + if affordable.Skipped != 0 { + t.Fatalf("setup: without a pre-spend the plan already skipped %d task(s); the budget is too tight to show anything", + affordable.Skipped) + } + + squeezed := ExecutePlan(context.Background(), plan(), PlanReadOnlyToolNames(), run, nil, + WithPreSpentTokens(routed)) + if squeezed.Skipped == 0 { + t.Fatalf("routing spent %d of a %d budget and the plan afforded exactly as much work as before: "+ + "the pre-spend never reached the meter", routed, budget) + } +} diff --git a/internal/specialist/plan_runner.go b/internal/specialist/plan_runner.go new file mode 100644 index 000000000..f0a33fcc2 --- /dev/null +++ b/internal/specialist/plan_runner.go @@ -0,0 +1,614 @@ +package specialist + +import ( + "context" + "fmt" + "sort" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/Gitlawb/zero/internal/measurements" + "github.com/Gitlawb/zero/internal/streamjson" + "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 + // 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 + // 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 +} + +// planTaskDescriptionPrefix labels a plan task's child session. +// +// ONE SPELLING, because worker_view reads it back to tell a plan task from a +// plain Task call. Two copies of a magic string is invariant 5 — the writer +// would be free to change without the reader noticing, and the only symptom +// would be every plan task quietly re-labelled as a direct delegation. +const planTaskDescriptionPrefix = "plan task " + +// 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, req PlanTaskRequest) (TaskResult, error) { + task, grantedTools := req.Task, req.Tools + 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 + } + + // 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) + // Zero leaves the production rule in place; only a test sets it. + watchdog.poll = req.StallPoll + taskCtx, cancelTask := context.WithCancel(ctx) + defer cancelTask() + 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{} + // THE TASK'S OWN NUMBERS, read from the commands it actually ran. Every + // tool result a child emits streams through here, which is the only place + // the parent ever sees what a task's commands printed. + measured := measurements.NewLedger() + // WHAT THIS TASK ACTUALLY CHANGED, accumulated from the child's own tool + // results. The handoff below is built from this rather than from prose, + // which matters twice: it costs nothing, and it cannot be invented — a + // file is on this list because a tool reported changing it. + var changedMu sync.Mutex + changedFiles := map[string]bool{} + counted := func(event streamjson.Event) { + if event.Type == streamjson.EventToolCall { + toolCalls.Add(1) + } + if event.Type == streamjson.EventToolResult { + measured.Record(event.Output) + if len(event.ChangedFiles) > 0 { + changedMu.Lock() + for _, file := range event.ChangedFiles { + changedFiles[file] = true + } + changedMu.Unlock() + } + } + 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.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))) + cancelTask() + } + } + if req.Progress != nil { + req.Progress(event) + } + } + + started := time.Now() + 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, + Description: planTaskDescriptionPrefix + task.ID, + Manifest: &manifest, + }, TaskRunOptions{ + // Per-call, from the tool's RunOptions — see PlanTaskRequest. + 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 + // the line that puts its children there. + Cwd: planTaskCwd(planCtx.Cwd, req.Cwd), + PermissionMode: planCtx.PermissionMode, + // THE PLAN'S SCRATCHPAD, so a task handed a truncated excerpt can + // open the whole answer its dependency wrote. Read-only: the + // executor is the sole writer there. + ExtraReadRoots: req.ReadRoots, + // The parent's request_permissions READ grants, on their own read-only + // flag, so a plan auditing a granted external path can read it without + // gaining write access (ExtraReadRoots above is emitted as --add-dir). + ReadOnlyRoots: req.ReadOnlyRoots, + // 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: 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, + // What it actually ran on, carried back so the report can say so. + Model: task.Model, + Duration: time.Since(started), + SessionID: res.SessionID, + // Carried so the executor can tell a task IT killed from one the OS + // killed — see TaskResult.Signal. + Signal: res.Signal, + // The id travels in SessionID, not in the prose. See + // WithoutSessionIDLine: a plan task's output is quoted into the + // report, into the dependency briefing every downstream task reads, + // and into the panel — so the line BuildFinalResult prepends for a + // Task caller surfaces a raw child session id inside a user-facing + // answer, three times over. + Output: WithoutSessionIDLine(res.Result.Output, res.SessionID), + // 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, + } + // 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. + // CHECKED AFTER THE CHILD HAS FINISHED, against the output it is handing + // back. Only conflicts are recorded, so an honest task carries nothing. + for _, conflict := range measured.Conflicts(result.Output) { + result.MeasurementConflicts = append(result.MeasurementConflicts, + fmt.Sprintf("%s: reported %s, but this task's own commands printed %s", + conflict.Name, formatConflictSeconds(conflict.Claimed), formatRecorded(conflict.Recorded))) + } + if reason, ok := overspent.Load().(string); ok && reason != "" { + result.Outcome = TaskCancelled + result.Err = reason + // A CUT-SHORT TASK MUST HAND SOMETHING ON. + // + // withDependencyBriefing already carries a cancelled task's output to + // its dependents, labelled INCOMPLETE — so the machinery for a + // follow-up task to pick this work up exists. It was fed nothing: a + // task killed mid-edit-loop has written no prose, so Output was empty + // and a measured run lost 858,231 tokens of real work with no record + // of what it had touched. + // + // NOT A MODEL CALL. The child's process is already gone by the time the + // meter trips, so a summary turn would mean resuming a task that just + // overspent — paying more for the report than the report is worth, and + // asking for prose from an agent whose last act was being stopped. The + // file list is free, exact, and is the thing a continuation actually + // needs: where the work got to. + changedMu.Lock() + handoff := planTaskHandoff(changedFiles, result.Output) + changedMu.Unlock() + result.Output = handoff + 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. + // + // 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.Stalled = true + result.Err = stallError(task.ID, watchdog.timeout, 1).Error() + return result, nil + } + 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 { + // 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 + 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 + // THE PROVIDER FAILED, NOT THE TASK. Same structural signal, same + // reason it is worth another attempt: nothing about the work produced + // this, so repeating the work is not what repeating the request buys. + result.ProviderFailed = res.ExitCode == childExitProvider + 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 + return result, nil + } +} + +// 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 { + 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. +// planManifestFilePath marks a manifest authored by the plan path. It is the +// provenance autoTaskModel keys on: a plan task's model was already decided by +// the plan tool's own assignment, and must not be re-decided at dispatch. +const planManifestFilePath = "(plan)" + +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: 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 + // 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: planTaskSystemPrompt(grantedTools), + Location: LocationBuiltin, + FilePath: planManifestFilePath, + // 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, + } +} + +// 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 { + // The two rules below are the plan-task half of the posture's evidence + // contract (agent.ZeromaxingEvidenceNotice), which a plan task does NOT + // receive: the contract rides the loop's posture reminders, and a task runs as + // a child process that is never handed --exec-profile, so it starts with the + // posture off. The rest of that contract was already here in substance — + // claims backed by what was read, quoted file:line, an honest "not found" — + // and these are what was missing. + // + // Stated here rather than shared with that constant, because this package + // CANNOT import agent: agent's own test binary imports specialist, so the + // reference would compile and then break `go test ./internal/agent` with a + // cycle — the same trap PostureReasoningEffort above exists to avoid. The + // framing differs anyway: that notice addresses an agent being pushed back on + // by a person, this addresses a task writing into a report nobody re-derives. + // TestAPlanTaskCarriesTheEvidenceRules pins the rules, not the wording. + 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. A passing test is not proof that a property holds: when you " + + "rest a claim on one, name the test and say in one sentence what it would still pass with. " + + "Any number you report — a timing, a count, a percentage — must come from a command you ran " + + "in this run; if you did not run it, say so rather than stating a figure the plan's report " + + "will carry as measured. " + 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 +} + +// 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) +} + +// formatConflictSeconds and formatRecorded render a measurement for a reader of +// the plan's report, where the numbers sit beside prose rather than in a table. +func formatConflictSeconds(value float64) string { + return strconv.FormatFloat(value, 'f', -1, 64) + "s" +} + +func formatRecorded(values []float64) string { + parts := make([]string, 0, len(values)) + for _, value := range values { + parts = append(parts, formatConflictSeconds(value)) + } + return strings.Join(parts, ", ") +} + +// planTaskHandoff describes where a cut-short task got to, so a follow-up task +// does not start blind. +// +// APPENDED TO WHATEVER THE TASK DID SAY, never replacing it: a task that wrote +// prose before it was stopped has already said something worth keeping, and the +// file list is an addition to that rather than a substitute for it. +func planTaskHandoff(changed map[string]bool, existing string) string { + if len(changed) == 0 { + return existing + } + files := make([]string, 0, len(changed)) + for file := range changed { + files = append(files, file) + } + sort.Strings(files) + + var b strings.Builder + if trimmed := strings.TrimSpace(existing); trimmed != "" { + b.WriteString(trimmed) + b.WriteString("\n\n") + } + b.WriteString("This task was stopped before it finished. It had already changed these files:\n") + for _, file := range files { + b.WriteString("- ") + b.WriteString(file) + b.WriteString("\n") + } + b.WriteString("Their contents on disk are where the work got to — read them before continuing, " + + "because they may be mid-change and need not compile.") + return b.String() +} diff --git a/internal/specialist/plan_runner_test.go b/internal/specialist/plan_runner_test.go new file mode 100644 index 000000000..0494162a1 --- /dev/null +++ b/internal/specialist/plan_runner_test.go @@ -0,0 +1,50 @@ +package specialist + +import ( + "strings" + "testing" +) + +// A PLAN TASK CARRIES THE EVIDENCE RULES, because it does not inherit them. +// +// The posture's contract (agent.ZeromaxingEvidenceNotice) rides the turn loop's +// reminders, and a plan task runs as a child process that BuildArgs never hands +// --exec-profile — so it starts with the posture off and the contract never +// reaches it. Most of the substance was already in this prompt; two rules were +// not, and both are ones a task can violate straight into the plan's report. +// +// Pinned on the RULES, not the wording, so the text can be rewritten freely and +// a deletion still fails. +func TestAPlanTaskCarriesTheEvidenceRules(t *testing.T) { + for _, grant := range [][]string{ + {"read_file", "grep"}, // read-only task + {"read_file", "edit_file"}, // write-capable task + } { + prompt := planTaskSystemPrompt(grant) + for _, required := range []string{ + // Already present, and must stay: a claim needs something read. + "backed by something you read in this run", + "file:line", + "not found", + // The two that were missing. + "passing test is not proof", + "what it would still pass with", + "must come from a command you ran", + } { + if !strings.Contains(prompt, required) { + t.Errorf("grant %v: the plan-task prompt no longer says %q", grant, required) + } + } + } +} + +// The rules must reach the CHILD, not merely exist in a helper. The manifest is +// what the child process is actually launched with. +func TestTheEvidenceRulesReachTheTaskManifest(t *testing.T) { + manifest := planTaskManifest("explorer", "", "", []string{"read_file"}) + for _, required := range []string{"passing test is not proof", "must come from a command you ran"} { + if !strings.Contains(manifest.SystemPrompt, required) { + t.Errorf("the manifest handed to the child does not carry %q", required) + } + } +} 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_scratchpad.go b/internal/specialist/plan_scratchpad.go new file mode 100644 index 000000000..684ade63d --- /dev/null +++ b/internal/specialist/plan_scratchpad.go @@ -0,0 +1,146 @@ +package specialist + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +// Where a plan's tasks leave their full answers for each other. +// +// THE PROBLEM IT SOLVES. A dependent task is handed an EXCERPT of what its +// dependencies found — bounded, because a twenty-task chain inheriting every +// ancestor's full output is a context-overflow machine. Measured: a code-review +// child produced 18,349 characters and a dependent saw 4,000 of them. The other +// 78% existed, was paid for, and was unreachable. +// +// THE DESIGN THAT AVOIDS EVERY TRAP. The obvious build — give tasks a shared +// directory and let them write to it — fails twice over in this codebase: +// +// - A READ-ONLY PLAN TASK HAS NO WRITE TOOL. planReadOnlyTools is read_file, +// grep, glob, list_directory, lsp_navigate. Handing those tasks a scratchpad +// to write into hands them something they cannot use, and the plans that most +// need this — parallel finders feeding a synthesiser — are exactly the +// read-only ones. +// - GRANTING THEM ONE CHANGES WHAT A READ-ONLY PLAN IS. RequiresIsolation() +// keys on precisely `!planReadOnlyTools[name]`, so adding a write tool would +// silently make every scratchpad-using plan demand a git worktree. +// +// So NO TASK WRITES HERE. The EXECUTOR does, single-threaded, as each result +// comes back — and dependents read with the read_file they already hold. Write +// contention is not locked against; it cannot arise, because there is exactly +// one writer. The excerpt stops being lossy and becomes a summary with the whole +// thing one tool call behind it. +// +// DISPOSABLE, and deliberately not memory: memory is durable, user-facing and +// believed by later sessions. This is one plan's working notes and it is deleted +// when the plan ends. + +// scratchpadTaskIDPattern is an ALLOW-LIST, because a task id becomes a +// filename. Enumerating what is permitted is the rule this repo already applies +// to plan names and memory names; a deny-list of traversal sequences is the +// pattern that has leaked here repeatedly. +var scratchpadTaskIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`) + +// Scratchpad is one plan run's directory of task outputs. +// +// A nil *Scratchpad is a working value: every method tolerates it and does +// nothing, so a plan that never got one behaves exactly as it did before this +// existed. +type Scratchpad struct { + root string +} + +// NewScratchpad creates a plan's directory. +// +// UNDER THE OS TEMP DIR, which is already a default sandbox write root on every +// platform (defaultTempWriteRootCandidatesForGOOS: /tmp and $TMPDIR on unix, +// TEMP/TMP on Windows). So the executor can write here without the plan holding +// any grant it did not already have — and the tasks still need the root added to +// their own scope to READ it, which is what PlanTaskRequest.ScratchpadRoot is +// for. +func NewScratchpad(planName string) (*Scratchpad, error) { + root, err := os.MkdirTemp("", "zero-plan-"+sanitizeScratchpadName(planName)+"-") + if err != nil { + return nil, fmt.Errorf("create a scratchpad for plan %q: %w", planName, err) + } + return &Scratchpad{root: root}, nil +} + +// Root is the directory, or "" when there is none. +func (pad *Scratchpad) Root() string { + if pad == nil { + return "" + } + return pad.root +} + +// Record persists one task's FULL output and returns the path to it. +// +// Returns "" with no error when there is no scratchpad or nothing to record — +// the caller then simply mentions nothing, rather than pointing a dependent at a +// file that does not exist. +func (pad *Scratchpad) Record(taskID, output string) (string, error) { + if pad == nil || pad.root == "" || strings.TrimSpace(output) == "" { + return "", nil + } + if !scratchpadTaskIDPattern.MatchString(taskID) { + // A task id that cannot be spelled as a filename is not an error worth + // failing the plan for — the task still ran and its excerpt still + // reaches its dependents. It simply gets no file. + return "", nil + } + path := filepath.Join(pad.root, taskID+".md") + if err := os.WriteFile(path, []byte(output), 0o600); err != nil { + return "", fmt.Errorf("record task %q output: %w", taskID, err) + } + return path, nil +} + +// Release deletes the whole directory. Safe to call twice and on nil, because +// it runs from a defer on a path that also has an error return. +func (pad *Scratchpad) Release() { + if pad == nil || pad.root == "" { + return + } + _ = os.RemoveAll(pad.root) + pad.root = "" +} + +// sanitizeScratchpadName keeps a plan's name out of the path unless it is +// obviously safe. The temp-file suffix guarantees uniqueness either way, so +// dropping an awkward name costs only readability. +func sanitizeScratchpadName(name string) string { + cleaned := strings.Map(func(r rune) rune { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_': + return r + default: + return -1 + } + }, name) + if len(cleaned) > 32 { + cleaned = cleaned[:32] + } + if cleaned == "" { + return "plan" + } + return cleaned +} + +// scratchpadPointer is the line appended to a truncated excerpt. +// +// ONLY WHEN TRUNCATED. A dependent that already received the whole answer has +// nothing to go and read, and a path in every briefing is noise that trains the +// reader to ignore the one that matters. +func scratchpadPointer(path string, fullLength int) string { + if path == "" { + return "" + } + return fmt.Sprintf( + "[The excerpt above is truncated. The COMPLETE output of this task — %d characters — is at %s. "+ + "Read that file if you need more than the excerpt, rather than re-deriving it.]", + fullLength, path) +} diff --git a/internal/specialist/plan_scratchpad_test.go b/internal/specialist/plan_scratchpad_test.go new file mode 100644 index 000000000..28beffe4f --- /dev/null +++ b/internal/specialist/plan_scratchpad_test.go @@ -0,0 +1,292 @@ +package specialist + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +// THE MEASURED LOSS THIS CLOSES. A code-review child produced 18,349 characters +// and a dependent task received 4,000 of them. The rest existed, was paid for, +// and was unreachable. +const measuredChildReport = 18_349 + +func TestATruncatedExcerptNowPointsAtTheWholeAnswer(t *testing.T) { + pad, err := NewScratchpad("audit") + if err != nil { + t.Fatal(err) + } + defer pad.Release() + + full := strings.Repeat("E", measuredChildReport) + path, err := pad.Record("by_name", full) + if err != nil { + t.Fatal(err) + } + if path == "" { + t.Fatal("nothing was recorded") + } + + results := map[string]TaskResult{ + "by_name": {ID: "by_name", Outcome: TaskSucceeded, Output: full, ScratchpadPath: path}, + } + brief := withDependencyBriefingBudget(Task{ID: "synth", DependsOn: []string{"by_name"}}, results, 4000, 12000) + + if !strings.Contains(brief, path) { + t.Fatalf("the dependent was truncated and never told where the rest is:\n%s", brief) + } + if !strings.Contains(brief, "18349") { + t.Fatalf("the briefing does not say how much it is holding back:\n%s", brief) + } + // And the file really holds the whole thing. + onDisk, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if len(onDisk) != measuredChildReport { + t.Fatalf("recorded %d characters, the task produced %d", len(onDisk), measuredChildReport) + } +} + +// A briefing that was NOT truncated has nothing to point at, and a path in every +// briefing trains the reader to ignore the one that matters. +func TestAnUntruncatedBriefingCarriesNoPointer(t *testing.T) { + pad, err := NewScratchpad("small") + if err != nil { + t.Fatal(err) + } + defer pad.Release() + path, err := pad.Record("find", "short answer") + if err != nil { + t.Fatal(err) + } + results := map[string]TaskResult{ + "find": {ID: "find", Outcome: TaskSucceeded, Output: "short answer", ScratchpadPath: path}, + } + brief := withDependencyBriefingBudget(Task{ID: "s", DependsOn: []string{"find"}}, results, 4000, 12000) + if strings.Contains(brief, path) { + t.Fatalf("an untruncated briefing carried a pointer:\n%s", brief) + } +} + +// WITHOUT A SCRATCHPAD, THE OLD SENTENCE. Every existing plan must be unchanged. +func TestWithoutAScratchpadTheBriefingIsExactlyWhatItWas(t *testing.T) { + results := map[string]TaskResult{ + "find": {ID: "find", Outcome: TaskSucceeded, Output: strings.Repeat("E", 9000)}, + } + brief := withDependencyBriefingBudget(Task{ID: "s", DependsOn: []string{"find"}}, results, 4000, 12000) + if !strings.Contains(brief, "[truncated — re-read the files named above if you need more]") { + t.Fatalf("the pre-existing truncation notice is gone:\n%s", brief) + } +} + +// A nil scratchpad is a working value on every method — the path every caller +// that never asked for one takes. +func TestANilScratchpadIsAWorkingValue(t *testing.T) { + var pad *Scratchpad + if root := pad.Root(); root != "" { + t.Fatalf("nil scratchpad reported root %q", root) + } + path, err := pad.Record("t", "output") + if err != nil || path != "" { + t.Fatalf("nil scratchpad recorded %q (err %v)", path, err) + } + pad.Release() + pad.Release() + if roots := scratchpadReadRoots(pad); roots != nil { + t.Fatalf("nil scratchpad granted %v", roots) + } +} + +// A TASK ID BECOMES A FILENAME, so it is allow-listed. Model-supplied ids reach +// this, and a deny-list of traversal sequences is the pattern that has leaked +// here repeatedly. +func TestATaskIDThatCannotBeAFilenameIsRefusedNotEscaped(t *testing.T) { + pad, err := NewScratchpad("p") + if err != nil { + t.Fatal(err) + } + defer pad.Release() + for _, id := range []string{ + "../escape", "a/b", "..", ".", "with space", "semi;colon", + strings.Repeat("x", 65), "", "nul\x00byte", + } { + path, err := pad.Record(id, "payload") + if err != nil { + t.Fatalf("id %q returned an error rather than declining: %v", id, err) + } + if path != "" { + t.Fatalf("id %q was accepted as a filename: %q", id, path) + } + } + // Nothing escaped the directory. + entries, err := os.ReadDir(pad.Root()) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("refused ids still wrote %d file(s)", len(entries)) + } +} + +// Release actually deletes it — a plan's working notes are disposable, and a +// directory per plan run that never went away would accumulate silently. +func TestReleaseDeletesTheWholeDirectory(t *testing.T) { + pad, err := NewScratchpad("p") + if err != nil { + t.Fatal(err) + } + root := pad.Root() + if _, err := pad.Record("a", "x"); err != nil { + t.Fatal(err) + } + pad.Release() + if _, err := os.Stat(root); !os.IsNotExist(err) { + t.Fatalf("the scratchpad survived release: %v", err) + } + if pad.Root() != "" { + t.Fatal("a released scratchpad still reports a root") + } +} + +// THE GRANT MUST REACH ARGV. A read root on a struct that never becomes +// --add-dir is a directory the child cannot open — the exact "layer B does not +// carry it" defect this branch has produced repeatedly. +func TestTheScratchpadReadRootReachesTheChildsArgv(t *testing.T) { + pad, err := NewScratchpad("p") + if err != nil { + t.Fatal(err) + } + defer pad.Release() + + executor := Executor{ + BinaryPath: "/bin/true", + NewSessionID: func() (string, error) { return "specialist_00000000000000000000000a", nil }, + Load: func(LoadOptions) (LoadResult, error) { return LoadResult{}, nil }, + } + built, err := executor.BuildArgs(BuildArgsInput{ + Prompt: "look", + Cwd: t.TempDir(), + CurrentDepth: 0, + PermissionMode: "auto", + ExtraReadRoots: []string{pad.Root()}, + }) + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + joined := strings.Join(built.Args, " ") + if !strings.Contains(joined, pad.Root()) { + t.Fatalf("the scratchpad never reached argv, so the child cannot read it:\n%s", joined) + } + if !strings.Contains(joined, "--add-dir") { + t.Fatalf("no --add-dir in argv:\n%s", joined) + } +} + +// END TO END: a real plan, a real executor seam, and a dependent that can open +// what its dependency wrote. +func TestAPlanRunLeavesEachTasksFullOutputReadableByItsDependent(t *testing.T) { + plan := mustParsePlan(t, map[string]any{ + "name": "research", + "tasks": []any{ + map[string]any{"id": "find", "prompt": "look"}, + map[string]any{"id": "synth", "prompt": "combine", "depends_on": []any{"find"}}, + }, + "budget": map[string]any{"max_workers": float64(1)}, + }, Limits{MaxTasks: 20, ParentTools: PlanReadOnlyToolNames()}) + + full := strings.Repeat("E", measuredChildReport) + var synthPrompt string + var synthRoots []string + var readBack int + var readErr error + run := func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + if req.Task.ID == "synth" { + synthPrompt = req.Task.Prompt + synthRoots = req.ReadRoots + // READ IT HERE, which is what a real dependent does: the scratchpad + // lives for the PLAN, and reading it after ExecutePlan returns would + // be asserting that a disposable directory was not disposed of. + for _, field := range strings.Fields(synthPrompt) { + candidate := strings.Trim(field, ".,]") + if !strings.HasSuffix(candidate, "find.md") { + continue + } + var body []byte + body, readErr = os.ReadFile(candidate) + readBack = len(body) + if len(synthRoots) == 1 && !strings.HasPrefix(candidate, synthRoots[0]) { + t.Errorf("the named path %q is outside the granted root %q", candidate, synthRoots[0]) + } + } + return TaskResult{ID: "synth", Outcome: TaskSucceeded, Output: "done"}, nil + } + return TaskResult{ID: req.Task.ID, Outcome: TaskSucceeded, Output: full}, nil + } + + report := ExecutePlan(context.Background(), plan, PlanReadOnlyToolNames(), run, nil, WithScratchpad()) + if report.Failed != 0 { + t.Fatalf("plan failed: %+v", report) + } + if len(synthRoots) != 1 { + t.Fatalf("the dependent was granted %v, want exactly the scratchpad", synthRoots) + } + if readErr != nil { + t.Fatalf("the dependent could not open what it was pointed at: %v", readErr) + } + if readBack != measuredChildReport { + t.Fatalf("the dependent read %d characters, the task produced %d", readBack, measuredChildReport) + } + // And the excerpt it was handed really was smaller than the whole answer, + // or the pointer solved nothing. + if strings.Count(synthPrompt, "E") >= measuredChildReport { + t.Fatal("the excerpt was not truncated, so this proves nothing about the pointer") + } +} + +// The plan's directory does not outlive the plan. +func TestAFinishedPlanLeavesNoScratchpadBehind(t *testing.T) { + plan := mustParsePlan(t, map[string]any{ + "name": "research", + "tasks": []any{map[string]any{"id": "find", "prompt": "look"}}, + "budget": map[string]any{"max_workers": float64(1)}, + }, Limits{MaxTasks: 20, ParentTools: PlanReadOnlyToolNames()}) + + var root string + run := func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + if len(req.ReadRoots) == 1 { + root = req.ReadRoots[0] + } + return TaskResult{ID: req.Task.ID, Outcome: TaskSucceeded, Output: "x"}, nil + } + ExecutePlan(context.Background(), plan, PlanReadOnlyToolNames(), run, nil, WithScratchpad()) + + if root == "" { + t.Fatal("no scratchpad was created, so this proves nothing") + } + if _, err := os.Stat(root); !os.IsNotExist(err) { + t.Fatalf("the scratchpad outlived the plan at %s", root) + } +} + +// A cancelled task's partial findings are evidence — plan_exec already says so +// where it briefs dependents — so they are kept for the same reason. +func TestACancelledTasksPartialOutputIsStillRecorded(t *testing.T) { + pad, err := NewScratchpad("p") + if err != nil { + t.Fatal(err) + } + defer pad.Release() + path, err := pad.Record("cut-short", "found three things before I was stopped") + if err != nil { + t.Fatal(err) + } + if path == "" { + t.Fatal("a cut-short task's findings were discarded") + } + if filepath.Dir(path) != pad.Root() { + t.Fatalf("recorded outside the scratchpad: %s", path) + } +} diff --git a/internal/specialist/plan_session_id_test.go b/internal/specialist/plan_session_id_test.go new file mode 100644 index 000000000..483ee3e78 --- /dev/null +++ b/internal/specialist/plan_session_id_test.go @@ -0,0 +1,157 @@ +package specialist + +import ( + "context" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/streamjson" +) + +// A CHILD'S SESSION ID IS NOT PART OF ITS ANSWER. +// +// BuildFinalResult prefixes "session_id: " so a Task caller can continue the +// child it started. A plan task has no such caller: the plan owns its children's +// lifetimes, TaskResult.SessionID already carries the id structurally, and the +// output is quoted into the report under "result:", pasted into the dependency +// briefing every downstream task reads, and rendered in the plan panel. So the +// line surfaced a raw session id in the middle of a user-facing answer three +// times over, and told every dependent task to treat it as a finding. +func TestAPlanTasksOutputCarriesNoSessionIDLine(t *testing.T) { + const id = "01JXYZ0000000000000000" + output := sessionIDLinePrefix + id + "\nThe watchdog resets on every event.\nSee plan_watchdog.go:66." + + cleaned := WithoutSessionIDLine(output, id) + if strings.Contains(cleaned, "session_id") { + t.Fatalf("the session id is still in the answer: %q", cleaned) + } + if !strings.HasPrefix(cleaned, "The watchdog resets") { + t.Fatalf("the answer itself was damaged: %q", cleaned) + } + if !strings.Contains(cleaned, "plan_watchdog.go:66") { + t.Fatalf("the rest of the answer was lost: %q", cleaned) + } +} + +// KEYED ON THE ID WE WERE HANDED, never on the pattern. +// +// Invariant 9 — fuzzy matching must not silently rewrite the wrong span. A child +// whose own prose opens with a line that merely LOOKS like the prefix must come +// through whole; the only line that may be cut is the one this package wrote. +func TestOnlyTheLineWeWroteIsRemoved(t *testing.T) { + for _, tc := range []struct { + name string + output string + sessionID string + }{ + { + name: "a different id is not ours to cut", + output: "session_id: 999\nfindings follow", + sessionID: "01JXYZ", + }, + { + name: "the child quoting the prefix mid-answer", + output: "The tool prints session_id: 01JXYZ when it starts.\nThat is the id to poll.", + sessionID: "01JXYZ", + }, + { + name: "our id appearing as a longer token", + output: "session_id: 01JXYZEXTRA\nfindings follow", + sessionID: "01JXYZ", + }, + { + name: "no id at all means nothing is cut", + output: "session_id: 01JXYZ\nfindings follow", + sessionID: "", + }, + } { + t.Run(tc.name, func(t *testing.T) { + if got := WithoutSessionIDLine(tc.output, tc.sessionID); got != tc.output { + t.Fatalf("rewrote a span it does not own:\n before: %q\n after: %q", tc.output, got) + } + }) + } +} + +// An output that is ONLY the session line leaves nothing behind, rather than a +// stray newline the report would render as a blank "result:". +func TestAnOutputThatIsOnlyTheSessionLineBecomesEmpty(t *testing.T) { + const id = "01JXYZ" + if got := WithoutSessionIDLine(sessionIDLinePrefix+id, id); got != "" { + t.Fatalf("expected nothing left, got %q", got) + } + if got := WithoutSessionIDLine(sessionIDLinePrefix+id+"\n", id); got != "" { + t.Fatalf("expected nothing left, got %q", got) + } +} + +// THE TASK PATH STILL GETS IT. Stripping it everywhere would break the thing it +// exists for: a parent that started a sub-agent and needs to continue it by id. +func TestTheTaskToolStillReceivesTheSessionID(t *testing.T) { + events, err := ParseStream(strings.NewReader(strings.Join([]string{ + `{"schemaVersion":2,"type":"run_start","runId":"run_1","sessionId":"01JXYZ","cwd":"/repo"}`, + `{"schemaVersion":2,"type":"final","runId":"run_1","text":"done"}`, + `{"schemaVersion":2,"type":"run_end","runId":"run_1","status":"success","exitCode":0}`, + "", + }, "\n"))) + if err != nil { + t.Fatalf("ParseStream returned error: %v", err) + } + result := BuildFinalResult(events, "", 0, "") + if !strings.Contains(result.Output, "session_id: 01JXYZ") { + t.Fatalf("a Task caller can no longer continue its child: %q", result.Output) + } + // And the plan boundary is what removes it — same output, stripped by id. + if got := WithoutSessionIDLine(result.Output, "01JXYZ"); got != "done" { + t.Fatalf("the plan boundary left %q", got) + } +} + +// ASSERTED AT THE CALLER, not at the helper. +// +// Every test above proves WithoutSessionIDLine does the right thing to a string. +// None of them proves the plan runner CALLS it — the exact defect class this +// repo keeps hitting: a value exists at one layer, is consumed at another, and +// the layer between does not carry it. So this runs a real plan task through the +// real executor seam and reads TaskResult.Output. +func TestThePlanRunnerStripsTheSessionIDFromWhatItHandsBack(t *testing.T) { + const childSession = "specialist_00000000000000000000000a" + executor := Executor{ + BinaryPath: "/bin/true", + NewSessionID: func() (string, error) { return childSession, nil }, + Load: func(LoadOptions) (LoadResult, error) { return LoadResult{}, nil }, + RunChild: func(_ context.Context, _ string, _ []string, progress func(streamjson.Event)) (ChildRunResult, error) { + events := []streamjson.Event{ + {Type: streamjson.EventRunStart, SessionID: childSession}, + {Type: streamjson.EventFinal, Text: "The watchdog resets on every event."}, + {Type: streamjson.EventRunEnd, Status: "success"}, + } + for _, event := range events { + if progress != nil { + progress(event) + } + } + return ChildRunResult{Started: true, ExitCode: 0, Events: events}, nil + }, + } + run := NewPlanRunner(PlanTaskContext{Executor: executor, Cwd: t.TempDir(), SpecialistName: "explorer"}) + + result, err := run(context.Background(), PlanTaskRequest{ + Task: Task{ID: "by_name", Prompt: "find it"}, + Tools: []string{"grep"}, + }) + if err != nil { + t.Fatalf("the task failed: %v", err) + } + // The premise: the id really did reach the runner, so the assertion below is + // about stripping rather than about an id that was never there. + if result.SessionID != childSession { + t.Fatalf("setup: the runner did not receive the child's id, got %q", result.SessionID) + } + if strings.Contains(result.Output, "session_id") { + t.Fatalf("the raw child session id is in what the report, the briefing and the panel all quote:\n%q", result.Output) + } + if !strings.Contains(result.Output, "The watchdog resets") { + t.Fatalf("the answer itself did not survive: %q", result.Output) + } +} diff --git a/internal/specialist/plan_signal_kill_test.go b/internal/specialist/plan_signal_kill_test.go new file mode 100644 index 000000000..47ab78afb --- /dev/null +++ b/internal/specialist/plan_signal_kill_test.go @@ -0,0 +1,220 @@ +package specialist + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/Gitlawb/zero/internal/streamjson" +) + +// A TASK THE PLAN KILLED MUST NOT BE REPORTED AS A TASK THAT FAILED. +// +// THE MEASURED RUN. Two plan tasks died at 300,017ms and 300,066ms — 49ms apart, +// dispatched together, on a plan with max_tokens 0 and no max_wall_seconds. The +// report said "cancelled: 0, failed: 2", and each carried: +// +// "Subagent terminated by a signal (signal: killed) — it was killed before it +// finished. Common causes: an out-of-memory kill, a timeout, or cancellation; +// check the signal to tell which." +// +// A guess list, printed by the component that had made the decision. The reader +// concluded their machine had run out of memory and went to raise sub-agent +// limits that were never involved. +// +// THE MECHANISM. osexec.CommandContext with no WaitDelay SIGKILLs the child when +// its context is cancelled, and Executor.Run returns that as a StatusError +// result with a NIL error. So `concluded := err == nil && !result.Stalled` was +// true for a task the plan had just killed, cutShort was false, and it fell +// through to TaskFailed. + +func signalKilledPlan(t *testing.T) Plan { + t.Helper() + return mustParsePlan(t, map[string]any{ + "name": "audit", + "tasks": []any{map[string]any{"id": "checker", "prompt": "audit this"}}, + "budget": map[string]any{"max_workers": float64(1)}, + }, Limits{MaxTasks: 20, ParentTools: PlanReadOnlyToolNames()}) +} + +// killedByItsOwnContext reproduces the shape exactly: the context is cancelled, +// the child comes back signal-terminated, and the error is nil. +func killedByItsOwnContext(t *testing.T) PlanRunner { + t.Helper() + return func(ctx context.Context, req PlanTaskRequest) (TaskResult, error) { + cancelParent, ok := ctx.Value(planCancelKey{}).(context.CancelFunc) + if ok { + cancelParent() + // Let the cancellation land before returning, as a real child's + // teardown would. + <-ctx.Done() + } + return TaskResult{ + ID: req.Task.ID, + Outcome: TaskFailed, + Signal: "signal: killed", + Err: "Subagent terminated by a signal (signal: killed) — it was killed before it finished. " + + "Common causes: an out-of-memory kill, a timeout, or cancellation; check the signal to tell which.", + }, nil + } +} + +type planCancelKey struct{} + +func TestATaskThePlanKilledIsReportedAsCancelledNotFailed(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + ctx = context.WithValue(ctx, planCancelKey{}, context.CancelFunc(cancel)) + + report := ExecutePlan(ctx, signalKilledPlan(t), PlanReadOnlyToolNames(), killedByItsOwnContext(t), nil) + + if report.Cancelled != 1 || report.Failed != 0 { + t.Fatalf("a task the plan killed was reported as cancelled=%d failed=%d, want cancelled=1 failed=0", + report.Cancelled, report.Failed) + } +} + +// AND THE REASON MUST BE NAMED, not guessed. The guess list is the thing that +// sent a reader to the wrong fix. +func TestACancelledTasksReasonNamesWhatStoppedIt(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + ctx = context.WithValue(ctx, planCancelKey{}, context.CancelFunc(cancel)) + + var got TaskResult + run := func(taskCtx context.Context, req PlanTaskRequest) (TaskResult, error) { + result, err := killedByItsOwnContext(t)(taskCtx, req) + return result, err + } + recorder := recordingPlanRecorder(&got) + ExecutePlan(ctx, signalKilledPlan(t), PlanReadOnlyToolNames(), run, recorder) + + if !strings.Contains(got.Err, "cancelled") { + t.Fatalf("the reason does not say the task was cancelled: %q", got.Err) + } + if strings.Contains(got.Err, "Common causes") || strings.Contains(got.Err, "out-of-memory") { + t.Fatalf("the guess list survived into a reason the plan actually knows: %q", got.Err) + } + // The signal is still reported — as a DETAIL of a stated reason, not in + // place of one. + if !strings.Contains(got.Err, "signal: killed") { + t.Fatalf("the signal was dropped entirely: %q", got.Err) + } +} + +// A WALL-BUDGET STOP SAYS SO. Two stops that feel identical to the child are +// different facts to the reader: one is the plan spending what it was allowed. +func TestAWallExpiredKillNamesTheWallBudget(t *testing.T) { + plan := mustParsePlan(t, map[string]any{ + "name": "audit", + "tasks": []any{map[string]any{"id": "checker", "prompt": "audit this"}}, + "budget": map[string]any{"max_workers": float64(1), "max_wall_seconds": float64(1)}, + }, Limits{MaxTasks: 20, ParentTools: PlanReadOnlyToolNames()}) + + var got TaskResult + run := func(ctx context.Context, req PlanTaskRequest) (TaskResult, error) { + // Outlive the wall budget, then come back signal-killed with a nil error. + select { + case <-ctx.Done(): + case <-time.After(3 * time.Second): + } + return TaskResult{ + ID: req.Task.ID, Outcome: TaskFailed, Signal: "signal: killed", + Err: "Subagent terminated by a signal (signal: killed) — Common causes: an out-of-memory kill…", + }, nil + } + report := ExecutePlan(context.Background(), plan, PlanReadOnlyToolNames(), run, recordingPlanRecorder(&got)) + + if report.Cancelled != 1 { + t.Fatalf("a wall-expired task was reported cancelled=%d failed=%d", report.Cancelled, report.Failed) + } + if !strings.Contains(got.Err, "max_wall_seconds") { + t.Fatalf("the reason does not name the wall budget: %q", got.Err) + } +} + +// A CHILD KILLED BY SOMETHING OUTSIDE THE PLAN IS STILL A FAILURE, and its +// message is still the honest guess list — because there the plan genuinely does +// not know. Widening the cancelled class must not swallow real kills. +func TestAnExternalKillIsStillAFailureWithItsOriginalReason(t *testing.T) { + original := "Subagent terminated by a signal (signal: killed) — it was killed before it finished. " + + "Common causes: an out-of-memory kill, a timeout, or cancellation." + var got TaskResult + run := func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + // Signal-killed, but NOTHING cancelled the plan's context. + return TaskResult{ID: req.Task.ID, Outcome: TaskFailed, Signal: "signal: killed", Err: original}, nil + } + report := ExecutePlan(context.Background(), signalKilledPlan(t), PlanReadOnlyToolNames(), run, recordingPlanRecorder(&got)) + + if report.Failed != 1 || report.Cancelled != 0 { + t.Fatalf("an outside kill was reported cancelled=%d failed=%d, want failed=1", report.Cancelled, report.Failed) + } + if got.Err != original { + t.Fatalf("the honest guess list was rewritten for a kill the plan did not make:\n %q", got.Err) + } +} + +// A task that ran to the end and reported its own failure is untouched — the +// case the original `concluded` guard exists to protect. +func TestATaskThatFailedOnItsOwnIsStillAFailure(t *testing.T) { + var got TaskResult + run := func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + return TaskResult{ID: req.Task.ID, Outcome: TaskFailed, Err: "the build does not compile"}, nil + } + report := ExecutePlan(context.Background(), signalKilledPlan(t), PlanReadOnlyToolNames(), run, recordingPlanRecorder(&got)) + if report.Failed != 1 || report.Cancelled != 0 { + t.Fatalf("a self-reported failure was reclassified: cancelled=%d failed=%d", report.Cancelled, report.Failed) + } + if got.Err != "the build does not compile" { + t.Fatalf("its own reason was overwritten: %q", got.Err) + } +} + +// recordingPlanRecorder captures the terminal result the executor reports, which +// is what a reader actually sees — the report's counts and the task's own reason. +type capturingRecorder struct{ into *TaskResult } + +func (r capturingRecorder) TaskDispatched(Task) {} +func (r capturingRecorder) TaskCompleted(result TaskResult) { *r.into = result } +func (r capturingRecorder) TaskFailed(result TaskResult) { *r.into = result } + +func recordingPlanRecorder(into *TaskResult) PlanRecorder { return capturingRecorder{into: into} } + +// THE SIGNAL MUST TRAVEL, not just be honoured once it arrives. +// +// Every test above hands the executor a TaskResult with Signal already set, so +// they prove the classification and nothing about the plumbing that fills it. A +// mutation deleting `Signal: run.Signal` from ExecResult passed all of them +// cleanly — the child's signal never reached TaskResult and no test noticed. +// This drives the real executor seam instead. +func TestTheChildsSignalReachesTheTaskResult(t *testing.T) { + const childSession = "specialist_00000000000000000000000a" + executor := Executor{ + BinaryPath: "/bin/true", + NewSessionID: func() (string, error) { return childSession, nil }, + Load: func(LoadOptions) (LoadResult, error) { return LoadResult{}, nil }, + RunChild: func(_ context.Context, _ string, _ []string, progress func(streamjson.Event)) (ChildRunResult, error) { + events := []streamjson.Event{{Type: streamjson.EventRunStart, SessionID: childSession}} + for _, event := range events { + if progress != nil { + progress(event) + } + } + // A child SIGKILLed by its own context: exit -1, a signal, nil error. + return ChildRunResult{Started: true, ExitCode: -1, Signal: "signal: killed", Events: events}, nil + }, + } + run := NewPlanRunner(PlanTaskContext{Executor: executor, Cwd: t.TempDir(), SpecialistName: "explorer"}) + + result, _ := run(context.Background(), PlanTaskRequest{ + Task: Task{ID: "checker", Prompt: "audit"}, + Tools: []string{"grep"}, + }) + if result.Signal == "" { + t.Fatal("the child's signal never reached TaskResult: the executor cannot tell a kill it made from one it did not") + } + if !strings.Contains(result.Signal, "killed") { + t.Fatalf("Signal = %q, want the child's own signal description", result.Signal) + } +} 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_store.go b/internal/specialist/plan_store.go new file mode 100644 index 000000000..5f7b39f14 --- /dev/null +++ b/internal/specialist/plan_store.go @@ -0,0 +1,304 @@ +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" + +// planTempExt is the extension SavePlan's in-progress file carries. Deliberately +// NOT planFileExt: ListPlans matches that exactly, so a temp file a crash left +// behind is never offered as a saved plan. +const planTempExt = ".tmp" + +// 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 +} + +// 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 { + Name string + Description string + TaskCount int + Path string + 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 +// 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. + // + // THE BYTES GO TO THE TEMP FILE, so that is the path that has to be safe — + // dir and path were both checked and the write went to neither. Creating it + // with os.CreateTemp rather than checking a fixed .json.tmp buys two + // things a check could not: + // + // O_EXCL. Lstat-then-write leaves a window: a symlink planted between the + // check and the write is followed, and "save my plan" becomes the + // file-overwrite primitive the checks above exist to prevent. O_EXCL + // refuses to open anything that already exists, symlink or not, so there + // is no window to hit. + // + // A unique name. Two saves of the same plan shared one .json.tmp and + // stomped each other's bytes; whichever renamed second won with a file the + // other had half-written. + // + // The random suffix keeps the ".tmp" extension, and ListPlans matches + // planFileExt exactly, so a temp file left by a crash is never listed as a + // plan. + file, err := os.CreateTemp(dir, name+".*"+planTempExt) + if err != nil { + return "", fmt.Errorf("create a temporary file in %s: %w", dir, err) + } + temp := file.Name() + // Named before any early return: every failure below has to remove it. + writeErr := func() error { + if _, err := file.Write(append(body, '\n')); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + return file.Close() + }() + if writeErr != nil { + _ = file.Close() + _ = os.Remove(temp) + return "", writeErr + } + // CreateTemp makes the file 0600 already; this is belt-and-braces against a + // umask-sensitive platform, and it runs before the rename so the plan is + // never briefly world-readable under its real name. + if err := os.Chmod(temp, 0o600); err != nil { + _ = os.Remove(temp) + return "", fmt.Errorf("set permissions on %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{} + // 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 + } + 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 + } + scope := PlanScopeUser + if project { + scope = PlanScopeProject + } + plans = append(plans, SavedPlan{ + Name: name, + Description: planString(args, "description"), + TaskCount: savedTaskCount(args), + Path: path, + Scope: scope, + 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..13be7729f --- /dev/null +++ b/internal/specialist/plan_store_test.go @@ -0,0 +1,684 @@ +package specialist + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "sync" + "testing" + + "github.com/Gitlawb/zero/internal/config" + "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(500_000), + "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) + } + plans = onlyOnDisk(plans) + if len(plans) != 2 { + 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() { + 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 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) + } +} + +// 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 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) + } + // 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) + } +} + +// 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) + } +} + +// 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") + } +} + +// 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 went to .json.tmp, a fixed name 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. +// +// The requirement is that the target is NOT overwritten. SavePlan is free to +// succeed while satisfying it, and now does: os.CreateTemp picks a name nobody +// can predict and opens it O_EXCL, so the planted link is never opened at all. +// That is stronger than refusing a symlink at a fixed name, which still leaves +// the window between the check and the open. +func TestSavingNeverWritesThroughASymlinkedTempFile(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) + } + // The old fixed temp name, planted. .json itself is absent, so the two + // existing checks both pass and this is the only thing in the way. + if err := os.Symlink(target, filepath.Join(dir, "sweep"+planFileExt+planTempExt)); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + + if _, err := SavePlan(dir, "sweep", savedPlanFixture(t)); err != nil { + t.Fatalf("SavePlan: %v", err) + } + 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) + } +} + +// TWO SAVES OF ONE PLAN MUST NOT STOMP EACH OTHER'S BYTES. +// +// The temp file used to be a fixed .json.tmp, so concurrent saves wrote +// into the same file and whichever renamed second published a plan the other had +// half-written. Every save must land a parseable plan, and nothing may be left +// behind in the directory but the plan itself. +func TestConcurrentSavesOfOnePlanDoNotCorruptIt(t *testing.T) { + dir := t.TempDir() + plan := savedPlanFixture(t) + + var wait sync.WaitGroup + errs := make([]error, 8) + for i := range errs { + wait.Add(1) + go func(i int) { + defer wait.Done() + _, errs[i] = SavePlan(dir, "sweep", plan) + }(i) + } + wait.Wait() + + // WHAT IS ASSERTED IS NON-CORRUPTION, not that every racer wins. + // + // Publishing is os.Rename, which on Windows is MoveFileEx with + // REPLACE_EXISTING; concurrent replaces of one destination can come back as a + // sharing violation there. That is a platform property this test cannot + // verify from any other OS, so asserting all eight succeed would be claiming + // something unchecked — and would turn into an intermittent failure on the + // one job that cannot be reproduced locally. + // + // Elsewhere every save must succeed: nothing should be able to lose. + landed := 0 + for i, err := range errs { + switch { + case err == nil: + landed++ + case runtime.GOOS == "windows": + t.Logf("save %d lost the rename race: %v", i, err) + default: + t.Fatalf("save %d: %v", i, err) + } + } + if landed == 0 { + t.Fatal("no concurrent save landed at all") + } + + if _, err := FindSavedPlan(PlanPaths{UserDir: dir}, "sweep"); err != nil { + t.Fatalf("the saved plan does not parse after concurrent saves: %v", err) + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if filepath.Ext(entry.Name()) == planTempExt { + t.Errorf("a temporary file survived a successful save: %s", entry.Name()) + } + } +} diff --git a/internal/specialist/plan_strict_lists_test.go b/internal/specialist/plan_strict_lists_test.go new file mode 100644 index 000000000..10d5783d0 --- /dev/null +++ b/internal/specialist/plan_strict_lists_test.go @@ -0,0 +1,250 @@ +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. +// +// THE PLAN IS SEEDED FIRST, and that is the whole difference between this test +// and a tautology. Against an empty directory resolveSavedPlan fails because +// "sweep" does not exist, so every case here returned a non-nil error and passed +// whether or not the override policy existed at all. With the plan present, the +// only thing left that can refuse is the policy — which is why the reason is +// asserted too, not just the failure. +func TestASavedPlanStillRefusesInlineContent(t *testing.T) { + dir := t.TempDir() + if _, err := SavePlan(dir, "sweep", savedPlanFixture(t)); err != nil { + t.Fatalf("seed the saved plan: %v", err) + } + tool := &OrchestrateTool{Plans: PlanPaths{UserDir: dir}} + + // The control: the seeded plan resolves cleanly on its own, so a failure + // below cannot be blamed on the fixture. + if _, err := tool.resolveSavedPlan(map[string]any{"saved": "sweep"}); err != nil { + t.Fatalf("the seeded plan does not resolve, so nothing below proves anything: %v", err) + } + + 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) + continue + } + if !strings.Contains(err.Error(), field) { + t.Errorf("%q was refused for a reason that does not name it, so the refusal may not be the override policy: %v", field, err) + } + } +} + +// 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") + } +} + +// THE TOOL TEACHES THE CONFLICT/RELAXATION SPLIT, because merging them is how a +// relaxation stops being reported. +// +// A measured run built to a specification, listed its requirement conflicts +// cleanly, and filed no relaxation at all — while having lowered a one-million +// bound to ten thousand, cut a sixty-second soak to five, and excluded a latency +// class from its own numbers. Each reached the reader as a cell in a results +// table. Nothing here parses or enforces the split; the tool description is the +// only place a plan author reads it, so its absence is the whole regression. +func TestTheToolTeachesConflictsAndRelaxationsApart(t *testing.T) { + tasks, ok := (&OrchestrateTool{}).Parameters().Properties["tasks"] + if !ok { + t.Fatal("the tasks property is missing") + } + for _, required := range []string{ + // Both names, so an author can tell which they are looking at. + "CONFLICT", + "RELAXATION", + // What separates them. + "spec disagreeing with itself", + "work coming in under the spec", + // The reporting rule that was actually violated. + "never only a cell in a results table", + // ...and that a defensible relaxation is still reported. + "even when it was the right call", + } { + if !strings.Contains(tasks.Description, required) { + t.Errorf("the tasks description no longer teaches %q", required) + } + } +} diff --git a/internal/specialist/plan_template.go b/internal/specialist/plan_template.go new file mode 100644 index 000000000..9a844e04b --- /dev/null +++ b/internal/specialist/plan_template.go @@ -0,0 +1,261 @@ +package specialist + +import ( + "fmt" + "sort" + "strings" +) + +// Plan shapes a model can ask for by name instead of composing from scratch. +// +// THE FAILURE THIS REMOVES. The model deciding to run a plan is not the problem +// — system_prompt.go already tells it when to delegate, and a measured run +// spawned a background sub-agent with the words "sub-agent", "spawn" and +// "parallel" appearing nowhere in the prompt. The problem is what happens next: +// it emits plan JSON that admission refuses, and a turn and a model call are +// spent learning a rule. A real run emitted budget.max_tokens_per_task: 5000 and +// was refused for sitting below the floor. +// +// SO THIS IS A GENERATOR, NOT A CLASSIFIER. Nothing here decides WHETHER to +// plan. It only turns "audit this" into a task graph that admission accepts. +// +// DETERMINISTIC, AND THAT IS THE MULTI-PROVIDER ARGUMENT. An LLM generator needs +// reliable structured output, which across this catalogue means JSON mode on +// some providers, forced tool-use on others and prose-with-fences on the rest — +// twenty-one of its entries are OpenAI-compatible gateways whose capabilities +// are whatever the upstream vendor supports that day. A template needs nothing +// from the provider, so it behaves identically on all of them, including a local +// Ollama that reports no capabilities at all. +// +// EVERY TEMPLATE IS RE-VALIDATED THROUGH ParsePlan before it runs. These emit +// the same args a model would; they are not a second admission path. + +// PlanTemplate is one named shape. +type PlanTemplate struct { + // Name is what a caller asks for. + Name string + // WhenToUse is one line, for the tool description. Templates a model cannot + // tell apart are templates it picks at random. + WhenToUse string + // Params are the values it needs, in the order a caller would give them. + Params []string + // build emits orchestrate arguments. Never called with a missing param — + // BuildTemplatePlan checks first, so this cannot silently produce a plan + // with an empty subject. + build func(params map[string]string) map[string]any +} + +// planTemplates are the shapes that recur. DELIBERATELY FEW: a template exists +// here when its task graph is genuinely reusable, and inventing one per phrasing +// would leave a list nobody can choose from. +var planTemplates = []PlanTemplate{ + { + Name: "audit", + WhenToUse: "Examine one subject from several independent angles, then verify the findings before reporting them.", + Params: []string{"subject"}, + build: func(params map[string]string) map[string]any { + subject := params["subject"] + return map[string]any{ + "name": "audit", + "description": "Hostile audit of " + subject, + "tasks": []any{ + templateTask("correctness", "find", "Find CORRECTNESS defects in "+subject+ + ". Report each as file:line, what is wrong, and a concrete reproduction. Prove it from the code; do not report what you suspect.", nil), + templateTask("safety", "find", "Find SAFETY and authority defects in "+subject+ + ": missing checks, trust placed in untrusted input, anything that fails open. Report each as file:line with the path that reaches it.", nil), + templateTask("tests", "find", "Find what TESTS "+subject+ + ", and name any test that asserts nothing meaningful. A behaviour with no test is itself a finding.", nil), + templateTask("refute", "verify", "Try to REFUTE every finding above. Default to refuted when uncertain. "+ + "For each: open the cited file:line and check the claim is what the code actually does. Report VERIFIED with the line that proves it, or REFUTED with the line that disproves it.", + []string{"correctness", "safety", "tests"}), + templateTask("report", "answer", "Report the findings that SURVIVED refutation, most severe first, each with its file:line and reproduction. "+ + "Say plainly how many were refuted and dropped — a reader who is not told the count cannot tell a clean audit from a shallow one.", + []string{"refute"}), + }, + "budget": map[string]any{"max_workers": float64(3)}, + } + }, + }, + { + Name: "compare", + WhenToUse: "Establish how two things differ, by examining each on its own before comparing them.", + Params: []string{"before", "after"}, + build: func(params map[string]string) map[string]any { + before, after := params["before"], params["after"] + return map[string]any{ + "name": "compare", + "description": "Compare " + before + " against " + after, + "tasks": []any{ + // EACH SIDE EXAMINED BLIND. A single task told to compare + // reads one side, forms a view, and reads the second looking + // for confirmation of it. + templateTask("left", "examine", "Describe "+before+" on its own terms: what it does, how, and with what limits. Cite file:line. Do NOT compare it to anything.", nil), + templateTask("right", "examine", "Describe "+after+" on its own terms: what it does, how, and with what limits. Cite file:line. Do NOT compare it to anything.", nil), + templateTask("diff", "answer", "Using the two descriptions above, state how "+before+" and "+after+ + " differ, and where they agree. Where the two descriptions conflict, say which you believe and open the code to settle it — do not average them.", + []string{"left", "right"}), + }, + "budget": map[string]any{"max_workers": float64(2)}, + } + }, + }, + { + Name: "sweep", + WhenToUse: "Ask the same question of several targets independently, then combine the answers.", + Params: []string{"question", "targets"}, + build: func(params map[string]string) map[string]any { + // A VARIABLE TASK COUNT is the shape a model most often gets wrong by + // hand: it has to invent one id per target, keep them unique, and + // list every one of them in the synthesis task's depends_on. Getting + // any of that wrong is a refusal at admission. + targets := splitTemplateList(params["targets"]) + tasks := make([]any, 0, len(targets)+1) + ids := make([]string, 0, len(targets)) + for index, target := range targets { + id := templateTaskID("t", index, target) + ids = append(ids, id) + tasks = append(tasks, templateTask(id, "examine", + params["question"]+"\n\nAnswer this for exactly one target: "+target+ + ". Cite file:line. If the answer is 'this does not apply here', say so plainly rather than stretching for one.", nil)) + } + tasks = append(tasks, templateTask("combine", "answer", + "Answer the question across every target above: "+params["question"]+ + "\n\nState what holds everywhere, what holds only somewhere — naming which — and what contradicts. A target whose answer was empty is itself a result; do not drop it.", + ids)) + return map[string]any{ + "name": "sweep", + "description": params["question"], + "tasks": tasks, + "budget": map[string]any{"max_workers": float64(templateWorkers(len(targets)))}, + } + }, + }, +} + +// PlanTemplates lists the shapes, sorted, for a tool description. +func PlanTemplates() []PlanTemplate { + out := append([]PlanTemplate(nil), planTemplates...) + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// BuildTemplatePlan emits the orchestrate arguments for one template. +// +// FAILS CLOSED IN BOTH DIRECTIONS, exactly as expandPlanParams does for saved +// plans: a missing value is refused rather than substituted as empty prose, and +// a value the template does not take is refused rather than dropped — a caller +// who supplied it meant it, and ignoring it runs a different plan than the one +// asked for while reporting success. +func BuildTemplatePlan(name string, params map[string]string) (map[string]any, error) { + template, found := findPlanTemplate(name) + if !found { + return nil, fmt.Errorf("no plan template named %q; the templates are %s", + name, strings.Join(planTemplateNames(), ", ")) + } + var missing []string + for _, param := range template.Params { + if strings.TrimSpace(params[param]) == "" { + missing = append(missing, param) + } + } + if len(missing) > 0 { + return nil, fmt.Errorf("the %q template needs %s: %s", + name, pluralParams(missing), strings.Join(quoteAll(missing), ", ")) + } + known := map[string]bool{} + for _, param := range template.Params { + known[param] = true + } + var unused []string + for supplied := range params { + if !known[supplied] { + unused = append(unused, supplied) + } + } + if len(unused) > 0 { + sort.Strings(unused) + return nil, fmt.Errorf("the %q template takes no %s: %s (it takes %s)", + name, pluralParams(unused), strings.Join(quoteAll(unused), ", "), + strings.Join(quoteAll(template.Params), ", ")) + } + return template.build(params), nil +} + +func findPlanTemplate(name string) (PlanTemplate, bool) { + wanted := strings.ToLower(strings.TrimSpace(name)) + for _, template := range planTemplates { + if template.Name == wanted { + return template, true + } + } + return PlanTemplate{}, false +} + +func planTemplateNames() []string { + out := make([]string, 0, len(planTemplates)) + for _, template := range PlanTemplates() { + out = append(out, template.Name) + } + return out +} + +func templateTask(id, phase, prompt string, dependsOn []string) map[string]any { + task := map[string]any{"id": id, "phase": phase, "prompt": prompt} + if len(dependsOn) > 0 { + deps := make([]any, 0, len(dependsOn)) + for _, dep := range dependsOn { + deps = append(deps, dep) + } + task["depends_on"] = deps + } + return task +} + +// templateTaskID builds an id from a target that is ALREADY UNIQUE by its index, +// so two targets that sanitise to the same string cannot collide — a collision +// would silently drop one target's task and its dependency edge. +func templateTaskID(prefix string, index int, target string) string { + var cleaned strings.Builder + for _, r := range strings.ToLower(target) { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + cleaned.WriteRune(r) + case r == '_' || r == '-': + cleaned.WriteRune('_') + } + if cleaned.Len() >= 20 { + break + } + } + id := fmt.Sprintf("%s%d", prefix, index+1) + if cleaned.Len() > 0 { + id += "_" + cleaned.String() + } + return id +} + +// splitTemplateList reads a comma-separated list, dropping blanks so a trailing +// comma does not become a task with no target. +func splitTemplateList(raw string) []string { + var out []string + for _, part := range strings.Split(raw, ",") { + if trimmed := strings.TrimSpace(part); trimmed != "" { + out = append(out, trimmed) + } + } + return out +} + +// templateWorkers keeps a generated plan inside the range max_workers accepts, +// so a sweep over thirty targets is refused by nothing. Never below 1: a plan +// with zero workers cannot run and the schema forbids it. +func templateWorkers(targets int) int { + switch { + case targets < 1: + return 1 + case targets > maxPlanWorkers: + return maxPlanWorkers + default: + return targets + } +} diff --git a/internal/specialist/plan_template_test.go b/internal/specialist/plan_template_test.go new file mode 100644 index 000000000..201b6ecee --- /dev/null +++ b/internal/specialist/plan_template_test.go @@ -0,0 +1,357 @@ +package specialist + +import ( + "context" + "fmt" + "strings" + "sync" + "testing" + + "github.com/Gitlawb/zero/internal/tools" +) + +// EVERY TEMPLATE MUST ADMIT. The whole point is that a model asking for a shape +// by name cannot produce plan JSON that admission refuses — which is the turn a +// real run spent discovering budget.max_tokens_per_task: 5000 was too low. +func TestEveryTemplateProducesAPlanThatAdmits(t *testing.T) { + samples := map[string]map[string]string{ + "audit": {"subject": "the retry watchdog"}, + "compare": {"before": "the old scheduler", "after": "the new scheduler"}, + "sweep": {"question": "does this package validate its input?", "targets": "internal/tui, internal/cli, internal/sandbox"}, + } + limits := Limits{MaxTasks: 50, ParentTools: PlanReadOnlyToolNames()} + + for _, template := range PlanTemplates() { + t.Run(template.Name, func(t *testing.T) { + params, ok := samples[template.Name] + if !ok { + t.Fatalf("template %q has no sample here: a template nothing exercises is untested", template.Name) + } + args, err := BuildTemplatePlan(template.Name, params) + if err != nil { + t.Fatalf("build: %v", err) + } + plan, err := ParsePlan(args, limits) + if err != nil { + t.Fatalf("the template emitted a plan admission refuses: %v", err) + } + if plan.TaskCount() < 2 { + t.Fatalf("a %d-task plan is not worth a plan", plan.TaskCount()) + } + // No placeholder survived into a prompt. + for _, task := range plan.Tasks() { + if strings.Contains(task.Prompt, "${") || strings.TrimSpace(task.Prompt) == "" { + t.Fatalf("task %q has an unfilled or empty prompt: %q", task.ID, task.Prompt) + } + } + // And the parameters actually reached the prompts, rather than the + // template emitting a generic graph that ignores its input. + // Every supplied value must appear, and a comma-separated list must + // appear ITEM BY ITEM — a sweep that mentioned the list verbatim but + // gave no target its own task would pass a laxer check. + joined := strings.ToLower(renderPlanPrompts(plan)) + for _, value := range params { + for _, piece := range strings.Split(value, ",") { + piece = strings.ToLower(strings.TrimSpace(piece)) + if piece == "" { + continue + } + if !strings.Contains(joined, piece) { + t.Errorf("parameter value %q never reached any prompt", piece) + } + } + } + }) + } +} + +// FAILS CLOSED IN BOTH DIRECTIONS, the same contract expandPlanParams holds for +// saved plans. +func TestATemplateRefusesMissingAndUnknownParameters(t *testing.T) { + if _, err := BuildTemplatePlan("audit", nil); err == nil { + t.Fatal("audit built a plan with no subject: five tasks would examine an empty string") + } + if _, err := BuildTemplatePlan("audit", map[string]string{"subject": " "}); err == nil { + t.Fatal("whitespace was accepted as a subject") + } + if _, err := BuildTemplatePlan("audit", map[string]string{"subject": "x", "scope": "y"}); err == nil { + t.Fatal("an unknown parameter was silently dropped: the caller meant it") + } + if _, err := BuildTemplatePlan("nosuchtemplate", nil); err == nil { + t.Fatal("an unknown template name produced a plan") + } + // And the refusal names the alternatives, or the model cannot recover. + _, err := BuildTemplatePlan("nosuchtemplate", nil) + for _, name := range []string{"audit", "compare", "sweep"} { + if !strings.Contains(err.Error(), name) { + t.Errorf("the refusal does not name %q: %v", name, err) + } + } +} + +// A VARIABLE TASK COUNT is the shape a model most often gets wrong by hand: one +// unique id per target, every one of them listed in the synthesis dependency. +func TestSweepWiresEveryTargetIntoTheSynthesis(t *testing.T) { + args, err := BuildTemplatePlan("sweep", map[string]string{ + "question": "does this validate input?", + "targets": "internal/tui, internal/cli, internal/sandbox, internal/agent", + }) + if err != nil { + t.Fatal(err) + } + plan, err := ParsePlan(args, Limits{MaxTasks: 50, ParentTools: PlanReadOnlyToolNames()}) + if err != nil { + t.Fatalf("sweep does not admit: %v", err) + } + if plan.TaskCount() != 5 { + t.Fatalf("4 targets produced %d tasks, want 4 + 1 synthesis", plan.TaskCount()) + } + var combine Task + ids := map[string]bool{} + for _, task := range plan.Tasks() { + ids[task.ID] = true + if task.ID == "combine" { + combine = task + } + } + if len(ids) != plan.TaskCount() { + t.Fatal("two tasks share an id, so one target's work is lost") + } + if len(combine.DependsOn) != 4 { + t.Fatalf("the synthesis waits on %d of 4 targets: the rest are read before they finish", len(combine.DependsOn)) + } + for _, dep := range combine.DependsOn { + if !ids[dep] { + t.Fatalf("the synthesis depends on %q, which is not a task", dep) + } + } +} + +// TARGETS THAT SANITISE TO THE SAME STRING MUST NOT COLLIDE. A collision drops +// one target's task and its dependency edge, silently. +func TestSweepTargetsThatLookAlikeStillGetDistinctTasks(t *testing.T) { + args, err := BuildTemplatePlan("sweep", map[string]string{ + "question": "q", + "targets": "internal/tui, internal/TUI, internal-tui, internal tui", + }) + if err != nil { + t.Fatal(err) + } + plan, err := ParsePlan(args, Limits{MaxTasks: 50, ParentTools: PlanReadOnlyToolNames()}) + if err != nil { + t.Fatalf("look-alike targets do not admit: %v", err) + } + if plan.TaskCount() != 5 { + t.Fatalf("4 look-alike targets produced %d tasks: ids collided", plan.TaskCount()) + } +} + +// A sweep over more targets than the worker ceiling must still admit — the plan +// runs them in waves rather than being refused. +func TestALargeSweepStaysInsideTheWorkerCeiling(t *testing.T) { + var targets []string + for i := 0; i < 30; i++ { + targets = append(targets, fmt.Sprintf("pkg%d", i)) + } + args, err := BuildTemplatePlan("sweep", map[string]string{ + "question": "q", "targets": strings.Join(targets, ","), + }) + if err != nil { + t.Fatal(err) + } + plan, err := ParsePlan(args, Limits{MaxTasks: 50, ParentTools: PlanReadOnlyToolNames()}) + if err != nil { + t.Fatalf("a 30-target sweep does not admit: %v", err) + } + if workers := plan.Budget().MaxWorkers; workers < 1 || workers > maxPlanWorkers { + t.Fatalf("max_workers = %d, outside 1..%d", workers, maxPlanWorkers) + } + // A trailing comma must not become a task with no target. + trailing, err := BuildTemplatePlan("sweep", map[string]string{"question": "q", "targets": "a,b,"}) + if err != nil { + t.Fatal(err) + } + if plan, err := ParsePlan(trailing, Limits{MaxTasks: 50, ParentTools: PlanReadOnlyToolNames()}); err != nil { + t.Fatalf("a trailing comma broke the plan: %v", err) + } else if plan.TaskCount() != 3 { + t.Fatalf("'a,b,' produced %d tasks, want 2 + 1", plan.TaskCount()) + } +} + +// A generated plan is READ-ONLY. It names no tools, so it inherits the +// read-only grant and never trips RequiresIsolation — a template that quietly +// demanded a git worktree would refuse to run wherever one is unavailable. +func TestGeneratedPlansAreReadOnlyAndNeedNoWorktree(t *testing.T) { + for _, sample := range []struct { + name string + params map[string]string + }{ + {"audit", map[string]string{"subject": "s"}}, + {"compare", map[string]string{"before": "a", "after": "b"}}, + {"sweep", map[string]string{"question": "q", "targets": "a,b"}}, + } { + args, err := BuildTemplatePlan(sample.name, sample.params) + if err != nil { + t.Fatal(err) + } + plan, err := ParsePlan(args, Limits{MaxTasks: 50, ParentTools: PlanReadOnlyToolNames()}) + if err != nil { + t.Fatal(err) + } + if plan.RequiresIsolation() { + t.Errorf("template %q requires a worktree: it cannot run where git is unavailable", sample.name) + } + } +} + +// Each template must be distinguishable, or the model picks at random. +func TestEveryTemplateSaysWhatItIsFor(t *testing.T) { + seen := map[string]bool{} + for _, template := range PlanTemplates() { + if strings.TrimSpace(template.WhenToUse) == "" { + t.Errorf("template %q says nothing about when to use it", template.Name) + } + if seen[template.Name] { + t.Errorf("two templates named %q", template.Name) + } + seen[template.Name] = true + if len(template.Params) == 0 { + t.Errorf("template %q takes no parameters, so it cannot be about anything", template.Name) + } + } +} + +func renderPlanPrompts(plan Plan) string { + var b strings.Builder + for _, task := range plan.Tasks() { + b.WriteString(task.Prompt) + b.WriteString("\n") + } + b.WriteString(plan.Description()) + return b.String() +} + +// THE TOOL PATH, not just the builder. A template the orchestrate tool cannot +// resolve is a feature nothing can reach — the "layer B does not carry it" +// defect this branch has produced repeatedly. +func TestTheOrchestrateToolResolvesATemplate(t *testing.T) { + args, err := resolveTemplatePlan(map[string]any{ + "template": "audit", + "params": map[string]any{"subject": "the retry watchdog"}, + }) + if err != nil { + t.Fatalf("the tool could not resolve a template: %v", err) + } + plan, err := ParsePlan(args, Limits{MaxTasks: 50, ParentTools: PlanReadOnlyToolNames()}) + if err != nil { + t.Fatalf("the resolved template does not admit: %v", err) + } + if plan.TaskCount() != 5 { + t.Fatalf("audit produced %d tasks", plan.TaskCount()) + } + if !strings.Contains(renderPlanPrompts(plan), "the retry watchdog") { + t.Fatal("the subject never reached the resolved plan") + } +} + +// A template alongside hand-written tasks is neither one, and picking silently +// would run something the caller did not describe. +func TestATemplateAlongsideTasksIsRefused(t *testing.T) { + for _, field := range []string{"tasks", "saved"} { + _, err := resolveTemplatePlan(map[string]any{ + "template": "audit", + "params": map[string]any{"subject": "x"}, + field: "anything", + }) + if err == nil { + t.Fatalf("template + %s was silently resolved to one of them", field) + } + } +} + +// Execution directives say HOW to run, not WHAT to run, so they survive the +// swap — dropping them silently ran a background plan in the foreground once. +func TestExecutionDirectivesSurviveTemplateResolution(t *testing.T) { + args, err := resolveTemplatePlan(map[string]any{ + "template": "audit", + "params": map[string]any{"subject": "x"}, + "background": true, + "auto_assign": true, + }) + if err != nil { + t.Fatal(err) + } + if args["background"] != true || args["auto_assign"] != true { + t.Fatalf("directives were dropped: %+v", args) + } +} + +// No template named means the arguments pass through untouched — every existing +// call site takes this path. +func TestNoTemplateLeavesTheArgumentsAlone(t *testing.T) { + original := map[string]any{"name": "p", "tasks": []any{}} + got, err := resolveTemplatePlan(original) + if err != nil { + t.Fatal(err) + } + if len(got) != len(original) || got["name"] != "p" { + t.Fatalf("arguments were rewritten without a template: %+v", got) + } +} + +// AND THE TOOL MUST ACTUALLY RESOLVE IT. The test above calls +// resolveTemplatePlan directly and proves nothing about whether the tool +// consults it — a mutation that dropped the call from Run passed it cleanly. +// Same seam, same lesson as TestTheToolPrintsWhereAWritePlanWrote. +func TestTheToolRunsATemplateEndToEnd(t *testing.T) { + // LOCKED, because a sweep sets max_workers to its target count and the + // runner really is called from several goroutines at once. The race + // detector caught this the first time it ran, which is the point of it. + var mu sync.Mutex + var ran []string + tool := &OrchestrateTool{ + PostureActive: func() bool { return true }, + ParentTools: PlanReadOnlyToolNames(), + RunTask: func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + mu.Lock() + ran = append(ran, req.Task.ID) + mu.Unlock() + return TaskResult{ID: req.Task.ID, Outcome: TaskSucceeded, Output: "looked at " + req.Task.ID}, nil + }, + } + result := tool.RunWithOptions(context.Background(), map[string]any{ + "template": "sweep", + "params": map[string]any{"question": "does it validate input?", "targets": "internal/tui, internal/cli"}, + }, tools.RunOptions{Model: "m"}) + + if result.Status == tools.StatusError { + t.Fatalf("the tool refused a template it advertises: %s", result.Output) + } + mu.Lock() + defer mu.Unlock() + if len(ran) != 3 { + t.Fatalf("the template ran %d tasks (%v), want 2 targets + 1 synthesis", len(ran), ran) + } +} + +// A template the tool cannot build must be refused with a usable message, not +// run as an empty plan. +func TestTheToolRefusesAnUnknownTemplateByName(t *testing.T) { + tool := &OrchestrateTool{ + PostureActive: func() bool { return true }, + ParentTools: PlanReadOnlyToolNames(), + RunTask: func(context.Context, PlanTaskRequest) (TaskResult, error) { + t.Fatal("a task ran for a template that does not exist") + return TaskResult{}, nil + }, + } + result := tool.RunWithOptions(context.Background(), map[string]any{ + "template": "nosuchshape", + }, tools.RunOptions{Model: "m"}) + if result.Status != tools.StatusError { + t.Fatalf("an unknown template was accepted: %s", result.Output) + } + if !strings.Contains(result.Output, "audit") { + t.Fatalf("the refusal does not name the real templates: %s", result.Output) + } +} diff --git a/internal/specialist/plan_test.go b/internal/specialist/plan_test.go new file mode 100644 index 000000000..ac2d90560 --- /dev/null +++ b/internal/specialist/plan_test.go @@ -0,0 +1,551 @@ +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} +} + +// 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(2_000_000)} +} + +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 { + // 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 +// 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, PlanTaskRequest) (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 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(), "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) + } + } +} + +// (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 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() + // 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) { + // 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) + } +} + +// ...and an unbounded plan runs every task, however much it spends. +func TestAnUnboundedPlanRunsEveryTask(t *testing.T) { + budget := okBudget() + delete(budget, "max_tokens") + 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) + } +} + +// (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, PlanTaskRequest) (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 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} + + 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) + } + // ...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") + } + } +} + +// (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, 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, PlanTaskRequest) (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, PlanTaskRequest) (TaskResult, error) { + return TaskResult{Outcome: TaskSucceeded, Output: "ok"}, nil + }, tools.StatusOK, "completed"}, + {"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, PlanTaskRequest) (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..e1fbc73f6 --- /dev/null +++ b/internal/specialist/plan_tool.go @@ -0,0 +1,1145 @@ +package specialist + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/streamjson" + "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 + // 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 + // 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 + // 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 + // RequirePlanKeyword makes a plan refusable unless the turn's own user text + // asks for one. Default false: enabling it silently would start refusing + // plans for everyone whose phrasing does not match. See plan_keyword.go. + RequirePlanKeyword bool + // ContextWindows reports the window of the model a task will run on, so its + // dependency briefing can be sized to what it can actually read. nil keeps + // the fixed caps, which is the behaviour every plan had before it existed — + // and the honest default for a provider that reports no window. + ContextWindows ContextWindowFunc + // ExtraReadRoots reports the paths the parent may read BEYOND its workspace — + // its request_permissions grants — at DISPATCH time, so a grant that landed + // mid-turn reaches the tasks dispatched after it. Every task gets read access + // to these, so a plan can audit a granted external path. nil hands tasks + // nothing beyond their workspace, the behaviour before this existed. + ExtraReadRoots func() []string + // 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 +} + +const ( + // 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 +) + +// planSchemaBound is a schema minimum/maximum. tools.PropertySchema takes *int +// so that "unbounded" and "bounded at zero" stay different things — a plain int +// would make max_retries: 0, which is a real and meaningful setting, mean the +// same as declaring no bound at all. +func planSchemaBound(n int) *int { return &n } + +func (tool *OrchestrateTool) Name() string { return OrchestrateToolName } + +func (tool *OrchestrateTool) Description() string { + 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 + // 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. +// +// 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() +} + +// 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", + 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", + // 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" + + // 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.\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.\n\n" + + // TWO GAPS THAT LOOK ALIKE AND ARE NOT, and merging them is how the + // second one disappears. + // + // A measured run built against a specification and reported a clean + // list of requirement conflicts while filing NOT ONE relaxation — + // though it had lowered a one-million-record bound to ten thousand, + // cut a sixty-second soak to five, and excluded a latency class from + // its own measurements. All three were real, all three were + // defensible, and all three reached the reader as cells inside a + // results table rather than as gaps. A reader who trusted the report + // never learned what had not been built. + // + // The distinction is not stylistic: a conflict is a question the + // spec failed to answer, and a relaxation is an answer the work + // failed to reach. One is resolved by deciding; the other is + // outstanding until someone builds it. + "If a plan BUILDS to a specification, keep two kinds of gap apart and report them separately:\n" + + "- A CONFLICT is the spec disagreeing with itself — two requirements that cannot both hold. " + + "It is resolved by choosing a reading, and the report names the reading chosen.\n" + + "- A RELAXATION is the work coming in under the spec — a bound lowered, a case left unhandled, " + + "a measurement taken under easier conditions than were asked for. Nothing disagreed; less was built.\n" + + "Give relaxations a task or a section of their own, and never only a cell in a results table. " + + "Listed beside conflicts they read as questions already settled rather than as work still owed, " + + "and a relaxation nobody wrote down is indistinguishable from a requirement nobody noticed. " + + "Report each one even when it was the right call: the judgement is the reader's to make, and they can only make it if they are told.", + }, + "params": { + Type: "object", + Description: "Values for a saved plan's ${placeholders}, as name/value pairs. Only usable with `saved`. " + + "A plan's prompts may contain ${name} where the target varies — a scope, a directory, a release — and these fill them in, " + + "so one reviewed plan runs against many targets instead of being copied and edited per target. " + + "Substituted into task prompts and the plan description only: never into tools, ids, depends_on, model or budget, " + + "which are authority and graph and stay as they were saved. " + + "A placeholder with no value is refused, and so is a value matching no placeholder.", + }, + "template": { + Type: "string", + Description: "Build the plan from a named SHAPE instead of writing tasks by hand: " + + "audit (examine one subject from several independent angles, then verify the findings), " + + "compare (describe two things separately, then contrast them), " + + "sweep (ask one question of several targets, then combine the answers — it emits one task per target plus a synthesis, so keep the target list inside this run's plan-size tier). " + + "Supply its values in params. Mutually exclusive with tasks and saved. " + + "Prefer this when the shape fits: the graph, the ids, the dependencies and the budget all come out valid, " + + "so the call is not refused and retried.", + }, + "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. " + + "Only available in the interactive TUI; a headless run exits when the turn ends, so it refuses this.", + }, + "budget": { + 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{ + // DECLARED BOUNDS, not merely described ones. Every number here is + // already enforced at admission, and prose saying "1 to 16" is + // advice to the same model that has to guess the field names — so + // a run emitted max_tokens_per_task: 5000, was refused for being + // below the floor, and spent a whole turn learning a rule the + // schema could have carried. A bound in the schema is checked by + // the provider before the call is ever made. + // + // EACH ONE MIRRORS ITS ENFORCEMENT CONSTANT rather than repeating + // the literal, because two spellings of one limit drift. + "max_workers": {Type: "integer", Minimum: planSchemaBound(1), Maximum: planSchemaBound(maxPlanWorkers), Description: "How many tasks may run at once, 1 to 16. 1 is sequential."}, + "max_tokens": {Type: "integer", Minimum: planSchemaBound(minimumPlausibleTaskTokens), 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", Minimum: planSchemaBound(minimumPlausibleTaskTokens), 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", Minimum: planSchemaBound(1), Description: "Optional wall-clock bound on the whole plan."}, + "max_stall_seconds": {Type: "integer", Minimum: planSchemaBound(int(minStallTimeout.Seconds())), Description: "How long ONE task may emit nothing before it is stopped. Default 180."}, + "max_retries": {Type: "integer", Minimum: planSchemaBound(0), Maximum: planSchemaBound(maxPlanRetries), 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. + // + // `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, + } +} + +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, + // 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 + // never becomes the thing holding the gate open. + AdvertiseInAuto: false, + } +} + +// 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. +// 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 + } + 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{}) +} + +// 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 +// 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. 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 — 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 + if run == nil { + return nil + } + recorder := tool.Recorder + return func(ctx context.Context, req PlanTaskRequest) (TaskResult, error) { + // 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, + // 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 + req.ParentToolCallID = options.ToolCallID + return run(ctx, req) + } +} + +// 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) + } + } + // params is NOT in that list, and must not be: it does not override the + // stored plan, it fills the holes the plan itself declared. A plan with no + // ${placeholder} takes none, and supplying one is refused below. + params, err := planParamsFromArgs(args) + if err != nil { + return nil, err + } + 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 + } + // 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 + } + } + // EXPANDED BEFORE ParsePlan, so admission validates the plan that will run + // rather than a template of it: a substituted prompt still faces the same + // tool-grant narrowing, depth cap and budget checks. Returns a copy, so the + // stored plan is unchanged for the next run. + return expandPlanParams(stored.Args, params) +} + +// 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 +// 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: size.MaxTasks(), + MaxTasksSource: fmt.Sprintf("the %q plan size", size), + MaxTokens: defaultPlanMaxTokens, + CurrentDepth: tool.Depth, + ParentTools: tool.ParentTools, + } + 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.", + } + } + // THE USER MUST HAVE ASKED, when this session requires it. Checked BEFORE the + // saved-plan lookup and before auto-assignment: both touch the disk or the + // provider, and a refused plan must cost neither. See plan_keyword.go for why + // this is an admission check rather than a line in the prompt. + if tool.RequirePlanKeyword && !planRequestedByUser(options.UserMessage) { + return tools.Result{Status: tools.StatusError, Output: "Error: " + planKeywordRefusal().Error()} + } + // 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. + // A TEMPLATE IS RESOLVED FIRST, and its output then travels the ordinary + // path: ParsePlan validates it, the grant is intersected, the budget is + // checked. It is a way to WRITE the arguments, never a second admission + // route — which is the rule every part of this feature follows, because its + // whole defect history is second call paths carrying less than the first. + args, err := resolveTemplatePlan(args) + if err != nil { + return tools.Result{Status: tools.StatusError, Output: "Error: " + err.Error()} + } + args, err = tool.resolveSavedPlan(args) + 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()} + } + // THE VERIFIER, BEFORE ASSIGNMENT so the appended task is routed and pinned + // exactly like a hand-written one — the verify role is precisely the one the + // user's strongest pin exists for. After the pre-parse, so a plan that was + // going to be refused is refused on what its author wrote. + verifyNote := tool.appendVerifyStage(args, options) + assignNotes, routerTokens, autoErr := tool.autoAssignModelsCosting(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)) + 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."} + } + // 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. + // + // 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.", + } + } + // 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 := ExecutePlanIn(backgroundCtx, plan, workspace, parentTools, run, recorder, tool.execOptionsFor(plan, routerTokens)...) + recordPlanCompleted(recorder, plan, report) + }) + if !launched { + workspace.Release() + } + 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"}, + } + } + + // 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) + // 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, tool.execOptionsFor(plan, routerTokens)...) + recordPlanCompleted(tool.Recorder, plan, report) + + result := tools.Result{ + Status: tools.StatusOK, + Output: report.Summary() + planBudgetWarning(budgetWarning) + planWorkspaceNote(workspace) + + autoAssignSummary(assignNotes) + verifyStageSummary(verifyNote), + 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. + // + // The ONE exception is a task the author did not write: when the appended + // verifier is the only thing that did not succeed, every task that was asked + // for is done, and calling that "error" makes the orchestrating model re-run + // a plan whose work already completed. The report still counts the failure + // and the caller is told the claims went unverified — the verdict on the + // author's plan is simply left to the author's tasks. + if report.Status != PlanCompleted { + if onlyTheAppendedVerifierFailed(report, verifyNote) { + result.Output += verifyStageUnverifiedNote(verifyNote) + } else { + result.Status = tools.StatusError + } + } + 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. +// autoAssignModels assigns per-task models and reports what it did. +// +// Kept at two returns for the seven call sites that only care about the notes. +// The production path needs a third thing — what routing COST — and gets it from +// autoAssignModelsCosting below; editing seven tests to thread a value they do +// not assert on would be seven tests changed to keep compiling. + +// autoAssignModelsCosting is autoAssignModels, also reporting the tokens the +// routing call spent — which no task performed and which therefore reached +// neither the plan's budget nor its reported total. +func (tool *OrchestrateTool) autoAssignModelsCosting(ctx context.Context, args map[string]any, options tools.RunOptions) ([]string, int, error) { + // DECLARED UP HERE so every early return can report it. Each of those + // returns precedes the routing call, so they honestly report 0: nothing was + // spent because nothing was routed. + var routerTokens int + // 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, routerTokens, 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"}, routerTokens, nil + } + return nil, routerTokens, 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, routerTokens, 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 { + return []string{"none — could not list the provider's models (" + err.Error() + + "), so every task kept this session's model"}, routerTokens, nil + } + return nil, routerTokens, 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. + 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) + 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}, routerTokens, 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, routerTokens, 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) + if strings.TrimSpace(router) != "" && len(routableTasks(raw)) >= routerMinimumTasks { + planPreflight(tool.Recorder, "asking "+router+" which model each task needs…") + } + routed, routerSpent, routeErr := routeTaskModels(ctx, tool.runnerForCall(options), PlanTaskRequest{Tools: routerGrant}, + router, routableTasks(raw), eligibleForRouting(models, tool.ModelPrefs), tool.ModelPrefs.RouterGuidance) + // THE HOISTED COUNTER, so the number the plan is CHARGED is the same one the + // headline PRINTS. Two spellings of one quantity is how a report and a budget + // drift apart — and this one already drifted once, silently, every run. + routerTokens = routerSpent + + 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, routerTokens, 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 +// 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 ") +} + +// resolveTemplatePlan swaps a `template` reference for the arguments it builds. +// +// REFUSED ALONGSIDE tasks OR saved, rather than merged. A template plus +// hand-written tasks is neither the template nor the tasks, and picking one +// silently would run something the caller did not describe — the same reasoning +// that makes resolveSavedPlan refuse a half-overridden saved plan. +func resolveTemplatePlan(args map[string]any) (map[string]any, error) { + name := planString(args, "template") + if name == "" { + return args, nil + } + for _, field := range []string{"tasks", "saved"} { + if _, present := args[field]; present { + return nil, fmt.Errorf( + "a template builds the plan for you: remove %q, or drop `template` and supply the plan yourself", field) + } + } + params, err := planParamsFromArgs(args) + if err != nil { + return nil, err + } + built, err := BuildTemplatePlan(name, params) + if err != nil { + return nil, err + } + // EXECUTION DIRECTIVES SURVIVE, plan content does not — the same two the + // saved-plan path carries across, for the same reason: they say HOW to run + // it, not WHAT to run, and dropping them silently ran a background plan in + // the foreground once already. + for _, directive := range []string{"background", "auto_assign"} { + if value, present := args[directive]; present { + built[directive] = value + } + } + return built, nil +} + +// execOptionsFor builds the execution options for one plan. +// +// ONE BUILDER FOR BOTH CALL SITES. A plan runs in the foreground or the +// background from two different places, and an option wired to one of them makes +// a feature that works or not depending on which the caller happened to pick — +// the shape TestBothExecutionPathsChargeRouterSpend already pins for spend. +func (tool *OrchestrateTool) execOptionsFor(plan Plan, routerTokens int) []ExecOption { + options := []ExecOption{WithPreSpentTokens(routerTokens)} + if tool.ContextWindows != nil { + options = append(options, WithContextWindows(tool.ContextWindows)) + } + // Called HERE, at dispatch — not captured at construction — so a + // request_permissions grant that landed earlier this turn is included. Empty + // is fine: WithReadRoots adds nothing, and the tasks keep their workspace-only + // reads. + if tool.ExtraReadRoots != nil { + if roots := tool.ExtraReadRoots(); len(roots) > 0 { + options = append(options, WithReadRoots(roots)) + } + } + // ONLY WHEN A BRIEFING CAN EXIST. The scratchpad's whole job is to make a + // TRUNCATED dependency briefing reachable, and a plan whose tasks depend on + // nothing never writes one — so a directory would be created, populated and + // deleted for no reader. + if planHasDependencies(plan) { + options = append(options, WithScratchpad()) + } + return options +} + +func planHasDependencies(plan Plan) bool { + for _, task := range plan.Tasks() { + if len(task.DependsOn) > 0 { + return true + } + } + return false +} diff --git a/internal/specialist/plan_verify_stage.go b/internal/specialist/plan_verify_stage.go new file mode 100644 index 000000000..3c22b42bb --- /dev/null +++ b/internal/specialist/plan_verify_stage.go @@ -0,0 +1,259 @@ +package specialist + +// THE CLAIMS NOBODY RE-DERIVES. +// +// A plan fans out, every task reports, and the report carries what they said. +// Under zeromaxing that is five or more sub-agents' worth of claims, each +// written by a model that cannot see the others' work and will never be asked +// about it again. The bundled `research` plan ends in a refute step for exactly +// this reason, and the evidence rules in planTaskSystemPrompt exist to make one +// possible — but both are habits. A plan whose author forgot to add a verifier +// finished green with nothing checked, and a wrong "looks fine" is +// indistinguishable from a right one once it reaches the report. +// +// So under the posture, a multi-task plan that names no verification gets one +// appended: a read-only task depending on every other, told to REFUTE rather +// than to agree. +// +// FIVE THINGS IT MUST NOT DO, each of which is why a condition below exists: +// +// - Refuse a plan the author wrote. The size tier caps TASK COUNT, so +// appending to a plan already at its ceiling would turn a valid plan into a +// rejected one — for a task the author never wrote. No headroom, no append. +// - Change what the plan may touch. The appended task names NO tools, which +// planToolGrant resolves to the read-only intersection of the parent's +// grant. A named write tool would flip RequiresIsolation for the WHOLE plan +// (isolation is plan-level), moving every other task into a worktree — the +// measured failure plan_workspace_reach.go exists to catch. +// - Second-guess a plan that already verifies. classifyTaskRole is the same +// classifier the model router uses; if any task already reads as verify, +// this does nothing. +// - Collide with an author's id. The id is probed against the plan's own ids +// and suffixed until free. +// - Appear from nowhere. Every append returns a note, which the tool prints +// with its assignment notes, so a task in the report that nobody wrote is +// explained where it happens. + +import ( + "fmt" + "strconv" + "strings" + + "github.com/Gitlawb/zero/internal/tools" +) + +// verifyStageTaskID is the appended task's preferred id. +const verifyStageTaskID = "verify_claims" + +// verifyStagePrompt is what the appended verifier is told. +// +// IT IS TOLD TO REFUTE, and to default to refuted when uncertain, because a +// verifier that sets out to agree always does — the same reasoning the bundled +// research plan's refute step carries, and the reason that plan ships at all. +// The wording is deliberately about CLAIMS rather than about code: the tasks +// above it may have read, measured or changed things, and the check that +// matters is whether what they reported is what actually happened. +const verifyStagePrompt = "Verify the claims the tasks above made, and try to REFUTE them. " + + "Default to refuted when uncertain. For each claim: open the cited file:line and check the claim " + + "is what the code actually does, look for a second path that behaves differently, and look for a " + + "case the claim does not cover. Any number reported as measured must be traceable to a command " + + "that was run; if you cannot trace it, say so. Report each claim as VERIFIED with the line that " + + "proves it, or REFUTED with the line that disproves it, and list separately anything you could not " + + "check and why. Refuting the other tasks' answers is the job; agreeing is not." + +// verifyStageNote explains the appended task wherever notes are shown. Built +// from the id ACTUALLY used, which is suffixed when the author already took the +// preferred one — a note naming a task the plan does not contain is worse than +// no note. +func verifyStageNote(id string) string { + return id + ": added — no task in this plan verifies the others' claims" +} + +// appendVerifyStageToTaskArgs adds a verification task to a plan that has none. +// +// WORKS ON THE ARGS, BEFORE ParsePlan, for the same reason assignModelsToTaskArgs +// does: every downstream property then falls out for free. The appended task is +// validated by the same constructor as a hand-written one, round-trips through +// Plan.Args() into a saved plan, and a resumed plan re-admits exactly what ran. +// Applying it to a parsed Plan instead would mean a second write path into the +// one object ParsePlan exists to be the sole author of. +// +// Returns the tasks unchanged and an empty note whenever any condition fails — +// this is an addition that must never be the reason a plan does not run. +func appendVerifyStageToTaskArgs(tasks []any, maxTasks, budgetMaxTokens, budgetPerTask int) ([]any, string) { + if len(tasks) < verifyStageMinimumTasks { + return tasks, "" + } + // HEADROOM FIRST, before anything is built: a plan at the tier's ceiling is + // a plan the author is entitled to run. + if maxTasks > 0 && len(tasks)+1 > maxTasks { + return tasks, "" + } + // AND THE BUDGET IS A SECOND CEILING, through a door the task-count check + // does not cover: refuseImplausibleBudget requires max_tokens to fund + // minimumPlausibleTaskTokens PER TASK, so one more task raises the bar by + // 50,000. A plan whose budget exactly funded its own tasks was refused + // outright — "raise max_tokens to at least …" — for a task its author never + // wrote. The existing suite caught this; without the check the feature turns + // working plans into rejected ones. + if budgetMaxTokens > 0 && budgetMaxTokens < (len(tasks)+1)*minimumPlausibleTaskTokens { + return tasks, "" + } + // AND THE PER-TASK CAP IS A THIRD CEILING, missed by the first version of + // this guard. refuseUnreachablePerTaskCap requires max_tokens to cover + // max_tokens_per_task × taskCount, so the extra task raises that bar by one + // whole per-task cap. A 4-task plan with max_tokens 400,000 and + // max_tokens_per_task 100,000 parses fine and was then REFUSED outright once + // the verifier made it five — naming a task count and a budget its author + // never chose. Any plan whose per-task cap exceeds the 50,000 floor above + // falls in this window, so the floor check alone does not cover it. + if budgetMaxTokens > 0 && budgetPerTask > 0 && budgetMaxTokens < (len(tasks)+1)*budgetPerTask { + return tasks, "" + } + ids := make([]string, 0, len(tasks)) + taken := map[string]bool{} + for _, raw := range tasks { + fields, ok := raw.(map[string]any) + if !ok { + // Not an object: ParsePlan owns every message about task shape, and a + // plan that is about to be refused must not first grow a task. + return tasks, "" + } + id := strings.TrimSpace(planString(fields, "id")) + if id == "" { + return tasks, "" + } + // ALREADY VERIFIES? Then this has nothing to add. The task's own tools + // are read for the classification the same way the router reads them, so + // a write-capable task is "implement" here exactly as it is there. + if classifyTaskRole(Task{ + ID: id, + Prompt: planString(fields, "prompt"), + Tools: planStrings(fields, "tools"), + }) == TaskRoleVerify { + return tasks, "" + } + ids = append(ids, id) + taken[id] = true + } + + id := freeVerifyStageID(taken) + verifier := map[string]any{ + "id": id, + "prompt": verifyStagePrompt, + // depends_on EVERY task, which is what makes this a fan-in: the verifier + // starts only once there is something to verify, and the dependency + // briefing carries the others' output into its prompt. No cycle is + // possible — nothing depends on a task that did not exist a moment ago. + "depends_on": idsAsAny(ids), + // NO "tools" KEY. planToolGrant resolves an absent grant to the + // read-only intersection of the parent's, so this can neither modify + // anything nor flip the plan into worktree isolation. + } + return append(append([]any{}, tasks...), verifier), id +} + +// verifyStageMinimumTasks is the smallest plan worth appending to. A single-task +// plan is one agent's work read by the person who asked for it; the failure this +// addresses is claims from SEVERAL agents that nobody re-derives. +const verifyStageMinimumTasks = 2 + +// freeVerifyStageID returns an id no task in the plan uses. +func freeVerifyStageID(taken map[string]bool) string { + if !taken[verifyStageTaskID] { + return verifyStageTaskID + } + for suffix := 2; ; suffix++ { + candidate := verifyStageTaskID + "_" + strconv.Itoa(suffix) + if !taken[candidate] { + return candidate + } + } +} + +func idsAsAny(ids []string) []any { + out := make([]any, 0, len(ids)) + for _, id := range ids { + out = append(out, id) + } + return out +} + +// appendVerifyStage is the tool's gate around appendVerifyStageToTaskArgs: the +// POSTURE decides whether this happens at all. +// +// Off unless zeromaxing is active, so a plain plan is byte-identical to what it +// was before this file existed — the same contract every other part of the +// posture follows. The size ceiling comes from the tool's own limits, so the +// headroom check upstream reads the tier this run is actually bounded by. +func (tool *OrchestrateTool) appendVerifyStage(args map[string]any, options tools.RunOptions) string { + if !tool.postureActive() { + return "" + } + raw, ok := args["tasks"].([]any) + if !ok { + return "" + } + // The budget is read from the ARGS, not from a parsed Budget: this runs + // before the plan is constructed, and planBudget's own refusal for a missing + // budget object belongs to ParsePlan. An unreadable or absent max_tokens + // reads as 0 — unbounded — which is exactly the case the ceiling check skips. + budgetTokens, budgetPerTask := 0, 0 + if budget, ok := args["budget"].(map[string]any); ok { + budgetTokens = planInt(budget, "max_tokens") + budgetPerTask = planInt(budget, "max_tokens_per_task") + } + tasks, id := appendVerifyStageToTaskArgs(raw, tool.limits(options).MaxTasks, budgetTokens, budgetPerTask) + if id == "" { + return "" + } + args["tasks"] = tasks + return id +} + +// verifyStageSummary renders the appended-task note for the plan's output. +func verifyStageSummary(id string) string { + if strings.TrimSpace(id) == "" { + return "" + } + return fmt.Sprintf("\n\nVerification stage added:\n %s", verifyStageNote(id)) +} + +// onlyTheAppendedVerifierFailed reports that every task which did NOT succeed is +// the one this file appended. +// +// A TASK THE AUTHOR DID NOT WRITE MUST NOT DECIDE THE AUTHOR'S VERDICT. The +// verifier runs last, on the strongest tier, with the largest dependency +// briefing — the task most likely to stall or exhaust its provider retry — and +// its failure took the whole call to StatusError with it. The orchestrating +// model then reads "the plan failed" and re-runs three tasks that had already +// succeeded, paying twice for work that was done. +// +// The REPORT stays honest either way: Failed still counts it, the summary still +// names it, and the caller is told plainly that the claims went unverified. Only +// the OK/error verdict on the author's plan is left to the author's tasks. +func onlyTheAppendedVerifierFailed(report PlanReport, verifyTaskID string) bool { + if strings.TrimSpace(verifyTaskID) == "" { + return false + } + sawIt := false + for _, task := range report.Tasks { + if task.Outcome == TaskSucceeded { + continue + } + if task.ID != verifyTaskID { + return false + } + sawIt = true + } + return sawIt +} + +// verifyStageUnverifiedNote is what the caller is told when the appended +// verifier is the only thing that did not succeed: the work stands, the +// checking did not happen, and re-running the plan is not the answer. +func verifyStageUnverifiedNote(id string) string { + return "\n\nNote: every task you asked for succeeded, but the appended " + id + + " did not finish, so their claims were NOT independently verified. " + + "The task results above are unchecked rather than wrong; re-running the plan would repeat work that already succeeded." +} diff --git a/internal/specialist/plan_verify_stage_test.go b/internal/specialist/plan_verify_stage_test.go new file mode 100644 index 000000000..6bc39d203 --- /dev/null +++ b/internal/specialist/plan_verify_stage_test.go @@ -0,0 +1,256 @@ +package specialist + +import ( + "context" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/tools" +) + +// THE CLAIMS NOBODY RE-DERIVES. A multi-task plan under the posture that names +// no verifier gets one appended: read-only, depending on every other task, told +// to refute. Everything below is a way that must NOT cost the author their plan. + +func verifyStageTasks(ids ...string) []any { + out := make([]any, 0, len(ids)) + for _, id := range ids { + out = append(out, map[string]any{"id": id, "prompt": "trace the " + id + " path"}) + } + return out +} + +func taskIDsOf(t *testing.T, tasks []any) []string { + t.Helper() + out := make([]string, 0, len(tasks)) + for _, raw := range tasks { + fields, ok := raw.(map[string]any) + if !ok { + t.Fatalf("task is not an object: %#v", raw) + } + out = append(out, planString(fields, "id")) + } + return out +} + +func TestAVerifierIsAppendedToAnUnverifiedPlan(t *testing.T) { + tasks, note := appendVerifyStageToTaskArgs(verifyStageTasks("scan", "trace"), 20, 0, 0) + if note == "" { + t.Fatal("a multi-task plan with no verifier must get one") + } + ids := taskIDsOf(t, tasks) + if len(ids) != 3 || ids[2] != verifyStageTaskID { + t.Fatalf("ids = %v, want the two originals plus %q last", ids, verifyStageTaskID) + } + verifier, _ := tasks[2].(map[string]any) + // FAN-IN: it must depend on every task, or it can start before there is + // anything to verify. + deps := planStrings(verifier, "depends_on") + if len(deps) != 2 || deps[0] != "scan" || deps[1] != "trace" { + t.Fatalf("depends_on = %v, want every task in the plan", deps) + } + // NO TOOLS KEY: planToolGrant then resolves the read-only intersection of the + // parent's grant, which is what keeps this from flipping the whole plan into + // worktree isolation. + if _, present := verifier["tools"]; present { + t.Fatal("the verifier must name no tools, or a write grant could isolate the plan") + } + // It must be told to refute, not to agree. + prompt := planString(verifier, "prompt") + if !strings.Contains(prompt, "REFUTE") || !strings.Contains(prompt, "Default to refuted") { + t.Fatalf("the verifier must be told to refute and default to refuted: %q", prompt) + } + // And it classifies as verify, so the user's verify pin routes it. + if role := classifyTaskRole(Task{ID: verifyStageTaskID, Prompt: prompt}); role != TaskRoleVerify { + t.Fatalf("the appended task classifies as %q, want verify — the verify pin would not route it", role) + } +} + +// A plan that already verifies is left alone: this adds a missing habit, it does +// not second-guess an author who has one. +func TestAPlanThatAlreadyVerifiesIsUntouched(t *testing.T) { + tasks := append(verifyStageTasks("scan", "trace"), + map[string]any{"id": "check", "prompt": "review the two findings above and confirm them"}) + got, note := appendVerifyStageToTaskArgs(tasks, 20, 0, 0) + if note != "" || len(got) != 3 { + t.Fatalf("a plan with a verify task must be untouched: note=%q tasks=%d", note, len(got)) + } +} + +// A single-task plan is one agent's work, read by the person who asked for it. +func TestASingleTaskPlanGetsNoVerifier(t *testing.T) { + if _, note := appendVerifyStageToTaskArgs(verifyStageTasks("only"), 20, 0, 0); note != "" { + t.Fatal("a one-task plan must not grow a verifier") + } +} + +// THE APPEND MUST NEVER BE THE REASON A PLAN IS REFUSED. Two ceilings can do +// that, and each has its own guard: the size tier caps task COUNT, and +// refuseImplausibleBudget requires max_tokens to fund 50,000 per task — so one +// more task raises the bar by 50,000. The budget case was found by the existing +// suite, not by this test: without the guard, TestOrchestrateForwardsProgress… +// failed with "raise max_tokens to at least 150000". +func TestTheAppendNeverCostsAnAuthorTheirPlan(t *testing.T) { + atTierCeiling := verifyStageTasks("a", "b", "c", "d", "e") + if _, note := appendVerifyStageToTaskArgs(atTierCeiling, 5, 0, 0); note != "" { + t.Fatal("a plan at the size tier's ceiling must not be pushed over it") + } + if _, note := appendVerifyStageToTaskArgs(atTierCeiling, 6, 0, 0); note == "" { + t.Fatal("one task of headroom is enough; the append should have happened") + } + // Budget exactly funds the two declared tasks (2 × 50,000) and nothing more. + twoTasks := verifyStageTasks("a", "b") + if _, note := appendVerifyStageToTaskArgs(twoTasks, 20, 2*minimumPlausibleTaskTokens, 0); note != "" { + t.Fatal("a plan whose budget exactly funds its own tasks must not be pushed over the floor") + } + if _, note := appendVerifyStageToTaskArgs(twoTasks, 20, 3*minimumPlausibleTaskTokens, 0); note == "" { + t.Fatal("a budget with room for one more task should have taken the verifier") + } + // An unset budget is unbounded, not zero-budget: the ceiling check skips it. + if _, note := appendVerifyStageToTaskArgs(twoTasks, 20, 0, 0); note == "" { + t.Fatal("an unbounded plan must still get a verifier") + } +} + +// An author who already used the id keeps it; the appended task moves aside. +func TestTheAppendedIDNeverCollides(t *testing.T) { + tasks := []any{ + map[string]any{"id": verifyStageTaskID, "prompt": "trace the parser"}, + map[string]any{"id": verifyStageTaskID + "_2", "prompt": "trace the lexer"}, + } + got, note := appendVerifyStageToTaskArgs(tasks, 20, 0, 0) + if note == "" { + t.Fatal("setup: the append should have happened") + } + ids := taskIDsOf(t, got) + if ids[2] != verifyStageTaskID+"_3" { + t.Fatalf("appended id = %q, want the first free suffix", ids[2]) + } + seen := map[string]bool{} + for _, id := range ids { + if seen[id] { + t.Fatalf("duplicate id %q — ParsePlan would refuse the whole plan", id) + } + seen[id] = true + } +} + +// A malformed task list is ParsePlan's to report, and a plan about to be refused +// must not first grow a task. +func TestAMalformedTaskListIsLeftToParsePlan(t *testing.T) { + for _, tasks := range [][]any{ + {"not an object", map[string]any{"id": "b", "prompt": "x"}}, + {map[string]any{"prompt": "no id here"}, map[string]any{"id": "b", "prompt": "x"}}, + } { + if got, note := appendVerifyStageToTaskArgs(tasks, 20, 0, 0); note != "" || len(got) != len(tasks) { + t.Fatalf("a malformed list must be untouched: note=%q", note) + } + } +} + +// THE POSTURE IS THE GATE. Off, a plan is byte-identical to what it was before +// this file existed — the contract every other part of the posture follows. +func TestTheVerifyStageIsPostureGated(t *testing.T) { + args := func() map[string]any { + return map[string]any{ + "name": "p", + "budget": map[string]any{"max_workers": float64(1)}, + "tasks": verifyStageTasks("scan", "trace"), + } + } + off := &OrchestrateTool{} + offArgs := args() + if note := off.appendVerifyStage(offArgs, tools.RunOptions{}); note != "" { + t.Fatalf("posture off must append nothing, got %q", note) + } + if tasks, _ := offArgs["tasks"].([]any); len(tasks) != 2 { + t.Fatalf("posture off rewrote the task list: %d tasks", len(tasks)) + } + on := &OrchestrateTool{PostureActive: func() bool { return true }} + onArgs := args() + if note := on.appendVerifyStage(onArgs, tools.RunOptions{}); note == "" { + t.Fatal("posture on must append the verifier") + } + if tasks, _ := onArgs["tasks"].([]any); len(tasks) != 3 { + t.Fatalf("posture on did not rewrite the task list: %d tasks", len(tasks)) + } +} + +// THE WHOLE PATH: the appended task is dispatched like any other, and the run +// says it was added rather than letting a task nobody wrote appear unexplained. +func TestTheAppendedVerifierRunsAndIsReported(t *testing.T) { + var dispatched []string + tool := &OrchestrateTool{ + PostureActive: func() bool { return true }, + ParentTools: []string{"read_file", "grep"}, + RunTask: func(_ context.Context, req PlanTaskRequest) (TaskResult, error) { + dispatched = append(dispatched, req.Task.ID) + return TaskResult{Outcome: TaskSucceeded, Output: "ok"}, nil + }, + } + result := tool.RunWithOptions(context.Background(), map[string]any{ + "name": "audit", + "budget": map[string]any{"max_workers": float64(1)}, + "tasks": verifyStageTasks("scan", "trace"), + }, tools.RunOptions{Model: "m"}) + + if result.Status == tools.StatusError { + t.Fatalf("plan refused: %s", result.Output) + } + if len(dispatched) != 3 || dispatched[2] != verifyStageTaskID { + t.Fatalf("dispatched %v, want the verifier last", dispatched) + } + if !strings.Contains(result.Output, verifyStageTaskID) { + t.Fatalf("the run must say a verifier was added:\n%s", result.Output) + } +} + +// THE THIRD CEILING. refuseUnreachablePerTaskCap requires max_tokens to cover +// max_tokens_per_task × taskCount, so the extra task raises that bar by one +// whole per-task cap — a window the 50,000 floor check does not cover whenever +// the per-task cap exceeds it. A 4-task plan with max_tokens 400,000 and +// max_tokens_per_task 100,000 parsed fine and was then refused outright. +func TestTheAppendRespectsThePerTaskCapCeiling(t *testing.T) { + four := verifyStageTasks("a", "b", "c", "d") + // Exactly funded for four at 100k each: the fifth task would need 500k. + if _, note := appendVerifyStageToTaskArgs(four, 20, 400_000, 100_000); note != "" { + t.Fatal("a plan whose per-task cap exactly consumes max_tokens must not be pushed over it") + } + // Room for five: the append is welcome. + if _, note := appendVerifyStageToTaskArgs(four, 20, 500_000, 100_000); note == "" { + t.Fatal("a budget with room for the verifier should have taken it") + } + // No per-task cap set: only the 50k floor applies, as before. + if _, note := appendVerifyStageToTaskArgs(four, 20, 400_000, 0); note == "" { + t.Fatal("without max_tokens_per_task the floor check alone governs") + } +} + +// A TASK THE AUTHOR DID NOT WRITE MUST NOT DECIDE THE AUTHOR'S VERDICT. The +// verifier runs last, on the strongest tier, with the largest briefing — the +// task most likely to stall — and its failure took the whole call to +// StatusError, so the orchestrating model re-ran a plan whose work was done. +func TestTheAppendedVerifiersFailureDoesNotFailThePlan(t *testing.T) { + report := PlanReport{ + Status: PlanPartial, + Succeeded: 3, + Failed: 1, + Tasks: []TaskResult{ + {ID: "a", Outcome: TaskSucceeded}, {ID: "b", Outcome: TaskSucceeded}, + {ID: "c", Outcome: TaskSucceeded}, {ID: verifyStageTaskID, Outcome: TaskFailed}, + }, + } + if !onlyTheAppendedVerifierFailed(report, verifyStageTaskID) { + t.Fatal("the verifier alone failing must be recognised as such") + } + // An author's task failing alongside it is a real failure, verifier or not. + report.Tasks[0].Outcome = TaskFailed + if onlyTheAppendedVerifierFailed(report, verifyStageTaskID) { + t.Fatal("an author task failed too; the plan really did fail") + } + // And with nothing appended, the exemption never applies. + report.Tasks[0].Outcome = TaskSucceeded + if onlyTheAppendedVerifierFailed(report, "") { + t.Fatal("no appended task means no exemption") + } +} 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/plan_wall_clock.go b/internal/specialist/plan_wall_clock.go new file mode 100644 index 000000000..b33941aa8 --- /dev/null +++ b/internal/specialist/plan_wall_clock.go @@ -0,0 +1,108 @@ +package specialist + +import ( + "context" + "sync/atomic" + "time" +) + +// planWallClock is a plan's wall budget, measured in RUNNING time. +// +// A PAUSED PLAN IS NOT SPENDING ITS WALL BUDGET. The bound used to be a plain +// context.WithTimeout started at admission, so the clock kept running while the +// user had the plan paused: pausing a thirty-minute plan for twenty minutes left +// it ten, and pausing one for longer than its budget meant it was already dead +// when the user resumed it — killed, with the reason "max_wall_seconds elapsed +// while this task was running", by time in which nothing ran at all. The budget +// exists to bound what a plan SPENDS, and a paused plan spends nothing. +// +// Nothing is subtracted from a plan that is never paused, so an ordinary run is +// bounded exactly as before. +type planWallClock struct { + budget time.Duration + started time.Time + now func() time.Time + // paused is nanoseconds accumulated across every pause. Atomic because the + // dispatch loop adds to it while the expiry goroutine reads it. + paused atomic.Int64 + // expired records that THIS clock cancelled the plan, which is what tells a + // wall-budget stop apart from a person pressing stop. The context can no + // longer answer that: it is cancelled rather than deadlined, precisely so its + // expiry time can move. + expired atomic.Bool +} + +// newPlanWallClock returns nil when the plan has no wall bound, which every +// method below treats as unbounded. +func newPlanWallClock(budget time.Duration, now func() time.Time) *planWallClock { + if budget <= 0 { + return nil + } + if now == nil { + now = time.Now + } + return &planWallClock{budget: budget, started: now(), now: now} +} + +// remaining is how much running time is left. Unbounded clocks return a positive +// duration forever. +func (c *planWallClock) remaining() time.Duration { + if c == nil { + return 1<<63 - 1 + } + ran := c.now().Sub(c.started) - time.Duration(c.paused.Load()) + return c.budget - ran +} + +// exhausted reports that the running-time budget is gone. +func (c *planWallClock) exhausted() bool { + return c != nil && c.remaining() <= 0 +} + +// wallExpired reports that this clock is what stopped the plan, as opposed to a +// user cancelling it. +func (c *planWallClock) wallExpired() bool { + return c != nil && c.expired.Load() +} + +// addPaused credits time the plan spent paused back to its budget. +func (c *planWallClock) addPaused(d time.Duration) { + if c == nil || d <= 0 { + return + } + c.paused.Add(int64(d)) +} + +// watch cancels the plan once its RUNNING time is spent. +// +// A rescheduling timer, not a poll: it sleeps until the budget would run out, +// and if pausing has since pushed that moment further away it sleeps again for +// exactly the difference. So a plan that is never paused wakes this goroutine +// once, and a paused one wakes it once per pause — no interval to tune, and no +// drift between the bound and the moment it is enforced. +func (c *planWallClock) watch(ctx context.Context, cancel context.CancelFunc) { + if c == nil { + return + } + go func() { + // Every goroutine gets recover(): a panic in the backstop must not take + // down the plan it exists to bound. + defer func() { _ = recover() }() + timer := time.NewTimer(c.remaining()) + defer timer.Stop() + for { + select { + case <-ctx.Done(): + return + case <-timer.C: + left := c.remaining() + if left <= 0 { + c.expired.Store(true) + cancel() + return + } + timer.Reset(left) + } + } + }() +} diff --git a/internal/specialist/plan_watchdog.go b/internal/specialist/plan_watchdog.go new file mode 100644 index 000000000..f0ea1a079 --- /dev/null +++ b/internal/specialist/plan_watchdog.go @@ -0,0 +1,213 @@ +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. +// +// 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 +// 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 + +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 +// 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. +// 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 new file mode 100644 index 000000000..b1216b120 --- /dev/null +++ b/internal/specialist/plan_watchdog_test.go @@ -0,0 +1,389 @@ +package specialist + +import ( + "context" + "errors" + "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) + } + // 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 +// 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 := map[string]int{} + report := ExecutePlan(parent, plan, []string{"read_file"}, + func(ctx context.Context, req PlanTaskRequest) (TaskResult, error) { + ran[req.Task.ID]++ + if req.Task.ID == "a" { + // 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) + + // 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") + } + 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. +// +// STALLPOLL IS WHAT MAKES THIS TEST ABLE TO FAIL. watch() floors its interval at +// one second, so a 60ms StallTimeout still polled once a second and a child +// living 300ms got zero ticks — the watcher never looked, and the test passed +// identically with watchedProgress unwired, which is the one thing it claims to +// cover. Driving the poll in milliseconds makes the goroutine actually run +// several times inside the child's life. +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, + // Roughly thirty checks inside the child's 300ms, each one a real chance + // to declare it stalled. Without this the watcher ticks zero times. + StallPoll: 10 * 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()) + } +} diff --git a/internal/specialist/plan_workspace_reach.go b/internal/specialist/plan_workspace_reach.go new file mode 100644 index 000000000..f191f148c --- /dev/null +++ b/internal/specialist/plan_workspace_reach.go @@ -0,0 +1,352 @@ +package specialist + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// A plan that isolated into a tree its own tasks cannot read. +// +// THE MEASURED FAILURE. A six-task research plan was started from a directory +// that happened to be a git repo with ONE tracked file. Five tasks asked for +// read_file/grep/glob; the sixth also asked for exec_command, which is not in +// planReadOnlyTools — so RequiresIsolation flipped for the WHOLE plan and every +// task was moved into a worktree containing that one file. The tasks had been +// pointed at absolute paths in a different tree, which was now outside their +// sandbox, and three of them died reporting "I cannot". About 143,000 tokens, +// and the sixth task succeeded only because asking for exec_command had raised +// its autonomy far enough to read back out. +// +// Isolation itself is right: write tasks belong in a worktree. What was missing +// is that nothing checked the worktree could serve the plan before dispatching +// six tasks into it. resolvePlanWorkspace already refuses when isolation is +// UNAVAILABLE; this refuses when it is available and useless. +// +// FAILS TOWARD RUNNING THE PLAN. A false refusal blocks work that would have +// succeeded, so this only fires on evidence that cannot be a guess: an absolute +// path, written in a task's own prompt, that EXISTS on disk, and that is outside +// the workspace the task will run in. All three, or nothing happens. A path that +// does not exist is somebody else's error; a relative path resolves against the +// workspace and is not this function's business; a plan that never isolated +// keeps the parent's tree and cannot have this problem at all. + +// absolutePathPattern finds absolute paths in prose — POSIX (/a/b) and Windows +// (C:\a\b, C:/a/b) forms, with either separator throughout, because a task +// prompt written on Windows names Windows paths and a POSIX-only pattern found +// none of them: the reach check silently never fired there. The character class +// includes '~' for Windows 8.3 short names (RUNNER~1), which real CI paths +// contain. +// +// Deliberately loose, because the EXISTENCE check below is what makes a match +// meaningful. It will happily match "/settings" out of a URL and "/v1/pay" out +// of a sentence; neither exists on disk, so neither survives — and a +// backslash-shaped candidate on POSIX fails the same Lstat. +var absolutePathPattern = regexp.MustCompile(`(?:[A-Za-z]:)?[/\\][A-Za-z0-9._+@~-]+(?:[/\\][A-Za-z0-9._+@~-]+)+`) + +// unreachablePlanPaths lists paths the plan's own prompts name that the plan's +// workspace cannot reach. Empty for every plan that is fine — including every +// plan that did not isolate. +func unreachablePlanPaths(plan Plan, workspace PlanWorkspace) []string { + if !workspace.Isolated || strings.TrimSpace(workspace.Path) == "" { + return nil + } + root := resolvedPath(workspace.Path) + seen := map[string]bool{} + var unreachable []string + for _, task := range plan.Tasks() { + for _, candidate := range absolutePathPattern.FindAllString(task.Prompt, -1) { + path, ok := existingPath(candidate) + if !ok || seen[path] { + continue + } + seen[path] = true + if !pathInsideRoot(root, resolvedPath(path)) { + unreachable = append(unreachable, path) + } + } + } + sort.Strings(unreachable) + return unreachable +} + +// existingPath reports the path if something is really there. +// +// Trailing punctuation is retried once, because a path at the end of a sentence +// arrives as "…/plan.go." and the bare Stat would miss it — a false NEGATIVE, +// which is the safe direction but a cheap one to reduce. +func existingPath(candidate string) (string, bool) { + trimmed := strings.TrimRight(candidate, ".,;:)]}\"'") + if _, err := os.Lstat(candidate); err == nil { + // AN EXACT HIT CAN BE A WINDOWS QUIRK, not a real dotted filename: NTFS + // ignores trailing dots, so Lstat("…/streamer.go.") succeeds for the + // file named "…/streamer.go" and the sentence's full stop came back as + // part of the path. When the trimmed spelling names the SAME file, + // prefer it — it is the canonical name on both platforms. A POSIX file + // genuinely named with a trailing dot keeps its exact spelling: its + // trimmed sibling either does not exist or is a different file. + if trimmed != candidate && trimmed != "" && sameLstatFile(candidate, trimmed) { + return trimmed, true + } + return candidate, true + } + if trimmed == candidate || trimmed == "" { + return "", false + } + if _, err := os.Lstat(trimmed); err == nil { + return trimmed, true + } + return "", false +} + +// sameLstatFile reports whether two paths name the same file, false on any +// error — an unanswerable comparison must not rewrite a spelling. +func sameLstatFile(a, b string) bool { + infoA, err := os.Lstat(a) + if err != nil { + return false + } + infoB, err := os.Lstat(b) + if err != nil { + return false + } + return os.SameFile(infoA, infoB) +} + +// resolvedPath follows symlinks so /var and /private/var are the same place. +// Falls back to a lexical clean when it cannot resolve, which is the honest +// answer for a path that exists but cannot be walked. +func resolvedPath(path string) string { + if resolved, err := filepath.EvalSymlinks(path); err == nil { + return filepath.Clean(resolved) + } + return filepath.Clean(path) +} + +// pathInsideRoot reports containment, SEPARATOR-AWARE: "/a/b" does not contain +// "/a/bc", which a bare strings.HasPrefix would get wrong. +// +// BOTH separators are boundaries on every platform. Keying on +// filepath.Separator alone made the check answer false for every /-separated +// pair on Windows — the whole reach guard quietly never contained anything — +// and prompts freely mix the two spellings of the same path. +func pathInsideRoot(root, path string) bool { + if root == "" || path == "" { + return false + } + if path == root { + return true + } + root = strings.TrimRight(root, `/\`) + if root == "" || !strings.HasPrefix(path, root) || len(path) <= len(root) { + return false + } + next := path[len(root)] + return next == '/' || next == '\\' +} + +// THE SECOND SHAPE OF THE SAME FAILURE, and the one the absolute-path check +// above misses entirely. +// +// A later run of the same plan named its targets RELATIVELY — "internal/ +// specialist/", "internal/swarm/" — plus a commit sha. Not one absolute path +// anywhere, so unreachablePlanPaths found nothing and six tasks were dispatched +// into a worktree holding a single file. The child said it plainly: "no Go files +// anywhere in the workspace… the only reachable commit is 3177684c". +// +// A relative path is far weaker evidence than an absolute one: prose is full of +// slash-shaped things, and a path that does not exist yet is exactly what a +// scaffolding task is for. So this fires only on a CONJUNCTION that a working +// plan cannot satisfy: +// +// - the plan isolated, and +// - it names at least two path-shaped tokens, and +// - NOT ONE of them exists in the workspace, and +// - the workspace is bare. +// +// A plan genuinely about its own tree resolves at least one token. A scaffolding +// plan creating new files runs in a worktree of a real repo, which is not bare. +// Both conditions have to be wrong at once, which is what the measured failure +// looked like and what an ordinary run does not. + +// relativePathPattern matches path-shaped tokens. +// +// DELIBERATELY NARROWER THAN "has a slash", because "and/or", "read/write" and +// "input/output" are not paths and appear in prompts constantly. A token +// qualifies only if it ends in a slash, carries a file extension, or has three +// or more segments — which those three do not. +var relativePathPattern = regexp.MustCompile(`\b[A-Za-z0-9._-]+/(?:[A-Za-z0-9._-]+/)*[A-Za-z0-9._-]*`) + +// urlPattern strips URLs before scanning, so "https://ollama.com/settings" does +// not contribute "ollama.com/settings" as a path. +var urlPattern = regexp.MustCompile(`\b[a-zA-Z][a-zA-Z0-9+.-]*://\S+`) + +// bareWorkspaceMaxEntries is how few visible entries make a workspace "bare". +// The measured failure had exactly one — gauntlet_test.txt. A checkout a plan +// could actually work in has many more, so this does not need to be near the +// boundary and deliberately is not. +const bareWorkspaceMaxEntries = 2 + +// planNamesNothingPresent reports the tokens a plan names when NONE of them are +// in the workspace and the workspace is bare. +func planNamesNothingPresent(plan Plan, workspace PlanWorkspace) ([]string, bool) { + if !workspace.Isolated || strings.TrimSpace(workspace.Path) == "" { + return nil, false + } + if !workspaceIsBare(workspace.Path) { + return nil, false + } + seen := map[string]bool{} + var named []string + for _, task := range plan.Tasks() { + text := urlPattern.ReplaceAllString(task.Prompt, " ") + for _, token := range relativePathPattern.FindAllString(text, -1) { + token = strings.TrimRight(token, ".,;:)]}\"'") + if !pathShapedToken(token) || seen[token] { + continue + } + seen[token] = true + // ANY hit ends it: the workspace is serving the plan after all, and + // bare or not, this is not the failure being detected. + if _, err := os.Lstat(filepath.Join(workspace.Path, filepath.FromSlash(token))); err == nil { + return nil, false + } + named = append(named, token) + } + } + if len(named) < 2 { + return nil, false + } + sort.Strings(named) + return named, true +} + +// pathShapedToken keeps only tokens that read as file paths rather than as +// ordinary prose containing a slash. +func pathShapedToken(token string) bool { + if token == "" || strings.HasPrefix(token, "/") { + return false + } + // A SLASH IS REQUIRED HERE, not merely guaranteed by the caller. The pattern + // that feeds this always includes one, so leaning on that would work — and + // would leave a helper that answers "yes" for "main.go", correct only by + // accident and wrong the moment anything else calls it. + if !strings.Contains(token, "/") { + return false + } + if strings.HasSuffix(token, "/") { + return true + } + segments := strings.Split(token, "/") + if len(segments) >= 3 { + return true + } + return strings.Contains(segments[len(segments)-1], ".") +} + +// workspaceIsBare reports whether the workspace holds almost nothing. Dot +// entries are skipped: a worktree always has .git, and counting it would make +// every bare tree look occupied by one. +func workspaceIsBare(root string) bool { + entries, err := os.ReadDir(root) + if err != nil { + // UNREADABLE IS NOT BARE. Refusing a plan because a directory could not + // be listed would turn a transient filesystem error into a refusal. + return false + } + visible := 0 + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), ".") { + continue + } + visible++ + if visible > bareWorkspaceMaxEntries { + return false + } + } + return true +} + +// refuseBareWorkspace is the relative-path refusal, and it leads with the fact +// that decides it: the workspace is empty of everything the plan mentions. +func refuseBareWorkspace(plan Plan, workspace PlanWorkspace, named []string) error { + shown := named + const maxShown = 3 + more := "" + if len(shown) > maxShown { + more = fmt.Sprintf(" (and %d more)", len(shown)-maxShown) + shown = shown[:maxShown] + } + because := "" + if taskID, tool := planIsolationTrigger(plan); taskID != "" { + because = fmt.Sprintf(" The plan runs in a worktree because task %q asks for %q, which is not a read-only tool — and isolation applies to every task, not just that one.", taskID, tool) + } + return fmt.Errorf( + "%s would run in an isolated worktree at %s that contains none of what its tasks name — not %s — and holds almost nothing at all. "+ + "Every task would search an empty tree.%s "+ + "Either start the run from the tree those paths are in, or remove the write tool so the plan stays read-only and runs where its parent does", + describePlan(plan), workspace.Path, strings.Join(shown, ", ")+more, because) +} + +// planIsolationTrigger names the first task and tool that made the plan need a +// worktree, so the refusal can say WHY it isolated rather than leaving the +// author to work it out. Isolation is a plan-level property and one task's +// grant decides it for all of them, which is exactly the surprising part. +func planIsolationTrigger(plan Plan) (taskID, tool string) { + for _, task := range plan.Tasks() { + for _, name := range task.Tools { + if !planReadOnlyTools[name] { + return task.ID, name + } + } + } + return "", "" +} + +// refuseUnreachableWorkspace turns the finding into the error a caller sees. +// +// IT NAMES BOTH FIXES, because the author cannot be expected to know that one +// task's tool grant moved every other task's workspace: run from the tree the +// paths are in, or drop the tool that forced isolation and let the plan stay +// where its parent is. +func refuseUnreachableWorkspace(plan Plan, workspace PlanWorkspace, unreachable []string) error { + shown := unreachable + const maxShown = 3 + more := "" + if len(shown) > maxShown { + more = fmt.Sprintf(" (and %d more)", len(shown)-maxShown) + shown = shown[:maxShown] + } + because := "" + if taskID, tool := planIsolationTrigger(plan); taskID != "" { + because = fmt.Sprintf(" The plan runs in a worktree because task %q asks for %q, which is not a read-only tool — and isolation applies to every task, not just that one.", taskID, tool) + } + return fmt.Errorf( + "%s would run in an isolated worktree at %s, and its tasks name %s that the worktree does not contain: %s%s.%s "+ + "Every task reading those paths would be refused by the sandbox. "+ + "Either start the run from the tree those paths are in, or remove the write tool so the plan stays read-only and runs where its parent does", + describePlan(plan), workspace.Path, pluralPaths(len(unreachable)), strings.Join(shown, ", "), more, because) +} + +func pluralPaths(n int) string { + if n == 1 { + return "a path" + } + return "paths" +} + +// describePlan names a plan for an error message. +// +// `name` is OPTIONAL on the orchestrate tool, so Plan.Name() is legitimately +// empty — and a refusal that interpolated it read `plan "" would run in an +// isolated worktree`, which names nothing and reads like a fault in the reader's +// own plan. An unnamed plan is described by what it is instead. +func describePlan(plan Plan) string { + if name := strings.TrimSpace(plan.Name()); name != "" { + return fmt.Sprintf("plan %q", name) + } + return fmt.Sprintf("this unnamed %d-task plan", plan.TaskCount()) +} diff --git a/internal/specialist/plan_workspace_reach_test.go b/internal/specialist/plan_workspace_reach_test.go new file mode 100644 index 000000000..094d5a0c4 --- /dev/null +++ b/internal/specialist/plan_workspace_reach_test.go @@ -0,0 +1,558 @@ +package specialist + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// THE MEASURED FAILURE, REPRODUCED. A six-task plan started from a directory +// that was a git repo with one tracked file; one task asked for exec_command, so +// every task was moved into a worktree of that directory; the five reading a +// different tree were refused by the sandbox and three died reporting "I +// cannot". This is that shape, in miniature. +func reachPlan(t *testing.T, prompt string, tools []string) Plan { + t.Helper() + anyTools := make([]any, 0, len(tools)) + for _, name := range tools { + anyTools = append(anyTools, name) + } + return mustParsePlan(t, map[string]any{ + "name": "swarm-session-id-leak", + "tasks": []any{ + map[string]any{"id": "finder", "prompt": prompt, "tools": anyTools}, + map[string]any{"id": "runner", "prompt": "run the tests", "tools": []any{"read_file", "exec_command"}}, + }, + "budget": map[string]any{"max_workers": float64(1)}, + }, Limits{MaxTasks: 20, ParentTools: []string{"read_file", "grep", "glob", "exec_command"}}) +} + +func isolatedWorkspace(path string) PlanWorkspace { + return PlanWorkspace{Path: path, Isolated: true, Describe: "worktree", Release: func() {}} +} + +func TestAPlanIsRefusedWhenItsWorktreeCannotReachThePathsItNames(t *testing.T) { + // The tree the tasks were pointed at. + realTree := t.TempDir() + target := filepath.Join(realTree, "internal", "swarm", "tools.go") + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, []byte("package swarm\n"), 0o600); err != nil { + t.Fatal(err) + } + // The worktree the plan actually got: a different tree, with one file. + worktree := t.TempDir() + if err := os.WriteFile(filepath.Join(worktree, "gauntlet_test.txt"), []byte("test"), 0o600); err != nil { + t.Fatal(err) + } + + plan := reachPlan(t, "Read "+target+" and report every render site.", []string{"read_file", "grep"}) + unreachable := unreachablePlanPaths(plan, isolatedWorkspace(worktree)) + if len(unreachable) == 0 { + t.Fatal("a plan naming a path outside its own worktree was allowed to dispatch") + } + if unreachable[0] != target { + t.Fatalf("named %q, want %q", unreachable[0], target) + } + + // The refusal must be ACTIONABLE: name the path, name why it isolated, and + // name both ways out. + err := refuseUnreachableWorkspace(plan, isolatedWorkspace(worktree), unreachable) + text := err.Error() + for _, want := range []string{target, worktree, "exec_command", "runner", "read-only"} { + if !strings.Contains(text, want) { + t.Errorf("the refusal never mentions %q:\n%s", want, text) + } + } +} + +// FAILS TOWARD RUNNING THE PLAN. Each of these must NOT fire — a false refusal +// blocks work that would have succeeded. +func TestTheReachGuardStaysQuietWithoutUnambiguousEvidence(t *testing.T) { + worktree := t.TempDir() + // A real file in a real tree that is NOT the worktree, so the only thing + // keeping the guard quiet in the non-isolated cases is the isolation check. + elsewhere := t.TempDir() + outsideAndReal := filepath.Join(elsewhere, "outside.go") + if err := os.WriteFile(outsideAndReal, []byte("package outside\n"), 0o600); err != nil { + t.Fatal(err) + } + inside := filepath.Join(worktree, "internal", "app.go") + if err := os.MkdirAll(filepath.Dir(inside), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(inside, []byte("package app\n"), 0o600); err != nil { + t.Fatal(err) + } + + for _, tc := range []struct { + name string + prompt string + workspace PlanWorkspace + }{ + { + // The overwhelmingly common case: no isolation, so the parent's tree + // is the workspace and nothing was ever relocated. + // + // THE PATH MUST EXIST AND BE OUTSIDE, or this case proves nothing: an + // earlier version pointed at a path that was not there, so the + // existence check filtered it and the isolation check was never + // reached — a mutation removing that check passed cleanly. + name: "a plan that never isolated", + prompt: "Read " + outsideAndReal + " for context.", + workspace: PlanWorkspace{}, + }, + { + // Same, with a path recorded but isolation false: only the Isolated + // flag stands between this and a refusal. + name: "a workspace that is named but not isolated", + prompt: "Read " + outsideAndReal + " for context.", + workspace: PlanWorkspace{Path: worktree, Isolated: false}, + }, + { + name: "a path inside the worktree", + prompt: "Read " + inside + " carefully.", + workspace: isolatedWorkspace(worktree), + }, + { + // Somebody else's error. A path that is not there would fail for its + // own reasons and this guard must not claim it. + name: "a path that does not exist", + prompt: "Read /Users/nobody/dev/ghost/internal/thing.go", + workspace: isolatedWorkspace(worktree), + }, + { + // Prose is full of slash-shaped things that are not paths. + name: "a URL path and an endpoint", + prompt: "See https://ollama.com/settings and the /v1/pay endpoint, then read app.go", + workspace: isolatedWorkspace(worktree), + }, + { + name: "relative paths resolve against the workspace", + prompt: "Read internal/app.go and internal/swarm/tools.go", + workspace: isolatedWorkspace(worktree), + }, + } { + t.Run(tc.name, func(t *testing.T) { + plan := reachPlan(t, tc.prompt, []string{"read_file", "grep"}) + if got := unreachablePlanPaths(plan, tc.workspace); len(got) > 0 { + t.Fatalf("refused a plan that would have worked: %v", got) + } + }) + } +} + +// A path at the end of a sentence arrives with punctuation attached; missing it +// is safe but cheap to avoid. +func TestAPathFollowedByPunctuationIsStillFound(t *testing.T) { + realTree := t.TempDir() + target := filepath.Join(realTree, "streamer.go") + if err := os.WriteFile(target, []byte("package x\n"), 0o600); err != nil { + t.Fatal(err) + } + worktree := t.TempDir() + + for _, suffix := range []string{".", ",", ")", "'", `"`, ";"} { + plan := reachPlan(t, "Open "+target+suffix+" and report.", []string{"read_file"}) + got := unreachablePlanPaths(plan, isolatedWorkspace(worktree)) + if len(got) != 1 || got[0] != target { + t.Errorf("suffix %q: got %v, want [%s]", suffix, got, target) + } + } +} + +// "/a/b" does not contain "/a/bc". A bare prefix check gets this wrong and would +// wave through a plan pointed at a sibling directory. +func TestContainmentIsSeparatorAware(t *testing.T) { + for _, tc := range []struct { + root, path string + want bool + }{ + {"/a/b", "/a/b", true}, + {"/a/b", "/a/b/c", true}, + {"/a/b", "/a/bc", false}, + {"/a/b", "/a/bc/d", false}, + {"/a/b/", "/a/b/c", true}, + {"", "/a", false}, + {"/a", "", false}, + } { + if got := pathInsideRoot(tc.root, tc.path); got != tc.want { + t.Errorf("pathInsideRoot(%q, %q) = %v, want %v", tc.root, tc.path, got, tc.want) + } + } +} + +// The refusal must name the tool that caused isolation, because one task's grant +// silently decides the workspace for every other task — the surprising part. +func TestTheTriggeringTaskAndToolAreNamed(t *testing.T) { + plan := reachPlan(t, "read something", []string{"read_file", "grep"}) + taskID, tool := planIsolationTrigger(plan) + if taskID != "runner" || tool != "exec_command" { + t.Fatalf("trigger = (%q, %q), want (\"runner\", \"exec_command\")", taskID, tool) + } + + // A wholly read-only plan has no trigger to name. + readOnly := mustParsePlan(t, map[string]any{ + "name": "p", + "tasks": []any{map[string]any{"id": "a", "prompt": "look", "tools": []any{"read_file"}}}, + "budget": map[string]any{"max_workers": float64(1)}, + }, Limits{MaxTasks: 20, ParentTools: PlanReadOnlyToolNames()}) + if id, name := planIsolationTrigger(readOnly); id != "" || name != "" { + t.Fatalf("a read-only plan reported a trigger (%q, %q)", id, name) + } +} + +// END TO END through resolvePlanWorkspace: the refusal must arrive from the one +// function both call sites use, and the worktree must not be left on disk. +func TestResolvePlanWorkspaceRefusesAndReleasesTheWorktree(t *testing.T) { + realTree := t.TempDir() + target := filepath.Join(realTree, "tools.go") + if err := os.WriteFile(target, []byte("package x\n"), 0o600); err != nil { + t.Fatal(err) + } + worktree := t.TempDir() + + released := false + isolate := func(context.Context, string) (PlanWorkspace, error) { + return PlanWorkspace{ + Path: worktree, Isolated: true, Describe: "worktree", + Release: func() { released = true }, + }, nil + } + plan := reachPlan(t, "Read "+target+" and report.", []string{"read_file"}) + + got, err := resolvePlanWorkspace(context.Background(), plan, isolate) + if err == nil { + t.Fatal("the plan was allowed to run in a worktree it cannot read from") + } + if got.Path != "" { + t.Fatalf("a refused resolution still handed back a workspace: %+v", got) + } + if !released { + t.Fatal("the worktree was left on disk: one leaked directory per attempt") + } + if !strings.Contains(err.Error(), target) { + t.Fatalf("the refusal does not name the unreachable path: %v", err) + } +} + +// A plan whose worktree DOES serve it must still run, and must keep its +// workspace — the guard is a check, not a new refusal path for ordinary plans. +func TestAWorktreeThatServesThePlanIsStillAccepted(t *testing.T) { + worktree := t.TempDir() + inside := filepath.Join(worktree, "tools.go") + if err := os.WriteFile(inside, []byte("package x\n"), 0o600); err != nil { + t.Fatal(err) + } + isolate := func(context.Context, string) (PlanWorkspace, error) { + return PlanWorkspace{Path: worktree, Isolated: true, Describe: "worktree", Release: func() {}}, nil + } + plan := reachPlan(t, "Read "+inside+" and report.", []string{"read_file"}) + + got, err := resolvePlanWorkspace(context.Background(), plan, isolate) + if err != nil { + t.Fatalf("a usable worktree was refused: %v", err) + } + if got.Path != worktree || !got.Isolated { + t.Fatalf("workspace = %+v, want the worktree", got) + } +} + +// The pre-existing refusals must survive: this adds a check, it does not replace +// the ones that were already there. +func TestTheExistingWorkspaceRefusalsStillFire(t *testing.T) { + writePlan := reachPlan(t, "read something", []string{"read_file"}) + + if _, err := resolvePlanWorkspace(context.Background(), writePlan, nil); err == nil { + t.Fatal("a write-capable plan ran with no isolator") + } + dishonest := func(context.Context, string) (PlanWorkspace, error) { + return PlanWorkspace{Path: "", Isolated: false}, nil + } + if _, err := resolvePlanWorkspace(context.Background(), writePlan, dishonest); err == nil { + t.Fatal("an isolator returning no isolation was believed") + } + failing := func(context.Context, string) (PlanWorkspace, error) { + return PlanWorkspace{}, errors.New("no git here") + } + if _, err := resolvePlanWorkspace(context.Background(), writePlan, failing); err == nil { + t.Fatal("a failed isolator was ignored") + } +} + +// THE 04:48 RUN, REPRODUCED. Same plan as the earlier failure, but its targets +// were named RELATIVELY — "internal/specialist/", "internal/swarm/" — plus a +// commit sha. Not one absolute path, so the check above found nothing and six +// tasks were dispatched into a worktree holding one file. +func bareWorktree(t *testing.T) string { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "gauntlet_test.txt"), []byte("test"), 0o600); err != nil { + t.Fatal(err) + } + // A worktree always has a .git entry; it must not count as content. + if err := os.WriteFile(filepath.Join(dir, ".git"), []byte("gitdir: /elsewhere\n"), 0o600); err != nil { + t.Fatal(err) + } + return dir +} + +func TestAPlanIsRefusedWhenABareWorktreeHoldsNothingItNames(t *testing.T) { + worktree := bareWorktree(t) + plan := reachPlan(t, + "Trace session_id emission. Read internal/specialist/streamer.go and internal/swarm/launcher_specialist.go, then check commit 5400fa46.", + []string{"read_file", "grep", "glob"}) + + named, bare := planNamesNothingPresent(plan, isolatedWorkspace(worktree)) + if !bare { + t.Fatal("a plan naming only paths absent from a bare worktree was allowed to dispatch") + } + if len(named) < 2 { + t.Fatalf("expected the paths it named, got %v", named) + } + err := refuseBareWorkspace(plan, isolatedWorkspace(worktree), named) + for _, want := range []string{"internal/specialist/streamer.go", worktree, "exec_command", "read-only"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal never mentions %q:\n%s", want, err.Error()) + } + } +} + +// THE CONJUNCTION IS THE SAFETY. Each of these breaks exactly one condition and +// must therefore NOT refuse — a relative path is weak evidence on its own. +func TestTheBareWorkspaceGuardNeedsEveryConditionAtOnce(t *testing.T) { + populated := t.TempDir() + for _, rel := range []string{"internal/specialist/streamer.go", "cmd/zero/main.go", "README.md", "go.mod"} { + full := filepath.Join(populated, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + } + bare := bareWorktree(t) + + // Bare by entry count — one visible directory — yet holding a file the plan + // names. Only the resolving-path clause keeps the guard quiet here. + bareButServing := t.TempDir() + served := filepath.Join(bareButServing, "src", "app.go") + if err := os.MkdirAll(filepath.Dir(served), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(served, []byte("package app"), 0o600); err != nil { + t.Fatal(err) + } + if !workspaceIsBare(bareButServing) { + t.Fatal("setup: this tree must read as bare or the case proves nothing") + } + + for _, tc := range []struct { + name string + prompt string + workspace PlanWorkspace + }{ + { + // A scaffolding plan: the files do not exist YET, but the worktree is + // a real checkout. This is the false refusal most worth avoiding. + name: "new files in a populated worktree", + prompt: "Create internal/brand/new.go and internal/brand/new_test.go.", + workspace: isolatedWorkspace(populated), + }, + { + // One token resolves, so the worktree IS serving the plan. + // + // THE TREE MUST BE BARE HERE, or this case proves nothing: pointed at + // a populated tree it was excluded by the bareness check before the + // resolving-path clause was reached, and a mutation deleting that + // clause passed cleanly. Bare AND one path present is the only shape + // that exercises it. + name: "one named path is present in an otherwise bare tree", + prompt: "Read src/app.go and internal/ghost/absent.go.", + workspace: isolatedWorkspace(bareButServing), + }, + { + name: "every named path is present", + prompt: "Read internal/specialist/streamer.go and cmd/zero/main.go.", + workspace: isolatedWorkspace(populated), + }, + { + name: "only one path-shaped token", + prompt: "Read internal/specialist/streamer.go and report.", + workspace: isolatedWorkspace(bare), + }, + { + name: "the plan never isolated", + prompt: "Read internal/specialist/streamer.go and internal/swarm/tools.go.", + workspace: PlanWorkspace{Path: bare, Isolated: false}, + }, + { + // Prose with slashes is not a path list. + name: "and/or, read/write, input/output", + prompt: "Decide and/or report, covering read/write and input/output behaviour.", + workspace: isolatedWorkspace(bare), + }, + { + // A URL must not contribute its path. + name: "a URL and a host path", + prompt: "See https://ollama.com/settings/keys and https://github.com/Gitlawb/zero/pull/829.", + workspace: isolatedWorkspace(bare), + }, + } { + t.Run(tc.name, func(t *testing.T) { + plan := reachPlan(t, tc.prompt, []string{"read_file", "grep"}) + if named, refused := planNamesNothingPresent(plan, tc.workspace); refused { + t.Fatalf("refused a plan that would have worked, on %v", named) + } + }) + } +} + +// The token rule itself, since it is what keeps prose out. +func TestOnlyPathShapedTokensCount(t *testing.T) { + for token, want := range map[string]bool{ + "internal/specialist/": true, + "internal/swarm/tools.go": true, + "a/b/c": true, + "main.go": false, // no slash at all + "and/or": false, + "read/write": false, + "input/output": false, + "cmd/zero": false, // two bare segments: safe to miss + "internal/app.go": true, + "": false, + "/absolute/path.go": false, // the other check owns absolutes + } { + if got := pathShapedToken(token); got != want { + t.Errorf("pathShapedToken(%q) = %v, want %v", token, got, want) + } + } +} + +// A worktree's .git must not make it look occupied, and a real checkout must +// never read as bare. +func TestBarenessIgnoresDotEntries(t *testing.T) { + if !workspaceIsBare(bareWorktree(t)) { + t.Fatal("a worktree with one file and .git did not read as bare") + } + populated := t.TempDir() + for _, name := range []string{"a", "b", "c", "d"} { + if err := os.WriteFile(filepath.Join(populated, name), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + } + if workspaceIsBare(populated) { + t.Fatal("a populated tree read as bare") + } + // UNREADABLE IS NOT BARE: a filesystem error must not become a refusal. + if workspaceIsBare(filepath.Join(t.TempDir(), "nope")) { + t.Fatal("an unreadable directory read as bare") + } +} + +// END TO END through the one function both call sites use, and the worktree must +// not be left behind. +func TestResolvePlanWorkspaceRefusesABareWorktree(t *testing.T) { + worktree := bareWorktree(t) + released := false + isolate := func(context.Context, string) (PlanWorkspace, error) { + return PlanWorkspace{ + Path: worktree, Isolated: true, Describe: "worktree", + Release: func() { released = true }, + }, nil + } + plan := reachPlan(t, + "Read internal/specialist/streamer.go and internal/swarm/tools.go and report.", + []string{"read_file", "grep"}) + + if _, err := resolvePlanWorkspace(context.Background(), plan, isolate); err == nil { + t.Fatal("the plan was allowed to search an empty worktree") + } else if !strings.Contains(err.Error(), "internal/specialist/streamer.go") { + t.Fatalf("the refusal does not name what it could not find: %v", err) + } + if !released { + t.Fatal("the worktree was left on disk") + } +} + +// `name` IS OPTIONAL ON THE ORCHESTRATE TOOL, so a refusal that interpolated +// Plan.Name() rendered `plan "" would run in an isolated worktree` — naming +// nothing, and reading like a fault in the reader's own plan. Seen verbatim. +func TestAnUnnamedPlanIsStillDescribedInTheRefusal(t *testing.T) { + worktree := bareWorktree(t) + unnamed := mustParsePlan(t, map[string]any{ + "tasks": []any{ + map[string]any{"id": "a", "prompt": "read internal/specialist/streamer.go and internal/swarm/tools.go", "tools": []any{"read_file"}}, + map[string]any{"id": "b", "prompt": "run it", "tools": []any{"read_file", "exec_command"}}, + }, + "budget": map[string]any{"max_workers": float64(1)}, + }, Limits{MaxTasks: 20, ParentTools: []string{"read_file", "exec_command"}}) + + if unnamed.Name() != "" { + t.Skip("plans now require a name; this case cannot arise") + } + named, bare := planNamesNothingPresent(unnamed, isolatedWorkspace(worktree)) + if !bare { + t.Fatal("setup: the guard did not fire, so there is no message to check") + } + text := refuseBareWorkspace(unnamed, isolatedWorkspace(worktree), named).Error() + if strings.Contains(text, `plan ""`) { + t.Fatalf("the refusal names nothing:\n%s", text) + } + if !strings.Contains(text, "unnamed") || !strings.Contains(text, "2-task") { + t.Fatalf("an unnamed plan is not described by what it is:\n%s", text) + } + // And a named plan still says its name. + namedPlan := reachPlan(t, "read internal/specialist/streamer.go and internal/swarm/tools.go", []string{"read_file"}) + got, _ := planNamesNothingPresent(namedPlan, isolatedWorkspace(worktree)) + if text := refuseBareWorkspace(namedPlan, isolatedWorkspace(worktree), got).Error(); !strings.Contains(text, `plan "swarm-session-id-leak"`) { + t.Fatalf("a named plan lost its name:\n%s", text) + } +} + +// AN EXACT HIT CAN BE A WINDOWS QUIRK. NTFS ignores trailing dots, so +// Lstat("streamer.go.") succeeds for "streamer.go" and the sentence's full stop +// came back as part of the path — the Windows CI failure this closes. The +// same-file preference must NOT rewrite a POSIX file genuinely named with a +// trailing dot, which these cases pin. +func TestExistingPathPrefersTheCanonicalSpelling(t *testing.T) { + if runtime.GOOS == "windows" { + // The dotted names below are not creatable on NTFS; the quirk itself is + // covered by TestAPathFollowedByPunctuationIsStillFound on the Windows + // runner. + t.Skip("trailing-dot filenames are not creatable on Windows") + } + dir := t.TempDir() + plain := filepath.Join(dir, "plain.go") + if err := os.WriteFile(plain, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + // A genuinely dot-suffixed file, alone: its exact spelling must survive. + dottedOnly := filepath.Join(dir, "only.") + if err := os.WriteFile(dottedOnly, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if got, ok := existingPath(dottedOnly); !ok || got != dottedOnly { + t.Fatalf("a real dotted filename was rewritten: got %q ok=%v", got, ok) + } + // Both spellings exist as DIFFERENT files: the exact one wins. + pairBase := filepath.Join(dir, "pair") + pairDotted := pairBase + "." + for _, path := range []string{pairBase, pairDotted} { + if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + if got, ok := existingPath(pairDotted); !ok || got != pairDotted { + t.Fatalf("a distinct dotted sibling was rewritten: got %q ok=%v", got, ok) + } + // The missing-with-punctuation case keeps its trim. + if got, ok := existingPath(plain + "."); !ok || got != plain { + t.Fatalf("trailing punctuation must still trim to the real file: got %q ok=%v", got, ok) + } +} diff --git a/internal/specialist/plan_worktree.go b/internal/specialist/plan_worktree.go new file mode 100644 index 000000000..a8b5248ba --- /dev/null +++ b/internal/specialist/plan_worktree.go @@ -0,0 +1,124 @@ +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: 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() {} + } + // AND THE WORKTREE MUST BE ABLE TO SERVE THE PLAN. The two refusals above + // cover isolation being unavailable or dishonest; this covers it being + // available, honest, and useless — a worktree of the wrong tree, which a + // six-task plan discovered one failed task at a time. See + // unreachablePlanPaths for the evidence bar this holds itself to. + // + // RELEASED BEFORE RETURNING. A refusal that left the worktree on disk would + // leak one directory per attempt, and the caller has no handle to clean up + // something it was never given. + if unreachable := unreachablePlanPaths(plan, workspace); len(unreachable) > 0 { + workspace.Release() + return PlanWorkspace{}, refuseUnreachableWorkspace(plan, workspace, unreachable) + } + // THE SAME FAILURE NAMED RELATIVELY. The check above sees only absolute + // paths, and a measured run that named its targets as "internal/specialist/" + // carried none — so six tasks searched an empty worktree and the guard said + // nothing. See planNamesNothingPresent for the conjunction this holds to. + if named, bare := planNamesNothingPresent(plan, workspace); bare { + workspace.Release() + return PlanWorkspace{}, refuseBareWorkspace(plan, workspace, named) + } + 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..929737518 --- /dev/null +++ b/internal/specialist/plan_worktree_test.go @@ -0,0 +1,270 @@ +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) + } +} + +// 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) + } +} 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/specialist/plan_write_test.go b/internal/specialist/plan_write_test.go new file mode 100644 index 000000000..42f36b9c7 --- /dev/null +++ b/internal/specialist/plan_write_test.go @@ -0,0 +1,442 @@ +package specialist + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/streamjson" + "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) + } +} + +// 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") + } +} + +// 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/plans/research.json b/internal/specialist/plans/research.json new file mode 100644 index 000000000..16052a7f2 --- /dev/null +++ b/internal/specialist/plans/research.json @@ -0,0 +1,37 @@ +{ + "name": "research", + "description": "Answer a question about ${subject} 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 ${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 ${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 ${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 this: ${subject}. 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/specialist/resume_manifest_test.go b/internal/specialist/resume_manifest_test.go new file mode 100644 index 000000000..7ee7e37e2 --- /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) + } +} 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, " ")) + } +} 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/session_budget.go b/internal/specialist/session_budget.go new file mode 100644 index 000000000..5ac9030e5 --- /dev/null +++ b/internal/specialist/session_budget.go @@ -0,0 +1,73 @@ +package specialist + +import ( + "fmt" + "sync/atomic" +) + +// SessionBudget bounds how many sub-agents ONE SESSION may start. +// +// WHAT IT CATCHES THAT THE OTHER BOUNDS DO NOT. Depth is capped at 8, a plan +// runs at most maxPlanWorkers at once, a session shows one plan at a time, and +// since the run-level token budget a single run is bounded by spend. All of +// those bound one run or one plan. None of them bounds a CONVERSATION that keeps +// starting more work — plan after plan, or Task after Task — because each new +// run arrives with a fresh budget. +// +// A COUNT, NOT A COST, and that is the honest limit of it. The measured failure +// that shaped the token budget was one run spending 35.8M tokens, which a count +// would not have seen at all. This is the cheap coarse backstop underneath that, +// not a replacement for it. +// +// A nil *SessionBudget is unbounded and every method tolerates it, so a caller +// that never wires one keeps today's behaviour exactly. +type SessionBudget struct { + started atomic.Int64 + max int +} + +// DefaultSessionSubagents is the ceiling on sub-agents started in one session. +// +// ANCHORED ON MEASUREMENT: across 75 recorded sessions the most any one started +// was 22. This is roughly nine times that, so it sits far above observed use and +// still stops a conversation that has begun spawning without end. It is a +// backstop, not a target — a session that legitimately needs more should raise +// it rather than have this number quietly shape the work. +const DefaultSessionSubagents = 200 + +// NewSessionBudget returns a budget for max sub-agents, or nil when max is not +// positive — so "no bound" is represented by the same nil every method already +// handles rather than by a second spelling of it. +func NewSessionBudget(max int) *SessionBudget { + if max <= 0 { + return nil + } + return &SessionBudget{max: max} +} + +// admit counts one sub-agent and reports whether it may start. +// +// COUNTED ON ADMISSION, never decremented. The question is "how much work has +// this conversation set going", not "how much is running now" — a session that +// started three hundred children and finished them has still spent three hundred +// children's worth. Concurrency is bounded separately, by maxPlanWorkers. +func (budget *SessionBudget) admit() error { + if budget == nil || budget.max <= 0 { + return nil + } + if started := budget.started.Add(1); started > int64(budget.max) { + return fmt.Errorf( + "this session has started %d sub-agents, which is its limit of %d: "+ + "finish what is running, or start a new session for further work", + started-1, budget.max) + } + return nil +} + +// Started reports how many sub-agents this session has begun, for display. +func (budget *SessionBudget) Started() int { + if budget == nil { + return 0 + } + return int(budget.started.Load()) +} diff --git a/internal/specialist/session_budget_test.go b/internal/specialist/session_budget_test.go new file mode 100644 index 000000000..957b6acf3 --- /dev/null +++ b/internal/specialist/session_budget_test.go @@ -0,0 +1,106 @@ +package specialist + +import ( + "context" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/streamjson" +) + +// budgetRun starts one sub-agent the way a plan task does — with a manifest, so +// the run does not depend on a registered specialist. +func budgetRun(t *testing.T, executor Executor) error { + t.Helper() + manifest := planTaskManifest("explorer", "", "", []string{"read_file"}) + _, err := executor.Run(context.Background(), TaskParameters{ + Name: "explorer", Prompt: "x", Manifest: &manifest, + }, TaskRunOptions{Cwd: t.TempDir()}) + return err +} + +func budgetExecutor(t *testing.T, budget *SessionBudget) Executor { + t.Helper() + return Executor{ + BinaryPath: "/bin/true", + SessionBudget: budget, + 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 + }, + } +} + +// A CONVERSATION THAT KEEPS STARTING WORK IS BOUNDED, which no other limit does. +// +// Depth caps nesting at 8, maxPlanWorkers caps concurrency, a session shows one +// plan at a time, and the run-level token budget bounds ONE run. Every one of +// those resets or applies per run, so a session that starts plan after plan was +// bounded by nothing at all. +func TestASessionStopsStartingSubAgentsAtItsLimit(t *testing.T) { + executor := budgetExecutor(t, NewSessionBudget(3)) + var lastErr error + started := 0 + for i := 0; i < 5; i++ { + err := budgetRun(t, executor) + if err == nil { + started++ + continue + } + lastErr = err + } + if started != 3 { + t.Fatalf("%d sub-agents started against a limit of 3", started) + } + if lastErr == nil { + t.Fatal("the fourth start was not refused") + } + for _, required := range []string{"3", "limit", "new session"} { + if !strings.Contains(lastErr.Error(), required) { + t.Errorf("the refusal does not mention %q: %v", required, lastErr) + } + } +} + +// AN UNWIRED BUDGET IS UNBOUNDED, so every caller that never sets one keeps +// today's behaviour — including every test and the MCP path. +func TestAnUnwiredSessionBudgetBoundsNothing(t *testing.T) { + executor := budgetExecutor(t, nil) + for i := 0; i < 12; i++ { + if err := budgetRun(t, executor); err != nil { + t.Fatalf("start %d was refused with no budget wired: %v", i, err) + } + } + if got := (*SessionBudget)(nil).Started(); got != 0 { + t.Errorf("a nil budget reported %d started", got) + } +} + +// COUNTED, NEVER DECREMENTED. The question is how much work this conversation +// has set going, not how much is running now — a session that started three +// hundred children and finished them has still spent that. +func TestFinishedSubAgentsStillCountAgainstTheSession(t *testing.T) { + budget := NewSessionBudget(2) + executor := budgetExecutor(t, budget) + for i := 0; i < 2; i++ { + if err := budgetRun(t, executor); err != nil { + t.Fatalf("start %d: %v", i, err) + } + } + // Both have returned. A third must still be refused. + if err := budgetRun(t, executor); err == nil { + t.Fatal("a finished sub-agent gave its slot back; the budget is a spend count, not a concurrency gauge") + } + if got := budget.Started(); got < 2 { + t.Errorf("Started() = %d, want at least 2", got) + } +} + +// The default is a backstop far above observed use: the most any of 75 recorded +// sessions started was 22. +func TestTheDefaultSessionCapSitsWellAboveObservedUse(t *testing.T) { + if DefaultSessionSubagents < 100 { + t.Fatalf("DefaultSessionSubagents = %d; the observed maximum was 22 and a cap near it would refuse ordinary work", DefaultSessionSubagents) + } +} diff --git a/internal/specialist/streamer.go b/internal/specialist/streamer.go index f9a3a2337..b2745185c 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 != "" { @@ -97,6 +116,41 @@ func SummarizeStream(events []streamjson.Event, processExitCode int) StreamResul return result } +// sessionIDLinePrefix heads the line BuildFinalResult prepends to a successful +// subagent's output, so the parent can continue that child by id. +const sessionIDLinePrefix = "session_id: " + +// WithoutSessionIDLine removes that line for a caller that already holds the id +// structurally and whose output a PERSON reads. +// +// A PLAN TASK IS BOTH. ExecResult.SessionID carries the id to the plan runner +// already, so the line adds nothing there — and a plan task's output is not a +// tool result the parent model consumes on its own. It is quoted into the report +// under "result:", pasted into the dependency briefing every downstream task +// reads, and rendered in the plan panel, so the line surfaced a raw child +// session id in the middle of a user-facing answer three separate ways. Nothing +// can continue a plan task's child by that id in any case: the plan owns its +// children's lifetimes. +// +// KEYED ON THE ID WE KNOW, never on the pattern. It strips the first line only +// when that line is exactly this prefix followed by the id the caller was handed, +// so it cannot cut a line of the child's own prose that happens to begin the same +// way — the fuzzy-match rule that this repo has broken more than once. +func WithoutSessionIDLine(output, sessionID string) string { + if sessionID == "" { + return output + } + line := sessionIDLinePrefix + sessionID + rest, ok := strings.CutPrefix(output, line) + if !ok { + return output + } + if rest != "" && !strings.HasPrefix(rest, "\n") { + return output + } + return strings.TrimLeft(rest, "\n") +} + func BuildFinalResult(events []streamjson.Event, stderrOutput string, processExitCode int, signalDesc string) tools.Result { summary := SummarizeStream(events, processExitCode) hasErrors := len(summary.Errors) > 0 || summary.ExitCode != 0 @@ -111,7 +165,7 @@ func BuildFinalResult(events []streamjson.Event, stderrOutput string, processExi if !hasErrors { output := summary.Text if summary.SessionID != "" { - output = "session_id: " + summary.SessionID + "\n" + output + output = sessionIDLinePrefix + summary.SessionID + "\n" + output } return tools.Result{Status: tools.StatusOK, Output: strings.TrimSpace(output)} } diff --git a/internal/specialist/task_model_autoassign_test.go b/internal/specialist/task_model_autoassign_test.go new file mode 100644 index 000000000..45699e08b --- /dev/null +++ b/internal/specialist/task_model_autoassign_test.go @@ -0,0 +1,444 @@ +package specialist + +import ( + "context" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/sessions" + "github.com/Gitlawb/zero/internal/streamjson" +) + +// PER-SUB-AGENT MODELS ON THE DELEGATION PATH. The plan tool routes a task to a +// role-appropriate model; a bare Task delegation used to inherit the parent's +// model unconditionally, so every sub-agent ran on the session model no matter +// what it was for. autoTaskModel closes that gap by reusing the plan path's own +// classifier and role pins — but ONLY under the zeromaxing posture with +// auto-assign configured, so nothing changes for anyone who did not ask for it. + +func autoAssignPrefs() ModelPreferences { + return ModelPreferences{ + AutoAssign: true, + Scan: "deepseek-v4-flash", + Implement: "gpt-oss:20b", + Verify: "kimi-k2.6", + } +} + +// delegatedManifest is what a genuine Task delegation resolves: a registry +// specialist, NOT a plan-authored manifest. The distinction is load-bearing — +// autoTaskModel keys on plan provenance ("(plan)") to avoid second-guessing the +// plan tool's own assignment, so a fixture built with planTaskManifest would be +// skipped for the wrong reason and these tests would pass against gutted code. +func delegatedManifest(tools ...string) Manifest { + manifest := planTaskManifest("explorer", "", "", tools) + manifest.FilePath = "explorer.yaml" + return manifest +} + +// servesEverything is a discovery stub listing the session model and every pin +// used in these tests, so the served-check passes and the routing logic itself +// is what is under test. The mismatch cases build their own narrower stubs. +func servesEverything(context.Context) ([]DiscoveredModel, error) { + return []DiscoveredModel{ + {ID: "glm-5.2"}, {ID: "deepseek-v4-flash"}, {ID: "gpt-oss:20b"}, {ID: "kimi-k2.6"}, + }, nil +} + +func TestAutoTaskModelRoutesByRole(t *testing.T) { + write := delegatedManifest("write_file") + readOnly := delegatedManifest("grep") + + on := func(prefs ModelPreferences) Executor { + return Executor{PostureActive: func() bool { return true }, ModelPrefs: prefs, DiscoverModels: servesEverything} + } + + cases := []struct { + name string + executor Executor + manifest Manifest + prompt string + want string + }{ + // The grant outranks the prose: a write-capable task is "implement" + // regardless of what the prompt says. + {"write grant -> implement pin", on(autoAssignPrefs()), write, "look into the parser", "gpt-oss:20b"}, + // Read-only, so the prose decides. Verify is tested before implement, so a + // review of a change is not sent to the coding model. + {"read-only review -> verify pin", on(autoAssignPrefs()), readOnly, "review the auth change", "kimi-k2.6"}, + {"read-only find -> scan pin", on(autoAssignPrefs()), readOnly, "find every caller", "deepseek-v4-flash"}, + // Unclassifiable: the honest answer is to inherit, not to force a bucket. + {"read-only neutral -> inherit", on(autoAssignPrefs()), readOnly, "hello there", ""}, + // The role classifies, but no pin exists for it: still inherit. + {"role has no pin -> inherit", on(ModelPreferences{AutoAssign: true}), write, "do it", ""}, + // The two gates. Either one off means the whole feature is off. + {"posture off -> inherit", Executor{PostureActive: func() bool { return false }, ModelPrefs: autoAssignPrefs(), DiscoverModels: servesEverything}, write, "do it", ""}, + {"nil posture -> inherit", Executor{ModelPrefs: autoAssignPrefs(), DiscoverModels: servesEverything}, write, "do it", ""}, + {"auto-assign off -> inherit", on(ModelPreferences{Implement: "gpt-oss:20b"}), write, "do it", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.executor.autoTaskModel(context.Background(), tc.manifest, tc.prompt, "glm-5.2"); got != tc.want { + t.Fatalf("autoTaskModel = %q, want %q", got, tc.want) + } + }) + } +} + +// THE JOIN, ASSERTED FROM ARGV. autoTaskModel returning the right id proves +// nothing if the id is dropped before launch — appendModelArgs is a separate +// seam, and this family of bug lives exactly in the gap between them. So this +// drives the real Run and reads the flag the child actually receives. +func TestTaskModelAutoAssignReachesChildArgv(t *testing.T) { + var argv []string + write := delegatedManifest("write_file") + executor := Executor{ + BinaryPath: "/bin/true", + NewSessionID: func() (string, error) { return "specialist_00000000000000000000000a", nil }, + Load: func(LoadOptions) (LoadResult, error) { return LoadResult{}, nil }, + PostureActive: func() bool { return true }, + ModelPrefs: ModelPreferences{AutoAssign: true, Implement: "gpt-oss:20b"}, + DiscoverModels: servesEverything, + RunChild: func(_ context.Context, _ string, args []string, _ func(streamjson.Event)) (ChildRunResult, error) { + argv = args + return ChildRunResult{Started: true}, nil + }, + } + if _, err := executor.Run(context.Background(), + TaskParameters{Name: "explorer", Prompt: "refactor the parser", Manifest: &write}, + TaskRunOptions{Cwd: t.TempDir(), ParentModel: "glm-5.2"}); err != nil { + t.Fatalf("Run: %v", err) + } + if !argsContainPair(argv, "--model", "gpt-oss:20b") { + t.Fatalf("the auto-assigned model never reached the child argv:\n%s", strings.Join(argv, " ")) + } +} + +// A PIN THE PROVIDER DOES NOT SERVE IS NEVER APPLIED — the failure this exists +// for was real: the session moved to xai while the pins named Ollama models, +// and three children died at spawn with `"not-found": The model kimi-k2.6 does +// not exist`. Every uncertain case inherits: an unserved pin, a served list the +// session's own model is missing from (discovery answering for a different +// provider), a failed listing, and no discoverer at all. +func TestAnUnservedPinIsNeverApplied(t *testing.T) { + write := delegatedManifest("write_file") + base := Executor{PostureActive: func() bool { return true }, + ModelPrefs: ModelPreferences{AutoAssign: true, Implement: "gpt-oss:20b"}} + + xaiOnly := func(context.Context) ([]DiscoveredModel, error) { + return []DiscoveredModel{{ID: "grok-4.5"}, {ID: "grok-4.3"}}, nil + } + cases := []struct { + name string + discover ModelDiscoverer + parent string + }{ + {"pin not served by this provider", xaiOnly, "grok-4.5"}, + {"session model missing from the list (wrong provider answered)", servesEverything, "grok-4.5"}, + {"discovery failed", func(context.Context) ([]DiscoveredModel, error) { + return nil, context.DeadlineExceeded + }, "glm-5.2"}, + {"no discoverer wired", nil, "glm-5.2"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + executor := base + executor.DiscoverModels = tc.discover + if got := executor.autoTaskModel(context.Background(), write, "refactor it", tc.parent); got != "" { + t.Fatalf("a pin was applied blind: %q", got) + } + }) + } +} + +// The serve cache holds one listing for the fan-out: five spawns, one probe. +func TestTheServeCacheProbesOnce(t *testing.T) { + calls := 0 + executor := Executor{ + PostureActive: func() bool { return true }, + ModelPrefs: ModelPreferences{AutoAssign: true, Implement: "gpt-oss:20b"}, + ServeCache: &ModelServeCache{}, + DiscoverModels: func(ctx context.Context) ([]DiscoveredModel, error) { + calls++ + return servesEverything(ctx) + }, + } + write := delegatedManifest("write_file") + for i := 0; i < 5; i++ { + if got := executor.autoTaskModel(context.Background(), write, "refactor it", "glm-5.2"); got != "gpt-oss:20b" { + t.Fatalf("spawn %d: pin = %q, want gpt-oss:20b", i, got) + } + } + if calls != 1 { + t.Fatalf("discovery ran %d times for five spawns, want 1", calls) + } +} + +// ADDITIVITY. Posture off, the spawn must be exactly what it was before this +// existed: no --model at all, inheriting the parent's model downstream. +func TestTaskModelAutoAssignOffEmitsNoModel(t *testing.T) { + var argv []string + write := delegatedManifest("write_file") + executor := Executor{ + BinaryPath: "/bin/true", + NewSessionID: func() (string, error) { return "specialist_00000000000000000000000a", nil }, + Load: func(LoadOptions) (LoadResult, error) { return LoadResult{}, nil }, + PostureActive: func() bool { return false }, + ModelPrefs: ModelPreferences{AutoAssign: true, Implement: "gpt-oss:20b"}, + RunChild: func(_ context.Context, _ string, args []string, _ func(streamjson.Event)) (ChildRunResult, error) { + argv = args + return ChildRunResult{Started: true}, nil + }, + } + if _, err := executor.Run(context.Background(), + TaskParameters{Name: "explorer", Prompt: "refactor the parser", Manifest: &write}, + TaskRunOptions{Cwd: t.TempDir()}); err != nil { + t.Fatalf("Run: %v", err) + } + if argsHaveFlag(argv, "--model") { + t.Fatalf("a posture-off spawn emitted --model — additivity is broken:\n%s", strings.Join(argv, " ")) + } +} + +// An explicit model on the call always wins over the role pin: auto-assignment +// fills the empty case only. +func TestExplicitTaskModelBeatsAutoAssign(t *testing.T) { + var argv []string + write := delegatedManifest("write_file") + executor := Executor{ + BinaryPath: "/bin/true", + NewSessionID: func() (string, error) { return "specialist_00000000000000000000000a", nil }, + Load: func(LoadOptions) (LoadResult, error) { return LoadResult{}, nil }, + PostureActive: func() bool { return true }, + ModelPrefs: ModelPreferences{AutoAssign: true, Implement: "gpt-oss:20b"}, + RunChild: func(_ context.Context, _ string, args []string, _ func(streamjson.Event)) (ChildRunResult, error) { + argv = args + return ChildRunResult{Started: true}, nil + }, + } + if _, err := executor.Run(context.Background(), + TaskParameters{Name: "explorer", Prompt: "refactor the parser", Model: "claude-sonnet-4.5", Manifest: &write}, + TaskRunOptions{Cwd: t.TempDir()}); err != nil { + t.Fatalf("Run: %v", err) + } + if !argsContainPair(argv, "--model", "claude-sonnet-4.5") { + t.Fatalf("the explicit model was overridden by the role pin:\n%s", strings.Join(argv, " ")) + } + if argsContainPair(argv, "--model", "gpt-oss:20b") { + t.Fatalf("the role pin leaked past an explicit model:\n%s", strings.Join(argv, " ")) + } +} + +// A specialist that DECLARES its own model keeps it: auto-assignment must not +// override a choice the manifest already made. +func TestManifestOwnModelSurvivesAutoAssign(t *testing.T) { + var argv []string + declared := delegatedManifest("write_file") + declared.Metadata.Model = "claude-sonnet-4.5" + executor := Executor{ + BinaryPath: "/bin/true", + NewSessionID: func() (string, error) { return "specialist_00000000000000000000000a", nil }, + Load: func(LoadOptions) (LoadResult, error) { return LoadResult{}, nil }, + PostureActive: func() bool { return true }, + ModelPrefs: ModelPreferences{AutoAssign: true, Implement: "gpt-oss:20b"}, + RunChild: func(_ context.Context, _ string, args []string, _ func(streamjson.Event)) (ChildRunResult, error) { + argv = args + return ChildRunResult{Started: true}, nil + }, + } + if _, err := executor.Run(context.Background(), + TaskParameters{Name: "explorer", Prompt: "refactor the parser", Manifest: &declared}, + TaskRunOptions{Cwd: t.TempDir()}); err != nil { + t.Fatalf("Run: %v", err) + } + if !argsContainPair(argv, "--model", "claude-sonnet-4.5") { + t.Fatalf("auto-assignment overrode the manifest's own model:\n%s", strings.Join(argv, " ")) + } +} + +// A PLAN TASK'S MODEL IS DECIDED ONCE, BY THE PLAN TOOL. The plan path runs its +// own assignment — router, pins, served-check, probes — honours a per-plan +// auto_assign override, and reports every decision in its notes. A plan task +// arriving at the executor with no model is therefore a DECISION (inherit), not +// an absence, and the Task-path pre-step must not second-guess it: doing so +// would contradict the plan's report, defeat an explicit auto_assign:false, and +// re-apply a pin the plan level passed over as not served by this provider. +func TestAPlanTaskIsNeverSecondGuessedByTaskAutoAssign(t *testing.T) { + var argv []string + executor := Executor{ + BinaryPath: "/bin/true", + NewSessionID: func() (string, error) { return "specialist_00000000000000000000000a", nil }, + Load: func(LoadOptions) (LoadResult, error) { return LoadResult{}, nil }, + PostureActive: func() bool { return true }, + ModelPrefs: autoAssignPrefs(), + RunChild: func(_ context.Context, _ string, args []string, _ func(streamjson.Event)) (ChildRunResult, error) { + argv = args + return ChildRunResult{Started: true}, nil + }, + } + run := NewPlanRunner(PlanTaskContext{Executor: executor, Cwd: t.TempDir(), SpecialistName: "explorer"}) + if _, err := run(context.Background(), PlanTaskRequest{ + // A write grant + an implement-classifying prompt: the strongest bait the + // pre-step could take. The plan decided "inherit" by assigning no model. + Task: Task{ID: "t", Prompt: "refactor the parser"}, + Tools: []string{"write_file", "read_file"}, + }); err != nil { + t.Fatalf("run: %v", err) + } + if argsHaveFlag(argv, "--model") { + t.Fatalf("the plan's model decision was second-guessed at dispatch:\n%s", strings.Join(argv, " ")) + } +} + +// A RESUMED TASK KEEPS THE MODEL IT RAN ON. BuildResumeArgs falls back to the +// parent's model when the manifest names none, so a task launched on an explicit +// or auto-assigned model used to come back from a resume on the parent's — the +// same drift BuildResumeArgsInput's own comment records, one layer up. The child +// recorded its resolved model in the session store at launch; resume must read it. +func TestAResumedTaskKeepsTheModelItRanOn(t *testing.T) { + store := freshStore(t) + session, err := store.Create(freshSessionInput(t, "gpt-oss:20b")) + if err != nil { + t.Fatalf("create session: %v", err) + } + var argv []string + executor := resumeExecutor(store, &argv) + if _, err := executor.Run(context.Background(), + TaskParameters{Prompt: "carry on", Resume: session.SessionID}, + TaskRunOptions{Cwd: t.TempDir(), ParentModel: "glm-5.2"}); err != nil { + t.Fatalf("Run: %v", err) + } + if !argsContainPair(argv, "--model", "gpt-oss:20b") { + t.Fatalf("the resumed child lost the model its session ran on:\n%s", strings.Join(argv, " ")) + } + if argsContainPair(argv, "--model", "glm-5.2") { + t.Fatalf("the resumed child drifted to the parent's model:\n%s", strings.Join(argv, " ")) + } +} + +// An explicit model on the resume call wins over the session's recorded one, +// exactly as it wins on a fresh launch — the two doors must not disagree. +func TestAnExplicitModelOnResumeWins(t *testing.T) { + store := freshStore(t) + session, err := store.Create(freshSessionInput(t, "gpt-oss:20b")) + if err != nil { + t.Fatalf("create session: %v", err) + } + var argv []string + executor := resumeExecutor(store, &argv) + if _, err := executor.Run(context.Background(), + TaskParameters{Prompt: "carry on", Resume: session.SessionID, Model: "claude-sonnet-4.5"}, + TaskRunOptions{Cwd: t.TempDir(), ParentModel: "glm-5.2"}); err != nil { + t.Fatalf("Run: %v", err) + } + if !argsContainPair(argv, "--model", "claude-sonnet-4.5") { + t.Fatalf("the explicit resume model was ignored:\n%s", strings.Join(argv, " ")) + } +} + +// A session that recorded no model resumes exactly as before: the parent's +// model, via appendModelArgs' own fallback. +func TestAResumedTaskWithNoRecordedModelStillInheritsTheParent(t *testing.T) { + store := freshStore(t) + session, err := store.Create(freshSessionInput(t, "")) + if err != nil { + t.Fatalf("create session: %v", err) + } + var argv []string + executor := resumeExecutor(store, &argv) + if _, err := executor.Run(context.Background(), + TaskParameters{Prompt: "carry on", Resume: session.SessionID}, + TaskRunOptions{Cwd: t.TempDir(), ParentModel: "glm-5.2"}); err != nil { + t.Fatalf("Run: %v", err) + } + if !argsContainPair(argv, "--model", "glm-5.2") { + t.Fatalf("the no-record fallback to the parent's model broke:\n%s", strings.Join(argv, " ")) + } +} + +func freshStore(t *testing.T) *sessions.Store { + t.Helper() + return sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()}) +} + +func freshSessionInput(t *testing.T, modelID string) sessions.CreateInput { + t.Helper() + return sessions.CreateInput{ + SessionID: "specialist_00000000000000000000000a", + SessionKind: sessions.SessionKindChild, + Cwd: t.TempDir(), + AgentName: "explorer", + Tag: sessionTagSpecialist, + ModelID: modelID, + } +} + +func resumeExecutor(store *sessions.Store, argv *[]string) Executor { + return Executor{ + BinaryPath: "/bin/true", + SessionStore: store, + Load: func(LoadOptions) (LoadResult, error) { + return LoadResult{Specialists: []Manifest{{ + Metadata: Metadata{Name: "explorer"}, + SystemPrompt: "x", + ResolvedTools: []string{"grep"}, + ToolsResolved: true, + Location: LocationBuiltin, + }}}, nil + }, + RunChild: func(_ context.Context, _ string, args []string, _ func(streamjson.Event)) (ChildRunResult, error) { + *argv = args + return ChildRunResult{Started: true}, nil + }, + } +} + +// EFFORT IS FORWARDED ONLY TO MODELS THE REGISTRY CAN VOUCH FOR — the same +// gate the plan path applies. The child clamps a forwarded effort only for +// models it can look up; anything else passes it to the provider untouched, +// and a provider that does not take the parameter rejects the whole request. +// Under zeromaxing the parent's effort is always raised, so the unconditional +// forward made every Task naming an uncurated model on such a provider DIE AT +// SPAWN — a real orchestrator hit it, gave up, and ran five sub-agents on the +// session's model. +func TestEffortIsNotForwardedToUncuratedTaskModels(t *testing.T) { + launch := func(model string) []string { + var argv []string + manifest := delegatedManifest("grep") + 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) { + argv = args + return ChildRunResult{Started: true}, nil + }, + } + if _, err := executor.Run(context.Background(), + TaskParameters{Name: "explorer", Prompt: "go", Model: model, Manifest: &manifest}, + TaskRunOptions{Cwd: t.TempDir(), ParentReasoningEffort: "high"}); err != nil { + t.Fatalf("Run(%s): %v", model, err) + } + return argv + } + uncurated := launch("gpt-oss:20b") + if !argsContainPair(uncurated, "--model", "gpt-oss:20b") { + t.Fatalf("setup: the model must reach argv:\n%s", strings.Join(uncurated, " ")) + } + if argsHaveFlag(uncurated, "--reasoning-effort") { + t.Fatalf("an uncurated model was sent an effort the provider may reject:\n%s", strings.Join(uncurated, " ")) + } + curated := launch("claude-sonnet-4.5") + if !argsContainPair(curated, "--reasoning-effort", "high") { + t.Fatalf("a curated model must keep the parent's raised effort:\n%s", strings.Join(curated, " ")) + } +} + +func argsHaveFlag(args []string, flag string) bool { + for _, arg := range args { + if arg == flag { + return true + } + } + return false +} diff --git a/internal/specialist/task_model_test.go b/internal/specialist/task_model_test.go new file mode 100644 index 000000000..04039f232 --- /dev/null +++ b/internal/specialist/task_model_test.go @@ -0,0 +1,113 @@ +package specialist + +import ( + "context" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/streamjson" +) + +// A TASK CAN NAME ITS OWN MODEL, so a standalone spawn need not inherit the +// parent's — the missing half of per-sub-agent model routing outside a plan. +// +// Asserted on ARGV, not the struct: a Model field that never reaches --model is +// a field that does nothing, which is exactly the "layer B doesn't carry it" +// defect this repo keeps finding. +func TestATaskModelReachesTheChildsArgv(t *testing.T) { + var childArgs []string + executor := Executor{ + BinaryPath: "/usr/local/bin/zero", + NewSessionID: func() (string, error) { return "specialist_00000000000000000000000a", nil }, + Load: func(LoadOptions) (LoadResult, error) { + return LoadResult{Specialists: []Manifest{{ + Metadata: Metadata{Name: "worker", Description: "does work"}, SystemPrompt: "work.", ResolvedTools: []string{"read_file"}, + }}}, nil + }, + // Captures the argv the real runFresh path built, so this exercises the + // call site — not applyTaskModel in isolation. + RunChild: func(_ context.Context, _ string, args []string, progress func(streamjson.Event)) (ChildRunResult, error) { + childArgs = append([]string(nil), args...) + events := []streamjson.Event{ + {Type: streamjson.EventRunStart, SessionID: "specialist_00000000000000000000000a"}, + {Type: streamjson.EventFinal, Text: "done"}, + {Type: streamjson.EventRunEnd, Status: "success"}, + } + for _, e := range events { + if progress != nil { + progress(e) + } + } + return ChildRunResult{Started: true, ExitCode: 0, Events: events}, nil + }, + } + + _, err := executor.Run(context.Background(), TaskParameters{Name: "worker", Prompt: "audit it", Model: "kimi-k2.6"}, + TaskRunOptions{Cwd: t.TempDir(), ParentReasoningEffort: "high"}) + if err != nil { + t.Fatalf("Run: %v", err) + } + joined := strings.Join(childArgs, " ") + if !strings.Contains(joined, "--model kimi-k2.6") { + t.Fatalf("the Task model never reached the child argv via the real Run path:\n%s", joined) + } +} + +// NAMING A MODEL MUST NOT DROP THE POSTURE'S EFFORT. appendModelArgs forwards +// the parent's effort only when NO model is named, so a Task naming a model +// would otherwise lose the raised zeromaxing effort. applyTaskModel forwards it. +func TestATaskModelStillForwardsTheParentEffort(t *testing.T) { + // A CURATED model: the child can clamp a forwarded effort only for models + // the registry knows. This test originally named kimi-k2.6 — uncurated — + // and so pinned in the exact behaviour that killed real spawns: the raised + // zeromaxing effort forwarded verbatim to a provider that rejects the + // parameter. Effort now forwards only where the registry can vouch + // (TestEffortIsNotForwardedToUncuratedTaskModels holds the other side). + var manifest Manifest + manifest.Metadata.Name = "worker" + if err := applyTaskModel(&manifest, "claude-sonnet-4.5", "high"); err != nil { + t.Fatal(err) + } + if manifest.Metadata.Model != "claude-sonnet-4.5" { + t.Fatalf("model = %q, want claude-sonnet-4.5", manifest.Metadata.Model) + } + if manifest.Metadata.ReasoningEffort != "high" { + t.Fatalf("naming a model dropped the parent effort: got %q, want high", manifest.Metadata.ReasoningEffort) + } + + // A manifest that names its OWN effort keeps it. + own := Manifest{} + own.Metadata.Name = "judge" + own.Metadata.ReasoningEffort = "max" + if err := applyTaskModel(&own, "glm-5.2", "high"); err != nil { + t.Fatal(err) + } + if own.Metadata.ReasoningEffort != "max" { + t.Fatalf("a manifest's own effort was overwritten: %q", own.Metadata.ReasoningEffort) + } +} + +// AN EMPTY MODEL CHANGES NOTHING — every existing spawn stays on the inherited +// model, byte-for-byte. +func TestAnEmptyTaskModelIsAPureNoOp(t *testing.T) { + manifest := Manifest{} + manifest.Metadata.Name = "worker" + before := manifest + if err := applyTaskModel(&manifest, " ", "high"); err != nil { + t.Fatal(err) + } + if manifest.Metadata.Model != "" || manifest.Metadata.ReasoningEffort != before.Metadata.ReasoningEffort { + t.Fatalf("an empty model was not a no-op: %+v", manifest.Metadata) + } +} + +// The parser reads the model argument. +func TestTheTaskToolParsesTheModelArgument(t *testing.T) { + params, err := parseTaskParameters(map[string]any{"name": "worker", "prompt": "x", "model": "deepseek-v4-flash"}) + if err != nil { + t.Fatal(err) + } + if params.Model != "deepseek-v4-flash" { + t.Fatalf("parsed model = %q", params.Model) + } +} 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/task_tool.go b/internal/specialist/task_tool.go index 339f2adcc..557ae177b 100644 --- a/internal/specialist/task_tool.go +++ b/internal/specialist/task_tool.go @@ -42,6 +42,11 @@ func (tool *TaskTool) Parameters() tools.Schema { Type: "string", Description: "Short label for the child session.", }, + "model": { + Type: "string", + Description: "Run THIS sub-agent on a specific model instead of inheriting yours — e.g. a cheap model " + + "for a scan and a strong one for a judgement. Omit to inherit. The model must be one this provider serves.", + }, "run_in_background": { Type: "boolean", Description: "Run the specialist in the background and return a task_id immediately.", @@ -57,6 +62,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, @@ -118,6 +129,28 @@ func (tool *TaskTool) RunWithOptions(ctx context.Context, args map[string]any, o if result.SessionID != "" { result.Result.Meta["session_id"] = result.SessionID } + // A BACKGROUND SPAWN HAS NOT FINISHED, and the caller has to be able to tell. + // + // Run returns as soon as the child is launched, with a task_id to poll — so a + // caller that treats "the Task tool returned" as "the sub-agent is done" + // reports work as complete that has not started producing any. The TUI did + // exactly that: four background workers rendered "✓ completed · 0 tool calls + // · 1s" and the AGENTS panel said "4 finished" while all four were still + // running, with no specialist_stop recorded for any of them. + // + // STRUCTURAL, not inferred from the output prose — the same rule the rest of + // this package follows for Stalled, ModelRejected and Signal. + if params.RunInBackground { + result.Result.Meta["background"] = "true" + } + // WHICH MODEL IT ACTUALLY RAN ON. The AGENTS sidebar already renders + // "on " — sidebar.go — and showed it for plan tasks only, because + // setModel was called from the plan path alone. A Task sub-agent inherits + // the parent's model unless its manifest names one, and only the executor + // knows which of those applied. + if model := strings.TrimSpace(result.Model); model != "" { + result.Result.Meta["model"] = model + } return result.Result } @@ -142,12 +175,17 @@ func parseTaskParameters(args map[string]any) (TaskParameters, error) { if err != nil { return TaskParameters{}, err } + model, err := optionalTaskString(args, "model") + if err != nil { + return TaskParameters{}, err + } params := TaskParameters{ Name: strings.TrimSpace(name), Prompt: strings.TrimSpace(prompt), Description: strings.TrimSpace(description), RunInBackground: runInBackground, Resume: strings.TrimSpace(resume), + Model: strings.TrimSpace(model), } if params.Name == "" && params.Resume == "" { return TaskParameters{}, fmt.Errorf("task requires name or resume") diff --git a/internal/specialist/task_tool_test.go b/internal/specialist/task_tool_test.go index f112f11ec..f2f3b1ae0 100644 --- a/internal/specialist/task_tool_test.go +++ b/internal/specialist/task_tool_test.go @@ -343,3 +343,154 @@ func TestTaskToolIsAdvertisedInAutoMode(t *testing.T) { t.Fatal("Task should be visible in auto mode so the TUI can request permission") } } + +// A BACKGROUND SPAWN MUST SAY SO, STRUCTURALLY. +// +// Run returns the moment the child is launched, so a caller reading "the Task +// tool returned" as "the sub-agent finished" reports work as done that has not +// started. The TUI did exactly that: four background workers rendered +// "✓ completed · 0 tool calls · 1s" with the header reading "4 finished" while +// every one was still running — four specialist_start events with +// mode=background and not one specialist_stop. +// +// The marker is in Meta rather than inferred from the summary prose, for the +// same reason Stalled, ModelRejected and Signal are flags. +func TestABackgroundTaskMarksItselfAsBackground(t *testing.T) { + manager, err := background.NewManager(t.TempDir()) + if err != nil { + t.Fatal(err) + } + executor := Executor{ + BinaryPath: "/usr/local/bin/zero", + BackgroundManager: manager, + NewSessionID: func() (string, error) { return "child_task", nil }, + Load: func(LoadOptions) (LoadResult, error) { + return LoadResult{Specialists: []Manifest{{ + Metadata: Metadata{Name: "worker", Description: "Does focused work"}, + SystemPrompt: "Work carefully.", + ResolvedTools: []string{"read_file"}, + }}}, nil + }, + LaunchBackground: func(string, []string, string, func(int)) (int, error) { return 4321, nil }, + } + + result := NewTaskTool(executor).RunWithOptions(context.Background(), map[string]any{ + "name": "worker", "prompt": "inspect auth", "run_in_background": true, + }, tools.RunOptions{SessionID: "parent_session"}) + + if result.Status != tools.StatusOK { + t.Fatalf("background spawn failed: %s", result.Output) + } + if result.Meta["background"] != "true" { + t.Fatalf("a background spawn does not declare itself, so a caller cannot tell it is still running: %#v", result.Meta) + } +} + +// And a FOREGROUND task must not claim to be one, or every finished sub-agent +// would be left rendering as though it were still working. +func TestAForegroundTaskDoesNotClaimToBeBackground(t *testing.T) { + const childSession = "specialist_00000000000000000000000a" + executor := Executor{ + BinaryPath: "/bin/true", + NewSessionID: func() (string, error) { return childSession, nil }, + Load: func(LoadOptions) (LoadResult, error) { + return LoadResult{Specialists: []Manifest{{ + Metadata: Metadata{Name: "explorer", Description: "Explores"}, + SystemPrompt: "Explore.", + ResolvedTools: []string{"read_file"}, + }}}, nil + }, + RunChild: func(_ context.Context, _ string, _ []string, progress func(streamjson.Event)) (ChildRunResult, error) { + events := []streamjson.Event{ + {Type: streamjson.EventRunStart, SessionID: childSession}, + {Type: streamjson.EventFinal, Text: "done"}, + {Type: streamjson.EventRunEnd, Status: "success"}, + } + for _, event := range events { + if progress != nil { + progress(event) + } + } + return ChildRunResult{Started: true, ExitCode: 0, Events: events}, nil + }, + } + result := NewTaskTool(executor).RunWithOptions(context.Background(), map[string]any{ + "name": "explorer", "prompt": "look around", + }, tools.RunOptions{Cwd: t.TempDir(), Model: "glm-5.2"}) + + // ASSERT IT SUCCEEDED FIRST. An earlier version checked only that the + // "background" key was absent — which is true of a NIL map, so it passed + // while the tool was erroring and Meta was never built at all. + if result.Status != tools.StatusOK { + t.Fatalf("the foreground task failed, so the assertion below proves nothing: %s", result.Output) + } + if len(result.Meta) == 0 { + t.Fatal("no Meta at all: an absent key here means nothing") + } + if _, present := result.Meta["background"]; present { + t.Fatalf("a foreground task claims to be background: %#v", result.Meta) + } +} + +// THE MODEL THE CHILD RAN ON MUST REACH THE CALLER. +// +// The AGENTS sidebar renders "on " and showed nothing for a Task +// sub-agent: the parent's specialist_start recorded model=(absent) on every one, +// while the child's own session metadata knew it was glm-5.2. Only the executor +// resolves it — manifest model when named, parent's otherwise — so it has to be +// carried back rather than recomputed. +func TestATaskResultNamesTheModelTheChildRanOn(t *testing.T) { + const childSession = "specialist_00000000000000000000000a" + executor := Executor{ + BinaryPath: "/bin/true", + NewSessionID: func() (string, error) { return childSession, nil }, + Load: func(LoadOptions) (LoadResult, error) { + return LoadResult{Specialists: []Manifest{{ + Metadata: Metadata{Name: "explorer", Description: "Explores"}, + SystemPrompt: "Explore.", + ResolvedTools: []string{"read_file"}, + }}}, nil + }, + RunChild: func(_ context.Context, _ string, _ []string, progress func(streamjson.Event)) (ChildRunResult, error) { + events := []streamjson.Event{ + {Type: streamjson.EventRunStart, SessionID: childSession}, + {Type: streamjson.EventFinal, Text: "done"}, + {Type: streamjson.EventRunEnd, Status: "success"}, + } + for _, event := range events { + if progress != nil { + progress(event) + } + } + return ChildRunResult{Started: true, ExitCode: 0, Events: events}, nil + }, + } + // Inherits the parent's model when the manifest names none. + result := NewTaskTool(executor).RunWithOptions(context.Background(), map[string]any{ + "name": "explorer", "prompt": "look around", + }, tools.RunOptions{Cwd: t.TempDir(), Model: "glm-5.2"}) + if result.Meta["model"] != "glm-5.2" { + t.Fatalf("the result does not name the model the child ran on: %#v", result.Meta) + } + + // And a manifest that names its own model wins, which is why the caller + // cannot just assume the session's. + named := Executor{ + BinaryPath: "/bin/true", + NewSessionID: func() (string, error) { return childSession, nil }, + Load: func(LoadOptions) (LoadResult, error) { + return LoadResult{Specialists: []Manifest{{ + Metadata: Metadata{Name: "judge", Model: "kimi-k2.6"}, + SystemPrompt: "Judge carefully.", + ResolvedTools: []string{"read_file"}, + }}}, nil + }, + RunChild: executor.RunChild, + } + result = NewTaskTool(named).RunWithOptions(context.Background(), map[string]any{ + "name": "judge", "prompt": "judge it", + }, tools.RunOptions{Cwd: t.TempDir(), Model: "glm-5.2"}) + if result.Meta["model"] != "kimi-k2.6" { + t.Fatalf("a manifest model was overridden by the session's: %#v", result.Meta) + } +} diff --git a/internal/specialist/unmetered_plan_test.go b/internal/specialist/unmetered_plan_test.go new file mode 100644 index 000000000..2eace3b9a --- /dev/null +++ b/internal/specialist/unmetered_plan_test.go @@ -0,0 +1,187 @@ +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. +// +// Asserted on the EFFECT — the child's context is cancelled and the report says +// the wall did it — rather than on ctx.Deadline() being set. The bound is a +// clock that cancels rather than a context.WithTimeout, because a fixed deadline +// cannot be moved and a paused plan must not burn its budget; a test pinned to +// the old mechanism would have failed for that change while the property it +// cares about was strictly better served. +func TestTheWallBackstopActuallyBoundsAPlan(t *testing.T) { + plan := mustPlan(t, []any{task("a", "x")}, + map[string]any{"max_workers": float64(1), "max_wall_seconds": float64(1)}, readOnlyLimits()) + + stopped := make(chan struct{}) + report := ExecutePlan(context.Background(), plan, []string{"read_file"}, + func(ctx context.Context, _ PlanTaskRequest) (TaskResult, error) { + // Runs until something stops it. Only the backstop can. + <-ctx.Done() + close(stopped) + // No reason of its own — the shape a child that was simply killed + // produces, and the one that lets the executor say WHY. + return TaskResult{Outcome: TaskFailed}, ctx.Err() + }, nil) + + select { + case <-stopped: + default: + t.Fatal("the child ran to completion: nothing bounded it") + } + if len(report.Tasks) != 1 { + t.Fatalf("expected one task, got %d", len(report.Tasks)) + } + if got := report.Tasks[0].Outcome; got != TaskCancelled { + t.Fatalf("outcome = %q, want %q: a wall-budget stop is not a defect", got, TaskCancelled) + } + // The reader has to tell a budget expiry from a person pressing stop, and + // the CONTEXT can no longer say which — it is plain-cancelled either way now + // that the deadline has to be movable. The clock's own flag carries it. + if !strings.Contains(report.Tasks[0].Err, "max_wall_seconds") { + t.Errorf("the report does not say the wall budget stopped it: %q", report.Tasks[0].Err) + } +} + +// ...and a PERSON stopping the plan must still read as a person, not as a budget +// expiry. This is the discrimination that used to come free from +// context.DeadlineExceeded and now comes from the clock's own flag; without it +// every user stop would be reported as "max_wall_seconds elapsed". +func TestAUserStopIsNotReportedAsAWallExpiry(t *testing.T) { + plan := mustPlan(t, []any{task("a", "x")}, + map[string]any{"max_workers": float64(1), "max_wall_seconds": float64(3600)}, readOnlyLimits()) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + report := ExecutePlan(ctx, plan, []string{"read_file"}, + func(taskCtx context.Context, _ PlanTaskRequest) (TaskResult, error) { + cancel() + <-taskCtx.Done() + return TaskResult{Outcome: TaskFailed}, taskCtx.Err() + }, nil) + + if len(report.Tasks) != 1 { + t.Fatalf("expected one task, got %d", len(report.Tasks)) + } + if got := report.Tasks[0].Err; strings.Contains(got, "max_wall_seconds") { + t.Fatalf("a user stop was reported as a wall-budget expiry: %q", got) + } + if got := report.Tasks[0].Err; !strings.Contains(got, "stopped") { + t.Errorf("a user stop does not say so: %q", got) + } +} + +// A PAUSED PLAN DOES NOT SPEND ITS WALL BUDGET. +// +// The bound used to be a context.WithTimeout started at admission, so the clock +// ran while the user had the plan paused: pausing a thirty-minute plan for +// twenty left it ten, and pausing one for longer than its budget meant it was +// already dead on resume — stopped, and told max_wall_seconds elapsed, by time +// in which no task ran. +func TestPausedTimeIsNotChargedToTheWallBudget(t *testing.T) { + now := time.Unix(1000, 0) + clock := newPlanWallClock(30*time.Minute, func() time.Time { return now }) + + // Twenty minutes pass, all of it paused. + now = now.Add(20 * time.Minute) + clock.addPaused(20 * time.Minute) + + if clock.exhausted() { + t.Fatal("a plan paused for twenty minutes of a thirty-minute budget is out of time") + } + if got := clock.remaining(); got != 30*time.Minute { + t.Fatalf("remaining = %v, want the full 30m: paused time was charged to the budget", got) + } + + // Ten minutes of actual running. + now = now.Add(10 * time.Minute) + if got := clock.remaining(); got != 20*time.Minute { + t.Fatalf("remaining = %v after 10m of running, want 20m", got) + } + + // ...and running time still exhausts it. + now = now.Add(20 * time.Minute) + if !clock.exhausted() { + t.Fatal("thirty minutes of running did not exhaust a thirty-minute budget") + } +} + +// An unbounded clock is nil, and every method has to survive that — the plan +// path calls all of them without checking. +func TestAnAbsentWallClockIsUnbounded(t *testing.T) { + var clock *planWallClock + if clock.exhausted() { + t.Error("a plan with no wall bound reported its budget spent") + } + if clock.wallExpired() { + t.Error("a plan with no wall bound claimed the wall stopped it") + } + if clock.remaining() <= 0 { + t.Error("an unbounded clock reported no time left") + } + clock.addPaused(time.Minute) + if newPlanWallClock(0, nil) != nil || newPlanWallClock(-time.Second, nil) != nil { + t.Error("a non-positive budget must produce no clock at all") + } +} + +// 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) + } +} 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)) + } +} diff --git a/internal/specialist/worker_view.go b/internal/specialist/worker_view.go new file mode 100644 index 000000000..7b49f4308 --- /dev/null +++ b/internal/specialist/worker_view.go @@ -0,0 +1,284 @@ +package specialist + +import ( + "encoding/json" + "sort" + "strings" + "time" + + "github.com/Gitlawb/zero/internal/sessions" +) + +// What this session has set going, and what it cost. +// +// A VIEW, NOT A STORE, and that is not a stylistic preference. ARCHITECTURE.md +// scopes the "no new store" rule to exactly this kind of state, and the reason +// is that two records of one fact eventually disagree — a registry file would +// have to be written on every dispatch, kept correct across crashes, resumes and +// background completions, and reconciled with the event log that already knows. +// Everything below is a fold over events that are ALREADY WRITTEN by +// accounting.go; nothing new is recorded, so there is nothing new to keep true. +// +// WHY IT IS WORTH HAVING. A session that has spawned Task children, plan tasks +// and background plans has no single answer to "what did this session start, and +// what did it cost". A measured run spawned one background child and the only +// way to learn its fate was to read events.jsonl by hand. +// +// MULTI-PROVIDER RULE: tokens are the one number EVERY provider reports, and +// cost is not — most of this catalogue's gateways report no rates at all. So +// this reports tokens always and says plainly how many workers it could not +// price, rather than presenting a total that silently omits them. + +// WorkerKind distinguishes what started a child. +type WorkerKind string + +const ( + // WorkerTask is a direct Task call — the parent delegating one job. + WorkerTask WorkerKind = "task" + // WorkerPlanTask is one task of an orchestrated plan. + WorkerPlanTask WorkerKind = "plan-task" +) + +// WorkerStatus is where a child got to. +type WorkerStatus string + +const ( + WorkerRunning WorkerStatus = "running" + WorkerCompleted WorkerStatus = "completed" + WorkerFailed WorkerStatus = "failed" +) + +// Worker is one child this session started. +type Worker struct { + SessionID string + Kind WorkerKind + Specialist string + Description string + Status WorkerStatus + Background bool + Model string + StartedAt time.Time + EndedAt time.Time + ExitCode int + Err string + // Tokens is what it spent. 0 means the provider reported no usage, which is + // a real and common answer — not an assertion that it was free. + Tokens int + // TokensReported distinguishes "spent nothing" from "nobody said". A provider + // that never emits usage cannot be budgeted by token count, and a view that + // showed 0 for it would be inventing a measurement. + TokensReported bool +} + +// Duration is how long it ran, or how long it has been running. +func (w Worker) Duration(now time.Time) time.Duration { + if w.StartedAt.IsZero() { + return 0 + } + if w.EndedAt.IsZero() { + return now.Sub(w.StartedAt) + } + return w.EndedAt.Sub(w.StartedAt) +} + +// WorkerSummary is the session's rollup. +type WorkerSummary struct { + Workers []Worker + Started int + Running int + Done int + Failed int + // Tokens totals only the workers that REPORTED usage. + Tokens int + // Unmeasured counts workers whose provider reported no usage at all. Named + // and surfaced rather than folded into the total as zero: a total that + // silently omits three of five workers reads as the session's whole cost. + Unmeasured int +} + +// ReduceWorkers folds a session's events into what it started. +// +// TOLERANT OF A TRUNCATED OR INTERLEAVED LOG. A background child's usage can +// land after its stop, a stop can be missing entirely if the process was killed, +// and events from several children interleave freely. So this keys everything by +// child session id and never assumes ordering beyond "start precedes stop". +func ReduceWorkers(events []sessions.Event) WorkerSummary { + byID := map[string]*Worker{} + var order []string + + get := func(id string) *Worker { + if worker, ok := byID[id]; ok { + return worker + } + worker := &Worker{SessionID: id, Status: WorkerRunning} + byID[id] = worker + order = append(order, id) + return worker + } + + for _, event := range events { + // Payload is json.RawMessage on disk, so it is decoded here rather than + // assumed to be a map — and a payload that will not decode is skipped + // rather than failing the whole fold, because one malformed event must + // not hide every other worker in the session. + var payload map[string]any + if len(event.Payload) == 0 { + continue + } + if err := json.Unmarshal(event.Payload, &payload); err != nil { + continue + } + id := payloadString(payload, "childSessionId") + if id == "" { + continue + } + switch event.Type { + case sessions.EventSpecialistStart: + worker := get(id) + worker.Specialist = payloadString(payload, "specialist") + worker.Description = payloadString(payload, "description") + worker.Background = payloadBool(payload, "background") + worker.Kind = workerKindOf(payload) + worker.StartedAt = eventTime(event) + case sessions.EventSpecialistStop: + worker := get(id) + worker.EndedAt = eventTime(event) + worker.ExitCode = payloadInt(payload, "exitCode") + worker.Err = payloadString(payload, "error") + worker.Status = workerStatusOf(payloadString(payload, "status"), worker.ExitCode, worker.Err) + // A stop may be the first event seen if the log was truncated at the + // head; fill in what it also carries so the row is not blank. + if worker.Specialist == "" { + worker.Specialist = payloadString(payload, "specialist") + } + if worker.Description == "" { + worker.Description = payloadString(payload, "description") + } + if worker.Kind == "" { + worker.Kind = workerKindOf(payload) + } + case sessions.EventUsage: + // USAGE ALONE DOES NOT PROVE A WORKER IS RUNNING. A child whose + // start and stop were compacted away still has usage on record, and + // creating a row for it here left a phantom "running" agent that + // never resolves. Spend is still counted against a worker the log + // does know about; it just cannot conjure one. + // ONLY SPECIALIST USAGE. The parent's own turns write EventUsage too, + // with no childSessionId — filtered above — and counting those would + // report the session's whole spend as its sub-agents'. + if payloadString(payload, "source") != specialistAccountingSource { + continue + } + worker, known := byID[id] + if !known { + continue + } + if total, ok := payloadIntOK(payload, "totalTokens"); ok { + worker.Tokens += total + worker.TokensReported = true + } + if model := payloadString(payload, "model"); model != "" { + worker.Model = model + } + } + } + + summary := WorkerSummary{} + for _, id := range order { + worker := *byID[id] + if worker.Kind == "" { + worker.Kind = WorkerTask + } + summary.Workers = append(summary.Workers, worker) + summary.Started++ + switch worker.Status { + case WorkerCompleted: + summary.Done++ + case WorkerFailed: + summary.Failed++ + default: + summary.Running++ + } + if worker.TokensReported { + summary.Tokens += worker.Tokens + } else { + summary.Unmeasured++ + } + } + // Newest first: a session's most recent work is what a reader is asking + // about. Ties keep insertion order so the same log renders the same way. + sort.SliceStable(summary.Workers, func(i, j int) bool { + return summary.Workers[i].StartedAt.After(summary.Workers[j].StartedAt) + }) + return summary +} + +// workerKindOf reads the kind from what the dispatcher recorded. A plan task's +// description is written by plan_runner as "plan task ", which is the only +// signal the payload carries today — so this reads it rather than inventing a +// field, and defaults to a plain Task, which is what an unrecognised child is. +func workerKindOf(payload map[string]any) WorkerKind { + if strings.HasPrefix(payloadString(payload, "description"), planTaskDescriptionPrefix) { + return WorkerPlanTask + } + return WorkerTask +} + +// workerStatusOf maps a recorded stop onto a status. +// +// STRUCTURAL, never message matching. The status string and the exit code are +// what the writer recorded; a non-zero exit or a recorded error is a failure +// whatever words came with it. +func workerStatusOf(status string, exitCode int, errText string) WorkerStatus { + if exitCode != 0 || strings.TrimSpace(errText) != "" { + return WorkerFailed + } + switch strings.ToLower(strings.TrimSpace(status)) { + case "", "success", "ok", "completed": + return WorkerCompleted + default: + return WorkerFailed + } +} + +func payloadString(payload map[string]any, key string) string { + if value, ok := payload[key].(string); ok { + return strings.TrimSpace(value) + } + return "" +} + +func payloadBool(payload map[string]any, key string) bool { + value, _ := payload[key].(bool) + return value +} + +func payloadInt(payload map[string]any, key string) int { + value, _ := payloadIntOK(payload, key) + return value +} + +// payloadIntOK reads a number that may have round-tripped through JSON as a +// float64 — which every event that has been written to disk and read back has. +func payloadIntOK(payload map[string]any, key string) (int, bool) { + switch value := payload[key].(type) { + case int: + return value, true + case int64: + return int(value), true + case float64: + return int(value), true + default: + return 0, false + } +} + +// eventTime parses an event's recorded timestamp. Zero when absent or +// unparseable, which Duration already treats as "unknown" rather than as 1970. +func eventTime(event sessions.Event) time.Time { + stamp, err := time.Parse(time.RFC3339, strings.TrimSpace(event.CreatedAt)) + if err != nil { + return time.Time{} + } + return stamp +} diff --git a/internal/specialist/worker_view_test.go b/internal/specialist/worker_view_test.go new file mode 100644 index 000000000..cede8a457 --- /dev/null +++ b/internal/specialist/worker_view_test.go @@ -0,0 +1,210 @@ +package specialist + +import ( + "encoding/json" + "testing" + "time" + + "github.com/Gitlawb/zero/internal/sessions" +) + +func workerEvent(t *testing.T, kind sessions.EventType, at string, payload map[string]any) sessions.Event { + t.Helper() + raw, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + return sessions.Event{Type: kind, CreatedAt: at, Payload: raw} +} + +// A child's whole life, folded from the events accounting.go already writes. +func TestReduceWorkersFoldsAChildsWholeLife(t *testing.T) { + events := []sessions.Event{ + workerEvent(t, sessions.EventSpecialistStart, "2026-08-03T14:00:00Z", map[string]any{ + "source": specialistAccountingSource, "childSessionId": "c1", + "specialist": "code-review", "description": "Hostile audit", "background": true, + }), + workerEvent(t, sessions.EventUsage, "2026-08-03T14:02:00Z", map[string]any{ + "source": specialistAccountingSource, "childSessionId": "c1", + "totalTokens": float64(493_955), "model": "glm-5.2", + }), + workerEvent(t, sessions.EventSpecialistStop, "2026-08-03T14:02:01Z", map[string]any{ + "source": specialistAccountingSource, "childSessionId": "c1", + "status": "success", "exitCode": float64(0), + }), + } + + summary := ReduceWorkers(events) + if summary.Started != 1 || summary.Done != 1 || summary.Running != 0 { + t.Fatalf("counts: started=%d done=%d running=%d", summary.Started, summary.Done, summary.Running) + } + worker := summary.Workers[0] + if worker.Specialist != "code-review" || worker.Description != "Hostile audit" || !worker.Background { + t.Fatalf("identity lost: %+v", worker) + } + if worker.Tokens != 493_955 || !worker.TokensReported || worker.Model != "glm-5.2" { + t.Fatalf("usage lost: %+v", worker) + } + if got := worker.Duration(time.Now()); got != 2*time.Minute+time.Second { + t.Fatalf("duration = %v, want 2m1s", got) + } +} + +// SPENT NOTHING AND NOBODY SAID ARE DIFFERENT ANSWERS. A provider that never +// emits usage cannot be budgeted by token count, and a view reporting 0 for it +// would be inventing a measurement it never took. +func TestAWorkerWithNoReportedUsageIsCountedAsUnmeasuredNotFree(t *testing.T) { + events := []sessions.Event{ + workerEvent(t, sessions.EventSpecialistStart, "2026-08-03T14:00:00Z", map[string]any{ + "source": specialistAccountingSource, "childSessionId": "quiet", "specialist": "worker", + }), + workerEvent(t, sessions.EventSpecialistStop, "2026-08-03T14:01:00Z", map[string]any{ + "source": specialistAccountingSource, "childSessionId": "quiet", "status": "success", + }), + workerEvent(t, sessions.EventSpecialistStart, "2026-08-03T14:00:30Z", map[string]any{ + "source": specialistAccountingSource, "childSessionId": "loud", "specialist": "worker", + }), + workerEvent(t, sessions.EventUsage, "2026-08-03T14:01:30Z", map[string]any{ + "source": specialistAccountingSource, "childSessionId": "loud", "totalTokens": float64(1000), + }), + } + summary := ReduceWorkers(events) + if summary.Unmeasured != 1 { + t.Fatalf("unmeasured = %d, want 1: a silent provider was reported as free", summary.Unmeasured) + } + if summary.Tokens != 1000 { + t.Fatalf("tokens = %d, want 1000", summary.Tokens) + } +} + +// THE PARENT'S OWN SPEND IS NOT ITS SUB-AGENTS'. Parent turns write EventUsage +// too; counting those would report the whole session as delegated work. +func TestTheParentsOwnUsageIsNotCountedAsAWorker(t *testing.T) { + events := []sessions.Event{ + workerEvent(t, sessions.EventUsage, "2026-08-03T14:00:00Z", map[string]any{ + "totalTokens": float64(16_000_000), + }), + workerEvent(t, sessions.EventUsage, "2026-08-03T14:00:01Z", map[string]any{ + "source": "somethingelse", "childSessionId": "c1", "totalTokens": float64(999), + }), + } + summary := ReduceWorkers(events) + if summary.Started != 0 || summary.Tokens != 0 { + t.Fatalf("the parent's own turns became workers: started=%d tokens=%d", summary.Started, summary.Tokens) + } +} + +// A plan task and a direct delegation are different things, and the label that +// tells them apart has ONE spelling shared with the writer. +func TestAPlanTaskIsDistinguishedFromADirectDelegation(t *testing.T) { + events := []sessions.Event{ + workerEvent(t, sessions.EventSpecialistStart, "2026-08-03T14:00:00Z", map[string]any{ + "source": specialistAccountingSource, "childSessionId": "p1", + "description": planTaskDescriptionPrefix + "by_name", + }), + workerEvent(t, sessions.EventSpecialistStart, "2026-08-03T14:00:01Z", map[string]any{ + "source": specialistAccountingSource, "childSessionId": "t1", "description": "Hostile audit", + }), + } + summary := ReduceWorkers(events) + kinds := map[string]WorkerKind{} + for _, w := range summary.Workers { + kinds[w.SessionID] = w.Kind + } + if kinds["p1"] != WorkerPlanTask { + t.Fatalf("a plan task was labelled %q", kinds["p1"]) + } + if kinds["t1"] != WorkerTask { + t.Fatalf("a direct delegation was labelled %q", kinds["t1"]) + } +} + +// A KILLED CHILD NEVER WRITES ITS STOP. It must still appear, as running, rather +// than vanishing from the view that exists to find it. +func TestAChildWithNoStopIsStillReported(t *testing.T) { + summary := ReduceWorkers([]sessions.Event{ + workerEvent(t, sessions.EventSpecialistStart, "2026-08-03T14:00:00Z", map[string]any{ + "source": specialistAccountingSource, "childSessionId": "orphan", "specialist": "worker", + }), + }) + if summary.Started != 1 || summary.Running != 1 { + t.Fatalf("a child with no stop vanished: %+v", summary) + } +} + +// STRUCTURAL, never message matching: a non-zero exit or a recorded error is a +// failure whatever words came with it. +func TestFailureIsReadFromTheExitCodeNotTheWords(t *testing.T) { + for _, tc := range []struct { + name string + payload map[string]any + want WorkerStatus + }{ + {"non-zero exit despite a success status", map[string]any{"status": "success", "exitCode": float64(1)}, WorkerFailed}, + {"an error recorded", map[string]any{"status": "success", "error": "context deadline exceeded"}, WorkerFailed}, + {"clean", map[string]any{"status": "success", "exitCode": float64(0)}, WorkerCompleted}, + {"no status at all", map[string]any{"exitCode": float64(0)}, WorkerCompleted}, + } { + t.Run(tc.name, func(t *testing.T) { + tc.payload["source"] = specialistAccountingSource + tc.payload["childSessionId"] = "c" + summary := ReduceWorkers([]sessions.Event{ + workerEvent(t, sessions.EventSpecialistStop, "2026-08-03T14:00:00Z", tc.payload), + }) + if got := summary.Workers[0].Status; got != tc.want { + t.Fatalf("status = %q, want %q", got, tc.want) + } + }) + } +} + +// One malformed event must not hide every other worker in the session. +func TestAMalformedEventDoesNotHideTheRest(t *testing.T) { + events := []sessions.Event{ + {Type: sessions.EventSpecialistStart, CreatedAt: "2026-08-03T14:00:00Z", Payload: json.RawMessage("{not json")}, + workerEvent(t, sessions.EventSpecialistStart, "2026-08-03T14:00:01Z", map[string]any{ + "source": specialistAccountingSource, "childSessionId": "good", "specialist": "worker", + }), + } + if summary := ReduceWorkers(events); summary.Started != 1 { + t.Fatalf("a malformed event hid the rest: started=%d", summary.Started) + } +} + +// A USAGE EVENT ALONE DOES NOT CONJURE A WORKER. +// +// AUDIT FINDING. A child whose start and stop had been compacted away still has +// usage on record, and creating a row from that left a phantom agent stuck at +// "running" that nothing could ever resolve — reproduced: started=1 running=1. +func TestUsageForAnUnknownChildDoesNotInventAWorker(t *testing.T) { + summary := ReduceWorkers([]sessions.Event{ + workerEvent(t, sessions.EventUsage, "2026-08-04T10:00:00Z", map[string]any{ + "source": specialistAccountingSource, "childSessionId": "ghost", "totalTokens": float64(500), + }), + }) + if summary.Started != 0 { + t.Fatalf("usage alone invented %d worker(s), stuck at running forever", summary.Started) + } +} + +// But usage that arrives AFTER a stop still counts — a background child's usage +// legitimately lands late, which is why the fold is order-tolerant. +func TestUsageArrivingAfterAStopIsStillCounted(t *testing.T) { + summary := ReduceWorkers([]sessions.Event{ + workerEvent(t, sessions.EventSpecialistStart, "2026-08-04T10:00:00Z", map[string]any{ + "source": specialistAccountingSource, "childSessionId": "c1", "specialist": "worker", + }), + workerEvent(t, sessions.EventSpecialistStop, "2026-08-04T10:01:00Z", map[string]any{ + "source": specialistAccountingSource, "childSessionId": "c1", "status": "success", + }), + workerEvent(t, sessions.EventUsage, "2026-08-04T10:01:05Z", map[string]any{ + "source": specialistAccountingSource, "childSessionId": "c1", "totalTokens": float64(9000), + }), + }) + if summary.Tokens != 9000 { + t.Fatalf("late usage was dropped: tokens=%d", summary.Tokens) + } + if summary.Done != 1 { + t.Fatalf("the worker is no longer done: %+v", summary) + } +} 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 { diff --git a/internal/swarm/launcher_session_id_test.go b/internal/swarm/launcher_session_id_test.go new file mode 100644 index 000000000..a50cc0bfa --- /dev/null +++ b/internal/swarm/launcher_session_id_test.go @@ -0,0 +1,129 @@ +package swarm + +import ( + "context" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/specialist" + "github.com/Gitlawb/zero/internal/streamjson" +) + +// A SWARM MEMBER'S SESSION ID IS NOT PART OF ITS ANSWER. +// +// BuildFinalResult prefixes "session_id: " to a successful child's output so +// a Task caller can continue that child. A swarm member has no such caller — +// MemberResult.SessionID carries the id structurally — and the string it returns +// is RENDERED: swarm_collect writes `result: ` (tools.go) and collapse +// only flattens newlines and truncates to 200 runes, so the id reached the user +// verbatim. +// +// THE TASK PATH ESCAPED THE SAME LEAK BY ACCIDENT OF RENDERING, not by design: +// toolCardSuppressedInTranscript drops "Task" and "update_plan" result rows from +// the transcript entirely. swarm_collect has no such suppression. +// +// THROUGH THE REAL LAUNCHER. The existing swarm tests use okFor, a fake that +// returns MemberResult{Result: "ok:" + spec.Task} and never touches +// BuildFinalResult — so no session_id prefix exists in the fixture and the test +// passes whether the leak is there or not. This drives NewSpecialistLauncher +// against a real executor seam instead. +func specialistLauncherFor(t *testing.T, childSession, answer string) MemberLauncher { + t.Helper() + executor := specialist.Executor{ + BinaryPath: "/bin/true", + NewSessionID: func() (string, error) { return childSession, nil }, + Load: func(specialist.LoadOptions) (specialist.LoadResult, error) { return specialist.LoadResult{}, nil }, + RunChild: func(_ context.Context, _ string, _ []string, progress func(streamjson.Event)) (specialist.ChildRunResult, error) { + events := []streamjson.Event{ + {Type: streamjson.EventRunStart, SessionID: childSession}, + {Type: streamjson.EventFinal, Text: answer}, + {Type: streamjson.EventRunEnd, Status: "success"}, + } + for _, event := range events { + if progress != nil { + progress(event) + } + } + return specialist.ChildRunResult{Started: true, ExitCode: 0, Events: events}, nil + }, + } + return NewSpecialistLauncher(executor) +} + +func TestASwarmMembersResultCarriesNoSessionIDLine(t *testing.T) { + const childSession = "specialist_00000000000000000000000a" + const answer = "The retry watchdog resets on every event." + + launcher := specialistLauncherFor(t, childSession, answer) + handle, err := launcher.Launch(context.Background(), MemberSpec{ + ID: "m1", Team: "t", AgentType: "subagent", Task: "trace the watchdog", Cwd: t.TempDir(), + }) + if err != nil { + t.Fatalf("the member did not start: %v", err) + } + result, err := handle.Wait() + if err != nil { + t.Fatalf("the member failed: %v", err) + } + // The premise: the id really did reach the launcher, so the assertion below + // is about stripping rather than about an id that was never there. + if result.SessionID != childSession { + t.Fatalf("setup: the launcher did not receive the child's id, got %q", result.SessionID) + } + if strings.Contains(result.Result, "session_id") { + t.Fatalf("the raw child session id is in what swarm_collect renders:\n%q", result.Result) + } + if !strings.Contains(result.Result, "watchdog resets") { + t.Fatalf("the answer itself did not survive: %q", result.Result) + } +} + +// AND IT MUST SURVIVE THE RENDER. Stripping at the launcher is only worth +// anything if the string that reaches the user is the stripped one — so this +// follows it through the coordinator and out through swarm_collect's own +// rendering, which is where the leak was actually visible. +func TestTheRenderedCollectOutputCarriesNoSessionID(t *testing.T) { + const childSession = "specialist_00000000000000000000000a" + launcher := specialistLauncherFor(t, childSession, "found three call sites") + + handle, err := launcher.Launch(context.Background(), MemberSpec{ + ID: "m1", Team: "t", AgentType: "subagent", Task: "find them", Cwd: t.TempDir(), + }) + if err != nil { + t.Fatal(err) + } + result, err := handle.Wait() + if err != nil { + t.Fatal(err) + } + // collapse is what swarm_collect applies before printing: it flattens + // newlines and truncates, and does NOT remove the line — which is precisely + // why the strip has to happen upstream of it. + rendered := " result: " + collapse(result.Result) + if strings.Contains(rendered, "session_id") { + t.Fatalf("the id survives into the rendered collect output:\n%s", rendered) + } + if !strings.Contains(rendered, "found three call sites") { + t.Fatalf("the answer did not survive the render: %s", rendered) + } +} + +// THE FIXTURE THAT COULD NOT CATCH THIS, pinned so it cannot quietly come back. +// +// okFor returns a hand-written Result and never runs BuildFinalResult, so a test +// built on it is green whether or not the production launcher strips anything. +// This asserts the gap rather than pretending it is closed. +func TestTheFakeLauncherCannotExerciseTheStrip(t *testing.T) { + fake, err := okFor(MemberSpec{ID: "m1", Task: "anything"}, 0) + if err != nil { + t.Fatal(err) + } + if strings.Contains(fake.Result, "session_id") { + t.Fatal("okFor now produces a session_id line; the tests built on it may be meaningful after all — re-read them") + } + // It is a fixture, not the production path: the real launcher is the only + // thing that can prove the strip, which is what the tests above use. + if fake.Result != "ok:anything" { + t.Fatalf("okFor changed shape to %q; the tests that rely on it need re-reading", fake.Result) + } +} diff --git a/internal/swarm/launcher_specialist.go b/internal/swarm/launcher_specialist.go index 8ba3a5972..44bb74c41 100644 --- a/internal/swarm/launcher_specialist.go +++ b/internal/swarm/launcher_specialist.go @@ -69,7 +69,27 @@ func NewSpecialistLauncher(executor specialist.Executor) MemberLauncher { // still drillable, and carry its report as the failure message. return MemberResult{SessionID: res.SessionID}, errors.New(res.Result.Output) } - return MemberResult{Result: res.Result.Output, SessionID: res.SessionID}, nil + // THE ID TRAVELS IN SessionID, NOT IN THE PROSE — the same fix the plan + // path already carries, and this is its sibling. + // + // BuildFinalResult prefixes "session_id: " to a successful child's + // output so a Task caller can continue that child. A swarm member has no + // such caller: MemberResult.SessionID carries the id structurally, the + // coordinator stores it separately (coordinator.go), and the string goes + // on to be RENDERED — swarm_collect writes `result: ` and collapse + // only flattens newlines and truncates, so the id reaches the user + // verbatim. The Task path escapes the same leak only because the TUI + // drops its raw result row entirely (toolCardSuppressedInTranscript is + // "Task" or "update_plan"); swarm_collect has no such suppression, so it + // has to be stripped at the source instead. + // + // ONLY THE SUCCESS PATH NEEDS IT. BuildFinalResult adds the line in its + // no-errors branch alone, so the StatusError return above carries a + // diagnostic that never had one. + return MemberResult{ + Result: specialist.WithoutSessionIDLine(res.Result.Output, res.SessionID), + SessionID: res.SessionID, + }, nil }} } diff --git a/internal/tools/bash.go b/internal/tools/bash.go index 6274c806c..2f738609e 100644 --- a/internal/tools/bash.go +++ b/internal/tools/bash.go @@ -297,7 +297,7 @@ func shellIssueBlockResult(issue shellIssue) Result { // commandText. PowerShell is preferred on Windows, with cmd.exe retained as // the fallback. Only cmd.exe needs the raw command-line override. func buildBashCommand(ctx context.Context, commandText string, absoluteCwd string, engine *zeroSandbox.Engine) (*exec.Cmd, zeroSandbox.CommandPlan, error) { - hostShell := detectShellRuntime(runtime.GOOS) + hostShell := shellRuntimeForEngine(engine, absoluteCwd) spec := zeroSandbox.CommandSpec{ Name: hostShell.Executable, Args: hostShell.arguments(commandText), @@ -334,6 +334,39 @@ func buildBashCommand(ctx context.Context, commandText string, absoluteCwd strin return command, plan, nil } +// shellRuntimeForEngine resolves the shell to run commandText with. When a +// sandbox engine will wrap the command, the shell must be probed through that +// engine: a candidate that starts fine in this process can be unable to start +// under the sandbox's restricted token, and picking it strands every command. +// Without an engine the command runs unwrapped, so the direct probe is already +// asking the right question. +func shellRuntimeForEngine(engine *zeroSandbox.Engine, absoluteCwd string) shellRuntime { + if engine == nil || runtime.GOOS != "windows" { + return detectShellRuntime(runtime.GOOS) + } + return detectShellRuntimeSandboxed(func(path string) bool { + return shellStartsUnderEngine(engine, path, absoluteCwd) + }) +} + +// shellStartsUnderEngine reports whether path can start a trivial command +// inside the sandbox. The probe deliberately uses its own timeout rather than +// the caller's context: the answer is cached for the process, so a cancelled +// request must not poison it for every later command. +func shellStartsUnderEngine(engine *zeroSandbox.Engine, path string, absoluteCwd string) bool { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + command, _, err := engine.CommandContext(ctx, zeroSandbox.CommandSpec{ + Name: path, + Args: []string{"-NoLogo", "-NoProfile", "-Command", "exit 0"}, + Dir: absoluteCwd, + }) + if err != nil || command == nil { + return false + } + return command.Run() == nil +} + func addSandboxMeta(meta map[string]string, plan zeroSandbox.CommandPlan) { if plan.Backend.Name == "" { return diff --git a/internal/tools/edit_file.go b/internal/tools/edit_file.go index a5b0ac308..1203c7e9b 100644 --- a/internal/tools/edit_file.go +++ b/internal/tools/edit_file.go @@ -140,7 +140,6 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any return errorResult(fileUnseenMessage(relativePath)) } - previouslySeenWhole := options.FileTracker.SeenWhole(absolutePath) updated := strings.Replace(content, oldString, newString, 1) replacedCount := 1 if replaceAll { @@ -165,15 +164,21 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any // Re-baseline to the content we just wrote so subsequent edits in this session // compare against the current on-disk state, not the pre-edit version. newInfo, _ := os.Stat(absolutePath) - options.FileTracker.Record(absolutePath, []byte(updated), newInfo) if updated == modelKnownContent { - if previouslySeenWhole { - options.FileTracker.RecordSeenRange(absolutePath, 1, lineCount(updated), lineCount(updated)) - } else { - for _, span := range editedSpans { - options.FileTracker.RecordSeenBytes(absolutePath, span.start, span.end, len(updated)) - } + // OUR edit, so we know precisely which lines moved: RecordEdit carries + // across the reads this edit did not disturb instead of dropping them. + // + // Record would drop all of them, and did — a file read in three pieces + // lost every piece to a single two-line edit, and the next six edits into + // regions that had been read were refused as unseen. See RecordEdit. + options.FileTracker.RecordEdit(absolutePath, []byte(content), []byte(updated), newInfo) + for _, span := range editedSpans { + options.FileTracker.RecordSeenBytes(absolutePath, span.start, span.end, len(updated)) } + } else { + // A formatter rewrote the file after us. We no longer know which line + // holds what was read, so the conservative drop is the right answer here. + options.FileTracker.Record(absolutePath, []byte(updated), newInfo) } suffix := "" diff --git a/internal/tools/edit_preserves_reads_test.go b/internal/tools/edit_preserves_reads_test.go new file mode 100644 index 000000000..4815a54e6 --- /dev/null +++ b/internal/tools/edit_preserves_reads_test.go @@ -0,0 +1,241 @@ +package tools + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +// editTrackerFixture writes a numbered file and returns its real path plus a +// tracker that has already recorded the whole content as the current version. +// +// EvalSymlinks matters: on macOS t.TempDir() hands back /var/..., the tools +// resolve it to /private/var/..., and a tracker keyed on the unresolved path +// silently misses every lookup — which makes an edit fail for the very reason +// this file is about, and for entirely the wrong cause. +func editTrackerFixture(t *testing.T, lines []string) (string, *FileTracker, Tool) { + t.Helper() + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(root, "index.go") + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644); err != nil { + t.Fatal(err) + } + tracker := NewFileTracker() + content, _ := os.ReadFile(path) + info, _ := os.Stat(path) + tracker.Record(path, content, info) + return path, tracker, NewScopedEditFileTool(root, nil) +} + +func numberedLines(total int, special map[int]string) []string { + lines := make([]string, 0, total) + for i := 1; i <= total; i++ { + if text, ok := special[i]; ok { + lines = append(lines, text) + continue + } + lines = append(lines, "filler") + } + return lines +} + +func runTrackedEdit(t *testing.T, tool Tool, tracker *FileTracker, path, oldString, newString string) Result { + t.Helper() + return tool.(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{ + "path": path, "old_string": oldString, "new_string": newString, + }, RunOptions{FileTracker: tracker}) +} + +// ONE EDIT MUST NOT ERASE EVERY OTHER READ, and this is the run that made the +// point: a 371-line file read in three pieces (40-45, 85-260, 260-371), one +// two-line edit at line 92, and then six consecutive refusals to edit regions +// that HAD been read and that the edit never touched — until the +// repeated-failure guard halted the run. The error told the model to re-read, +// which its next successful edit would have undone again. +func TestASuccessfulEditKeepsTheReadsItDidNotDisturb(t *testing.T) { + path, tracker, tool := editTrackerFixture(t, numberedLines(371, map[int]string{ + 92: "cmp := keyCmp(k, n.split)", + 200: "prio: l.prio, split: l.split,", + })) + tracker.RecordSeenRange(path, 40, 45, 371) + tracker.RecordSeenRange(path, 85, 260, 371) + tracker.RecordSeenRange(path, 260, 371, 371) + + if !tracker.SeenRange(path, 200, 200) { + t.Fatal("setup: line 200 sits inside the 85-260 read and must start out seen") + } + + first := runTrackedEdit(t, tool, tracker, path, "cmp := keyCmp(k, n.split)", "cmp := keyCmp(k, n.pivot)") + if strings.Contains(first.Output, "Error") { + t.Fatalf("the first edit failed, so the test never reaches its subject: %s", first.Output) + } + + if !tracker.SeenRange(path, 200, 200) { + t.Error("line 200 was read, the edit at line 92 did not touch it, and the file still has 371 lines — but it is no longer considered seen") + } + // The assertion that matters: the SECOND edit is what the run could never do. + second := runTrackedEdit(t, tool, tracker, path, "prio: l.prio, split: l.split,", "prio: l.prio, pivot: l.pivot,") + if strings.Contains(second.Output, "not been read exactly in this session") { + t.Fatalf("a second edit into an already-read region was refused as unseen: %s", second.Output) + } + if strings.Contains(second.Output, "Error") { + t.Fatalf("the second edit failed: %s", second.Output) + } +} + +// LINE NUMBERS MOVE WHEN AN EDIT CHANGES THE LINE COUNT, so a range after the +// edit has to be carried across SHIFTED, not left where it was. +func TestReadsAfterAnEditAreShiftedByTheLineDelta(t *testing.T) { + path, tracker, tool := editTrackerFixture(t, numberedLines(100, map[int]string{ + 10: "short", + 80: "target line", + })) + tracker.RecordSeenRange(path, 5, 20, 100) + tracker.RecordSeenRange(path, 70, 90, 100) + + // Replace one line with three: everything after line 10 moves down by two. + res := runTrackedEdit(t, tool, tracker, path, "short", "one\ntwo\nthree") + if strings.Contains(res.Output, "Error") { + t.Fatalf("setup edit failed: %s", res.Output) + } + + // "target line" was line 80 and is now line 82. It was read either way. + content, _ := os.ReadFile(path) + moved := strings.Count(strings.SplitN(string(content), "target line", 2)[0], "\n") + 1 + if moved != 82 { + t.Fatalf("fixture moved the target to line %d, expected 82", moved) + } + if !tracker.SeenRange(path, moved, moved) { + t.Errorf("the read at lines 70-90 was not shifted with the content: line %d reads as unseen", moved) + } + second := runTrackedEdit(t, tool, tracker, path, "target line", "target changed") + if strings.Contains(second.Output, "Error") { + t.Fatalf("editing a line that was read and merely moved was refused: %s", second.Output) + } +} + +// THE GUARD STILL GUARDS. Carrying reads across must not credit the model with +// content it never saw — that is the whole reason the check exists. +func TestAnUnreadRegionIsStillRefusedAfterAnEdit(t *testing.T) { + path, tracker, tool := editTrackerFixture(t, numberedLines(300, map[int]string{ + 50: "seen line", + 250: "never read line", + })) + // Only 40-60 is read. Line 250 is not. + tracker.RecordSeenRange(path, 40, 60, 300) + + if res := runTrackedEdit(t, tool, tracker, path, "seen line", "seen line edited"); strings.Contains(res.Output, "Error") { + t.Fatalf("setup edit failed: %s", res.Output) + } + res := runTrackedEdit(t, tool, tracker, path, "never read line", "sneaky") + if !strings.Contains(res.Output, "not been read exactly in this session") { + t.Fatalf("an edit into a region that was NEVER read was allowed: %s", res.Output) + } +} + +// A RANGE THE EDIT SPANS IS SPLIT, NOT DROPPED — and getting this wrong is not +// hypothetical: dropping on overlap was my first attempt, and it left the +// original defect exactly in place. A read almost always CONTAINS the line it +// is about to edit, because containing it is why it was read. +// +// So the flanks survive and only the rewritten lines stop being known. +func TestARangeTheEditSpansIsSplitAroundIt(t *testing.T) { + path, tracker, tool := editTrackerFixture(t, numberedLines(100, map[int]string{ + 20: "alpha", + 21: "beta", + 22: "gamma", + })) + tracker.RecordSeenRange(path, 15, 30, 100) + + // Replace a three-line span with one line, entirely inside the read range. + if res := runTrackedEdit(t, tool, tracker, path, "alpha\nbeta\ngamma", "merged"); strings.Contains(res.Output, "Error") { + t.Fatalf("setup edit failed: %s", res.Output) + } + + // Before the edit: still known, still at the same line numbers. + if !tracker.SeenRange(path, 15, 19) { + t.Error("lines 15-19 sit before the edit and did not move, but were forgotten") + } + // After it: still known, shifted up by the two lines the edit removed. + if !tracker.SeenRange(path, 21, 28) { + t.Error("lines 23-30 were read and merely moved to 21-28, but were forgotten") + } + // The rewritten span itself is no longer described by that read. + if tracker.SeenRange(path, 15, 30) { + t.Error("the whole original range still reads as seen, so the model is credited with content the edit replaced") + } +} + +// AN EXTERNAL CHANGE STILL INVALIDATES EVERYTHING. RecordEdit is only for edits +// we made; a file changed behind our back is exactly the case the blanket drop +// is right for, and it must keep working. +func TestAnExternalChangeStillDropsEveryRead(t *testing.T) { + path, tracker, _ := editTrackerFixture(t, numberedLines(100, map[int]string{50: "hello"})) + tracker.RecordSeenRange(path, 40, 60, 100) + if !tracker.SeenRange(path, 50, 50) { + t.Fatal("setup: line 50 should be seen") + } + + // Something outside Zero rewrites the file. + changed := strings.Repeat("different\n", 100) + if err := os.WriteFile(path, []byte(changed), 0o644); err != nil { + t.Fatal(err) + } + info, _ := os.Stat(path) + tracker.Record(path, []byte(changed), info) + + if tracker.SeenRange(path, 50, 50) { + t.Error("an external rewrite left the old read ranges in place") + } +} + +// A file read in FULL stays read in full: an edit of ours does not make that +// untrue, and re-reading a whole file after every edit is the cost this avoids. +func TestAWhollyReadFileStaysWhollyRead(t *testing.T) { + path, tracker, tool := editTrackerFixture(t, numberedLines(40, map[int]string{5: "one"})) + tracker.RecordSeenRange(path, 1, 40, 40) + if !tracker.SeenWhole(path) { + t.Fatal("setup: the file should read as wholly seen") + } + + if res := runTrackedEdit(t, tool, tracker, path, "one", "one\ntwo"); strings.Contains(res.Output, "Error") { + t.Fatalf("edit failed: %s", res.Output) + } + if !tracker.SeenWhole(path) { + t.Error("a wholly-read file stopped being wholly read after an edit") + } +} + +// The span helpers, directly: these decide what survives, so their edges are +// worth pinning independently of the tool. +func TestChangedSpanHelpers(t *testing.T) { + t.Run("a single line replaced in place", func(t *testing.T) { + first, last, delta := changedLineSpan("a\nb\nc", "a\nB\nc") + if first != 2 || last != 2 || delta != 0 { + t.Fatalf("got (%d,%d,%d), want (2,2,0)", first, last, delta) + } + }) + t.Run("one line becomes three", func(t *testing.T) { + first, last, delta := changedLineSpan("a\nb\nc", "a\nx\ny\nz\nc") + if first != 2 || last != 2 || delta != 2 { + t.Fatalf("got (%d,%d,%d), want (2,2,2)", first, last, delta) + } + }) + t.Run("lines removed", func(t *testing.T) { + first, last, delta := changedLineSpan("a\nb\nc\nd", "a\nd") + if first != 2 || last != 3 || delta != -2 { + t.Fatalf("got (%d,%d,%d), want (2,3,-2)", first, last, delta) + } + }) + t.Run("bytes", func(t *testing.T) { + first, last, delta := changedByteSpan([]byte("abcdef"), []byte("abXYef")) + if first != 2 || last != 4 || delta != 0 { + t.Fatalf("got (%d,%d,%d), want (2,4,0)", first, last, delta) + } + }) +} diff --git a/internal/tools/file_tracker.go b/internal/tools/file_tracker.go index 3444816eb..6f9374418 100644 --- a/internal/tools/file_tracker.go +++ b/internal/tools/file_tracker.go @@ -6,6 +6,7 @@ import ( "errors" "os" "sort" + "strings" "sync" "time" ) @@ -165,6 +166,138 @@ func (tracker *FileTracker) RecordSeenBytes(absPath string, start, end, total in tracker.seen[absPath] = observation } +// RecordEdit re-baselines absPath after an edit THIS SESSION made, keeping the +// reads the edit did not disturb. +// +// WHY THIS EXISTS RATHER THAN Record. RecordHash drops every recorded range when +// the content hash moves, which is right for a change we did not make: we cannot +// say which lines still hold what was read. After our own edit we can say +// exactly. The content before the first changed line is byte-identical and sits +// at the same line numbers; the content after the last changed line is +// byte-identical and has moved by a known delta. Only the lines the edit +// actually spans stop describing the file. +// +// Dropping the lot instead cost a real run. A 371-line file was read in three +// pieces (40-45, 85-260, 260-371) and one two-line edit at line 92 erased the +// credit for all three: the next six edits — into regions that had been read, +// that the edit did not touch, in a file whose line count had not changed — were +// each refused as content "not read in this session", and the repeated-failure +// guard halted the run. The error even told the model to re-read, which would +// have been undone by its next successful edit. The guard is right that a model +// must not edit what it has not seen; it was wrong about what it had seen. +func (tracker *FileTracker) RecordEdit(absPath string, before, after []byte, info os.FileInfo) { + if tracker == nil { + return + } + firstLine, lastLineBefore, lineDelta := changedLineSpan(string(before), string(after)) + firstByte, lastByteBefore, byteDelta := changedByteSpan(before, after) + + tracker.mu.Lock() + defer tracker.mu.Unlock() + + version := FileVersion{Hash: HashContent(after)} + if info != nil { + version.Size = info.Size() + version.MTime = info.ModTime() + } + tracker.versions[absPath] = version + + observation, tracked := tracker.seen[absPath] + if !tracked { + return + } + if observation.whole { + // Still whole: every line was read, and an edit of ours does not make + // that untrue. + observation.total = countLines(after) + observation.totalBytes = len(after) + tracker.seen[absPath] = observation + return + } + + // SPLIT AROUND THE EDIT, never drop the whole range. A read almost always + // SPANS the line it is about to edit — that is why it was read — so dropping + // on overlap would have thrown away 85-260 to change line 92 and left the + // original defect in place under a longer implementation. + kept := make([]lineRange, 0, len(observation.ranges)+1) + for _, seen := range observation.ranges { + if end := min(seen.end, firstLine-1); seen.start <= end { + kept = append(kept, lineRange{start: seen.start, end: end}) + } + if start := max(seen.start, lastLineBefore+1); start <= seen.end { + kept = append(kept, lineRange{start: start + lineDelta, end: seen.end + lineDelta}) + } + } + observation.ranges = kept + if observation.total != 0 { + observation.total = countLines(after) + } + + // Same split, on half-open byte intervals. + keptBytes := make([]lineRange, 0, len(observation.byteRanges)+1) + for _, seen := range observation.byteRanges { + if end := min(seen.end, firstByte); seen.start < end { + keptBytes = append(keptBytes, lineRange{start: seen.start, end: end}) + } + if start := max(seen.start, lastByteBefore); start < seen.end { + keptBytes = append(keptBytes, lineRange{start: start + byteDelta, end: seen.end + byteDelta}) + } + } + observation.byteRanges = keptBytes + if observation.totalBytes != 0 { + observation.totalBytes = len(after) + } + tracker.seen[absPath] = observation +} + +// changedLineSpan reports the 1-based first line that differs between before and +// after, the 1-based last line of BEFORE that differs, and the line-count delta. +// +// Computed from a common prefix and suffix rather than from the caller's +// replacement spans: one edit_file call with replace_all can rewrite many +// scattered occurrences, and the span between the outermost two is the only +// region that is honestly unknown afterwards. +func changedLineSpan(before, after string) (firstChanged, lastChangedBefore, delta int) { + beforeLines := splitLinesForTracking(before) + afterLines := splitLinesForTracking(after) + prefix := 0 + for prefix < len(beforeLines) && prefix < len(afterLines) && beforeLines[prefix] == afterLines[prefix] { + prefix++ + } + suffix := 0 + for suffix < len(beforeLines)-prefix && suffix < len(afterLines)-prefix && + beforeLines[len(beforeLines)-1-suffix] == afterLines[len(afterLines)-1-suffix] { + suffix++ + } + return prefix + 1, len(beforeLines) - suffix, len(afterLines) - len(beforeLines) +} + +// changedByteSpan is changedLineSpan in bytes: the first differing offset, the +// end offset of the changed region in BEFORE, and the size delta. +func changedByteSpan(before, after []byte) (firstChanged, lastChangedBefore, delta int) { + prefix := 0 + for prefix < len(before) && prefix < len(after) && before[prefix] == after[prefix] { + prefix++ + } + suffix := 0 + for suffix < len(before)-prefix && suffix < len(after)-prefix && + before[len(before)-1-suffix] == after[len(after)-1-suffix] { + suffix++ + } + return prefix, len(before) - suffix, len(after) - len(before) +} + +func splitLinesForTracking(text string) []string { + if text == "" { + return nil + } + return strings.Split(text, "\n") +} + +func countLines(content []byte) int { + return len(splitLinesForTracking(string(content))) +} + // coversFully reports whether ranges together cover every line in [start, end]. // // Ranges are merged rather than scanned line by line: a caller asking about a diff --git a/internal/tools/memory.go b/internal/tools/memory.go new file mode 100644 index 000000000..25beeda62 --- /dev/null +++ b/internal/tools/memory.go @@ -0,0 +1,183 @@ +package tools + +import ( + "context" + "fmt" + "strings" + + "github.com/Gitlawb/zero/internal/memory" +) + +// The memory tools: read freely, write on approval. +// +// THE ASYMMETRY IS THE DESIGN. Reading a note is reading a file the user already +// has, so it needs no more ceremony than read_file. Writing one puts text into +// the user's repo that will be read back in every future session and believed — +// a note that says "this package is safe to change freely" is load-bearing the +// moment anyone trusts it. So memory_write prompts like every other write tool, +// and nothing lands silently. +// +// PLAN TASKS GET NEITHER BY DEFAULT. planReadOnlyTools is the allow-list a plan +// task's grant is validated against, and neither of these is in it — so a task +// cannot read a note (which would let a stale note steer a fan-out) or write one +// (which would let twenty tasks race to describe the same finding). That is a +// default, not a ceiling: a considered decision to grant them is a one-line +// change in that list. + +const ( + MemoryToolName = "memory" + MemoryWriteToolName = "memory_write" +) + +type memoryTool struct { + baseTool + paths memory.Paths +} + +// NewMemoryTool reads durable notes. Paths with no directories configured makes +// the tool report that memory is unavailable rather than silently finding none — +// "you have no notes" and "notes are switched off here" are different answers. +func NewMemoryTool(paths memory.Paths) Tool { + return memoryTool{ + baseTool: baseTool{ + name: MemoryToolName, + description: "Read durable notes saved in earlier sessions: project conventions, decisions, and confirmed findings. " + + "Call with no arguments to list what exists (name, scope and a one-line description) and with a name to read one. " + + "Prefer listing first: the descriptions are there so you can choose what to open instead of reading everything.", + parameters: Schema{ + Type: "object", + Properties: map[string]PropertySchema{ + "name": {Type: "string", Description: "The note to read. Omit to list every note instead."}, + "scope": {Type: "string", Description: `Which store to read: "project" (shared, checked in) or "local" (this machine). Omit to search both.`}, + }, + AdditionalProperties: false, + }, + safety: readOnlySafety("Reads saved notes."), + capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: true}, + }, + paths: paths, + } +} + +func (tool memoryTool) Run(_ context.Context, args map[string]any) Result { + name, err := aliasedStringArg(args, []string{"name", "note", "key"}, "", false, true) + if err != nil { + return errorResult("Error: Invalid arguments for memory: " + err.Error()) + } + scope, err := aliasedStringArg(args, []string{"scope"}, "", false, true) + if err != nil { + return errorResult("Error: Invalid arguments for memory: " + err.Error()) + } + if tool.paths.ProjectDir == "" && tool.paths.LocalDir == "" { + return errorResult("Error: memory is not available in this run.") + } + + if strings.TrimSpace(name) == "" { + return okResult(renderMemoryList(memory.List(tool.paths))) + } + for _, candidate := range memoryScopes(scope) { + note, err := memory.Read(tool.paths, candidate, name) + if err != nil { + continue + } + return okResult(fmt.Sprintf("memory %q (%s)\n\n%s", note.Name, note.Scope, note.Body)) + } + return errorResult(fmt.Sprintf("Error: no memory named %q. Call memory with no arguments to see what exists.", name)) +} + +type memoryWriteTool struct { + baseTool + paths memory.Paths +} + +// NewMemoryWriteTool saves a durable note. +func NewMemoryWriteTool(paths memory.Paths) Tool { + return memoryWriteTool{ + baseTool: baseTool{ + name: MemoryWriteToolName, + description: "Save a durable note for future sessions, or delete one. " + + "Write what a later session could not work out for itself — a convention, a decision and its reason, a finding already confirmed. " + + "Do NOT write what the code, the tests or git history already say; a note that repeats them is one more thing to keep true. " + + `Use scope "local" by default; "project" is checked in and shared with everyone who clones the repo, so save there only what the whole team should read.`, + parameters: Schema{ + Type: "object", + Properties: map[string]PropertySchema{ + "name": {Type: "string", Description: "Short identifier: letters, digits, hyphen and underscore."}, + "content": {Type: "string", Description: "The note itself. Omit to DELETE the note of this name."}, + "description": {Type: "string", Description: "One line saying what this note is for. Shown when listing, so a reader can choose without opening it."}, + "scope": {Type: "string", Description: `"local" (this machine, the default) or "project" (checked in, shared).`}, + }, + Required: []string{"name"}, + AdditionalProperties: false, + }, + safety: promptSafety(SideEffectWrite, "Saves a note that future sessions will read and believe."), + capabilities: ToolCapabilities{Effect: EffectWorkspaceWrite, ThreadSafe: false}, + }, + paths: paths, + } +} + +func (tool memoryWriteTool) Run(_ context.Context, args map[string]any) Result { + name, err := aliasedStringArg(args, []string{"name", "note", "key"}, "", true, false) + if err != nil { + return errorResult("Error: Invalid arguments for memory_write: " + err.Error()) + } + content, err := aliasedStringArg(args, []string{"content", "body", "text"}, "", false, true) + if err != nil { + return errorResult("Error: Invalid arguments for memory_write: " + err.Error()) + } + description, err := aliasedStringArg(args, []string{"description", "summary"}, "", false, true) + if err != nil { + return errorResult("Error: Invalid arguments for memory_write: " + err.Error()) + } + rawScope, err := aliasedStringArg(args, []string{"scope"}, "", false, true) + if err != nil { + return errorResult("Error: Invalid arguments for memory_write: " + err.Error()) + } + // LOCAL BY DEFAULT. A note the model chose to keep should not land in a + // shared, checked-in file unless someone said so — the same reasoning that + // makes project config unable to raise a spend ceiling. + scope := memory.ScopeLocal + if trimmed := strings.TrimSpace(strings.ToLower(rawScope)); trimmed != "" { + scope = memory.Scope(trimmed) + } + + if strings.TrimSpace(content) == "" { + if err := memory.Forget(tool.paths, scope, name); err != nil { + return errorResult("Error: " + err.Error()) + } + return okResult(fmt.Sprintf("Forgot %q (%s).", name, scope)) + } + if _, err := memory.Write(tool.paths, scope, name, description, content); err != nil { + return errorResult("Error: " + err.Error()) + } + return okResult(fmt.Sprintf("Saved %q (%s).", name, scope)) +} + +func memoryScopes(requested string) []memory.Scope { + switch memory.Scope(strings.TrimSpace(strings.ToLower(requested))) { + case memory.ScopeProject: + return []memory.Scope{memory.ScopeProject} + case memory.ScopeLocal: + return []memory.Scope{memory.ScopeLocal} + default: + return []memory.Scope{memory.ScopeProject, memory.ScopeLocal} + } +} + +func renderMemoryList(notes []memory.Note) string { + if len(notes) == 0 { + return "No saved notes yet. Use memory_write to keep something a future session could not work out for itself." + } + var b strings.Builder + fmt.Fprintf(&b, "%d saved note(s):\n", len(notes)) + for _, note := range notes { + fmt.Fprintf(&b, "- %s (%s)", note.Name, note.Scope) + if note.Description != "" { + b.WriteString(" — ") + b.WriteString(note.Description) + } + b.WriteString("\n") + } + return strings.TrimRight(b.String(), "\n") +} diff --git a/internal/tools/memory_tool_test.go b/internal/tools/memory_tool_test.go new file mode 100644 index 000000000..fa96010dc --- /dev/null +++ b/internal/tools/memory_tool_test.go @@ -0,0 +1,107 @@ +package tools + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/memory" +) + +func memoryPaths(t *testing.T) memory.Paths { + t.Helper() + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + return memory.DefaultPaths(root) +} + +// READ FREELY, WRITE ON APPROVAL. Reading a note is reading a file the user +// already has. Writing one puts text into their repo that every future session +// reads and believes, so it prompts like any other write. +func TestTheMemoryToolsSplitReadFromWrite(t *testing.T) { + read := NewMemoryTool(memoryPaths(t)) + if got := read.Safety().Permission; got != PermissionAllow { + t.Errorf("reading a note prompts (%v); it is no more than a read_file", got) + } + if got := read.Safety().SideEffect; got != SideEffectRead { + t.Errorf("read side effect = %v", got) + } + write := NewMemoryWriteTool(memoryPaths(t)) + if got := write.Safety().Permission; got != PermissionPrompt { + t.Errorf("saving a note does not prompt (%v); a future session will read it and believe it", got) + } + if got := write.Safety().SideEffect; got != SideEffectWrite { + t.Errorf("write side effect = %v", got) + } +} + +func TestANoteSavedIsANoteListedAndRead(t *testing.T) { + paths := memoryPaths(t) + write, read := NewMemoryWriteTool(paths), NewMemoryTool(paths) + + res := write.Run(context.Background(), map[string]any{ + "name": "errors", "description": "how this repo wraps errors", "content": "Always %w.", + }) + if res.Status != StatusOK { + t.Fatalf("write: %s", res.Output) + } + + listed := read.Run(context.Background(), map[string]any{}) + if !strings.Contains(listed.Output, "errors") || !strings.Contains(listed.Output, "how this repo wraps errors") { + t.Fatalf("the listing does not show the note and its description:\n%s", listed.Output) + } + // The description is what makes a listing useful — a reader chooses what to + // open instead of reading everything. + if strings.Contains(listed.Output, "Always %w.") { + t.Errorf("the listing dumped the body; it exists so the body need not be read:\n%s", listed.Output) + } + + one := read.Run(context.Background(), map[string]any{"name": "errors"}) + if !strings.Contains(one.Output, "Always %w.") { + t.Fatalf("reading by name did not return the body:\n%s", one.Output) + } +} + +// LOCAL BY DEFAULT. A note the model chose to keep must not land in a shared, +// checked-in file unless someone said so. +func TestANoteDefaultsToTheLocalScope(t *testing.T) { + paths := memoryPaths(t) + res := NewMemoryWriteTool(paths).Run(context.Background(), map[string]any{"name": "n", "content": "x"}) + if res.Status != StatusOK { + t.Fatalf("write: %s", res.Output) + } + if !strings.Contains(res.Output, "local") { + t.Errorf("a scopeless write did not go local: %q", res.Output) + } + if _, err := memory.Read(paths, memory.ScopeProject, "n"); err == nil { + t.Error("a scopeless write landed in the shared, checked-in scope") + } +} + +// Deleting is asking for it to be gone, so a missing note is not an error. +func TestOmittingContentForgetsTheNote(t *testing.T) { + paths := memoryPaths(t) + write, read := NewMemoryWriteTool(paths), NewMemoryTool(paths) + write.Run(context.Background(), map[string]any{"name": "temp", "content": "x"}) + if res := write.Run(context.Background(), map[string]any{"name": "temp"}); res.Status != StatusOK { + t.Fatalf("forget: %s", res.Output) + } + if res := read.Run(context.Background(), map[string]any{"name": "temp"}); res.Status != StatusError { + t.Errorf("a forgotten note is still readable: %s", res.Output) + } + if res := write.Run(context.Background(), map[string]any{"name": "never-existed"}); res.Status != StatusOK { + t.Errorf("forgetting a missing note errored: %s", res.Output) + } +} + +// Memory switched off says so, rather than reporting an empty store — "you have +// no notes" and "notes are off here" are different answers. +func TestMemoryUnavailableSaysSo(t *testing.T) { + res := NewMemoryTool(memory.Paths{}).Run(context.Background(), map[string]any{}) + if res.Status != StatusError || !strings.Contains(res.Output, "not available") { + t.Errorf("an unconfigured store did not say so: %+v", res) + } +} diff --git a/internal/tools/registry.go b/internal/tools/registry.go index a03209370..748ae96d0 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -26,6 +26,14 @@ type RunOptions struct { ReasoningEffort string Depth int Cwd string + // UserMessage is the RAW text the user typed for this turn, before any tool + // output, file content or previous assistant turn joined the context. + // + // It exists so a tool that costs real money can require the USER to have + // asked for it, rather than firing because an imperative sentence appeared in + // something the model read. Empty when the caller does not supply it, and a + // gate reading it must decide what empty means for itself. + UserMessage string // FileTracker, when set, records the version of each file read or written this // session so write_file/edit_file can refuse to clobber a file that changed on // disk outside Zero since it was last read. nil disables the feature entirely @@ -70,6 +78,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/tools/shell_runtime.go b/internal/tools/shell_runtime.go index 276f1ef0b..79d6d540a 100644 --- a/internal/tools/shell_runtime.go +++ b/internal/tools/shell_runtime.go @@ -49,6 +49,13 @@ const windowsPowerShellExitSuffix = "\nif ($null -ne $LASTEXITCODE) { exit $LAST var ( hostShellOnce sync.Once hostShell shellRuntime + + // The sandboxed answer is cached separately from the unsandboxed one + // because the two environments genuinely disagree: PowerShell probes fine + // in this process and then cannot start inside the sandbox. Sharing one + // cache would let whichever question was asked first answer both. + sandboxedShellOnce sync.Once + sandboxedShell shellRuntime ) // windowsMsysProneNames is the single source of truth for POSIX coreutil and @@ -92,6 +99,30 @@ func detectShellRuntime(goos string) shellRuntime { return hostShell } +// detectShellRuntimeSandboxed picks the shell using a probe that starts the +// candidate the way a tool command will actually start it — inside the sandbox +// — rather than in this unrestricted process. +// +// The plain probe asks the wrong environment. On Windows the sandbox runs +// commands under a WRITE_RESTRICTED restricted token, and PowerShell is a .NET +// program whose crypto initialization fails there: +// +// System.DllNotFoundException: Unable to load DLL 'BCrypt.dll' ... (0x8007045A) +// +// So PowerShell answers "usable" when probed directly, is then selected, and +// cannot run so much as `echo` once wrapped. cmd.exe does survive the token, and +// is already the documented fallback, so an honest probe reaches it on its own. +// This is the same restricted-token incompatibility class as the Schannel and +// MSYS2 limitations recorded in internal/sandbox/windows_command_runner_windows.go. +// +// usable must run the candidate through the same sandbox the command will use. +func detectShellRuntimeSandboxed(usable func(string) bool) shellRuntime { + sandboxedShellOnce.Do(func() { + sandboxedShell = detectShellRuntimeWithProbe(runtime.GOOS, exec.LookPath, os.Getenv, usable) + }) + return sandboxedShell +} + func detectShellRuntimeWithLookup(goos string, lookPath func(string) (string, error), getenv func(string) string) shellRuntime { return detectShellRuntimeWithProbe(goos, lookPath, getenv, func(string) bool { return true }) } diff --git a/internal/tools/shell_runtime_test.go b/internal/tools/shell_runtime_test.go index 87dd5b5cb..b59a50d52 100644 --- a/internal/tools/shell_runtime_test.go +++ b/internal/tools/shell_runtime_test.go @@ -463,3 +463,33 @@ func TestDetectShellOutputIssueSignatureOmitsCommandText(t *testing.T) { t.Fatalf("expected real MSYS output to still be flagged, got %#v", issue) } } + +// The probe decides which shell every sandboxed command uses, so it has to run +// where those commands run. On Windows the sandbox wraps commands in a +// WRITE_RESTRICTED token that PowerShell cannot start under — .NET fails crypto +// init with "Unable to load DLL 'BCrypt.dll'" — while the same PowerShell starts +// perfectly in the unsandboxed parent. A probe that answers for the parent +// therefore selects a shell that cannot run anything at all, and every +// exec_command and bash call fails. cmd.exe does survive the token. +func TestWindowsShellFallsBackToCmdWhenPowerShellCannotStartSandboxed(t *testing.T) { + lookPath := func(name string) (string, error) { + if name == "powershell.exe" { + return `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`, nil + } + return "", errors.New("not found") + } + getenv := func(string) string { return "" } + + // Probed in the parent, PowerShell looks fine and is chosen. + unsandboxed := detectShellRuntimeWithProbe("windows", lookPath, getenv, func(string) bool { return true }) + if unsandboxed.Kind != shellKindPowerShell { + t.Fatalf("unsandboxed shell = %#v, want PowerShell", unsandboxed) + } + + // Probed through the sandbox, it cannot start, so detection must reach the + // cmd.exe fallback rather than returning a shell that is unable to run. + sandboxed := detectShellRuntimeWithProbe("windows", lookPath, getenv, func(string) bool { return false }) + if sandboxed.Kind != shellKindCmd || !strings.EqualFold(sandboxed.Executable, "cmd.exe") { + t.Fatalf("sandboxed shell = %#v, want cmd.exe fallback", sandboxed) + } +} diff --git a/internal/tools/types.go b/internal/tools/types.go index 3929484cd..74c7ab118 100644 --- a/internal/tools/types.go +++ b/internal/tools/types.go @@ -160,6 +160,40 @@ 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 +} + +// 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. diff --git a/internal/tui/autocomplete_test.go b/internal/tui/autocomplete_test.go index e8fdae52e..7a3f34ea7 100644 --- a/internal/tui/autocomplete_test.go +++ b/internal/tui/autocomplete_test.go @@ -395,7 +395,11 @@ 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, then + // /workers pushed /permissions out — so what this pins is the window's start + // and size, not those two names. + if !strings.Contains(plain, "│ ❯ provider") || !strings.Contains(plain, "│ workers") { t.Fatalf("top of palette should render first visible command window, got %q", plain) } if strings.Contains(plain, "compact") { diff --git a/internal/tui/background_agent_status_test.go b/internal/tui/background_agent_status_test.go new file mode 100644 index 000000000..f2c9d8ca6 --- /dev/null +++ b/internal/tui/background_agent_status_test.go @@ -0,0 +1,88 @@ +package tui + +import "testing" + +// A BACKGROUND SUB-AGENT THAT WAS ONLY JUST LAUNCHED IS NOT A FINISHED ONE. +// +// THE MEASURED RUN. Four workers were spawned with run_in_background, and the +// TUI rendered each as "✓ completed · 0 tool calls · 1s" with the header +// reading "4 finished" — while all four were still running. The session log has +// four specialist_start events with mode=background and NOT ONE +// specialist_stop. +// +// THE MECHANISM. model.go completed specialist tracking whenever the Task tool +// returned, and a background Task returns the instant the child is launched. So +// "the tool returned" was read as "the sub-agent is done". +// +// Same invariant as "never report failure as success": work that has not +// happened must not be shown as work that has. +func TestABackgroundStatusOnlyCompletesWhenItReallyHas(t *testing.T) { + for _, tc := range []struct { + raw string + want specialistStatus + isDone bool + }{ + {"completed", specialistCompleted, true}, + {"error", specialistError, true}, + {"killed", specialistCancelled, true}, + // The case this exists for: polling an agent that is still working must + // leave it running. + {"running", specialistRunning, false}, + // Fail-safe: anything unrecognised keeps showing the work as in flight + // rather than declaring it done. + {"", specialistRunning, false}, + {"queued", specialistRunning, false}, + {"COMPLETED", specialistRunning, false}, + } { + t.Run(tc.raw, func(t *testing.T) { + status, done := backgroundAgentStatus(tc.raw) + if done != tc.isDone { + t.Fatalf("backgroundAgentStatus(%q) terminal=%v, want %v", tc.raw, done, tc.isDone) + } + if status != tc.want { + t.Fatalf("backgroundAgentStatus(%q) = %v, want %v", tc.raw, status, tc.want) + } + }) + } +} + +// A killed background agent is NOT red. Cancelled and skipped are deliberately +// not failures elsewhere in this UI, and a member the user stopped is the same +// kind of thing. +func TestAKilledBackgroundAgentIsNotShownAsAnError(t *testing.T) { + status, done := backgroundAgentStatus("killed") + if !done { + t.Fatal("a killed agent is terminal") + } + if status == specialistError { + t.Fatal("a killed agent is drawn as a defect; cancelled is not a failure in this UI") + } +} + +// THE RULE ITSELF. A background spawn must not finish its agent; a foreground +// one must. +// +// SCOPE, stated honestly: this covers the predicate, not the call site inside +// runAgentWithOptions — that function runs a full agent loop and no unit test +// drives it. Naming the rule is what makes the call site a single visible call +// rather than an inline condition that can be widened back unnoticed. +func TestOnlyAForegroundTaskFinishesItsSpecialist(t *testing.T) { + for _, tc := range []struct { + name string + tool string + meta map[string]string + want bool + }{ + {"a foreground Task finishes it", "Task", map[string]string{"session_id": "s"}, true}, + {"a background Task does not", "Task", map[string]string{"session_id": "s", "background": "true"}, false}, + {"nil meta is foreground", "Task", nil, true}, + {"another tool never finishes a specialist", "TaskOutput", map[string]string{"background": "true"}, false}, + {"read_file never does", "read_file", nil, false}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := taskResultFinishesSpecialist(tc.tool, tc.meta); got != tc.want { + t.Fatalf("taskResultFinishesSpecialist(%q, %v) = %v, want %v", tc.tool, tc.meta, got, tc.want) + } + }) + } +} diff --git a/internal/tui/background_completion_test.go b/internal/tui/background_completion_test.go new file mode 100644 index 000000000..83d7b4b4d --- /dev/null +++ b/internal/tui/background_completion_test.go @@ -0,0 +1,105 @@ +package tui + +import "testing" + +// A BACKGROUND SUB-AGENT MUST EVENTUALLY COMPLETE — the regression the +// "don't complete on spawn" fix created. +// +// The row is registered keyed on the tool CALL id. A background Task returns a +// task_id (the child session id) and finishes later via a TaskOutput poll keyed +// on THAT id. Suppressing the spawn-time completion — correct in itself — left +// the row keyed on the call id, so the poll's completion, keyed on task_id, +// never found it. The agent ran forever. +// +// Driven through the real Update handler, because the defect is entirely in +// which key each message targets. +func TestABackgroundAgentCompletesWhenPolled(t *testing.T) { + m := sidebarTestModel() + m.activeRunID = 7 + + // Spawn: the row is keyed on the tool call id. + updated, _ := m.Update(specialistStartMsg{ + runID: 7, name: "worker", description: "W1", childSessionID: "call_abc", model: "glm-5.2", + }) + m = updated.(model) + + // The background Task returns immediately. The DECISION to rebind is the + // predicate below; a mutation removing the emission cannot be caught here + // (the emission lives inside runAgentWithOptions, a full agent loop), so the + // predicate is what is pinned, and the handler is driven with its output. + from, to, ok := backgroundSpawnRebind("Task", "call_abc", map[string]string{"background": "true", "task_id": "task_xyz"}) + if !ok || from != "call_abc" || to != "task_xyz" { + t.Fatalf("a background spawn did not ask to rebind: from=%q to=%q ok=%v", from, to, ok) + } + updated, _ = m.Update(specialistRebindMsg{runID: 7, fromKey: from, toKey: to}) + m = updated.(model) + + if _, ok := m.specialists.getBySessionID("task_xyz"); !ok { + t.Fatal("the row did not rebind to the task id, so the poll can never find it") + } + + // TaskOutput polls it terminal, keyed on the task id. + updated, _ = m.Update(specialistCompleteMsg{ + runID: 7, toolCallID: "task_xyz", childSessionID: "task_xyz", status: specialistCompleted, + }) + m = updated.(model) + + info, ok := m.specialists.getBySessionID("task_xyz") + if !ok { + t.Fatal("the row vanished after completion") + } + if info.status != specialistCompleted { + t.Fatalf("a polled background agent is still %v, not completed — it runs forever", info.status) + } +} + +// A foreground Task is unchanged: it reconciles and completes inside the one +// completion message, and no rebind is emitted for it. +func TestAForegroundAgentStillCompletesWithoutARebind(t *testing.T) { + m := sidebarTestModel() + m.activeRunID = 7 + updated, _ := m.Update(specialistStartMsg{ + runID: 7, name: "explorer", description: "look", childSessionID: "call_1", model: "glm-5.2", + }) + m = updated.(model) + updated, _ = m.Update(specialistCompleteMsg{ + runID: 7, toolCallID: "call_1", childSessionID: "sess_1", status: specialistCompleted, + }) + m = updated.(model) + + info, ok := m.specialists.getBySessionID("sess_1") + if !ok { + t.Fatal("the foreground row did not reconcile+complete") + } + if info.status != specialistCompleted { + t.Fatalf("foreground agent status = %v", info.status) + } +} + +// THE REBIND DECISION, pinned. A foreground Task and a spawn whose ids already +// agree must NOT rebind; only a background spawn with a distinct task_id does. +func TestOnlyABackgroundSpawnWithADistinctTaskIDRebinds(t *testing.T) { + for _, tc := range []struct { + name string + tool string + toolCallID string + meta map[string]string + wantOK bool + }{ + {"background, distinct task id", "Task", "call_1", map[string]string{"background": "true", "task_id": "task_1"}, true}, + {"foreground Task", "Task", "call_1", map[string]string{"session_id": "sess_1"}, false}, + {"background but task id equals the call id", "Task", "call_1", map[string]string{"background": "true", "task_id": "call_1"}, false}, + {"background with no task id", "Task", "call_1", map[string]string{"background": "true"}, false}, + {"another tool", "TaskOutput", "call_1", map[string]string{"background": "true", "task_id": "task_1"}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + from, to, ok := backgroundSpawnRebind(tc.tool, tc.toolCallID, tc.meta) + if ok != tc.wantOK { + t.Fatalf("rebind ok = %v, want %v (from=%q to=%q)", ok, tc.wantOK, from, to) + } + if ok && (from != tc.toolCallID || to != tc.meta["task_id"]) { + t.Fatalf("rebind from=%q to=%q, want %q -> %q", from, to, tc.toolCallID, tc.meta["task_id"]) + } + }) + } +} diff --git a/internal/tui/background_spend_test.go b/internal/tui/background_spend_test.go new file mode 100644 index 000000000..79b64bb93 --- /dev/null +++ b/internal/tui/background_spend_test.go @@ -0,0 +1,73 @@ +package tui + +import "testing" + +// A BACKGROUND WORKER'S TOKENS AND TOOL COUNT SHOW, bridged from TaskOutput. +// +// THE MEASURED GAP. Four background workers rendered "0 tok · 0 tools" while the +// parent log recorded their real spend (W2: 1,355,600 tokens) — because a +// detached background child never streams via OnToolProgress, so the live +// bridge never saw it. TaskOutput is the one channel it has, and now carries the +// running totals: SET (whole), not added (per-turn). +func TestABackgroundWorkersSpendShowsFromThePoll(t *testing.T) { + m := sidebarTestModel() + m.activeRunID = 3 + u, _ := m.Update(specialistStartMsg{runID: 3, name: "worker", description: "W2: HTTP checker", childSessionID: "task_w2"}) + m = u.(model) + + // A poll mid-run: a running total. + u, _ = m.Update(specialistTotalTokensMsg{runID: 3, toolCallID: "task_w2", totalTokens: 400000}) + m = u.(model) + u, _ = m.Update(specialistToolCountMsg{runID: 3, toolCallID: "task_w2", tools: 12}) + m = u.(model) + if info, _ := m.specialists.getBySessionID("task_w2"); info.tokenCount != 400000 || info.toolCount != 12 { + t.Fatalf("first poll: tokens=%d tools=%d, want 400000 and 12", info.tokenCount, info.toolCount) + } + + // A later poll: the total GREW. It must set, not double. + u, _ = m.Update(specialistTotalTokensMsg{runID: 3, toolCallID: "task_w2", totalTokens: 1355600}) + m = u.(model) + u, _ = m.Update(specialistToolCountMsg{runID: 3, toolCallID: "task_w2", tools: 40}) + m = u.(model) + info, _ := m.specialists.getBySessionID("task_w2") + if info.tokenCount != 1355600 { + t.Fatalf("second poll set tokens to %d, want 1355600 (a running total, not a sum)", info.tokenCount) + } + if info.toolCount != 40 { + t.Fatalf("tool count = %d, want 40", info.toolCount) + } +} + +// setToolCount never LOWERS the count — a late poll must not undo a higher live +// count from streaming. +func TestSetToolCountNeverGoesBackwards(t *testing.T) { + m := sidebarTestModel() + m.specialists.start("w", "d", "c1", m.now()) + m.specialists.setToolCount("c1", 20) + m.specialists.setToolCount("c1", 5) // a stale/lower poll + if info, _ := m.specialists.getBySessionID("c1"); info.toolCount != 20 { + t.Fatalf("tool count regressed to %d, want 20", info.toolCount) + } +} + +// THE POLL-PARSE DECISION, pinned — the emit lives in the untestable agent loop. +func TestBackgroundPollUpdateParsesTheMeta(t *testing.T) { + got, ok := backgroundPollUpdate("TaskOutput", map[string]string{ + "task_id": "task_w2", "status": "completed", "tokens": "1355600", "tools": "40", + }) + if !ok || got.taskID != "task_w2" || got.tokens != 1355600 || got.tools != 40 || !got.done || got.status != specialistCompleted { + t.Fatalf("terminal poll parsed to %+v (ok=%v)", got, ok) + } + // A running poll: spend present, not yet done. + got, ok = backgroundPollUpdate("TaskOutput", map[string]string{"task_id": "t", "status": "running", "tokens": "500"}) + if !ok || got.done || got.tokens != 500 { + t.Fatalf("running poll parsed to %+v", got) + } + // Not a TaskOutput, or no task id: no update. + if _, ok := backgroundPollUpdate("read_file", map[string]string{"task_id": "t"}); ok { + t.Fatal("a non-TaskOutput tool produced a poll update") + } + if _, ok := backgroundPollUpdate("TaskOutput", map[string]string{"status": "completed"}); ok { + t.Fatal("a poll with no task id produced an update") + } +} diff --git a/internal/tui/cancel_settles_agents_test.go b/internal/tui/cancel_settles_agents_test.go new file mode 100644 index 000000000..591aa70b8 --- /dev/null +++ b/internal/tui/cancel_settles_agents_test.go @@ -0,0 +1,172 @@ +package tui + +import ( + "strings" + "testing" + "time" + + "github.com/charmbracelet/x/ansi" +) + +// "RUN CANCELLED." MUST NOT SIT BESIDE AGENTS THAT LOOK ALIVE. Cancelling the +// run kills the children with the run context, but the trackers were never +// told: a specialist row stayed specialistRunning — spinner on, clock ticking, +// "live" in MODELS — and an orchestrate task stayed orchestrateRunning in the +// PLAN bar, indefinitely, over processes that no longer existed. A real session +// showed exactly that: the cancellation marker in the transcript, and the +// sidebar still "working" a minute later. +func TestCancelSettlesEveryRunningAgentAndTask(t *testing.T) { + start := time.Unix(1000, 0) + m := sidebarTestModel() + m.pending = true + m.now = func() time.Time { return start } + + m.specialists.start("audit", "Audit specialist", "sess-1", start) + m.specialists.setModel("sess-1", "grok-4.5") + m.specialists.setCurrentTool("sess-1", "read_file", "internal/tui/model.go") + m.specialists.start("done-before", "finished earlier", "sess-2", start) + m.specialists.complete("sess-2", specialistCompleted, 0, "", start) + + m.orchestrate.tasks = []orchestrateTask{ + {id: "a", status: orchestrateRunning, startedAt: start}, + {id: "b", status: orchestrateDone}, + } + + m.cancelRun() + + for _, info := range m.specialists.specialists { + if info.childSessionID == "sess-1" { + if info.status != specialistCancelled { + t.Fatalf("the running specialist stayed %v after cancel", info.status) + } + if info.currentTool != "" { + t.Fatalf("a cancelled specialist still claims to run %q", info.currentTool) + } + if info.completedAt.IsZero() { + t.Fatal("a cancelled specialist has no end time — its clock would tick forever") + } + } + if info.childSessionID == "sess-2" && info.status != specialistCompleted { + t.Fatalf("cancel rewrote a specialist that had already finished: %v", info.status) + } + } + + done, failed, _, cancelled, running := m.orchestrate.counts() + if running != 0 { + t.Fatalf("%d orchestrate task(s) still running after cancel", running) + } + if cancelled != 1 || done != 1 || failed != 0 { + t.Fatalf("counts after cancel: done=%d failed=%d cancelled=%d, want 1/0/1", done, failed, cancelled) + } + + // And the MODELS section agrees: nothing is "live" any more. + for _, entry := range modelMixEntries(m.sidebarSpecialists(), "glm-5.2") { + if entry.working > 0 { + t.Fatalf("MODELS still counts %d live agent(s) on %s after cancel", entry.working, entry.model) + } + } + if joined := strings.Join(m.renderContextSidebar(44, 40), "\n"); strings.Contains(ansi.Strip(joined), "live") { + t.Fatalf("the sidebar still says live after cancel:\n%s", ansi.Strip(joined)) + } +} + +// A BACKGROUND PLAN OUTLIVES THE RUN THAT LAUNCHED IT, BY DESIGN — beginRun +// makes exactly this exemption a few lines above the settle. Marking its tasks +// cancelled when the user stops the foreground turn was the mirror image of the +// defect the background-status tests exist to prevent: work still spending +// tokens and writing files, reported to the user as stopped. +func TestCancelLeavesALiveBackgroundPlanAlone(t *testing.T) { + start := time.Unix(1000, 0) + m := sidebarTestModel() + m.pending = true + m.now = func() time.Time { return start } + m.specialists.start("bg", "background worker", "sess-bg", start) + // MARKED, as the production path does: a background plan's task-start + // message carries the flag, and a background Task spawn is marked on its + // rebind. Without the mark a row is foreground by definition, and the + // tracker holds both kinds together — see cancelRunning. + m.specialists.markBackground("sess-bg") + m.orchestrate.tasks = []orchestrateTask{{id: "a", status: orchestrateRunning, startedAt: start}} + + // A live background plan: the same condition beginRun consults. + m.planProgress = NewPlanProgressBridge() + m.planProgress.SetBackground(true) + // PlanRunning is what a launched plan calls with its cancel func; together + // with the background flag that is exactly what BackgroundPlanLive reads. + m.planProgress.PlanRunning(func() {}) + if !m.planProgress.BackgroundPlanLive() { + t.Fatal("setup: the background plan must look live for this test to mean anything") + } + + m.cancelRun() + + if _, _, _, _, running := m.orchestrate.counts(); running != 1 { + t.Fatal("a live background plan's task was marked cancelled; it is still running") + } + for _, info := range m.specialists.specialists { + if info.childSessionID == "sess-bg" && info.status != specialistRunning { + t.Fatalf("a background sub-agent was marked %v while still working", info.status) + } + } +} + +// THE MIXED CASE, which neither of the two tests above covers and which both +// reviewers reproduced independently. +// +// The specialist tracker holds foreground and background children TOGETHER, so +// gating the whole settle on BackgroundPlanLive left a FOREGROUND sub-agent +// spinning forever whenever any background plan happened to be live: still +// specialistRunning, still showing its current tool, still counted live in +// MODELS — over a process that died with the run context. Nothing else settles +// it, because a late completion is dropped by the stale-run guard once +// cancelRun has zeroed activeRunID. +func TestCancelSettlesForegroundChildrenEvenWithALiveBackgroundPlan(t *testing.T) { + start := time.Unix(1000, 0) + m := sidebarTestModel() + m.pending = true + m.now = func() time.Time { return start } + + // A foreground sub-agent: dies with the run context, must be settled. + m.specialists.start("fg", "foreground worker", "sess-fg", start) + m.specialists.setCurrentTool("sess-fg", "read_file", "internal/tui/model.go") + // A background one: outlives the run, must be left alone. + m.specialists.start("bg", "background worker", "sess-bg", start) + m.specialists.markBackground("sess-bg") + + m.orchestrate.tasks = []orchestrateTask{{id: "a", status: orchestrateRunning, startedAt: start}} + m.planProgress = NewPlanProgressBridge() + m.planProgress.SetBackground(true) + m.planProgress.PlanRunning(func() {}) + if !m.planProgress.BackgroundPlanLive() { + t.Fatal("setup: the background plan must look live") + } + + m.cancelRun() + + for _, info := range m.specialists.specialists { + switch info.childSessionID { + case "sess-fg": + if info.status != specialistCancelled { + t.Fatalf("the FOREGROUND child stayed %v; its process died with the run context "+ + "and nothing else will settle it", info.status) + } + if info.currentTool != "" { + t.Fatalf("a settled child still claims to run %q", info.currentTool) + } + case "sess-bg": + if info.status != specialistRunning { + t.Fatalf("the BACKGROUND child was marked %v while still working", info.status) + } + } + } + // The background plan's own task is still untouched. + if _, _, _, _, running := m.orchestrate.counts(); running != 1 { + t.Fatal("a live background plan's task was cancelled; it is still running") + } + // And MODELS must not count the settled foreground child as live. + for _, entry := range modelMixEntries(m.sidebarSpecialists(), "glm-5.2") { + if entry.working > 1 { + t.Fatalf("MODELS counts %d live agents; only the background one is still working", entry.working) + } + } +} 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/commands.go b/internal/tui/commands.go index 137f7beb4..954868fea 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -25,6 +25,8 @@ const ( commandDebug commandDoctor commandPlan + commandPlans + commandWorkers commandSearch commandResume commandRename @@ -117,6 +119,29 @@ var commandDefinitions = []commandDefinition{ description: "Show planning mode status.", kind: commandPlan, }, + { + name: "/plans", + 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 + // orchestrate plan's task graph. The description says so, because the + // names alone do not. + description: "Show or control the orchestrate plan: stop/pause/resume the running one, or save, list and run a named plan.", + kind: commandPlans, + }, + { + name: "/workers", + usage: "/workers", + group: commandGroupSession, + // DISTINCT FROM /plans, which shows the plan running right now. This is + // everything this SESSION has set going — plan tasks and direct Task + // delegations alike, finished ones included — because a session that + // spawned a background child had no way to ask what became of it short + // of reading events.jsonl by hand. + description: "List every sub-agent this session has started, with status and token spend.", + kind: commandWorkers, + }, { name: "/permissions", usage: "/permissions", @@ -270,7 +295,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..a651bef48 --- /dev/null +++ b/internal/tui/effort_options_test.go @@ -0,0 +1,149 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/execprofile" +) + +// 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 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} + + 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. +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/export_test.go b/internal/tui/export_test.go index 180e4b03d..5ba63efd6 100644 --- a/internal/tui/export_test.go +++ b/internal/tui/export_test.go @@ -188,16 +188,8 @@ func renderSelectableList(options selectableListOptions) string { return strings.Join(lines, "\n") } -// addTokens adds tokens to the running total for the specialist with -// childSessionID. Unknown specialists are ignored. -func (t *specialistTracker) addTokens(childSessionID string, tokens int) { - for index := range t.specialists { - if t.specialists[index].childSessionID == childSessionID { - t.specialists[index].tokenCount += tokens - return - } - } -} +// addTokens now lives in specialist_card.go — it moved from here (test-only, +// so production never called it) to the OnToolProgress usage bridge. // hasRunning reports whether any tracked specialist is still running. func (t *specialistTracker) hasRunning() bool { @@ -275,3 +267,19 @@ func transcriptViewportStartForFrame(body string, frame transcriptFrameLayout, s func transcriptViewportForBody(body string, frame transcriptFrameLayout, offset int) transcriptViewport { return newTranscriptViewport(len(viewLines(body)), frame.bodyRect.height, offset) } + +// Moved from the production file: test-only convenience seam (deadcode gate). +func (s *orchestratePanelState) markDone(taskID, outcome string, tokens, attempts int, now time.Time) { + s.markDoneOn(taskID, outcome, "", "", tokens, attempts, now) +} + +// Moved from the production file: test-only convenience seam (deadcode gate). +func joinColumns(chat []string, sidebar []string, chatW, sidebarW int) []string { + // A cell of air on each side of the rule (" │ ") so the columns don't butt + // flush against it. The chat side gets its gutter from the leading space; the + // sidebar side from the trailing space (plus items' own leading inset, which + // nests them under the flush section headers). Budgeted by chatColumnWidth(-3). + return joinColumnsWith(chat, sidebar, chatW, sidebarW, func(int, int) string { + return " " + zeroTheme.line.Render("│") + " " + }) +} diff --git a/internal/tui/files_panel.go b/internal/tui/files_panel.go index 624b72ffa..638bc4809 100644 --- a/internal/tui/files_panel.go +++ b/internal/tui/files_panel.go @@ -204,9 +204,9 @@ type fileHit struct { func (m model) sidebarFilesHeader(width int) string { n := len(m.touchedFiles()) if n == 0 { - return sidebarHeader("FILES", width) + return m.postureHeader("FILES", width) } - return sidebarHeaderWithCount("FILES", fmt.Sprintf("%d", n), zeroTheme.muted, width) + return m.postureHeaderWithCount("FILES", fmt.Sprintf("%d", n), zeroTheme.muted, width) } // sidebarFileLines renders the FILES section body: the live "writing" pulse row @@ -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/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/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/keep_finished_agents_test.go b/internal/tui/keep_finished_agents_test.go new file mode 100644 index 000000000..22d738ba7 --- /dev/null +++ b/internal/tui/keep_finished_agents_test.go @@ -0,0 +1,72 @@ +package tui + +import ( + "context" + "testing" + + "github.com/Gitlawb/zero/internal/config" +) + +// KEEP-FINISHED-AGENTS IS A STANDING PREFERENCE, defaulting to the drop. +// +// Dropping finished sub-agents 1.5s after they complete is the product default +// (PR #345). The panel has always had a per-session click-toggle, but it reset +// every session. This is the config lever that makes "keep them" stick. +func TestKeepFinishedAgentsDefaultsToDrop(t *testing.T) { + if (config.PreferencesConfig{}).KeepsFinishedAgents() { + t.Fatal("an unset preference keeps agents; the established default is to drop them") + } + yes := true + if !(config.PreferencesConfig{KeepFinishedAgents: &yes}).KeepsFinishedAgents() { + t.Fatal("an explicit true does not keep them") + } + no := false + if (config.PreferencesConfig{KeepFinishedAgents: &no}).KeepsFinishedAgents() { + t.Fatal("an explicit false keeps them") + } +} + +// The preference SEEDS showDoneAgents, so finished agents stay from the first +// render without a click. +// +// THROUGH THE REAL CONSTRUCTOR. An earlier version hand-built a model literal, +// which is trivially true and proves nothing about the newModel wiring — a +// mutation deleting that line passed it. This drives newModel(options). +func TestTheKeepPreferenceSeedsTheAgentsPanel(t *testing.T) { + on := newModel(context.Background(), Options{KeepFinishedAgents: true}) + if !on.showDoneAgents { + t.Fatal("KeepFinishedAgents=true did not seed showDoneAgents through newModel") + } + off := newModel(context.Background(), Options{KeepFinishedAgents: false}) + if off.showDoneAgents { + t.Fatal("an unset preference seeded the panel on through newModel") + } +} + +// The click-toggle round-trips through config, so a UI choice survives restart — +// the same contract /recaps has. +func TestTogglingKeepFinishedAgentsPersists(t *testing.T) { + dir := t.TempDir() + path := dir + "/config.json" + + // The writer returns the persisted FileConfig; reload it from disk to prove + // it actually wrote, not just echoed. + if _, err := config.SetKeepFinishedAgents(path, true); err != nil { + t.Fatal(err) + } + reloaded, err := config.SetKeepFinishedAgents(path, true) // reads the file, then writes + if err != nil { + t.Fatal(err) + } + if !reloaded.Preferences.KeepsFinishedAgents() { + t.Fatal("the saved 'keep' preference did not survive a round trip through the file") + } + + off, err := config.SetKeepFinishedAgents(path, false) + if err != nil { + t.Fatal(err) + } + if off.Preferences.KeepsFinishedAgents() { + t.Fatal("turning it back off did not persist") + } +} 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 916b26221..564b9068c 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "os" + "strconv" "strings" "time" "unicode" @@ -24,12 +25,14 @@ import ( internalmcp "github.com/Gitlawb/zero/internal/mcp" "github.com/Gitlawb/zero/internal/modelregistry" "github.com/Gitlawb/zero/internal/notify" + "github.com/Gitlawb/zero/internal/providercatalog" "github.com/Gitlawb/zero/internal/providerhealth" "github.com/Gitlawb/zero/internal/providermodeldiscovery" "github.com/Gitlawb/zero/internal/providers/providerio" "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" @@ -113,14 +116,35 @@ type model struct { // already been attempted this process, so a finished turn re-fires the title // generator at most once per session (even before its async result lands). // Lazily initialized. - titledSessions map[string]bool - renamePrompt *sessionRenamePrompt - usageTracker *usage.Tracker - sessionCompactor SessionCompactor - prService *PrService - prState PrState - prWatcherStop func() - runtimeMessageSink func(tea.Msg) + titledSessions map[string]bool + renamePrompt *sessionRenamePrompt + 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. + // + // 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. + // 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. + orchestrate orchestratePanelState prepareRunCompletionWarning func() runCompletionWarning func() string agentOptions agent.Options @@ -144,20 +168,36 @@ 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 + // 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. + 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. @@ -168,6 +208,21 @@ 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 + // 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 + // 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 @@ -636,6 +691,11 @@ type specialistStartMsg struct { name string description string childSessionID string + // model is what the child is EXPECTED to run on — the session's own, since + // a Task sub-agent inherits it unless its manifest names another. Shown + // while the agent runs, and corrected from the result's Meta["model"] when + // it finishes, which is the authoritative answer. + model string } // specialistCompleteMsg carries specialist completion info from the @@ -646,6 +706,24 @@ type specialistCompleteMsg struct { childSessionID string status specialistStatus errorMsg string + // model is what the child actually ran on, from the executor's own + // resolution. Empty leaves whatever the start message showed. + model string +} + +// specialistRebindMsg rebinds a running specialist row from its temporary +// tool-call key to the id later events will use, WITHOUT completing it. +// +// A BACKGROUND SPAWN NEEDS THIS AND A FOREGROUND ONE DOES NOT. A foreground +// Task reconciles inside its completion, which fires when the child finishes. A +// background Task returns immediately with a task_id and finishes later via a +// TaskOutput poll keyed on THAT id — but the row is still keyed on the tool call +// id, so the poll's completion never finds it and the row runs forever. This +// rebinds the key the moment the spawn returns, so the later completion lands. +type specialistRebindMsg struct { + runID int + fromKey string + toKey string } // swarmSessionsMsg carries swarm task_id -> member session_id pairs (from @@ -665,6 +743,33 @@ type specialistProgressMsg struct { detail string } +// specialistUsageMsg carries a running child's LIVE token spend, so the AGENTS +// row shows what it costs WHILE it works — not only after. Bridged from the +// child's EventUsage, which OnToolProgress previously ignored, which is why a +// Task sub-agent's card sat at zero tokens for its whole life. +type specialistUsageMsg struct { + runID int + toolCallID string + totalTokens int +} + +// specialistTotalTokensMsg carries a background child's RUNNING TOTAL from a +// TaskOutput poll — a whole number, not a per-turn delta, so it SETS rather than +// adds. A detached child cannot stream per-turn usage; the poll is all it has. +type specialistTotalTokensMsg struct { + runID int + toolCallID string + totalTokens int +} + +// specialistToolCountMsg carries a background child's tool-call count from a +// poll, likewise a total that sets rather than increments. +type specialistToolCountMsg struct { + runID int + toolCallID string + tools int +} + type mcpCommandOrigin int const ( @@ -877,6 +982,7 @@ func newModel(ctx context.Context, options Options) model { favoriteModels: favoriteModelSet(options.FavoriteModels), recentModels: normalizeRecentModelEntries(options.RecentModels), recapsEnabled: options.RecapsEnabled, + showDoneAgents: options.KeepFinishedAgents, provider: options.Provider, newProvider: options.NewProvider, probeProviderHealth: options.ProbeProviderHealth, @@ -886,6 +992,8 @@ func newModel(ctx context.Context, options Options) model { sessionStore: sessionStore, sandboxStore: sandboxStore, mcpConfig: options.MCPConfig, + zeromaxingDisabled: options.ZeromaxingDisabled, + zeromaxingGate: options.ZeromaxingGate, mcpPermissionStore: options.MCPPermissionStore, mcpTokenStore: options.MCPTokenStore, mcpCommand: options.MCPCommand, @@ -893,6 +1001,8 @@ func newModel(ctx context.Context, options Options) model { agentOptions: options.AgentOptions, sessionCompactor: options.SessionCompactor, runtimeMessageSink: options.RuntimeMessageSink, + planProgress: options.PlanProgress, + planPaths: options.PlanPaths, permissionMode: permissionMode, reasoningEffort: options.ReasoningEffort, responseStyle: defaultedResponseStyle(options.ResponseStyle), @@ -1695,6 +1805,26 @@ 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() { + // 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 + 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 @@ -2140,7 +2270,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++ @@ -2221,7 +2351,10 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { // A resumed/idle session may already hold sidebar agents now that geometry // (and thus sidebarActive) is known; kick the ripple tick loop if so. No-op // when the loop is already running or there is nothing to animate. - return m, m.ensureSpinnerTick() + // Sequenced: see the note in provider_wizard.go — the pointer receiver + // must run before m is copied into the return. + tick := m.ensureSpinnerTick() + return m, tick case permissionRequestMsg: // The agent goroutine that raised this request is BLOCKED waiting on the // decision callback, so every branch below must resolve it exactly once — @@ -2356,6 +2489,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 @@ -2383,6 +2517,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 @@ -2596,6 +2734,24 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.specialists.start(msg.name, msg.description, msg.childSessionID, m.now()) + // SHOWN WHILE IT RUNS. The sidebar renders "on " and had nothing + // to render for a Task sub-agent: setModel was reached from the plan + // path alone, so a delegated agent named no model for its whole life. + m.specialists.setModel(msg.childSessionID, msg.model) + return m, nil + case specialistRebindMsg: + if msg.runID != m.activeRunID { + return m, nil + } + if msg.fromKey != "" && msg.toKey != "" && msg.fromKey != msg.toKey { + m.specialists.reconcileSessionID(msg.fromKey, msg.toKey) + } + // A REBIND IS THE BACKGROUND SIGNAL. backgroundSpawnRebind emits this + // only for a Task whose result carried background=true, so reaching here + // is proof the child outlives this run. + if msg.toKey != "" { + m.specialists.markBackground(msg.toKey) + } return m, nil case specialistCompleteMsg: if msg.runID != m.activeRunID { @@ -2607,6 +2763,12 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { // entry's childSessionID to the real session ID so subchat.enter can // find the child session's events in the store. m.specialists.complete(msg.toolCallID, msg.status, 0, msg.errorMsg, m.now()) + // CORRECTED, not merely set: a specialist whose manifest names its own + // model did not run on the session's, and the row seeded at start would + // keep naming the wrong one. Empty leaves the seed alone. + if msg.model != "" { + m.specialists.setModel(msg.toolCallID, msg.model) + } if msg.childSessionID != "" && msg.childSessionID != msg.toolCallID { m.specialists.reconcileSessionID(msg.toolCallID, msg.childSessionID) } @@ -2622,10 +2784,150 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m.transcript = appendTranscriptRow(m.transcript, cardRow) } return m, nil + case planAdmittedMsg: + // 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()) + 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: + // 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, msg.model, m.now()) + m.specialists.start(msg.taskID, msg.summary, msg.cardKey, m.now()) + if msg.background { + // A background plan's tasks outlive this run, so cancelling the turn + // must not settle them — see specialistTracker.cancelRunning. + m.specialists.markBackground(msg.cardKey) + } + 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 + // 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.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 + // 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()) + 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 + // 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 + } + m.orchestrate.linkCard(msg.taskID, cardKey) + 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: + // 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.complete(msg, m.now()) + 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 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 + } + // 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 specialistUsageMsg: + if msg.runID != m.activeRunID { + return m, nil + } + m.specialists.addTokens(msg.toolCallID, msg.totalTokens) + return m, nil + case specialistTotalTokensMsg: + if msg.runID != m.activeRunID { + return m, nil + } + m.specialists.setTokens(msg.toolCallID, msg.totalTokens) + return m, nil + case specialistToolCountMsg: + if msg.runID != m.activeRunID { + return m, nil + } + m.specialists.setToolCount(msg.toolCallID, msg.tools) + 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 @@ -2939,7 +3241,7 @@ func (m model) twoColumnTranscriptView() string { chatBlock := viewLines(m.scrollableTranscriptItemsView(header, bodyItems, footer, width, overlayForViewport)) sidebar := m.renderContextSidebar(sidebarW, len(chatBlock)) - rows := joinColumns(chatBlock, sidebar, chatW, sidebarW) + rows := joinColumnsWith(chatBlock, sidebar, chatW, sidebarW, m.postureDivider) return strings.Join(rows, "\n") } @@ -2995,6 +3297,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") @@ -3526,7 +3837,7 @@ func (m model) workingStatusLine() string { // wave moving one character per spinner tick (shared m.spinnerPhase clock). A // 6-char wavelength fits the 7-letter word so a full oscillation is visible. // Under reduced motion the phase is frozen, so this renders a static gradient. - working := rippleText("Working", ripplePalette(), m.spinnerPhase, 6) + working := rippleText("Working", m.postureRipplePalette(), m.spinnerPhase, m.postureRippleWaveLen()) line := zeroTheme.accent.Render(m.spinnerGlyph()) + " " + working // Phase label so a long, output-less step reads as live progress rather than a // frozen screen: "writing" while the answer streams, "thinking" otherwise @@ -4054,18 +4365,18 @@ func (m model) composerBox(width int) string { lines := strings.Split(content, "\n") rendered := make([]string, 0, len(lines)+3) - rendered = append(rendered, zeroTheme.lineStrong.Render("╭"+strings.Repeat("─", width-2)+"╮")) + rendered = append(rendered, m.postureComposerTop(width)) // Attachment chips ([Image #1] …) render INSIDE the box, above the input line, // instead of as a separate row above the box. if chips := renderAttachmentChips(m.pendingImageLabels, m.pendingDocuments); chips != "" { fitted := fitStyledLine(zeroTheme.muted.Render(chips), innerWidth) pad := strings.Repeat(" ", maxInt(0, innerWidth-lipgloss.Width(fitted))) - rendered = append(rendered, zeroTheme.lineStrong.Render("│ ")+fitted+pad+zeroTheme.lineStrong.Render(" │")) + rendered = append(rendered, m.postureComposerSide(false)+fitted+pad+m.postureComposerSide(true)) } for _, line := range lines { fitted := fitStyledLine(line, innerWidth) pad := strings.Repeat(" ", maxInt(0, innerWidth-lipgloss.Width(fitted))) - rendered = append(rendered, zeroTheme.lineStrong.Render("│ ")+fitted+pad+zeroTheme.lineStrong.Render(" │")) + rendered = append(rendered, m.postureComposerSide(false)+fitted+pad+m.postureComposerSide(true)) } rendered = append(rendered, m.composerDividerLine(width)) return strings.Join(rendered, "\n") @@ -4503,6 +4814,16 @@ func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) { return m.openSTTModelPicker() case commandVoice: return m.toggleVoiceMode() + case commandPlans: + // 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. + return m.handlePlansCommand(command.text) + case commandWorkers: + m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.workersText()}) + return m, nil case commandContext: m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.contextText()}) return m, nil @@ -4875,6 +5196,19 @@ func (m model) beginRun(cancel context.CancelFunc) model { // previous turn don't bleed into the new one. m.specialists.clear() m.plan.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 + // solution. + m.planProgress.Attach(m.runtimeMessageSink, m.runID, m.sessionStore, m.activeSession.SessionID) m.stepWork = nil m.stepNarration = nil m.stepExplanation = nil @@ -4902,7 +5236,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 @@ -5028,10 +5362,36 @@ 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 - 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 + // THE TRACKERS MUST AGREE WITH THE CANCEL. The children die with the run + // context, but a specialist row left specialistRunning keeps its spinner, + // its ticking clock and its "live" mark in MODELS forever — "Run cancelled." + // in the transcript beside agents that look like they are still working. An + // orchestrate task left orchestrateRunning does the same to the PLAN bar. + // + // EXCEPT WHEN A BACKGROUND PLAN IS STILL LIVE, and that exemption is the + // same one beginRun makes a few lines above: a background plan outlives the + // run that launched it BY DESIGN, so cancelling this turn does not cancel + // it. Marking its tasks cancelled here was the mirror image of the defect + // the background-status tests exist to prevent — work still spending tokens + // and writing files, reported to the user as stopped. The children of a + // FOREGROUND run do die with the run context, so they are still settled. + // SPECIALISTS SETTLE THEMSELVES: cancelRunning skips the rows marked + // background and settles the rest, because this tracker holds foreground and + // background children TOGETHER. Gating the whole call on BackgroundPlanLive + // — as the first version did — left every foreground sub-agent spinning + // forever whenever any background plan happened to be live. + m.specialists.cancelRunning(m.now()) + // The orchestrate panel holds ONE plan at a time, so the panel is background + // or it is not; there is no mixed case to discriminate here. + if !m.planProgress.BackgroundPlanLive() { + m.orchestrate.cancelRunning(m.now()) + } m.pendingPermission = nil m.pendingAskUser = nil // The interim block renders streamingText live; a cancelled run's partial @@ -5115,6 +5475,11 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str options.SessionID = m.activeSession.SessionID options.ProviderName = m.providerName options.Model = m.modelName + // FROM THE LIVE PROFILE, not the one captured at startup. /model can + // switch provider mid-session, and a family resolved once at launch would + // keep describing the provider the session began on — the same staleness + // that had plan discovery assigning from the wrong provider's model list. + options.ModelFamily = providercatalog.ModelFamilyFor(m.providerProfile.CatalogID) options.ReasoningEffort = string(m.reasoningEffort) options.ResponseStyle = m.responseStyle options.Cwd = m.cwd @@ -5349,6 +5714,7 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str name: name, description: desc, childSessionID: call.ID, + model: m.modelName, }) } } @@ -5387,7 +5753,16 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str } options.OnToolProgress = func(toolCallID string, event streamjson.Event) { - if event.Type == streamjson.EventToolCall && m.runtimeMessageSink != nil { + if m.runtimeMessageSink == nil { + return + } + if tokens, ok := specialistProgressTokens(event); ok { + // LIVE TOKEN SPEND. EventUsage was ignored here, so a Task + // sub-agent's card never moved off zero — the gap the tracker's + // setTokens comment named, and addTokens existed only in a test. + m.runtimeMessageSink(specialistUsageMsg{runID: runID, toolCallID: toolCallID, totalTokens: tokens}) + } + if event.Type == streamjson.EventToolCall { m.runtimeMessageSink(specialistProgressMsg{ runID: runID, toolCallID: toolCallID, @@ -5454,8 +5829,28 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str Type: sessions.EventToolResult, Payload: toolPayload, }) - // Complete specialist tracking when the Task tool returns. - if result.Name == "Task" { + // A BACKGROUND SPAWN REBINDS ITS ROW KEY NOW. The row was keyed on + // the tool call id; the child's later TaskOutput completion is keyed + // on the task_id (the child session id). Without rebinding here the + // completion can never find the row, and it runs forever — the + // regression the suppression below created. + if from, to, ok := backgroundSpawnRebind(result.Name, result.ToolCallID, result.Meta); ok && m.runtimeMessageSink != nil { + m.runtimeMessageSink(specialistRebindMsg{runID: runID, fromKey: from, toKey: to}) + } + // Complete specialist tracking when the Task tool returns — + // EXCEPT for a background spawn, which returns the moment the child + // is launched. + // + // Completing on that return reported work as finished that had not + // begun: four background workers rendered "✓ completed · 0 tool + // calls · 1s" and the header said "4 finished" while every one was + // still running, with no specialist_stop recorded for any of them. + // The same invariant as "never report failure as success" — a + // not-yet-started agent is not a finished one. + // + // A background agent completes below instead, when TaskOutput polls + // it and reports a terminal status. + if taskResultFinishesSpecialist(result.Name, result.Meta) { status := specialistCompleted if result.Status == tools.StatusError { status = specialistError @@ -5471,6 +5866,37 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str childSessionID: childSessionID, status: status, errorMsg: result.Output, + // THE AUTHORITATIVE ANSWER. A specialist whose manifest + // names its own model did not run on the session's, and + // only the executor knows which applied. + model: result.Meta["model"], + }) + } + } + // A POLLED BACKGROUND AGENT COMPLETES WHEN IT REALLY HAS. TaskOutput + // already carries task_id and status structurally, so this reads the + // status rather than the summary prose. + // + // AND IT CARRIES THE SPEND. A background child is detached and never + // streams via OnToolProgress, so the live token/tool bridge never + // sees it — its row sat at "0 tok · 0 tools" while the real numbers + // were recorded elsewhere. TaskOutput is its one channel, so the + // tokens and tool count ride the poll, on EVERY poll (not only the + // terminal one) so the row fills in while it is still running. + if update, ok := backgroundPollUpdate(result.Name, result.Meta); ok && m.runtimeMessageSink != nil { + if update.tokens > 0 { + m.runtimeMessageSink(specialistTotalTokensMsg{runID: runID, toolCallID: update.taskID, totalTokens: update.tokens}) + } + if update.tools > 0 { + m.runtimeMessageSink(specialistToolCountMsg{runID: runID, toolCallID: update.taskID, tools: update.tools}) + } + if update.done { + m.runtimeMessageSink(specialistCompleteMsg{ + runID: runID, + toolCallID: update.taskID, + childSessionID: update.taskID, + status: update.status, + errorMsg: result.Output, }) } } @@ -5679,3 +6105,105 @@ func toolResultRowText(result agent.ToolResult) string { } return fmt.Sprintf("tool result: %s %s %s", result.Name, status, truncateTUIOutput(result.Output, tuiToolOutputLimit)) } + +// backgroundAgentStatus maps a polled background task's status onto the +// tracker's, and reports whether it is terminal at all. +// +// "running" is the case this exists for: polling an agent that is still working +// must leave it running, not quietly mark it done. Anything unrecognised is +// treated as still running for the same reason — the fail-safe direction is to +// keep showing work as in flight until something says otherwise. +func backgroundAgentStatus(raw string) (specialistStatus, bool) { + switch raw { + case "completed": + return specialistCompleted, true + case "error": + return specialistError, true + case "killed": + return specialistCancelled, true + default: + return specialistRunning, false + } +} + +// taskResultFinishesSpecialist reports whether a Task tool result means the +// sub-agent is DONE. +// +// A BACKGROUND SPAWN DOES NOT. Task returns the instant the child is launched, +// so completing on that return reported work as finished that had not begun: +// four background workers rendered "✓ completed · 0 tool calls · 1s" with the +// header reading "4 finished" while all four were still running. Those agents +// finish via backgroundAgentStatus instead, when a TaskOutput poll reports a +// terminal status. +// +// NAMED rather than left inline so the rule is one greppable thing with one +// test, instead of a condition anyone can quietly widen back. +func taskResultFinishesSpecialist(name string, meta map[string]string) bool { + return name == "Task" && meta["background"] != "true" +} + +// backgroundSpawnRebind reports the key rebind a background Task spawn needs, if +// any: from the tool-call id the row was registered under, to the task_id its +// later TaskOutput completion will target. +// +// NAMED so the rule has one greppable definition and a test — the emission +// itself lives inside runAgentWithOptions, which runs a full agent loop and is +// not unit-driven, so this predicate is what a test can hold. Empty toolCallID +// or a task_id equal to it means no rebind: a foreground Task, or a spawn whose +// ids already agree. +func backgroundSpawnRebind(name, toolCallID string, meta map[string]string) (from, to string, ok bool) { + if name != "Task" || meta["background"] != "true" { + return "", "", false + } + taskID := meta["task_id"] + if taskID == "" || taskID == toolCallID { + return "", "", false + } + return toolCallID, taskID, true +} + +// specialistProgressTokens reports a child progress event's live token spend, if +// it carries one. Named so the EventUsage bridge has a test — the OnToolProgress +// callback lives inside runAgentWithOptions, a full agent loop that is not +// unit-driven, so this predicate is what a test can hold. +func specialistProgressTokens(event streamjson.Event) (int, bool) { + if event.Type != streamjson.EventUsage || event.TotalTokens == nil || *event.TotalTokens <= 0 { + return 0, false + } + return *event.TotalTokens, true +} + +// backgroundPollUpdate parses a TaskOutput result into the AGENTS-panel updates +// a poll carries: a background child's running token total, tool count, and — if +// terminal — its completion. +// +// NAMED so the poll bridge has a test. The emit itself is in runAgentWithOptions +// (a full agent loop, not unit-driven), so this predicate is what a mutation +// check can hold. ok is false for anything that is not a TaskOutput carrying a +// task id. +type backgroundPoll struct { + taskID string + tokens int + tools int + done bool + status specialistStatus +} + +func backgroundPollUpdate(toolName string, meta map[string]string) (backgroundPoll, bool) { + if toolName != "TaskOutput" { + return backgroundPoll{}, false + } + taskID := meta["task_id"] + if taskID == "" { + return backgroundPoll{}, false + } + update := backgroundPoll{taskID: taskID} + if tokens, err := strconv.Atoi(meta["tokens"]); err == nil && tokens > 0 { + update.tokens = tokens + } + if tools, err := strconv.Atoi(meta["tools"]); err == nil && tools > 0 { + update.tools = tools + } + update.status, update.done = backgroundAgentStatus(meta["status"]) + return update, true +} diff --git a/internal/tui/models_panel.go b/internal/tui/models_panel.go new file mode 100644 index 000000000..939cf6555 --- /dev/null +++ b/internal/tui/models_panel.go @@ -0,0 +1,228 @@ +package tui + +// The MODELS section: the live mix of models the session's sub-agents are +// running on. The AGENTS rows say what each agent does; nothing said what the +// fleet as a whole is running ON — with per-role auto-assignment the answer +// stopped being "the session's model", and the only way to see the routing work +// was to expand agents one by one. +// +// A separate template over separate data, deliberately: every renderer here is +// a pure function of ([]modelMixEntry, width), so the section is testable +// without a model and reusable by any surface that has the entries. +// +// ABSENT UNTIL IT SAYS SOMETHING. The section renders only when at least one +// agent runs on a known model — a plain session (posture off, no assignments) +// never grows a new section, and the sidebar's layout stays exactly what it +// was. Same contract as ACTIVITY. + +import ( + "hash/fnv" + "sort" + "strconv" + "strings" + + "charm.land/lipgloss/v2" +) + +// modelMixEntry is one model's slice of the fleet. +type modelMixEntry struct { + model string + total int + working int + // inherited marks the "no model recorded" bucket: agents on whatever the + // session runs. Rendered last, in muted, under the session's own model name + // when known. + inherited bool +} + +// modelMixEntries groups the visible agents by the model they run on. +// Agents with no recorded model fold into one inherited bucket labelled with +// the session's model (or "session model" when even that is unknown). +// Sorted by size descending, then name, with the inherited bucket pinned last — +// the routed models are the news; the default is the backdrop. +func modelMixEntries(agents []specialistInfo, sessionModel string) []modelMixEntry { + if len(agents) == 0 { + return nil + } + byModel := map[string]*modelMixEntry{} + order := []string{} + inherited := modelMixEntry{inherited: true} + for _, agent := range agents { + name := strings.TrimSpace(agent.model) + if name == "" { + inherited.total++ + if agent.status == specialistRunning { + inherited.working++ + } + continue + } + entry, ok := byModel[name] + if !ok { + entry = &modelMixEntry{model: name} + byModel[name] = entry + order = append(order, name) + } + entry.total++ + if agent.status == specialistRunning { + entry.working++ + } + } + out := make([]modelMixEntry, 0, len(order)+1) + for _, name := range order { + out = append(out, *byModel[name]) + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].total != out[j].total { + return out[i].total > out[j].total + } + return out[i].model < out[j].model + }) + if inherited.total > 0 { + inherited.model = strings.TrimSpace(sessionModel) + if inherited.model == "" { + inherited.model = "session model" + } + out = append(out, inherited) + } + return out +} + +// modelMixDiversified reports whether the entries say anything the AGENTS +// header does not: at least one agent on a KNOWN model. All-inherited is the +// old world and renders nothing. +func modelMixDiversified(entries []modelMixEntry) bool { + for _, entry := range entries { + if !entry.inherited && entry.total > 0 { + return true + } + } + return false +} + +// modelMixPalette is the cycle of styles a model's segments and dot draw in. +// Assignment is by NAME HASH, not position — a model keeps its colour while +// counts shift the sort order around it, so the bar never reshuffles under the +// reader's eyes. The inherited bucket is always muted and never hashes. +func modelMixPalette() []lipgloss.Style { + return []lipgloss.Style{zeroTheme.accent, zeroTheme.blue, zeroTheme.amber, zeroTheme.green} +} + +func modelMixStyle(entry modelMixEntry) lipgloss.Style { + if entry.inherited { + return zeroTheme.muted + } + palette := modelMixPalette() + hash := fnv.New32a() + hash.Write([]byte(entry.model)) + return palette[int(hash.Sum32())%len(palette)] +} + +// modelMixBar renders the one-line proportional mix: each model a run of ▰ +// cells sized to its share of the fleet, every model guaranteed at least one +// cell so a single agent on a distinct model is never rounded away. Empty when +// nothing fits or nothing is diversified. +func modelMixBar(entries []modelMixEntry, width int) string { + total := 0 + for _, entry := range entries { + total += entry.total + } + if total == 0 || width < len(entries) { + return "" + } + cells := make([]int, len(entries)) + used := 0 + for i, entry := range entries { + cells[i] = maxInt(1, entry.total*width/total) + used += cells[i] + } + // Rounding overshoot comes out of the largest slice; undershoot goes into it. + // One pass each way is enough: len(entries) ≤ width by the guard above. + for used > width { + largest := 0 + for i := range cells { + if cells[i] > cells[largest] { + largest = i + } + } + if cells[largest] <= 1 { + break + } + cells[largest]-- + used-- + } + for used < width { + largest := 0 + for i := range cells { + if cells[i] > cells[largest] { + largest = i + } + } + cells[largest]++ + used++ + } + var bar strings.Builder + for i, entry := range entries { + bar.WriteString(modelMixStyle(entry).Render(strings.Repeat("▰", cells[i]))) + } + return bar.String() +} + +// modelMixRows renders one line per model: a coloured dot, the model's name, +// and its count — with "·N live" while any of its agents still works, in the +// model's own colour so a glance pairs the row with its bar segment. The +// inherited bucket draws an open dot: those agents were not routed anywhere. +func modelMixRows(entries []modelMixEntry, width int) []string { + rows := make([]string, 0, len(entries)) + for _, entry := range entries { + style := modelMixStyle(entry) + dot := style.Render("●") + if entry.inherited { + dot = zeroTheme.faint.Render("○") + } + count := "×" + strconv.Itoa(entry.total) + if entry.working > 0 { + count += " ·" + strconv.Itoa(entry.working) + " live" + } + countRendered := style.Render(count) + name := zeroTheme.ink.Render(truncateStep(entry.model, + maxInt(1, width-2-lipgloss.Width(dot)-lipgloss.Width(countRendered)-1))) + gap := width - 1 - lipgloss.Width(dot) - 1 - lipgloss.Width(name) - lipgloss.Width(countRendered) + if gap < 1 { + rows = append(rows, " "+dot+" "+name) + continue + } + rows = append(rows, " "+dot+" "+name+strings.Repeat(" ", gap)+countRendered) + } + return rows +} + +// sidebarModelLines assembles the MODELS section for the sidebar: header with +// the distinct-model count, the mix bar, then a row per model. Empty — no +// header, no blank — when the fleet is not diversified, so the section only +// exists while the routing is actually routing. +func (m model) sidebarModelLines(width int) []string { + entries := modelMixEntries(m.sidebarSpecialists(), m.modelName) + if !modelMixDiversified(entries) { + return nil + } + distinct := 0 + anyWorking := false + for _, entry := range entries { + if !entry.inherited { + distinct++ + } + if entry.working > 0 { + anyWorking = true + } + } + countStyle := zeroTheme.muted + if anyWorking { + countStyle = zeroTheme.accent + } + lines := []string{m.postureHeaderWithCount("MODELS", strconv.Itoa(distinct), countStyle, width)} + if bar := modelMixBar(entries, maxInt(0, width-2)); bar != "" { + lines = append(lines, " "+bar) + } + lines = append(lines, modelMixRows(entries, width)...) + return lines +} diff --git a/internal/tui/models_panel_test.go b/internal/tui/models_panel_test.go new file mode 100644 index 000000000..328639e73 --- /dev/null +++ b/internal/tui/models_panel_test.go @@ -0,0 +1,198 @@ +package tui + +import ( + "strings" + "testing" + "time" + + "github.com/charmbracelet/x/ansi" +) + +// THE MODELS SECTION EXISTS EXACTLY WHEN THE FLEET DIVERSIFIES. A session where +// every agent inherits renders nothing — the sidebar's layout must stay what it +// was before this section existed — and one routed agent is enough to earn it. + +func mixAgent(model string, status specialistStatus) specialistInfo { + return specialistInfo{name: "a", model: model, status: status} +} + +func TestModelMixEntriesGroupSortAndPinInheritedLast(t *testing.T) { + entries := modelMixEntries([]specialistInfo{ + mixAgent("kimi-k2.6", specialistRunning), + mixAgent("gpt-oss:20b", specialistRunning), + mixAgent("gpt-oss:20b", specialistCompleted), + mixAgent("", specialistRunning), + }, "glm-5.2") + if len(entries) != 3 { + t.Fatalf("entries = %d, want 3 (two routed models + inherited)", len(entries)) + } + if entries[0].model != "gpt-oss:20b" || entries[0].total != 2 || entries[0].working != 1 { + t.Fatalf("largest first, with live count: %+v", entries[0]) + } + if entries[1].model != "kimi-k2.6" { + t.Fatalf("second = %+v, want kimi-k2.6", entries[1]) + } + last := entries[2] + if !last.inherited || last.model != "glm-5.2" || last.total != 1 { + t.Fatalf("inherited bucket must be pinned last under the session's model: %+v", last) + } +} + +func TestModelMixHidesWhenNothingIsRouted(t *testing.T) { + entries := modelMixEntries([]specialistInfo{ + mixAgent("", specialistRunning), + mixAgent("", specialistRunning), + }, "glm-5.2") + if modelMixDiversified(entries) { + t.Fatal("an all-inherited fleet is the old world and must render nothing") + } + if modelMixDiversified(nil) { + t.Fatal("no agents must render nothing") + } +} + +// The bar is exactly as wide as asked, every model owns at least one cell, and +// shares are proportional — a single routed agent is never rounded away. +func TestModelMixBarIsProportionalAndExact(t *testing.T) { + entries := modelMixEntries([]specialistInfo{ + mixAgent("big", specialistRunning), mixAgent("big", specialistRunning), + mixAgent("big", specialistRunning), mixAgent("big", specialistRunning), + mixAgent("big", specialistRunning), mixAgent("big", specialistRunning), + mixAgent("big", specialistRunning), + mixAgent("small", specialistRunning), + }, "") + bar := ansi.Strip(modelMixBar(entries, 24)) + if got := len([]rune(bar)); got != 24 { + t.Fatalf("bar width = %d, want exactly 24", got) + } + if !strings.Contains(bar, "▰") { + t.Fatalf("bar carries no cells: %q", bar) + } + // One agent of eight at width 24 = 3 cells; the guarantee is ≥1 even at + // widths where its share rounds to zero. + tiny := ansi.Strip(modelMixBar(entries, 4)) + if got := len([]rune(tiny)); got != 4 { + t.Fatalf("tiny bar width = %d, want exactly 4", got) + } +} + +func TestModelMixBarVanishesWhenTooNarrow(t *testing.T) { + entries := modelMixEntries([]specialistInfo{ + mixAgent("a", specialistRunning), mixAgent("b", specialistRunning), + mixAgent("c", specialistRunning), + }, "") + if bar := modelMixBar(entries, 2); bar != "" { + t.Fatalf("a bar narrower than its models must vanish, got %q", bar) + } +} + +func TestModelMixRowsCarryCountsAndLive(t *testing.T) { + entries := modelMixEntries([]specialistInfo{ + mixAgent("gpt-oss:20b", specialistRunning), + mixAgent("gpt-oss:20b", specialistCompleted), + mixAgent("", specialistCompleted), + }, "glm-5.2") + rows := modelMixRows(entries, 40) + if len(rows) != 2 { + t.Fatalf("rows = %d, want 2", len(rows)) + } + first := ansi.Strip(rows[0]) + if !strings.Contains(first, "gpt-oss:20b") || !strings.Contains(first, "×2") || !strings.Contains(first, "·1 live") { + t.Fatalf("row must name the model, count and live share: %q", first) + } + second := ansi.Strip(rows[1]) + if !strings.Contains(second, "○") || !strings.Contains(second, "glm-5.2") || strings.Contains(second, "live") { + t.Fatalf("inherited row: open dot, session model, no live suffix when idle: %q", second) + } + // Rows never exceed the asked width. + for _, row := range rows { + if w := len([]rune(ansi.Strip(row))); w > 40 { + t.Fatalf("row wider than asked (%d > 40): %q", w, ansi.Strip(row)) + } + } +} + +// A model keeps its colour while counts shuffle the sort order around it — the +// style is a function of the NAME, not the position. +func TestModelMixColourIsStablePerModel(t *testing.T) { + one := modelMixEntry{model: "kimi-k2.6"} + if modelMixStyle(one).Render("x") != modelMixStyle(modelMixEntry{model: "kimi-k2.6", total: 9}).Render("x") { + t.Fatal("a model's colour changed with its count") + } + if modelMixStyle(modelMixEntry{inherited: true}).Render("x") != zeroTheme.muted.Render("x") { + t.Fatal("the inherited bucket must always be muted") + } +} + +// A LOOK AT IT. Not an assertion beyond existence — run with +// +// go test ./internal/tui/ -run ModelsPanelPreview -count=1 -v +// +// in a real terminal to see the section exactly as the sidebar draws it. +func TestModelsPanelPreview(t *testing.T) { + start := time.Unix(1000, 0) + m := sidebarTestModel() + m.modelName = "glm-5.2" + m.specialists.start("impl", "refactor the parser", "s1", start) + m.specialists.setModel("s1", "gpt-oss:20b") + m.specialists.start("impl2", "fix the lexer", "s2", start) + m.specialists.setModel("s2", "gpt-oss:20b") + m.specialists.start("verify", "review the change", "s3", start) + m.specialists.setModel("s3", "kimi-k2.6") + m.specialists.complete("s3", specialistCompleted, 0, "", start) + m.specialists.start("scan", "find callers", "s4", start) + m.specialists.setModel("s4", "deepseek-v4-flash") + m.specialists.start("misc", "summarize", "s5", start) + m.now = func() time.Time { return start } + lines := m.sidebarModelLines(44) + if len(lines) < 4 { + t.Fatalf("preview should have header+bar+rows, got %d lines", len(lines)) + } + for _, line := range lines { + t.Log(line) + } +} + +// THE SECTION IN PLACE: a diversified fleet grows a MODELS header in the +// sidebar; an undiversified one renders the sidebar without it — the layout +// that existed before this section. +func TestSidebarGrowsModelsSectionOnlyWhenRouted(t *testing.T) { + start := time.Unix(1000, 0) + routed := sidebarTestModel() + routed.specialists.start("impl", "refactor the parser", "sess-1", start) + routed.specialists.setModel("sess-1", "gpt-oss:20b") + routed.specialists.start("scan", "find callers", "sess-2", start) + routed.now = func() time.Time { return start } + joined := strings.Join(routed.renderContextSidebar(44, 40), "\n") + if !strings.Contains(ansi.Strip(joined), "MODELS") { + t.Fatalf("a routed fleet must grow a MODELS section:\n%s", ansi.Strip(joined)) + } + if !strings.Contains(ansi.Strip(joined), "gpt-oss:20b") { + t.Fatalf("the MODELS section must name the routed model:\n%s", ansi.Strip(joined)) + } + + plain := sidebarTestModel() + plain.specialists.start("scan", "find callers", "sess-2", start) + plain.now = func() time.Time { return start } + joinedPlain := strings.Join(plain.renderContextSidebar(44, 40), "\n") + if strings.Contains(ansi.Strip(joinedPlain), "MODELS") { + t.Fatalf("an unrouted fleet must not grow a MODELS section:\n%s", ansi.Strip(joinedPlain)) + } +} + +// EVERY SCRIPT, not just Latin. The token filter tested ASCII ranges, so a task +// or agent described in Chinese, Russian, Greek or Arabic had every token +// rejected as "not a word" and the sidebar row lost its label — a silent +// degradation for anyone not writing in English. +func TestNonLatinLabelsAreRealWords(t *testing.T) { + for _, token := range []string{"解析器", "парсер", "ανάλυση", "محلل", "parser", "v2"} { + if !hasNameLetter(token) { + t.Errorf("%q was rejected as not-a-word; its label would be dropped", token) + } + } + for _, token := range []string{"---", "...", "→", ""} { + if hasNameLetter(token) { + t.Errorf("%q was accepted as a word", token) + } + } +} diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go index de8c2df63..7e1c90f47 100644 --- a/internal/tui/mouse.go +++ b/internal/tui/mouse.go @@ -87,6 +87,53 @@ 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. + // 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. + // + // 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 + } + } + 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 + // 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) { + m.orchestrate.expanded = !m.orchestrate.expanded + return m, nil + } if mouseLeftPress(msg) { switch { case m.providerWizard != nil: @@ -146,7 +193,20 @@ 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. + // + // Sequenced, never `return hovered, hovered.ensureSpinnerTick()`: the + // method takes a POINTER receiver and sets spinnerTicking, and Go does + // not specify whether the plain operand is copied before or after the + // call operand. Copied first, the returned model still says false and + // every later hover issues another Tick — the exact double-issue the + // flag exists to prevent. + cmd := hovered.ensureSpinnerTick() + return hovered, cmd } switch { @@ -244,7 +304,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/onboarding.go b/internal/tui/onboarding.go index ed09f3a87..2a6e49817 100644 --- a/internal/tui/onboarding.go +++ b/internal/tui/onboarding.go @@ -715,7 +715,10 @@ func (m model) resolveSetupAimlapi(cmd tea.Cmd, outcome aimlapiOutcome) (tea.Mod } // Keep the shared spinner tick alive whenever the sub-flow just entered a busy // or progress state, so its animated spinner advances during onboarding. - return m, tea.Batch(cmd, m.ensureSpinnerTick()) + // Sequenced: see the note in provider_wizard.go — the pointer receiver + // must run before m is copied into the return. + tick := m.ensureSpinnerTick() + return m, tea.Batch(cmd, tick) } func (m model) nextSetupStage() setupStage { diff --git a/internal/tui/options.go b/internal/tui/options.go index 409110704..51bed47c4 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" @@ -21,37 +22,60 @@ import ( // Options configures the reusable Zero terminal UI shell. type Options struct { - Cwd string - Version string // CLI build version, shown on the home screen; empty hides it - UserConfigPath string - DoctorUserConfigPath string - ProjectConfigPath string - ProviderName string - ModelName string - ProviderProfile config.ProviderProfile - SavedProviders []config.ProviderProfile // all configured providers, for the /model multi-provider list - FavoriteModels []string - RecentModels []config.RecentModelEntry - RecapsEnabled bool + Cwd string + Version string // CLI build version, shown on the home screen; empty hides it + UserConfigPath string + DoctorUserConfigPath string + ProjectConfigPath string + ProviderName string + ModelName string + ProviderProfile config.ProviderProfile + SavedProviders []config.ProviderProfile // all configured providers, for the /model multi-provider list + FavoriteModels []string + RecentModels []config.RecentModelEntry + RecapsEnabled bool + // KeepFinishedAgents seeds showDoneAgents so finished sub-agents stay in the + // AGENTS panel from the first render, without a click. + KeepFinishedAgents bool Provider zeroruntime.Provider NewProvider func(config.ProviderProfile) (zeroruntime.Provider, error) ProbeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result 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 + // 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 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 + // 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 + 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/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_control.go b/internal/tui/orchestrate_control.go new file mode 100644 index 000000000..879d3a3cc --- /dev/null +++ b/internal/tui/orchestrate_control.go @@ -0,0 +1,91 @@ +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, "+ + "/plans stop | pause | resume for the running plan, "+ + "or /plans save | list | show | run | restart for saved ones.") + } +} + +// 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..2faa96483 --- /dev/null +++ b/internal/tui/orchestrate_control_test.go @@ -0,0 +1,505 @@ +package tui + +import ( + "context" + "strings" + "testing" + "time" + + 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 +// 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) + } +} + +// 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") + } + } +} + +// 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/orchestrate_panel.go b/internal/tui/orchestrate_panel.go new file mode 100644 index 000000000..92c84d500 --- /dev/null +++ b/internal/tui/orchestrate_panel.go @@ -0,0 +1,787 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + "charm.land/lipgloss/v2" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" +) + +// 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 + // 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 + // 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 + // spend through it rather than duplicating that state here. + cardKey string +} + +// 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 + // 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{} } + +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, cardKey, model 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 + if strings.TrimSpace(model) != "" { + s.tasks[index].model = model + } + if cardKey != "" { + s.tasks[index].cardKey = cardKey + } +} + +// cancelRunning marks every still-running task cancelled. Called when the USER +// cancels the run: the children are killed by the run context, so a task left +// orchestrateRunning would spin in the sidebar forever over a process that no +// longer exists. Cancelled, not failed — the user stopping a run is not a +// defect (the same distinction sidebarOrchestrateStyle already draws). +func (s *orchestratePanelState) cancelRunning(now time.Time) { + for index := range s.tasks { + if s.tasks[index].status == orchestrateRunning { + s.tasks[index].status = orchestrateCancelled + s.tasks[index].endedAt = now + } + } +} + +// 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 + } +} + +// 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 + } + // 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.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 + // 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 + // 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 + } + 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 + // orchestrateMaxIndentDepth bounds the dependency indent. + 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 +} + +// 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 + } + // 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 +// 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 m.sidebarOwnsOrchestrate() { + return "" + } + if width < orchestrateMinWidth { + width = orchestrateMinWidth + } + + 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() + } + + // 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. + // + // 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 note := orchestrateHiddenNote(above, below); note != "" { + b.WriteString("\n") + b.WriteString(zeroTheme.faint.Render(" … " + note)) + } + 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 + // 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)) + } + 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))) + } + if !state.expanded { + b.WriteString(" ") + b.WriteString("click to open · ctrl+g to expand") + } + return b.String() +} + +func orchestrateFooterLine(state orchestratePanelState) string { + var parts []string + 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) + 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. + // + // 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 { + 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. + // + // MEASURED IN COLUMNS, NOT RUNES. head carries the styled glyph, so its + // escape sequences counted as visible characters — around a dozen columns + // of phantom width per row. remaining came out short, the `> 8` guard + // suppressed summaries that fit comfortably, and the wider the terminal the + // more often a finished task rendered with nothing to say. lipgloss.Width + // ignores the escapes and counts wide runes properly. + remaining := width - lipgloss.Width(head) - lipgloss.Width(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 "·" + } +} + +// 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) +} + +// 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 + if state.isEmpty() || state.sidebarCollapsed { + return nil + } + room := maxInt(4, width-3) + + 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 note := orchestrateHiddenNote(above, below); note != "" { + lines = append(lines, " "+zeroTheme.faint.Render(" "+note)) + } + 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 { + return sidebarProgressBarWith(state, width, progressBarSkin{ + settled: "█", + running: "▓", + pending: func(_, n, _ int) string { + return zeroTheme.faint.Render(strings.Repeat("░", n)) + }, + }) +} + +// progressBarSkin is the glyph-and-track dress a progress bar renders in. The +// plain skin above is the historical bar byte-for-byte; the zeromaxing skin +// (posturePlanProgressBar) swaps the language, never the layout or the counts. +type progressBarSkin struct { + settled string + running string + // pending renders the unfilled track: (startCell, cellCount, totalCells). + pending func(startCell, n, cells int) string + // paint, when non-nil, renders a filled segment instead of the semantic + // block colours — (kind, startCell, cellCount, totalCells) with kind one of + // "done", "failed", "skipped", "running". nil keeps the historical styles, + // which is what makes the plain bar byte-identical. + paint func(kind string, startCell, n, cells int) string +} + +func sidebarProgressBarWith(state orchestratePanelState, width int, skin progressBarSkin) 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) + } + // 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 + kind string + mark string + style lipgloss.Style + }{ + {done, "done", skin.settled, zeroTheme.green}, + {failed, "failed", skin.settled, zeroTheme.red}, + {skipped + cancelled, "skipped", skin.settled, zeroTheme.muted}, + {running, "running", skin.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 + } + if skin.paint != nil { + b.WriteString(skin.paint(segment.kind, used, n, cells)) + } else { + b.WriteString(segment.style.Render(strings.Repeat(segment.mark, n))) + } + used += n + } + if used < cells { + b.WriteString(skin.pending(used, cells-used, cells)) + } + 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 +// 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)) + } +} diff --git a/internal/tui/orchestrate_panel_test.go b/internal/tui/orchestrate_panel_test.go new file mode 100644 index 000000000..5b402675d --- /dev/null +++ b/internal/tui/orchestrate_panel_test.go @@ -0,0 +1,899 @@ +package tui + +import ( + "context" + "fmt" + "path" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" +) + +// 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"}}, + }, + } +} + +// 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 +} + +// 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) + 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") + } +} + +// 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", 0, 1, 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)) + + 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", 0, 1, now) + for _, id := range []string{"b", "c", "d"} { + m.orchestrate.markDone(id, "cancelled", 0, 1, 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 below") { + 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 + + // 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, "click to open") == 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") { + 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") + } +} + +// 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, "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 "} { + 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) + } + } +} + +// 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", 0, 1, 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", 0, 1, 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)) + } +} + +// 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, 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, 1, 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, 1, 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, 1, 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) + } +} + +// 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 + }, + "the sidebar's PLAN section was collapsed by a click": func(m model) model { + m.orchestrate.sidebarCollapsed = true + 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 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.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 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) + } + } +} + +// 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) + } +} diff --git a/internal/tui/orchestrate_panel_width_test.go b/internal/tui/orchestrate_panel_width_test.go new file mode 100644 index 000000000..41c631ea3 --- /dev/null +++ b/internal/tui/orchestrate_panel_width_test.go @@ -0,0 +1,64 @@ +package tui + +import ( + "strings" + "testing" + "time" + + "charm.land/lipgloss/v2" +) + +// A ROW'S FREE SPACE IS MEASURED IN COLUMNS, NOT RUNES. +// +// The status glyph is styled, so it is one column of "✓" wrapped in around +// twenty runes of escape sequence. Subtracting len([]rune(head)) charged the +// row for all of them, so the space left for the summary came out roughly +// twenty columns short and the `remaining > 8` guard dropped summaries that +// fit with room to spare — on wide terminals most of all, where there was +// never any real pressure on the line. +func TestATaskSummaryIsKeptWhenItFitsInColumns(t *testing.T) { + task := orchestrateTask{ + id: "trace", + status: orchestrateDone, + summary: "found the seam", + } + const width = 40 + + line := model{}.renderOrchestrateTaskLine(task, time.Time{}, width) + + if !strings.Contains(line, "found the seam") { + t.Fatalf("the summary was dropped from a %d-column row that had room for it:\n visible: %q (%d columns)", + width, ansiStripped(line), lipgloss.Width(line)) + } +} + +// The other half: measuring in columns must not let a row overflow its width. +func TestATaskRowStaysInsideItsWidth(t *testing.T) { + task := orchestrateTask{ + id: "trace", + status: orchestrateDone, + summary: strings.Repeat("long summary text ", 20), + } + for _, width := range []int{28, 40, 80, 120} { + line := model{}.renderOrchestrateTaskLine(task, time.Time{}, width) + if got := lipgloss.Width(line); got > width { + t.Errorf("a %d-column row rendered %d columns wide: %q", width, got, ansiStripped(line)) + } + } +} + +func ansiStripped(text string) string { + var b strings.Builder + inEscape := false + for _, r := range text { + switch { + case r == 0x1b: + inEscape = true + case inEscape && (r == 'm' || r == 'K'): + inEscape = false + case !inEscape: + b.WriteRune(r) + } + } + return b.String() +} 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 new file mode 100644 index 000000000..c26210312 --- /dev/null +++ b/internal/tui/orchestrate_saved.go @@ -0,0 +1,624 @@ +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, 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. + // + // /plans resume → un-pause the plan running right now + // /plans resume → pick up a saved plan where its last run + // stopped + // + // Bare resume keeps un-pausing a LIVE plan; with no plan running it now + // resumes the last one from where it stopped — the pause control shipped + // before this still fires whenever a plan is actually in flight. + if strings.TrimSpace(rest) == "" { + // A LIVE plan un-pauses; a plan that was CANCELLED resumes from where + // it stopped. Both are "continue what was interrupted", and which one + // applies is decided by whether a plan is actually running now — not + // by the user having to remember two spellings. + if m.planProgress.PlanRunningNow() { + return m.appendPlansNotice(m.orchestrateControlText(args)), nil + } + return m.resumeLastPlan() + } + return m.runSavedPlan(rest, true) + 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 { + // 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 { + 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 .") + } + // 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 { + fmt.Fprintf(&b, " %-20s %2d tasks %-8s", plan.Name, plan.TaskCount, plan.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") + } + } + 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") +} + +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 { + // THE SAME SANITIZER THE LIVE PATH USES (planTaskSummary → + // sanitizeCardText). A saved plan's prompts are model-authored text read + // back from disk — the same trust level as a live task's — and this line + // only cut at the first newline, so control characters (ESC included, the + // byte that starts an ANSI sequence) reached the display. Untrusted input + // is untrusted on every path it enters by. + return truncateRunes(sanitizeCardText(prompt), 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. +// +// resume narrows the plan to the work the session log says has not succeeded. +func (m model) runSavedPlan(args string, resume bool) (tea.Model, tea.Cmd) { + verb := "run" + if resume { + verb = "resume" + } + name, values := splitPlanTarget(args) + if name == "" { + 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. + stored, err := specialist.FindSavedPlan(m.planPaths, name) + if err != nil { + return m.appendPlansNotice(planControlNotice("warning", err.Error())), nil + } + // A PARAMETERISED PLAN IS REFUSED HERE, for the same reason an unknown name + // is: expandPlanParams would refuse it too, but only after a turn and a model + // call have been spent reaching admission. The bundled research plan is five + // child agents, so the difference between refusing here and refusing there is + // five sub-agent runs against a placeholder. + paramsArg, err := savedPlanParamsArg(stored, values, verb) + if err != nil { + return m.appendPlansNotice(planControlNotice("warning", err.Error())), nil + } + + 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 + } + // CONCATENATED, never interpolated. paramsArg carries the user's own words, + // and a subject containing a percent sign would be read as a verb by Sprintf + // and rewritten into the instruction as "%!s(MISSING)". + text := fmt.Sprintf(instruction, string(encoded)) + if paramsArg != "" { + text += " " + paramsArg + } + return m.dispatchCommand(parsedCommand{ + kind: commandPrompt, + text: text, + }) +} + +// splitPlanTarget separates a saved plan's name from the text that follows it. +// +// NOT splitPlansArgs: that lowercases its first field so a verb matches whatever +// case it was typed in, and a plan name is compared exactly — lowercasing one +// would make /plans run Sweep report that no plan named "sweep" exists. +func splitPlanTarget(args string) (name, rest string) { + trimmed := strings.TrimSpace(args) + fields := strings.Fields(trimmed) + if len(fields) == 0 { + return "", "" + } + return fields[0], strings.TrimSpace(strings.TrimPrefix(trimmed, fields[0])) +} + +// savedPlanParamsArg turns the words after a plan's name into the `params` +// object the orchestrate tool takes, or refuses. +// +// FAILS CLOSED IN BOTH DIRECTIONS, matching expandPlanParams: a plan that +// declares a parameter will not run without one, and words supplied to a plan +// that declares none are refused rather than dropped — a user who typed them +// meant them, and silently ignoring them runs a different plan than the one they +// asked for while reporting success. +func savedPlanParamsArg(stored specialist.SavedPlan, values, verb string) (string, error) { + declared := specialist.PlanParams(stored.Args) + values = strings.TrimSpace(values) + switch { + case len(declared) == 0 && values == "": + return "", nil + case len(declared) == 0: + return "", fmt.Errorf("%q takes no parameters, so %q would be ignored. Run it as /plans %s %s", + stored.Name, values, verb, stored.Name) + case values == "": + return "", fmt.Errorf("%q takes %s. Run it as /plans %s %s <%s>", + stored.Name, describePlanParams(declared), verb, stored.Name, strings.Join(declared, "> <")) + } + // ONE PARAMETER IS RESOLVED HERE, not by the model. The mapping is total — + // everything after the name is the value — so deciding it in the UI makes the + // substituted plan a function of what was typed rather than of what a model + // inferred from it. + if len(declared) == 1 { + encoded, err := json.Marshal(map[string]string{declared[0]: values}) + if err != nil { + return "", fmt.Errorf("could not read that parameter: %w", err) + } + return "Set the `params` argument to exactly " + string(encoded) + ".", nil + } + // SEVERAL PARAMETERS HAVE NO TOTAL MAPPING from one line of prose, so the + // model splits it — and is told the exact key set, so it fills those and + // invents none. + encoded, err := json.Marshal(values) + if err != nil { + return "", fmt.Errorf("could not read those parameters: %w", err) + } + return "Set the `params` argument to an object whose keys are exactly " + + strings.Join(quotePlanParams(declared), ", ") + + ", taking the values from this text: " + string(encoded) + ".", nil +} + +func describePlanParams(declared []string) string { + if len(declared) == 1 { + return "a parameter: " + declared[0] + } + return "parameters: " + strings.Join(declared, ", ") +} + +func quotePlanParams(declared []string) []string { + out := make([]string, 0, len(declared)) + for _, name := range declared { + out = append(out, `"`+name+`"`) + } + return out +} + +// 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. +// resumeLastPlan runs the plan that last ran, from WHERE IT STOPPED — the +// completed tasks are skipped and their findings are folded into the tasks that +// remain (RemainingPlan). It is bare "/plans resume" when no plan is live to +// un-pause: the one-command "continue what I cancelled" the pause/restart pair +// did not cover. +// +// SAME-SESSION, because the progress comes from this session's event log. A new +// session has no record of what already ran, so there is nothing to resume from +// and the message says so rather than silently restarting. +func (m model) resumeLastPlan() (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 resume. "+ + "/plans list shows what is saved, and /plans run starts one.")), nil + } + if m.planProgress.PlanRunningNow() { + return m.appendPlansNotice(planControlNotice("warning", + "A plan is still running. Pause it with /plans pause, or stop it with /plans stop.")), nil + } + if m.sessionStore == nil || m.activeSession.SessionID == "" { + return m.appendPlansNotice(planControlNotice("warning", + "This session has no event log, so there is no record of what already ran.")), nil + } + events, err := m.sessionStore.ReadEvents(m.activeSession.SessionID) + if err != nil { + return m.appendPlansNotice(planControlNotice("warning", "Could not read this session's events: "+err.Error())), nil + } + progress, found := specialist.ReducePlanEvents(events) + if !found { + return m.appendPlansNotice(planControlNotice("warning", + "No plan has run in this session, so there is nothing to resume.")), 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 resumed: "+err.Error())), nil + } + remaining, err := specialist.RemainingPlan(plan, progress, m.savedPlanLimits()) + if err != nil { + // The only error here is "everything already succeeded" — which is not a + // failure, it is the plan being done. Say so plainly. + return m.appendPlansNotice(planControlNotice("info", 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, resumePlanName, remaining); err != nil { + return m.appendPlansNotice(planControlNotice("warning", "Could not stage the remaining plan: "+err.Error())), nil + } + label := planName + if label == "" { + label = "the last plan" + } + m = m.appendPlansNotice(planControlNotice("info", + resumeNotice(label, plan, remaining, progress, resumePlanName))) + encoded, err := json.Marshal(resumePlanName) + 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.", + }) +} + +// resumeNotice describes a staged resume: how much is left, how much is already +// done, and — when an edit brought a completed task back — which tasks re-run. +// +// The changed clause is the recovery diagnostic. Without it a user who edited a +// saved plan between runs sees a task they believe finished reappear in the +// remainder with no explanation; naming it, and saying it changed, is what makes +// identity-aware resume legible rather than surprising. "Already done" counts +// only the tasks that stay done, so an edited task is not both done and left. +func resumeNotice(label string, plan, remaining specialist.Plan, progress specialist.PlanProgress, stagedName string) string { + changed := specialist.ResumeChangedTasks(plan, progress) + doneCount := len(progress.Succeeded) - len(changed) + if doneCount < 0 { + doneCount = 0 + } + msg := fmt.Sprintf("Resuming %s from where it stopped: %d task(s) left, %d already done.", + label, remaining.TaskCount(), doneCount) + if len(changed) > 0 { + msg += fmt.Sprintf(" Re-running %s because they or an input changed since the last run.", + strings.Join(changed, ", ")) + } + msg += fmt.Sprintf(" Staged as %q — /plans show %s to read it first.", stagedName, stagedName) + return msg +} + +// restartLastPlan runs the plan that last ran, from the beginning. +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; resumePlanName is +// where a resume-from-stop stages the remainder. A FIXED name each, overwritten +// each time: a directory of last_run_1, _2, _3 is one nobody can tell apart, and +// only the newest is ever the right one. +const restartPlanName = "last_run" +const resumePlanName = "last_run_resume" + +// 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 + } + // 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 + } + + 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 + } + // 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" + } + notice = fmt.Sprintf( + "Resuming %q: %d of %d tasks already succeeded, %d left — %s.", + stored.Name, len(progress.Succeeded), len(progress.Order), remaining.TaskCount(), why) + // A completed task that reappears in the remainder was EDITED since it ran + // (or depends on one that was). Naming it is what keeps an identity-aware + // resume legible: the file changed between runs, so the fingerprint no longer + // matches and the task is not resumed as done. + if changed := specialist.ResumeChangedTasks(plan, progress); len(changed) > 0 { + notice += fmt.Sprintf("\n%s changed since the last run and will re-run (with anything that depends on them).", + strings.Join(changed, ", ")) + } + notice += fmt.Sprintf("\nSaved the remainder as %q — /plans show %s to read it first.", resumeName, resumeName) + return resumeName, planControlNotice("info", notice), true +} diff --git a/internal/tui/orchestrate_saved_params_test.go b/internal/tui/orchestrate_saved_params_test.go new file mode 100644 index 000000000..a708722bc --- /dev/null +++ b/internal/tui/orchestrate_saved_params_test.go @@ -0,0 +1,144 @@ +package tui + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/specialist" +) + +// A parameterised plan is refused HERE, before a turn is spent. +// +// expandPlanParams refuses it too, but only at admission — after the command has +// dispatched a prompt, the model has composed an orchestrate call and the run has +// begun. The bundled research plan is five child agents, so the difference is +// five sub-agent runs against a placeholder. +func writeParamPlan(t *testing.T, dir, name, body string) { + t.Helper() + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, name+".json"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +func mustPlanArgs(t *testing.T, body string) map[string]any { + t.Helper() + var args map[string]any + if err := json.Unmarshal([]byte(body), &args); err != nil { + t.Fatal(err) + } + return args +} + +const oneParamPlan = `{"name":"research","tasks":[{"id":"a","prompt":"Find where ${subject} is defined"}],"budget":{"max_workers":1}}` + +func TestRunningAParameterisedPlanWithNoValueRefusesBeforeStartingATurn(t *testing.T) { + m, paths := savedPlanModel(t) + writeParamPlan(t, paths.ProjectDir, "research", oneParamPlan) + + updated, cmd := m.runSavedPlan("research", false) + if cmd != nil { + t.Fatal("a plan missing its parameter started a turn: the whole point is that it costs nothing") + } + rendered := transcriptText(updated.(model).transcript) + if !strings.Contains(rendered, "status: warning") { + t.Fatalf("must refuse: %s", rendered) + } + // It has to name the parameter AND show the syntax — a refusal the user + // cannot act on just moves the dead end earlier. + if !strings.Contains(rendered, "subject") { + t.Fatalf("the refusal must name the parameter: %s", rendered) + } + if !strings.Contains(rendered, "/plans run research ") { + t.Fatalf("the refusal must show how to supply it: %s", rendered) + } +} + +// The other direction: words handed to a plan that takes none must be refused, +// not dropped. A user who typed them meant them. +func TestRunningAPlainPlanWithExtraWordsIsRefusedRatherThanIgnored(t *testing.T) { + m, paths := savedPlanModel(t) + writeParamPlan(t, paths.ProjectDir, "sweep", + `{"name":"sweep","tasks":[{"id":"a","prompt":"look around"}],"budget":{"max_workers":1}}`) + + updated, cmd := m.runSavedPlan("sweep the retry watchdog", false) + if cmd != nil { + t.Fatal("extra words were silently dropped and the plan ran anyway") + } + rendered := transcriptText(updated.(model).transcript) + if !strings.Contains(rendered, "takes no parameters") { + t.Fatalf("must say why: %s", rendered) + } +} + +// A single parameter is resolved by the COMMAND, not by the model: everything +// after the name is the value, which is a total mapping, so the substituted plan +// is a function of what was typed rather than of what a model inferred. +func TestASingleParameterIsResolvedFromTheCommandLine(t *testing.T) { + stored := specialist.SavedPlan{Name: "research", Args: mustPlanArgs(t, oneParamPlan)} + + arg, err := savedPlanParamsArg(stored, "the retry watchdog", "run") + if err != nil { + t.Fatalf("supplying the parameter must succeed: %v", err) + } + if !strings.Contains(arg, `{"subject":"the retry watchdog"}`) { + t.Fatalf("the params object must carry the typed value verbatim: %s", arg) + } +} + +// A percent sign in the subject must survive. The instruction is built by +// concatenation for exactly this reason: a value interpolated through Sprintf +// comes out as "%!s(MISSING)" and the plan researches a formatting artefact. +func TestAParameterContainingAPercentSignSurvivesIntoTheInstruction(t *testing.T) { + m, paths := savedPlanModel(t) + writeParamPlan(t, paths.ProjectDir, "research", oneParamPlan) + + updated, _ := m.runSavedPlan("research why cache hit rate dropped 40% today", false) + rendered := transcriptText(updated.(model).transcript) + if strings.Contains(rendered, "MISSING") || strings.Contains(rendered, "%!") { + t.Fatalf("the percent sign was interpreted as a verb: %s", rendered) + } + // ASSERTED POSITIVELY. "no %!" is satisfied by an instruction that dropped the + // parameter altogether, which is the failure this is supposed to catch. + if !strings.Contains(rendered, `{"subject":"why cache hit rate dropped 40% today"}`) { + t.Fatalf("the subject did not reach the instruction verbatim: %s", rendered) + } + // And the plan is still named, so the params did not displace the reference. + if !strings.Contains(rendered, `"research"`) { + t.Fatalf("the instruction no longer names the plan: %s", rendered) + } +} + +// Several parameters have no total mapping from one line, so the model splits it +// — and is told the exact key set so it fills those and invents none. +func TestSeveralParametersNameTheExactKeySet(t *testing.T) { + stored := specialist.SavedPlan{Name: "compare", Args: mustPlanArgs(t, + `{"name":"compare","tasks":[{"id":"a","prompt":"compare ${before} against ${after}"}],"budget":{"max_workers":1}}`)} + + arg, err := savedPlanParamsArg(stored, "v1 and v2", "run") + if err != nil { + t.Fatalf("supplying text for several parameters must succeed: %v", err) + } + for _, want := range []string{`"after"`, `"before"`, "v1 and v2"} { + if !strings.Contains(arg, want) { + t.Fatalf("instruction must carry %s: %s", want, arg) + } + } +} + +// A plan name is compared exactly, so the split that separates it from its +// parameters must not lowercase it the way the verb split does. +func TestAPlanNameKeepsItsCaseWhenSplitFromItsParameters(t *testing.T) { + name, rest := splitPlanTarget(" Sweep the retry watchdog ") + if name != "Sweep" { + t.Fatalf("name was rewritten to %q: /plans run Sweep would report no such plan", name) + } + if rest != "the retry watchdog" { + t.Fatalf("parameter text was %q", rest) + } +} diff --git a/internal/tui/orchestrate_saved_test.go b/internal/tui/orchestrate_saved_test.go new file mode 100644 index 000000000..2ee070360 --- /dev/null +++ b/internal/tui/orchestrate_saved_test.go @@ -0,0 +1,452 @@ +package tui + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/Gitlawb/zero/internal/sessions" + "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) + } + // 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) + } + } +} + +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) + // 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, "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) + } +} + +// 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", false) + 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) + } +} + +// 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)) + } +} + +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 +} + +// 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()) + } +} + +// A SAVED PLAN'S PROMPTS ARE MODEL-AUTHORED TEXT READ BACK FROM DISK — the +// same trust level as a live task's, sanitized on the same chokepoint. The +// saved path only cut at the first newline, so ESC (the byte that starts an +// ANSI sequence) and other control characters reached the display. +func TestSavedPlanSummariesDropControlCharacters(t *testing.T) { + got := planTaskSummaryLine("audit \x1b[31mred\x1b[0m thing\x07 done.\nsecond line") + if strings.ContainsAny(got, "\x1b\x07\r\n") { + t.Fatalf("control characters reached the summary: %q", got) + } + if !strings.Contains(got, "audit") || !strings.Contains(got, "done.") { + t.Fatalf("the printable text must survive: %q", got) + } + if strings.Contains(got, "second line") { + t.Fatalf("only the first line belongs in a summary: %q", got) + } +} 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) + } +} 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..3acd5cf0e --- /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/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/picker.go b/internal/tui/picker.go index 1e7580bbf..337777995 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,42 @@ 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() { + 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 } } + // 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) diff --git a/internal/tui/plan_card_regression_test.go b/internal/tui/plan_card_regression_test.go new file mode 100644 index 000000000..c27a9d174 --- /dev/null +++ b/internal/tui/plan_card_regression_test.go @@ -0,0 +1,133 @@ +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) + } + // 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) + } + } +} + +// ...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_durability_test.go b/internal/tui/plan_durability_test.go new file mode 100644 index 000000000..5fc9216a7 --- /dev/null +++ b/internal/tui/plan_durability_test.go @@ -0,0 +1,302 @@ +package tui + +import ( + "encoding/json" + "fmt" + "sync" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + + "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) { + 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) + } +} + +// 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) + } + } +} 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 new file mode 100644 index 000000000..a3d3aace6 --- /dev/null +++ b/internal/tui/plan_messages.go @@ -0,0 +1,170 @@ +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. +// +// 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 + 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. +type planGraphTask struct { + id string + dependsOn []string + phase string +} + +// 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 + // 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 +} + +// 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 +// (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 + // 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 + // 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 + // 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 +} + +// 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 + name string + status string + succeeded int + failed int + skipped int + cancelled int + 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. +// 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_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 new file mode 100644 index 000000000..d53f084f3 --- /dev/null +++ b/internal/tui/plan_progress.go @@ -0,0 +1,728 @@ +package tui + +import ( + "context" + "fmt" + "strings" + "sync" + "unicode/utf8" + + tea "charm.land/bubbletea/v2" + + "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. +// +// 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 + // 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 + // 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. + 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{} + // 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 + // 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. + // + // 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, + // so the card is keyed by this and reconciled on completion — exactly how + // the Task tool's card works. + 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) + _ specialist.PlanPreflightReporter = (*PlanProgressBridge)(nil) +) + +// 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, store *sessions.Store, sessionID string) { + if bridge == nil { + return + } + bridge.mu.Lock() + defer bridge.mu.Unlock() + bridge.sink = sink + bridge.runID = runID + // 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 +} + +// 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() + } +} + +// 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 + } + bridge.mu.Lock() + defer bridge.mu.Unlock() + bridge.cancelPlan = cancel + 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) { + 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 +} + +// 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 +} + +// 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() { + bridge.paused = false + if bridge.resume != nil { + close(bridge.resume) + bridge.resume = nil + } +} + +// 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 +} + +// 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 { + 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. +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) } + +// 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) { + 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 + + // 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 { + // 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, + background: bridge.isBackground()} + }) +} + +// TaskDispatched opens a card for the task that is about to run. +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) + if bridge.cardByTask == nil { + bridge.cardByTask = map[string]string{} + } + bridge.cardByTask[task.ID] = key + bridge.mu.Unlock() + + 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, + model: model, background: bridge.isBackground()} + }) +} + +// 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)) +} + +func (bridge *PlanProgressBridge) finish(result specialist.TaskResult, status specialistStatus) { + 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, 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 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 + // 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, + model: ranOn, + fellBackFrom: fellBackFrom, + background: bridge.isBackground(), + } + }) +} + +// PlanCompleted reports the plan's terminal state. +func (bridge *PlanProgressBridge) PlanCompleted(plan specialist.Plan, report specialist.PlanReport) { + bridge.record(specialist.PlanCompletedEvent(plan, report)) + + // 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. + // + // 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() + 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 + 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, + 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 +// 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 { + // 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 "" + } + 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..fa2f8a3da --- /dev/null +++ b/internal/tui/plan_progress_test.go @@ -0,0 +1,495 @@ +package tui + +import ( + "context" + "strings" + "testing" + "time" + "unicode/utf8" + + 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, nil, "") +} + +func TestPlanProgressBridgeEmitsACardPerTask(t *testing.T) { + var got []tea.Msg + bridge := NewPlanProgressBridge() + 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"}) + 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, nil, "") + + 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) + } + } +} + +// 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) + } +} + +// 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") + } +} + +// 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) + } +} + +// 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(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/plan_step_detail.go b/internal/tui/plan_step_detail.go index 9b8c19034..1dc7e5b67 100644 --- a/internal/tui/plan_step_detail.go +++ b/internal/tui/plan_step_detail.go @@ -113,9 +113,16 @@ func (m model) sidebarPlanSelectables(width int) []planStepHit { agentBody = 1 // the "no agents spawned" placeholder occupies one line } base := 1 + agentBody + 2 // AGENTS header + body + (blank line + PLAN header) + if m.todoPlanBar(width) != "" { + // The zeromaxing bar renders as the checklist's first line; the step + // rows sit one below it. + base++ + } 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/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) + } +} 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/provider_wizard.go b/internal/tui/provider_wizard.go index 8f4ee8bb3..3a24429e8 100644 --- a/internal/tui/provider_wizard.go +++ b/internal/tui/provider_wizard.go @@ -119,7 +119,11 @@ func (m model) resolveProviderWizardAimlapi(cmd tea.Cmd, outcome aimlapiOutcome) } // Keep the shared spinner tick alive whenever the sub-flow just entered a busy // or progress state, so its animated spinner advances during onboarding. - return m, tea.Batch(cmd, m.ensureSpinnerTick()) + // Sequenced: ensureSpinnerTick has a pointer receiver and sets + // spinnerTicking, and Go does not specify whether the plain operand m is + // copied before or after the call operand in the same return. + tick := m.ensureSpinnerTick() + return m, tea.Batch(cmd, tick) } // applyProviderWizardDeviceCode handles phase 1 of device-code login: show the diff --git a/internal/tui/provider_wizard_discovery.go b/internal/tui/provider_wizard_discovery.go index b0dcada69..9ab4de059 100644 --- a/internal/tui/provider_wizard_discovery.go +++ b/internal/tui/provider_wizard_discovery.go @@ -180,7 +180,10 @@ func (m model) checkExistingAimlapiBalance() (model, tea.Cmd) { balance, err := aimlapi.NewClient(endpoints, nil).GetBalance(ctx, key) return aimlapiExistingBalanceMsg{wizard: wizard, gen: gen, balance: balance, err: err} } - return m, tea.Batch(cmd, m.ensureSpinnerTick()) + // Sequenced: see the note in provider_wizard.go — the pointer receiver + // must run before m is copied into the return. + tick := m.ensureSpinnerTick() + return m, tea.Batch(cmd, tick) } func (m model) applyExistingAimlapiBalance(msg aimlapiExistingBalanceMsg) (model, tea.Cmd) { diff --git a/internal/tui/render_cache.go b/internal/tui/render_cache.go index a5ba02059..50cb7b1e9 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,45 @@ 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 TestSpecialistFingerprintCoversEvery +// Field walks the struct by reflection instead of listing fields by hand. +// +// THE THREE THAT WERE MISSING all share one shape: they are written AFTER the +// card's first render — setTokens, setModel and setResult each mutate an entry +// that has already been drawn and cached. So the key was unchanged, the stale +// render was served, and a finished plan task kept showing no tokens, no model +// and no output. A field that only ever arrives at start() would have hidden +// the bug; these arrive late, which is exactly when a cache key has to move. +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), + strconv.Itoa(info.tokenCount), + info.currentTool, + info.currentDetail, + info.model, + strconv.FormatBool(info.background), + info.result, + 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/render_cache_fingerprint_test.go b/internal/tui/render_cache_fingerprint_test.go new file mode 100644 index 000000000..e0e5be2df --- /dev/null +++ b/internal/tui/render_cache_fingerprint_test.go @@ -0,0 +1,71 @@ +package tui + +import ( + "reflect" + "testing" + "time" +) + +// EVERY FIELD OF specialistInfo MUST MOVE THE CACHE KEY. +// +// Three of them did not: tokenCount, model and result are all written AFTER the +// card's first render (setTokens, setModel, setResult), so the entry changed, +// the key did not, and the cache kept serving the render made before the values +// arrived — a finished plan task showing no tokens, no model and no output. A +// field that only ever arrives at start() would have hidden the bug; these +// arrive late, which is exactly when a cache key has to move. +// +// The mutator table below is CHECKED AGAINST THE STRUCT BY REFLECTION rather +// than merely written alongside it. specialistInfo's fields are unexported, so +// reflection cannot set them, but it can still enumerate them — which is what +// turns "someone forgot" into a failing test: a new field with no mutator fails +// here, and the fix is to add both a mutator and a fingerprint entry. +func TestSpecialistFingerprintCoversEveryField(t *testing.T) { + // One entry per field of specialistInfo, each making a change a reader of + // the card would see. + mutators := map[string]func(*specialistInfo){ + "name": func(info *specialistInfo) { info.name = "changed" }, + "description": func(info *specialistInfo) { info.description = "changed" }, + "childSessionID": func(info *specialistInfo) { info.childSessionID = "changed" }, + "status": func(info *specialistInfo) { info.status = specialistCancelled }, + "startedAt": func(info *specialistInfo) { info.startedAt = time.Unix(1, 0) }, + "completedAt": func(info *specialistInfo) { info.completedAt = time.Unix(1, 0) }, + "exitCode": func(info *specialistInfo) { info.exitCode = 7 }, + "errorMsg": func(info *specialistInfo) { info.errorMsg = "changed" }, + "toolCount": func(info *specialistInfo) { info.toolCount = 7 }, + "tokenCount": func(info *specialistInfo) { info.tokenCount = 7 }, + "currentTool": func(info *specialistInfo) { info.currentTool = "changed" }, + "currentDetail": func(info *specialistInfo) { info.currentDetail = "changed" }, + "model": func(info *specialistInfo) { info.model = "changed" }, + "background": func(info *specialistInfo) { info.background = true }, + "result": func(info *specialistInfo) { info.result = "changed" }, + } + + structType := reflect.TypeOf(specialistInfo{}) + declared := map[string]bool{} + for i := 0; i < structType.NumField(); i++ { + declared[structType.Field(i).Name] = true + } + + for name := range mutators { + if !declared[name] { + t.Errorf("mutator for %q, which specialistInfo no longer has: drop it and check the fingerprint still lists only real fields", name) + } + } + + baseline := specialistCacheFingerprint(&specialistInfo{}) + for i := 0; i < structType.NumField(); i++ { + name := structType.Field(i).Name + t.Run(name, func(t *testing.T) { + mutate, ok := mutators[name] + if !ok { + t.Fatalf("specialistInfo grew field %q with no mutator here: add one, and add the field to specialistCacheFingerprint, or a card that changes through it will keep serving its previous render", name) + } + changed := specialistInfo{} + mutate(&changed) + if got := specialistCacheFingerprint(&changed); got == baseline { + t.Fatalf("changing %s left the cache key identical: specialistCacheFingerprint does not read this field, so a card mutated through it never redraws", name) + } + }) + } +} diff --git a/internal/tui/rendering.go b/internal/tui/rendering.go index 38331e104..b976427a3 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, "") diff --git a/internal/tui/resume_from_stop_test.go b/internal/tui/resume_from_stop_test.go new file mode 100644 index 000000000..51db43acd --- /dev/null +++ b/internal/tui/resume_from_stop_test.go @@ -0,0 +1,176 @@ +package tui + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/Gitlawb/zero/internal/sessions" + "github.com/Gitlawb/zero/internal/specialist" +) + +// resumeModelWithHistory is a model whose session log already holds a plan that +// ran, completed its first task WITH a finding, and was then cut short — a real +// bridge wrote every event, so the durable-output write (TaskCompletedEvent) is +// exercised here, not hand-built. +func resumeModelWithHistory(t *testing.T) (model, specialist.PlanPaths) { + 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, err := specialist.ParsePlan(map[string]any{ + "name": "audit", + "tasks": []any{ + map[string]any{"id": "find", "prompt": "find it"}, + map[string]any{"id": "synth", "prompt": "combine the findings", "depends_on": []any{"find"}}, + }, + "budget": map[string]any{"max_workers": float64(1)}, + }, specialist.Limits{MaxTasks: 20, ParentTools: specialist.PlanReadOnlyToolNames()}) + if err != nil { + t.Fatalf("ParsePlan: %v", err) + } + + // A real run through the real bridge: admit, dispatch find, complete find with + // a finding, then stop — synth was never dispatched. + m.planProgress.PlanAdmitted(plan) + m.planProgress.TaskDispatched(specialist.Task{ID: "find"}) + m.planProgress.TaskCompleted(specialist.TaskResult{ + ID: "find", + Outcome: specialist.TaskSucceeded, + Output: "the parser handles a UTF-8 BOM (extract.go:41)", + }) + if err := m.planProgress.RecordingError(); err != nil { + t.Fatalf("recording: %v", err) + } + return m, paths +} + +// BARE "/plans resume" CONTINUES A CANCELLED PLAN FROM WHERE IT STOPPED, briefing +// the remaining task on what its completed dependency found. This is the whole +// point of the #2+#3 pair: the completed task's finding survives the interruption +// and reaches the task that was meant to build on it. +// +// Driven through the real command (handlePlansCommand), not resumeLastPlan +// directly, so the arity/running routing is exercised too. cmd is deliberately +// NOT asserted: launchPrompt returns a nil cmd without a provider (this harness +// has none), exactly as the restart test documents — the staged remainder and +// the notice are what resume is responsible for. +func TestBareResumeContinuesACancelledPlanFromItsStop(t *testing.T) { + m, paths := resumeModelWithHistory(t) + + updated, _ := m.handlePlansCommand("resume") + notice := transcriptText(updated.(model).transcript) + if !strings.Contains(notice, "from where it stopped") || !strings.Contains(notice, "1 task") { + t.Fatalf("the notice does not describe a resume-from-stop:\n%s", notice) + } + + // The staged remainder must be exactly synth, briefed on find's finding. If + // the bare-resume fall-through were reverted to the un-pause control, nothing + // would be staged and this fails — which is the mutation this test guards. + staged, err := specialist.FindSavedPlan(paths, "last_run_resume") + if err != nil { + t.Fatalf("the remainder was not staged: %v", err) + } + plan, err := specialist.ParsePlan(staged.Args, m.savedPlanLimits()) + if err != nil { + t.Fatalf("the staged remainder does not validate: %v", err) + } + if plan.TaskCount() != 1 || plan.Order()[0] != "synth" { + t.Fatalf("staged %d task(s), want only synth: %v", plan.TaskCount(), plan.Order()) + } + if !strings.Contains(plan.Tasks()[0].Prompt, "UTF-8 BOM") { + t.Fatalf("the resumed task was not briefed on find's finding:\n%s", plan.Tasks()[0].Prompt) + } +} + +// With NOTHING ever run, bare resume says so plainly and starts no turn — it does +// not fall through to the un-pause control and appear to work. +func TestBareResumeWithNothingToResumeSaysSo(t *testing.T) { + m, _ := savedPlanModel(t) + updated, cmd := m.handlePlansCommand("resume") + if cmd != nil { + t.Fatal("bare resume started a turn with no plan") + } + text := transcriptText(updated.(model).transcript) + if !strings.Contains(text, "nothing to resume") { + t.Fatalf("expected a nothing-to-resume notice:\n%s", text) + } +} + +// C5: the SAVED-plan resume path names a completed task that was EDITED since it +// ran, so the user understands why a task they believe finished is back. Driven +// through the real resumeSavedPlan: a plan runs, its saved file is (in effect) +// edited — modelled by a completion whose recorded fingerprint no longer matches +// the current task — and resume reports it changed. +func TestResumeSavedPlanNamesAnEditedTaskInItsNotice(t *testing.T) { + 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.Fatal(err) + } + m.sessionStore = store + m.activeSession = session + m.planProgress.Attach(func(tea.Msg) {}, 1, store, session.SessionID) + + plan, err := specialist.ParsePlan(map[string]any{ + "name": "sweep", + "tasks": []any{map[string]any{"id": "scan", "prompt": "scan it"}}, + "budget": map[string]any{"max_workers": float64(1)}, + }, m.savedPlanLimits()) + if err != nil { + t.Fatal(err) + } + if _, err := specialist.SavePlan(paths.ProjectDir, "sweep", plan); err != nil { + t.Fatal(err) + } + + record := func(build func() (sessions.EventType, map[string]any)) { + typ, payload := build() + if _, err := store.AppendEvent(session.SessionID, sessions.AppendEventInput{Type: typ, Payload: payload}); err != nil { + t.Fatalf("append %s: %v", typ, err) + } + } + record(func() (sessions.EventType, map[string]any) { return specialist.PlanAdmittedEvent(plan) }) + // A recorded fingerprint that cannot match the current task's — as if the + // saved file was edited after the run. + record(func() (sessions.EventType, map[string]any) { + return specialist.TaskCompletedEvent(specialist.TaskResult{ + ID: "scan", Outcome: specialist.TaskSucceeded, Output: "found", Identity: "stale-fingerprint"}) + }) + + stored, err := specialist.FindSavedPlan(paths, "sweep") + if err != nil { + t.Fatal(err) + } + _, notice, ok := m.resumeSavedPlan(stored) + if !ok { + t.Fatalf("resume refused: %s", notice) + } + if !strings.Contains(notice, "scan changed since the last run") { + t.Fatalf("the notice did not flag the edited task as re-running:\n%s", notice) + } +} + +// A plan admitted in a session with no event log (its store never came up) cannot +// be resumed from stop — there is no record of what ran. Resume must refuse rather +// than silently restart from the top. +func TestBareResumeWithoutAnEventLogRefuses(t *testing.T) { + m, _ := savedPlanModel(t) // attached to a nil store: LastPlan is set, nothing is recorded + m.planProgress.PlanAdmitted(samplePlan(t)) + updated, cmd := m.handlePlansCommand("resume") + if cmd != nil { + t.Fatal("resume with no event log started a turn") + } + if !strings.Contains(transcriptText(updated.(model).transcript), "no event log") { + t.Fatalf("expected a no-event-log refusal:\n%s", transcriptText(updated.(model).transcript)) + } +} diff --git a/internal/tui/session.go b/internal/tui/session.go index c38b61b86..76e414d47 100644 --- a/internal/tui/session.go +++ b/internal/tui/session.go @@ -653,12 +653,12 @@ func transcriptRowsFromSessionEvents(events []sessions.Event) []transcriptRow { rows = append(rows, transcriptRow{kind: rowSystem, text: "forked from session: " + parentID}) } case sessions.EventSpecialistStart: - info := specialistInfoFromPayload(payload) + info := specialistInfoFromPayload(payload, sessionEventTime(event)) if info != nil { rows = append(rows, transcriptRow{kind: rowSpecialist, specialistInfo: info}) } case sessions.EventSpecialistStop: - info := specialistInfoFromPayload(payload) + info := specialistInfoFromPayload(payload, sessionEventTime(event)) if info != nil { // Reconcile: update the existing Start row with the same // childSessionID instead of appending a duplicate. On resume @@ -667,6 +667,11 @@ func transcriptRowsFromSessionEvents(events []sessions.Event) []transcriptRow { for i := range rows { if rows[i].kind == rowSpecialist && rows[i].specialistInfo != nil && rows[i].specialistInfo.childSessionID == info.childSessionID { + // THE START TIME COMES FROM THE START EVENT. Replacing + // the row wholesale would drop it and leave the card + // computing its elapsed from the stop against itself. + info.completedAt = info.startedAt + info.startedAt = rows[i].specialistInfo.startedAt rows[i].specialistInfo = info found = true break @@ -891,7 +896,16 @@ func firstNonEmptyString(values ...string) string { // specialistInfoFromPayload builds a specialistInfo from a specialist_start or // specialist_stop session event payload. Returns nil if the payload lacks a // childSessionId (the minimum required field). -func specialistInfoFromPayload(payload map[string]any) *specialistInfo { +// at is the event's own timestamp, which is what makes a RESTORED card able to +// report how long its agent ran. +// +// WITHOUT IT THE CARD SHOWED 153722867m16s — exactly math.MaxInt64 nanoseconds, +// Go's largest time.Duration. specialist_card.go computes `m.now().Sub(startedAt)` +// with no zero guard, so a rebuilt row whose startedAt was never set subtracted +// the year-1 zero time and clamped. Every agent in a resumed session rendered +// that, alongside "0 tool calls", which read as four sub-agents that had done +// nothing for 292 years. +func specialistInfoFromPayload(payload map[string]any, at time.Time) *specialistInfo { childSessionID := payloadString(payload, "childSessionId") if childSessionID == "" { return nil @@ -900,6 +914,7 @@ func specialistInfoFromPayload(payload map[string]any) *specialistInfo { name: payloadString(payload, "specialist"), description: payloadString(payload, "description"), childSessionID: childSessionID, + startedAt: at, } statusStr := payloadString(payload, "status") switch statusStr { @@ -917,3 +932,14 @@ func specialistInfoFromPayload(payload map[string]any) *specialistInfo { } return info } + +// sessionEventTime parses an event's recorded timestamp, zero when absent or +// unparseable — which the card's own guard then treats as "unknown" rather than +// as the year 1. +func sessionEventTime(event sessions.Event) time.Time { + stamp, err := time.Parse(time.RFC3339, strings.TrimSpace(event.CreatedAt)) + if err != nil { + return time.Time{} + } + return stamp +} diff --git a/internal/tui/session_controls.go b/internal/tui/session_controls.go index 69a65b255..86d0ccc09 100644 --- a/internal/tui/session_controls.go +++ b/internal/tui/session_controls.go @@ -60,27 +60,97 @@ 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 { + // 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" { + // "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 { + // The SAME idle-session rule entering the posture enforces, because + // this is the same mutation in reverse: revertExecProfile moves the + // turn budget, the self-correct setting and the shared orchestrate + // gate, and doing that mid-run leaves one turn running under two + // different budgets. Entering through /effort zeromaxing is guarded; + // leaving through /effort auto must not be the unguarded door. + if m.pending { + return m, `Effort +Finish or stop the current run before leaving the zeromaxing posture.` + } + 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) } - 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.") } @@ -115,21 +185,45 @@ 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 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()...) 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}) + 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(), "no reasoning controls on this model"}, - Sections: []commandCardSection{{Title: "State", Fields: fields}}, + Summary: []string{"active effort: " + m.effortDisplay(), summary}, + Sections: []commandCardSection{{Title: "State", Fields: fields, Lines: stateLines}}, Actions: actions, }) } 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))}, - Sections: []commandCardSection{{Title: "State", Fields: fields}}, + Sections: []commandCardSection{{Title: "State", Fields: fields, Lines: stateLines}}, Actions: actions, }) } @@ -195,7 +289,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 == "" } @@ -208,7 +302,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 } @@ -217,17 +311,32 @@ 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 == "": 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 } @@ -243,6 +352,68 @@ 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 +} + +// 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. +// +// 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(modelName) == "" { + return false + } + 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 !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 { if m.reasoningEffort == "" { return "auto" @@ -300,6 +471,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 { @@ -423,6 +638,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() @@ -438,13 +660,31 @@ func (m model) handleProfileCommand(args string) (model, string) { m.execProfileDisplacedMaxTurns = displacedMaxTurns config.SetMaxTurnsEnv(profile.MaxTurns) } + // Spend budget. Deliberately NOT exported to the environment the way the turn + // budget is: a child inheriting the parent's ceiling would be handed the whole + // budget each, so a plan of ten tasks could spend ten times it. The parent's + // bound is the parent's. + // + // No displacement bookkeeping either, because nothing else sets this — there + // is no /tokens command to preserve, so revert simply clears it. + if profile.MaxTokens > 0 { + m.agentOptions.MaxTokens = profile.MaxTokens + } // 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) { + if m.profileEffortApplies(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 @@ -455,6 +695,14 @@ 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 + // 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() } @@ -469,6 +717,9 @@ func (m model) revertExecProfile() model { if m.execProfileName == "" { return m } + // The spend budget comes only from the profile, so removing the profile + // removes it. Unconditional because nothing else can have set it. + m.agentOptions.MaxTokens = 0 if !m.execProfileTurnsTouched && m.execProfileAppliedMaxTurns > 0 && m.agentOptions.MaxTurns == m.execProfileAppliedMaxTurns { m.agentOptions.MaxTurns = m.execProfileDisplacedMaxTurns if m.execProfileDisplacedMaxTurns > 0 { @@ -487,11 +738,24 @@ 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 + // 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 m.execProfileAppliedMaxTurns = 0 m.execProfileAppliedEffort = "" + m.execProfileEffortUnraised = "" m.execProfileArmedSelfCorrect = false m.execProfileTurnsTouched = false m.execProfileEffortTouched = false @@ -499,33 +763,131 @@ 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 { + if m.execProfileName != execprofile.Name { + return nil + } + // 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 + } +} + +// profileText is the /profile status card: ALIGNED KEY-VALUE ROWS, each fact +// said once. The previous version stacked three renderers — a labelled list, +// resolvedPostureLine, and the posture delta sentence — so the profile name +// appeared twice and the effort and turn budget three times each; a status +// card that repeats itself reads as noise, and this one was called irritating +// to its face. The delta facts survive as short clauses on the row they +// qualify ("was 80", "raised by the posture") instead of a second paragraph. func (m model) profileText() string { name := m.execProfileName if name == "" { name = execprofile.Balanced.Name + " (default)" } - lines := []string{ - "execution profile: " + name, - fmt.Sprintf("max tool-turns per run: %d", m.agentOptions.MaxTurns), - "reasoning effort: " + m.effortDisplay(), + // KEY VALUE, single space — not padded columns: compactCommandOutputText + // collapses every whitespace run at render, so alignment written here would + // silently disappear. The single-space form is what actually reaches the + // screen, and what the tests assert. + row := func(key, value string) string { return key + " " + value } + + turns := strconv.Itoa(m.agentOptions.MaxTurns) + effort := m.effortDisplay() + verify := "lsp only" + if m.selfCorrectTests { + verify = "lsp + tests" } - 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 { - turnTarget = fmt.Sprintf("restores the turn budget to %d", target) + if m.execProfileName == execprofile.Name { + if was := m.execProfileDisplacedMaxTurns; was > 0 && was != m.agentOptions.MaxTurns { + turns += fmt.Sprintf(" · was %d", was) } - lines = append(lines, - "escalation: armed — one-shot on a tool-failure streak, a failing self-correct cycle, or a critical-risk mutation; "+turnTarget, - "note: the uncertain-completion signal is headless-only (the completion gate never runs interactively), so it cannot fire in the TUI") + turns += " · inherited by sub-agents" + switch m.effortTransition() { + case execprofile.EffortRaised: + effort += " · raised by the posture" + case execprofile.EffortNotSupported: + // NAME THE LEVEL. "could not raise it" states a failure without its + // object; the recorded unraised level is exactly what the user needs + // to know this model refused. + effort += " · NOT raised — " + string(m.execProfileEffortUnraised) + " is unsupported on this model" + } + // The verify row carries its TRANSITION, tracked from live state — a + // user who turns self-correct back off must not keep reading a raise + // the session no longer has. Worded without "raised" so the one-effort- + // transition invariant (TestBothStatusSurfacesShowResolvedState) counts + // the effort claim alone. + switch m.selfCorrectTransition() { + case execprofile.SelfCorrectRaised: + verify = "lsp → tests (posture)" + case execprofile.SelfCorrectAlreadyOn: + verify = "unchanged (tests) — already on" + case execprofile.SelfCorrectOverridden: + verify = "lsp only — your /selfcorrect off overrides the posture" + } + } + parts := []string{ + row("profile", name), + row("turns", turns), + row("effort", effort), + row("verify", verify), } + if m.agentOptions.Profile != nil && m.agentOptions.Profile.Escalate != nil { + target := "keeps the pinned turn budget" + if t := m.agentOptions.Profile.Escalate.MaxTurns; t > 0 { + target = fmt.Sprintf("→ %d turns", t) + } + // The headless-only clause is a real asymmetry, not decoration: the + // uncertain-completion trigger cannot fire interactively, and a user + // arming escalation in the TUI deserves to know one of its tripwires + // is not live here. + parts = append(parts, row("escalate", "armed · one-shot on a failure streak · "+target+" · uncertain-completion trigger is headless-only")) + } + // ONE LINE. Four key-value rows still made a five-line card for what is a + // single sentence of state; a status readout the user asked for by name + // needs no section header and no hint advertising the command they just + // typed. Every fact keeps its clause; the renderer wraps when narrow. return renderCommandOutput(commandOutput{ Title: "Profile", Status: commandStatusOK, Sections: []commandSection{{ - Title: "State", - Lines: lines, + Lines: []string{strings.Join(parts, " · ")}, }}, - Hints: []string{"/profile " + strings.Join(execprofile.Names(), "|") + " switches the next run's loop posture (turn budget, effort, self-correction, escalation); pick the model separately with /model"}, }) } @@ -1113,3 +1475,64 @@ 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. +// +// 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. +// +// 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/sidebar.go b/internal/tui/sidebar.go index 7eb1cd45b..a09c3a164 100644 --- a/internal/tui/sidebar.go +++ b/internal/tui/sidebar.go @@ -15,6 +15,7 @@ import ( "regexp" "strings" "time" + "unicode" "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" @@ -144,6 +145,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() } @@ -200,8 +211,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 } @@ -210,6 +224,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 @@ -226,10 +259,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 { - return sidebarHeader("AGENTS", width) + 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 m.postureHeader("AGENTS", width) } - return sidebarHeaderWithCount("AGENTS", fmt.Sprintf("%d", n), zeroTheme.muted, width) + return m.postureHeaderWithCount("AGENTS", count, zeroTheme.muted, width) } // swarmSpawnRe extracts a member id from a swarm_spawn tool result, whose text @@ -389,6 +441,14 @@ 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 + // 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 @@ -423,13 +483,14 @@ 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("✓") } - name := strings.TrimSpace(a.name) - if name == "" { - name = "agent" - } + // THE ASSIGNED JOB, not the generic specialist type. See specialistJobName. + name := specialistJobName(a.name, a.description) nameStyle := zeroTheme.ink // As a finished specialist nears the end of its linger, dim the whole row // toward faint so its removal reads as a fade-out rather than a pop. @@ -441,25 +502,44 @@ func (m model) sidebarAgentRows(width int) ([]string, []sidebarAgentHit) { icon = zeroTheme.faint.Render(glyph) nameStyle = zeroTheme.faint } + // 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}) + } lines = append(lines, " "+icon+" "+nameStyle.Render(truncateStep(name, room))) - if a.status != specialistRunning { - continue + // ALWAYS-VISIBLE SPEND: tokens then the model, per sub-agent, so the + // panel answers "what did each one cost, and on what" without a click. + // Order is deliberate — token consumption first, model after it. + if spend := specialistSpendLine(a); spend != "" { + lines = append(lines, " "+zeroTheme.faint.Render(truncateStep(spend, maxInt(2, room-2)))) } - // 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 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)))) + // 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)...) } } // Swarm/team members: a live member's whole task-name carries a mild, slow cool @@ -496,6 +576,131 @@ 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 + +// 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. +// +// 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") + } + // 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)) + } + 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))) + } + } + + // 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 +} + // 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 — @@ -506,9 +711,61 @@ func (m model) sidebarAgentSelectables(width int) []sidebarAgentHit { for i := range hits { hits[i].lineOffset++ // shift past the AGENTS header at sidebar index 0 } - return hits + // 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, + }) + } + 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 +// 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. @@ -565,9 +822,103 @@ func swarmPulseStyles() []lipgloss.Style { return out } +// specialistSpendLine is the always-visible per-agent cost: token consumption +// first, then the specific model it ran on. Empty when neither is known yet, so +// a just-spawned row shows only its name rather than a line of zeros. +// +// TOKENS BEFORE MODEL, deliberately: "how much did this cost" is the more +// wanted number, and the model is the qualifier after it. +func specialistSpendLine(info specialistInfo) string { + var parts []string + if info.tokenCount > 0 { + parts = append(parts, humanCount(info.tokenCount)+" tok") + } + if model := strings.TrimSpace(info.model); model != "" { + parts = append(parts, model) + } + return strings.Join(parts, " · ") +} + +// specialistJobName is the 1-2 word name a specialist row shows: the ASSIGNED +// JOB, not the generic specialist type. +// +// The row used to render info.name, which for a Task sub-agent is the specialist +// TYPE — so four workers all read "worker" and the panel could not tell them +// apart. The job lives in the description ("W1: HTML link extractor"), so this +// condenses THAT: strip a leading worker label like "W1:" or the "plan task " +// prefix, then take the first two significant words — "HTML link", "HTTP +// checker", "Concurrency pool". Falls back to the name when there is no +// description to condense, so a bare Task keeps its type. +func specialistJobName(name, description string) string { + job := stripSpecialistLabel(strings.TrimSpace(description)) + condensed := shortTaskName(job) + // A description that reads as a SENTENCE — "You are auditing package X" — is + // a prompt, not a label, and its first words are conversational. The agent's + // own name is the better identifier there, so prefer it. A short job label + // ("HTML link extractor") has no such opener and wins. + if condensed != "" && !startsWithConversationalWord(condensed) { + return condensed + } + if n := strings.TrimSpace(name); n != "" { + return n + } + return "agent" +} + +// startsWithConversationalWord reports whether a condensed name opens with a +// pronoun or filler that marks it as prose rather than a job label. +func startsWithConversationalWord(condensed string) bool { + fields := strings.Fields(condensed) + if len(fields) == 0 { + return false + } + switch strings.ToLower(strings.Trim(fields[0], ".,:;!?\"'`")) { + case "you", "your", "i", "i'll", "i'm", "we", "we'll", "it", "they", + "he", "she", "let", "let's", "here", "now", "please", "this", "the": + return true + default: + return false + } +} + +// stripSpecialistLabel removes a leading bookkeeping prefix that is not part of +// the job: a worker tag like "W1:" / "W12 -" and the "plan task " prefix that +// plan_runner stamps on every plan child. +func stripSpecialistLabel(description string) string { + trimmed := strings.TrimSpace(description) + // "plan task " is the prefix plan_runner stamps (internal/specialist); a + // literal here rather than an import, since one string does not justify one. + const planTaskPrefix = "plan task " + if len(trimmed) >= len(planTaskPrefix) && strings.EqualFold(trimmed[:len(planTaskPrefix)], planTaskPrefix) { + return strings.TrimSpace(trimmed[len(planTaskPrefix):]) + } + // A worker label: one or two letters, some digits, then a separator. + if m := workerLabelRe.FindString(trimmed); m != "" { + return strings.TrimSpace(trimmed[len(m):]) + } + return trimmed +} + +// workerLabelRe matches a leading "W1:", "W12 -", "S3 —" style tag. +var workerLabelRe = regexp.MustCompile(`^[A-Za-z]{1,2}[0-9]{1,3}\s*[:.)\-\x{2014}]\s*`) + // shortTaskName condenses a task briefing into a 1-2 word agent name: the first // significant word (usually the verb) plus the next non-filler word, so a member // reads as e.g. "Explore repository" instead of the full one-line briefing. +// EVERY SCRIPT, not just Latin. The ASCII ranges this used to test rejected +// every CJK, Cyrillic, Greek, Hebrew and Arabic token as "not a word", so a task +// or agent described in one of them had every token dropped and the sidebar row +// fell back to a generic label — a silent degradation for anyone not writing in +// English. unicode.IsLetter/IsDigit is the same question asked correctly. +func hasNameLetter(token string) bool { + for _, r := range token { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + return true + } + } + return false +} + func shortTaskName(task string) string { task = strings.TrimSpace(task) if task == "" { @@ -576,7 +927,10 @@ func shortTaskName(task string) string { picked := make([]string, 0, 2) for _, w := range strings.Fields(task) { clean := strings.Trim(w, ".,:;!?\"'`()[]{}") - if clean == "" { + // A token that is ALL punctuation ("+", "&", "->") is not a word: it left + // "CLI + report" reading as "CLI +". Skip it rather than spend a name + // slot on it. + if clean == "" || !hasNameLetter(clean) { continue } if len(picked) > 0 && nameFillerWords[strings.ToLower(clean)] { @@ -593,6 +947,19 @@ func shortTaskName(task string) string { return strings.Join(picked, " ") } +// hiddenAgentsPlaceholder is what the AGENTS section says when it has no lines +// to show, which is not always because nothing ran. +func hiddenAgentsPlaceholder(done int) string { + switch { + case done == 1: + return "1 finished — click to show" + case done > 1: + return fmt.Sprintf("%d finished — click to show", done) + default: + return "no agents spawned" + } +} + // nameFillerWords are skipped (after the first word) when condensing a task into // a short agent name, so "Review the current branch" → "Review current". var nameFillerWords = map[string]bool{ @@ -619,7 +986,14 @@ func (m model) renderContextSidebar(width, height int) []string { add(m.sidebarAgentHeader(width)) agentLines := m.sidebarAgentLines(width) if len(agentLines) == 0 { - add(sidebarPlaceholder("no agents spawned", width)) + // "no agents spawned" IS ONLY TRUE WHEN NONE WERE. sidebarSpecialists drops + // a finished agent once it is past its linger, while doneAgentCount counts + // exactly those — so a completed two-task plan rendered "AGENTS ▸2 done" + // above "no agents spawned", a header and a body contradicting each other + // about the same run. The count is the true one; the placeholder was + // reporting the emptiness of a filtered list as the emptiness of the + // session, and it names the toggle that brings them back. + add(sidebarPlaceholder(hiddenAgentsPlaceholder(m.doneAgentCount()), width)) } else { lines = append(lines, agentLines...) } @@ -628,10 +1002,14 @@ 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 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: lines = append(lines, planLines...) + default: + add(sidebarPlaceholder("no active plan", width)) } // FILES section: the files this session has touched (files_panel.go). @@ -647,15 +1025,39 @@ func (m model) renderContextSidebar(width, height int) []string { lines = append(lines, fileLines...) } + // MODELS section: the live mix of models the fleet runs on (models_panel.go). + // Absent until an agent runs on a known model, so a plain session's layout is + // untouched. Below FILES for the same click-offset reason: nothing hit-tested + // sits beneath it. + if modelLines := m.sidebarModelLines(width); len(modelLines) > 0 { + add("") + lines = append(lines, modelLines...) + } + // ACTIVITY section: recent completed work + a live "generating…" pulse. Shown // BELOW the plan steps so it never shifts sidebarPlanSelectables' click offsets, // and budgeted (height-1 minus what's used) so it clips ITSELF from the bottom // rather than letting the end-truncation eat into the plan. Absent when empty. if activityLines := m.sidebarActivityLines(width, maxInt(0, height-1-len(lines))); len(activityLines) > 0 { add("") - add(sidebarHeader("ACTIVITY", width)) + add(m.postureHeader("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 + // 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) @@ -717,6 +1119,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 } @@ -733,7 +1145,12 @@ func sidebarHeader(label string, _ int) string { // count (e.g. "PLAN 2/5") rendered in countStyle, so a section can colour its // count by state — accent while in-flight, green when complete. func sidebarHeaderWithCount(label, count string, countStyle lipgloss.Style, width int) string { - left := zeroTheme.muted.Bold(true).Render(strings.ToUpper(label)) + return sidebarHeaderAlign(zeroTheme.muted.Bold(true).Render(strings.ToUpper(label)), count, countStyle, width) +} + +// sidebarHeaderAlign right-aligns a rendered count beside an already-rendered +// label — the shared core of the plain and posture-painted header variants. +func sidebarHeaderAlign(left, count string, countStyle lipgloss.Style, width int) string { right := countStyle.Render(count) gap := width - lipgloss.Width(left) - lipgloss.Width(right) if gap < 1 { @@ -751,7 +1168,18 @@ func sidebarPlaceholder(text string, width int) string { func (m model) sidebarPlanHeader(width int) string { state := m.plan if state.isEmpty() { - return sidebarHeader("PLAN", width) + 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 m.postureHeaderWithCount("PLAN", + fmt.Sprintf("%d/%d", done, len(orchestrate.tasks)), style, width) + } + return m.postureHeader("PLAN", width) } total := len(state.steps) done := 0 @@ -765,20 +1193,45 @@ func (m model) sidebarPlanHeader(width int) string { if done == total { countStyle = zeroTheme.green } - return sidebarHeaderWithCount("PLAN", fmt.Sprintf("%d/%d", done, total), countStyle, width) + return m.postureHeaderWithCount("PLAN", fmt.Sprintf("%d/%d", done, total), countStyle, width) } // sidebarPlanLines renders the plan step list for the sidebar using the same // 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() { return nil } room := maxInt(4, width-3) - lines := make([]string, 0, len(state.steps)) + lines := make([]string, 0, len(state.steps)+1) + // The zeromaxing bar, when the posture wears one — INSIDE this list, so + // everything measured off this renderer (sidebarOrchestrateSelectables' + // prefix math) stays correct without a hand-written correction term. + // sidebarPlanSelectables carries the one remaining correction, beside its + // own base formula. + if bar := m.todoPlanBar(width); bar != "" { + lines = append(lines, bar) + } for _, step := range state.steps { var icon, body string switch step.status { @@ -800,6 +1253,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 @@ -932,16 +1425,16 @@ func (m model) sidebarTokenText() string { // divider cell between them, into total-width rows. Both blocks are normalized // to their column widths and to the same row count first, so every joined row // is exactly chatWidth + 1 + sidebarWidth cells and the columns stay aligned. -func joinColumns(chat []string, sidebar []string, chatW, sidebarW int) []string { + +// joinColumnsWith is joinColumns with a caller-supplied divider painter, so a +// skin can colour the rule per row (the zeromaxing rail) without this function +// knowing about postures. The painter receives (row, rows) and must return the +// same three cells every plain divider renders. +func joinColumnsWith(chat []string, sidebar []string, chatW, sidebarW int, divider func(row, rows int) string) []string { rows := len(chat) if len(sidebar) > rows { rows = len(sidebar) } - // A cell of air on each side of the rule (" │ ") so the columns don't butt - // flush against it. The chat side gets its gutter from the leading space; the - // sidebar side from the trailing space (plus items' own leading inset, which - // nests them under the flush section headers). Budgeted by chatColumnWidth(-3). - divider := " " + zeroTheme.line.Render("│") + " " out := make([]string, rows) for i := 0; i < rows; i++ { left := "" @@ -954,7 +1447,7 @@ func joinColumns(chat []string, sidebar []string, chatW, sidebarW int) []string } left = padStyledLine(left, chatW) right = padStyledLine(right, sidebarW) - out[i] = left + divider + right + out[i] = left + divider(i, rows) + right } return out } diff --git a/internal/tui/sidebar_done_agents_test.go b/internal/tui/sidebar_done_agents_test.go new file mode 100644 index 000000000..0f52a3fca --- /dev/null +++ b/internal/tui/sidebar_done_agents_test.go @@ -0,0 +1,89 @@ +package tui + +import ( + "strings" + "testing" + "time" +) + +// THE HEADER AND THE BODY MUST NOT CONTRADICT EACH OTHER ABOUT THE SAME RUN. +// +// sidebarSpecialists drops a finished agent once it is past its linger, so the +// AGENTS section has no lines to render. doneAgentCount counts exactly those +// agents, so the header says "▸2 done". A completed two-task plan therefore drew +// +// AGENTS ▸2 done +// no agents spawned +// +// which is the header reporting the session and the placeholder reporting the +// emptiness of a filtered list, in the same box. The count is the true one: two +// agents ran. The placeholder was the sentence that had to change, and it names +// the toggle that brings them back rather than merely going quiet. +func allFinishedAgentsModel(t *testing.T) model { + t.Helper() + m := sidebarTestModel() + start := time.Unix(1000, 0) + m.specialists.start("by_name", "find definitions", "sess-1", start) + m.specialists.start("by_use", "find call sites", "sess-2", start) + m.specialists.complete("sess-1", specialistCompleted, 0, "", start) + m.specialists.complete("sess-2", specialistCompleted, 0, "", start) + // Past the linger, which is what drops them from the list while leaving them + // in the count. + m.now = func() time.Time { return start.Add(10 * sidebarAgentLinger) } + return m +} + +func TestAFinishedPlanDoesNotClaimNoAgentsWereSpawned(t *testing.T) { + m := allFinishedAgentsModel(t) + + if done := m.doneAgentCount(); done != 2 { + t.Fatalf("setup: expected 2 finished agents, got %d", done) + } + width := sidebarWidth(m.width) + if len(m.sidebarAgentLines(width)) != 0 { + t.Fatal("setup: the agents are still listed, so the contradiction cannot arise") + } + + rendered := strings.Join(m.renderContextSidebar(width, 24), "\n") + header := ansiStripLine(m.sidebarAgentHeader(width)) + if !strings.Contains(header, "2 done") { + t.Fatalf("setup: the header no longer reports the count: %q", header) + } + if strings.Contains(ansiStripLine(rendered), "no agents spawned") { + t.Fatalf("the header says %q while the body says no agents were spawned:\n%s", + strings.TrimSpace(header), ansiStripLine(rendered)) + } + // And it must say something USEFUL — the count, and how to see them. + plain := ansiStripLine(rendered) + if !strings.Contains(plain, "2 finished") { + t.Fatalf("the placeholder does not report what actually ran:\n%s", plain) + } + if !strings.Contains(plain, "click to show") { + t.Fatalf("the placeholder does not name the toggle that reveals them:\n%s", plain) + } +} + +// The original sentence is still correct when it is true, and that is the whole +// distinction: an empty section means "none ran" only when none did. +func TestASessionThatSpawnedNothingStillSaysSo(t *testing.T) { + m := sidebarTestModel() + m.now = func() time.Time { return time.Unix(1000, 0) } + + if m.doneAgentCount() != 0 { + t.Fatal("setup: this session has finished agents") + } + plain := ansiStripLine(strings.Join(m.renderContextSidebar(sidebarWidth(m.width), 24), "\n")) + if !strings.Contains(plain, "no agents spawned") { + t.Fatalf("a session that spawned nothing must say so:\n%s", plain) + } +} + +// Singular reads as English rather than as "1 finished agents". +func TestOneFinishedAgentIsReportedInTheSingular(t *testing.T) { + if got := hiddenAgentsPlaceholder(1); !strings.HasPrefix(got, "1 finished") { + t.Fatalf("one finished agent renders as %q", got) + } + if got := hiddenAgentsPlaceholder(0); got != "no agents spawned" { + t.Fatalf("none renders as %q", got) + } +} diff --git a/internal/tui/sidebar_plan_detail.go b/internal/tui/sidebar_plan_detail.go new file mode 100644 index 000000000..29700f8e6 --- /dev/null +++ b/internal/tui/sidebar_plan_detail.go @@ -0,0 +1,302 @@ +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() { + return nil + } + agentBody := len(m.sidebarAgentLines(width)) + if agentBody == 0 { + agentBody = 1 + } + // 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 { + if lineOffset := base + offset; m.sidebarRowOnScreen(lineOffset) { + hits = append(hits, orchestrateTaskHit{lineOffset: lineOffset, 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 + } + // 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 { + 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() { + 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 + } + 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{ + "", + m.postureHeaderWithCount("TASK", task.id, orchestrateStatusStyle(task.status), width), + } + + head := task.status.label() + 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))) + + // 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) + 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.orchestrate.isEmpty() || m.orchestrate.sidebarCollapsed { + return "" + } + return m.posturePlanProgressBar(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..536af3d66 --- /dev/null +++ b/internal/tui/sidebar_plan_detail_test.go @@ -0,0 +1,281 @@ +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, 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") + 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, 1, 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) + } +} + +// 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/sidebar_plan_test.go b/internal/tui/sidebar_plan_test.go new file mode 100644 index 000000000..288d18e99 --- /dev/null +++ b/internal/tui/sidebar_plan_test.go @@ -0,0 +1,160 @@ +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, 1, now) + m.orchestrate.markStarted("b", "left", "k2", "", now) + m.orchestrate.markDone("b", "failed", 200, 1, 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") + } +} + +// 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 := 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 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) + } +} + +// 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()) + + // 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+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") { + 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, 1, 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)) + } +} diff --git a/internal/tui/sidebar_test.go b/internal/tui/sidebar_test.go index 2c0d15cc7..29ca84213 100644 --- a/internal/tui/sidebar_test.go +++ b/internal/tui/sidebar_test.go @@ -2,6 +2,8 @@ package tui import ( "context" + "fmt" + "path" "strings" "testing" "time" @@ -334,11 +336,13 @@ func TestSidebarShowsSpawnedAgents(t *testing.T) { width := sidebarWidth(m.width) plain := stripSidebar(m.sidebarAgentLines(width)) - if !strings.Contains(plain, "explorer") { - t.Fatalf("running subagent name missing:\n%s", plain) + // The row shows the assigned JOB (condensed description), not the specialist + // type — see specialistJobName. "map the codebase" -> "map codebase". + if !strings.Contains(plain, "map codebase") { + t.Fatalf("running subagent job name missing:\n%s", plain) } - if !strings.Contains(plain, "reviewer") { - t.Fatalf("completed subagent name missing:\n%s", plain) + if !strings.Contains(plain, "review diff") { + t.Fatalf("completed subagent job name missing:\n%s", plain) } // The running subagent surfaces its live working detail (current tool). if !strings.Contains(plain, "grep") { @@ -568,10 +572,12 @@ func TestSidebarHidesNotFoundSpecialistMisroutes(t *testing.T) { t.Fatalf("not-found misroute should be filtered; want only worker, got %+v", got) } plain := stripSidebar(m.sidebarAgentLines(sidebarWidth(m.width))) - if strings.Contains(plain, "swarm_send") { + if strings.Contains(plain, "coordinate") { t.Fatalf("bogus swarm_send specialist should not appear:\n%s", plain) } - if !strings.Contains(plain, "worker") { + // The row shows the job ("build frontend"), not the specialist type; the + // data-level name assertion above still pins the filtering. + if !strings.Contains(plain, "build frontend") { t.Fatalf("real worker specialist should still appear:\n%s", plain) } } @@ -686,3 +692,468 @@ 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) + } + }) + } +} + +// "" 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) + } +} + +// 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")) + // The two finished agents have sentence descriptions ("You are auditing + // package X"), so they fall back to their names; the running one has a label + // description ("producing the report") and shows the job. + for _, want := range []string{"a-reltime", "a-fsutil", "producing 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) + } + } +} + +// 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) + } + } +} + +// 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 2bd7160fd..564050ce6 100644 --- a/internal/tui/specialist_card.go +++ b/internal/tui/specialist_card.go @@ -9,6 +9,7 @@ package tui import ( "fmt" + "github.com/Gitlawb/zero/internal/config" "strconv" "strings" "time" @@ -25,6 +26,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. @@ -41,6 +48,18 @@ 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 + // background marks a child that OUTLIVES the run that launched it: a + // background Task spawn, or a task of a background plan. It is the one + // distinction cancelRun needs — a foreground child dies with the run + // context and must be settled, a background one keeps working and must not + // be reported as cancelled. + background bool + // 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 @@ -88,8 +107,122 @@ func (t *specialistTracker) complete(childSessionID string, status specialistSta } } +// markBackground records that a child outlives the run that launched it. +// +// Called from the two places that can know: a background plan's task-start +// message carries the flag, and a specialistRebindMsg is only ever emitted for +// a background Task spawn (backgroundSpawnRebind refuses anything else). +func (t *specialistTracker) markBackground(childSessionID string) { + for index := range t.specialists { + if t.specialists[index].childSessionID == childSessionID { + t.specialists[index].background = true + return + } + } +} + +// cancelRunning marks every still-running FOREGROUND specialist cancelled. +// +// Called when the USER cancels the run: those children die with the run +// context, so a row left specialistRunning would keep its spinner, its ticking +// clock and its "live" mark in MODELS over a process that no longer exists. The +// current-tool line is cleared for the same reason — nothing is running it. +// +// BACKGROUND CHILDREN ARE SKIPPED INDIVIDUALLY, not by refusing to settle at +// all. The first version of this guard wrapped the whole call in +// BackgroundPlanLive, and this tracker holds foreground and background children +// TOGETHER — so one live background plan left every foreground sub-agent +// spinning forever, and nothing else settles them: a late completion is dropped +// by the stale-run guard once cancelRun has zeroed activeRunID. That is the +// exact defect TestCancelSettlesEveryRunningAgentAndTask exists to prevent, +// reintroduced by the fix for its opposite. +func (t *specialistTracker) cancelRunning(now time.Time) { + for index := range t.specialists { + if t.specialists[index].background { + continue + } + if t.specialists[index].status == specialistRunning { + t.specialists[index].status = specialistCancelled + t.specialists[index].completedAt = now + t.specialists[index].currentTool = "" + t.specialists[index].currentDetail = "" + } + } +} + // 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. +// addTokens ADDS to a child's running total, for live per-turn usage events. +// Unknown children are ignored, like every other setter here. +func (t *specialistTracker) addTokens(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 + } + } +} + +// setToolCount sets a child's tool-call count to an absolute value, for a +// background child whose count arrives whole from a TaskOutput poll rather than +// one increment at a time. Never lowers it: a late poll must not undo a higher +// live count. Unknown children ignored. +func (t *specialistTracker) setToolCount(childSessionID string, count int) { + for index := range t.specialists { + if t.specialists[index].childSessionID == childSessionID { + if count > t.specialists[index].toolCount { + t.specialists[index].toolCount = count + } + return + } + } +} + +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 + } + } +} + +// 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) == "" { + 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 { @@ -159,7 +292,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" } } @@ -250,12 +390,22 @@ func (m model) renderSpecialistCard(info specialistInfo, width int) string { // Elapsed: live while running, frozen at completion once the specialist is // done. + // A ZERO START MEANS UNKNOWN, NOT THE YEAR 1. + // + // Subtracting the zero time overflows int64 nanoseconds, and Go clamps to + // its largest Duration — which rendered as "153722867m16s" on every card in + // a resumed session, because the restore path rebuilt rows without a + // timestamp. sidebar.go has always guarded this; the card did not, so the + // same data read correctly in one panel and absurdly in the other. var elapsed time.Duration - if info.status == specialistRunning { + switch { + case info.startedAt.IsZero(): + elapsed = 0 + case info.status == specialistRunning: elapsed = m.now().Sub(info.startedAt) - } else if !info.completedAt.IsZero() { + case !info.completedAt.IsZero(): elapsed = info.completedAt.Sub(info.startedAt) - } else { + default: elapsed = m.now().Sub(info.startedAt) } elapsedStr := formatSpecialistElapsed(elapsed) @@ -286,7 +436,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 @@ -472,7 +626,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 @@ -480,3 +639,13 @@ func renderSpecialistSummary(specialists []specialistInfo, spinnerView string) s tailStart := 2 + len(spinnerView) return zeroTheme.accent.Render(spinnerView) + zeroTheme.muted.Render(summary[tailStart:]) } + +// persistKeepFinishedAgents writes the finished-agents preference to user +// config, mirroring persistRecapsEnabled: a UI toggle that survives restart. +func (m model) persistKeepFinishedAgents() error { + if strings.TrimSpace(m.userConfigPath) == "" { + return nil + } + _, err := config.SetKeepFinishedAgents(m.userConfigPath, m.showDoneAgents) + return err +} diff --git a/internal/tui/specialist_job_name_test.go b/internal/tui/specialist_job_name_test.go new file mode 100644 index 000000000..bb360ee76 --- /dev/null +++ b/internal/tui/specialist_job_name_test.go @@ -0,0 +1,89 @@ +package tui + +import "testing" + +// A SPECIALIST ROW SHOWS THE ASSIGNED JOB, not the generic specialist type. +// +// Four workers all read "worker" in the panel because the row rendered +// info.name (the type). The job is in the description; this condenses it to +// 1-2 words, stripping the worker label and the "plan task " prefix. +func TestSpecialistJobNameIsTheJobNotTheType(t *testing.T) { + for _, tc := range []struct { + name, desc, want string + }{ + // The screenshot: four "worker" rows become four distinct jobs. + {"worker", "W1: HTML link extractor", "HTML link"}, + {"worker", "W2: HTTP checker", "HTTP checker"}, + {"worker", "W3: Concurrency pool", "Concurrency pool"}, + {"worker", "W4: CLI + report", "CLI report"}, + // Other label shapes. + {"worker", "S3 — auth boundary trace", "auth boundary"}, + {"worker", "W12 - fuzz the parser", "fuzz parser"}, + // A plan task keeps its own id after the prefix is stripped. + {"m4-checker", "plan task m4-checker", "m4-checker"}, + // A verb-first briefing condenses the usual way. + {"explorer", "Review the current branch", "Review current"}, + // No description: fall back to the type rather than "agent". + {"worker", "", "worker"}, + // A SENTENCE description is a prompt, not a label: fall back to the name. + {"a-reltime", "You are auditing package a-reltime", "a-reltime"}, + {"finder", "I will trace the retry watchdog", "finder"}, + // Nothing at all: a safe placeholder, never empty. + {"", "", "agent"}, + } { + t.Run(tc.desc+"/"+tc.name, func(t *testing.T) { + if got := specialistJobName(tc.name, tc.desc); got != tc.want { + t.Fatalf("specialistJobName(%q, %q) = %q, want %q", tc.name, tc.desc, got, tc.want) + } + }) + } +} + +// The label strip must not eat a real word that merely looks tag-like. +func TestTheLabelStripLeavesRealWordsAlone(t *testing.T) { + for _, tc := range []struct{ in, want string }{ + {"W1: HTML extractor", "HTML extractor"}, + // "IPv4" is a word, not a label — no separator follows a bare tag here. + {"IPv4 address parser", "IPv4 address parser"}, + // A long token is not a label. + {"Concurrency pool design", "Concurrency pool design"}, + {"plan task lint", "lint"}, + } { + if got := stripSpecialistLabel(tc.in); got != tc.want { + t.Errorf("stripSpecialistLabel(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +// THE RENDERED ROW shows the job — the screenshot's four "worker" rows become +// four distinct names. +func TestTheAgentsPanelRendersDistinctJobNames(t *testing.T) { + m := sidebarTestModel() + jobs := []string{"W1: HTML link extractor", "W2: HTTP checker", "W3: Concurrency pool", "W4: CLI + report"} + for i, desc := range jobs { + id := "call_" + string(rune('1'+i)) + m.specialists.start("worker", desc, id, m.now()) + } + rendered := stripSidebar(m.sidebarAgentLines(sidebarWidth(m.width))) + + // The generic type must not be what identifies a row. + if n := countOccurrences(rendered, "worker"); n > 0 { + t.Fatalf("the panel still shows the generic type %d time(s), not the job:\n%s", n, rendered) + } + for _, want := range []string{"HTML link", "HTTP checker", "Concurrency pool", "CLI report"} { + if !containsLine(rendered, want) { + t.Fatalf("the job %q is not shown:\n%s", want, rendered) + } + } +} + +func countOccurrences(s, sub string) int { + n := 0 + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + n++ + } + } + return n +} +func containsLine(s, sub string) bool { return countOccurrences(s, sub) > 0 } diff --git a/internal/tui/specialist_live_tokens_test.go b/internal/tui/specialist_live_tokens_test.go new file mode 100644 index 000000000..8adc33c2d --- /dev/null +++ b/internal/tui/specialist_live_tokens_test.go @@ -0,0 +1,77 @@ +package tui + +import ( + "testing" + + "github.com/Gitlawb/zero/internal/streamjson" +) + +// A SUB-AGENT'S TOKEN SPEND SHOWS WHILE IT WORKS, not only after. +// +// OnToolProgress bridged EventToolCall (tool count, current tool) but ignored +// EventUsage, so a Task sub-agent's card sat at "0 tok" for its whole life while +// a plan task's moved — the gap named in the tracker's own setTokens comment. +// Usage events are per-turn, so they add cumulatively. +func TestASubAgentsTokenSpendAccumulatesLive(t *testing.T) { + m := sidebarTestModel() + m.activeRunID = 7 + updated, _ := m.Update(specialistStartMsg{ + runID: 7, name: "worker", description: "W1", childSessionID: "call_1", model: "glm-5.2", + }) + m = updated.(model) + + for _, turn := range []int{12000, 8000, 5000} { + updated, _ = m.Update(specialistUsageMsg{runID: 7, toolCallID: "call_1", totalTokens: turn}) + m = updated.(model) + } + + info, _ := m.specialists.getBySessionID("call_1") + if info.tokenCount != 25000 { + t.Fatalf("live tokens = %d, want 25000 (12k+8k+5k accumulated)", info.tokenCount) + } +} + +// addTokens ignores unknown children and non-positive deltas, like every setter. +func TestAddTokensIsSafeAndAdditive(t *testing.T) { + m := sidebarTestModel() + m.specialists.start("w", "d", "c1", m.now()) + + m.specialists.addTokens("ghost", 999) // unknown child + m.specialists.addTokens("c1", 0) // no-op + m.specialists.addTokens("c1", -5) // no-op + m.specialists.addTokens("c1", 100) + m.specialists.addTokens("c1", 50) + + info, _ := m.specialists.getBySessionID("c1") + if info.tokenCount != 150 { + t.Fatalf("tokenCount = %d, want 150", info.tokenCount) + } + if _, ok := m.specialists.getBySessionID("ghost"); ok { + t.Fatal("addTokens invented a child") + } +} + +// THE USAGE-BRIDGE DECISION, pinned — the emission lives in the untestable +// agent loop, so a mutation there passes a test that drives the message by hand. +func TestOnlyAUsageEventWithTokensBridges(t *testing.T) { + n := 9000 + zero := 0 + for _, tc := range []struct { + name string + event streamjson.Event + want int + ok bool + }{ + {"usage with tokens", streamjson.Event{Type: streamjson.EventUsage, TotalTokens: &n}, 9000, true}, + {"usage, zero tokens", streamjson.Event{Type: streamjson.EventUsage, TotalTokens: &zero}, 0, false}, + {"usage, nil tokens", streamjson.Event{Type: streamjson.EventUsage}, 0, false}, + {"a tool call is not usage", streamjson.Event{Type: streamjson.EventToolCall, Name: "grep"}, 0, false}, + } { + t.Run(tc.name, func(t *testing.T) { + got, ok := specialistProgressTokens(tc.event) + if ok != tc.ok || got != tc.want { + t.Fatalf("specialistProgressTokens = (%d, %v), want (%d, %v)", got, ok, tc.want, tc.ok) + } + }) + } +} diff --git a/internal/tui/specialist_model_test.go b/internal/tui/specialist_model_test.go new file mode 100644 index 000000000..2bb70b7ac --- /dev/null +++ b/internal/tui/specialist_model_test.go @@ -0,0 +1,70 @@ +package tui + +import "testing" + +// A DELEGATED SUB-AGENT MUST NAME THE MODEL IT RUNS ON. +// +// The AGENTS sidebar already renders "on " (sidebar.go), and it showed +// nothing for a Task sub-agent for its whole life — setModel was reached from +// the PLAN path alone, so a delegated agent was drawn as an anonymous "worker" +// while a plan task beside it named its model. +// +// Reproduced from the logs: every specialist_start the parent recorded carried +// model=(absent), while the child's own session metadata knew it was glm-5.2. +func TestADelegatedAgentNamesItsModelWhileItRuns(t *testing.T) { + // THROUGH THE REAL HANDLER, not by calling setModel directly: an earlier + // version did the latter and a mutation deleting the handler's setModel + // call passed it cleanly, because nothing proved the message was consulted. + m := sidebarTestModel() + m.activeRunID = 7 + updated, _ := m.Update(specialistStartMsg{ + runID: 7, + name: "worker", + description: "W1: HTML link extractor", + childSessionID: "call_1", + model: "glm-5.2", + }) + live := updated.(model) + + info, ok := live.specialists.getBySessionID("call_1") + if !ok { + t.Fatal("the agent was not tracked") + } + if info.model != "glm-5.2" { + t.Fatalf("a running sub-agent names model %q; the sidebar renders \"on \" and has nothing to show", info.model) + } +} + +// THE RESULT IS AUTHORITATIVE. A specialist whose manifest names its own model +// did not run on the session's, and the row seeded at start would keep naming +// the wrong one. +func TestTheResultsModelCorrectsTheSeededOne(t *testing.T) { + m := sidebarTestModel() + m.specialists.start("code-review", "audit", "call_1", m.now()) + m.specialists.setModel("call_1", "glm-5.2") // seeded from the session + + // The executor reports what the child actually used. + m.specialists.setModel("call_1", "kimi-k2.6") + + info, _ := m.specialists.getBySessionID("call_1") + if info.model != "kimi-k2.6" { + t.Fatalf("the row still names %q after the executor reported kimi-k2.6", info.model) + } +} + +// An empty report must not blank a model already shown. +func TestAnEmptyModelReportDoesNotBlankTheRow(t *testing.T) { + m := sidebarTestModel() + m.specialists.start("worker", "w", "call_1", m.now()) + m.specialists.setModel("call_1", "glm-5.2") + + // This is the guard in the complete handler: only a non-empty model is set. + reported := "" + if reported != "" { + m.specialists.setModel("call_1", reported) + } + info, _ := m.specialists.getBySessionID("call_1") + if info.model != "glm-5.2" { + t.Fatalf("an empty report blanked the row: %q", info.model) + } +} diff --git a/internal/tui/specialist_restore_test.go b/internal/tui/specialist_restore_test.go new file mode 100644 index 000000000..2ecea4822 --- /dev/null +++ b/internal/tui/specialist_restore_test.go @@ -0,0 +1,67 @@ +package tui + +import ( + "strings" + "testing" + "time" + + "github.com/Gitlawb/zero/internal/sessions" +) + +// A RESTORED AGENT CARD SHOWED 153722867m16s. +// +// That is exactly math.MaxInt64 nanoseconds — Go's largest time.Duration — +// produced by subtracting the ZERO time. specialistInfoFromPayload rebuilt rows +// on resume without a timestamp, and the card computed m.now().Sub(startedAt) +// with no zero guard. Every agent in a resumed session rendered ~292 years, +// beside "0 tool calls", which reads as sub-agents that did nothing at all. +func TestARestoredAgentCardDoesNotRenderTheMaxDuration(t *testing.T) { + const maxDuration = time.Duration(1<<63 - 1) + + // The shape that produced it: a row rebuilt with no start time. + unset := specialistInfo{name: "worker", description: "W1", childSessionID: "c1", status: specialistCompleted} + if got := time.Since(unset.startedAt); got != maxDuration { + t.Fatalf("setup: a zero start no longer overflows (%v); this test guards the wrong thing", got) + } + + m := sidebarTestModel() + card := m.renderSpecialistCard(unset, 80) + if strings.Contains(card, "153722867m") { + t.Fatalf("the card still renders the clamped max duration:\n%s", card) + } + // 292 years in any spelling is wrong; nothing near it may appear. + for _, absurd := range []string{"2562047h", "153722867"} { + if strings.Contains(card, absurd) { + t.Fatalf("the card renders an overflowed elapsed (%s):\n%s", absurd, card) + } + } +} + +// AND THE TIMESTAMP MUST ACTUALLY BE RESTORED, not merely guarded away. The +// event carries CreatedAt; the restore path simply never read it. +func TestResumeRestoresAnAgentsStartAndEndTimes(t *testing.T) { + start := time.Date(2026, 8, 4, 14, 1, 14, 0, time.UTC) + info := specialistInfoFromPayload(map[string]any{ + "childSessionId": "c1", "specialist": "worker", "description": "W1: HTML link extractor", + }, start) + if info == nil { + t.Fatal("the payload produced no card") + } + if !info.startedAt.Equal(start) { + t.Fatalf("startedAt = %v, want the event's own time %v", info.startedAt, start) + } +} + +// A malformed or absent timestamp is UNKNOWN, and the guard then reports no +// elapsed rather than 292 years. +func TestAnUnparseableEventTimeIsZeroNotTheYearOne(t *testing.T) { + for _, raw := range []string{"", "not-a-time", "2026-08-04 14:01:14"} { + if got := sessionEventTime(sessions.Event{CreatedAt: raw}); !got.IsZero() { + t.Fatalf("CreatedAt %q parsed to %v, want zero", raw, got) + } + } + valid := sessionEventTime(sessions.Event{CreatedAt: "2026-08-04T14:01:14Z"}) + if valid.IsZero() { + t.Fatal("a valid RFC3339 timestamp was discarded") + } +} diff --git a/internal/tui/specialist_spend_line_test.go b/internal/tui/specialist_spend_line_test.go new file mode 100644 index 000000000..f1841474b --- /dev/null +++ b/internal/tui/specialist_spend_line_test.go @@ -0,0 +1,61 @@ +package tui + +import ( + "strings" + "testing" +) + +// EACH SUB-AGENT SHOWS ITS TOKEN CONSUMPTION, THEN ITS MODEL — always, without +// a click. Order is deliberate: tokens first, model after. +func TestSpecialistSpendLineIsTokensThenModel(t *testing.T) { + line := specialistSpendLine(specialistInfo{tokenCount: 284000, model: "glm-5.2"}) + if !strings.Contains(line, "284K") || !strings.Contains(line, "glm-5.2") { + t.Fatalf("spend line missing tokens or model: %q", line) + } + // Tokens must come BEFORE the model. + if strings.Index(line, "284K") > strings.Index(line, "glm-5.2") { + t.Fatalf("model appears before tokens: %q", line) + } +} + +// Each piece is optional: a row with only one of them shows just that; a fresh +// row shows nothing rather than a line of zeros. +func TestSpecialistSpendLineOmitsWhatItLacks(t *testing.T) { + if got := specialistSpendLine(specialistInfo{model: "glm-5.2"}); got != "glm-5.2" { + t.Fatalf("tokens-absent line = %q, want just the model", got) + } + if got := specialistSpendLine(specialistInfo{tokenCount: 1000}); got != "1K tok" { + t.Fatalf("model-absent line = %q, want just the tokens", got) + } + if got := specialistSpendLine(specialistInfo{}); got != "" { + t.Fatalf("a fresh row produced %q, want empty", got) + } +} + +// THE ROW RENDERS IT. Four workers each show a distinct model and token count in +// the always-visible sidebar, not only when expanded. +func TestTheRowShowsPerAgentTokensAndModel(t *testing.T) { + m := sidebarTestModel() + workers := []struct { + id, job, model string + tokens int + }{ + {"c1", "W1: HTML link extractor", "deepseek-v4-flash", 284000}, + {"c2", "W2: HTTP checker", "kimi-k2.6", 1355600}, + } + for _, w := range workers { + m.specialists.start("worker", w.job, w.id, m.now()) + m.specialists.setModel(w.id, w.model) + m.specialists.setTokens(w.id, w.tokens) + } + rendered := stripSidebar(m.sidebarAgentLines(sidebarWidth(m.width))) + // The token counts ALWAYS show — they come first, so the narrow column never + // truncates them. A short model name shows in full; a long one shows its + // start (the whole name is in the click-to-expand), since tokens win the + // space by design. + for _, want := range []string{"284K", "kimi-k2.6", "1.4M", "deepseek"} { + if !strings.Contains(rendered, want) { + t.Fatalf("the always-visible row does not show %q:\n%s", want, rendered) + } + } +} diff --git a/internal/tui/spinner_tick_order_test.go b/internal/tui/spinner_tick_order_test.go new file mode 100644 index 000000000..004e03ae0 --- /dev/null +++ b/internal/tui/spinner_tick_order_test.go @@ -0,0 +1,93 @@ +package tui + +import ( + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "strings" + "testing" +) + +// pointerReceiverMutators are methods on model that MUTATE through a pointer +// receiver and are called for their effect on the model as much as for their +// return value. Add one here when you write it. +var pointerReceiverMutators = map[string]bool{ + "ensureSpinnerTick": true, +} + +// A RETURN MUST NOT BOTH RETURN A MODEL AND MUTATE IT IN THE SAME STATEMENT. +// +// `return m, m.ensureSpinnerTick()` reads as though the call runs first. Go does +// not promise that: the spec orders function calls among THEMSELVES left to +// right, but leaves the order of a plain operand relative to a call operand +// unspecified. If m is copied first, the returned model carries the old +// spinnerTicking and the flag stops suppressing anything — every later hover +// issues another spinner.Tick, which is the precise double-issue the flag was +// added to prevent. It went unnoticed because the compiler happens to pick the +// helpful order today, at five separate call sites. +// +// The fix at every site is one line: call, assign, then return. +func TestNoReturnMutatesTheModelItAlsoReturns(t *testing.T) { + sources, err := filepath.Glob("*.go") + if err != nil { + t.Fatalf("list the tui sources: %v", err) + } + fileSet := token.NewFileSet() + + for _, path := range sources { + if strings.HasSuffix(path, "_test.go") { + continue + } + file, err := parser.ParseFile(fileSet, path, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", path, err) + } + ast.Inspect(file, func(node ast.Node) bool { + ret, ok := node.(*ast.ReturnStmt) + if !ok || len(ret.Results) < 2 { + return true + } + for i, result := range ret.Results { + returned, ok := result.(*ast.Ident) + if !ok { + continue + } + for j, other := range ret.Results { + if i == j { + continue + } + if method := mutatingCallOn(other, returned.Name); method != "" { + t.Errorf("%s:%d: `return %s, ... %s.%s() ...` mutates %s through a pointer receiver in the same statement that returns it, and Go does not specify which happens first. Assign the call to a local, then return.", + filepath.Base(path), fileSet.Position(ret.Pos()).Line, + returned.Name, returned.Name, method, returned.Name) + } + } + } + return true + }) + } +} + +// mutatingCallOn reports the method name when expr contains a call to one of the +// pointer-receiver mutators on the variable named receiver, at any depth — the +// call is often wrapped, as in tea.Batch(cmd, m.ensureSpinnerTick()). +func mutatingCallOn(expr ast.Expr, receiver string) string { + found := "" + ast.Inspect(expr, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + selector, ok := call.Fun.(*ast.SelectorExpr) + if !ok || !pointerReceiverMutators[selector.Sel.Name] { + return true + } + if ident, ok := selector.X.(*ast.Ident); ok && ident.Name == receiver { + found = selector.Sel.Name + return false + } + return true + }) + return found +} diff --git a/internal/tui/theme.go b/internal/tui/theme.go index 9e708c3b3..5b9c208b3 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -67,7 +67,15 @@ 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 + // spectrum is a hue ramp drawn from THIS palette, for the posture chip's + // 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) @@ -179,7 +187,10 @@ 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), + 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/theme_palettes.go b/internal/tui/theme_palettes.go index 054dfdd13..95c75f520 100644 --- a/internal/tui/theme_palettes.go +++ b/internal/tui/theme_palettes.go @@ -49,17 +49,33 @@ var darkPalette = palette{ // draculaPalette — the Dracula scheme (dracula.com): muted-violet surface, purple // accent, high-chroma pink/green/cyan signals. var draculaPalette = palette{ - panel: "#282a36", - promptBg: "#383c4d", - line: "#363a4b", - line2: "#484c62", - ink: "#f8f8f2", - muted: "#b9bccb", - faint: "#a2a5b8", - faintest: "#9195ac", - accent: "#bd93f9", - green: "#50fa7b", - red: "#ff5555", + panel: "#282a36", + promptBg: "#383c4d", + line: "#363a4b", + line2: "#484c62", + ink: "#f8f8f2", + muted: "#b9bccb", + faint: "#a2a5b8", + faintest: "#9195ac", + accent: "#bd93f9", + // green/red HOLD DRACULA'S OWN CYAN/PINK, not green and red. The stock + // #50fa7b / #ff5555 are the highest-chroma signals in the registry, and in + // a zeromaxing session — success ticks and error rows arriving constantly — + // they read as alarms, not status. The token NAMES stay green/red because + // every consumer binds to them as "success" and "failure"; on this palette + // those roles are carried by the scheme's signature cyan (✓, success) and + // pink (✗, errors), which sit naturally beside the purple accent and stay + // clear of amber's permission/warning meaning. Diff colours (gitAdd/addBg…) + // keep their conventional green/red — a diff is a diff on every theme. + // Contrast stays above the registry's asserted floors. + // + // SUCCESS IS A TEAL, NOT THE CYAN ITSELF. The first version set green to + // #8be9fd — byte-identical to this palette's blue — which collapsed two of + // the four series colours modelMixPalette cycles through, so two models in + // the MODELS bar could render indistinguishably. This sits in the same cool + // family without colliding with blue, amber or pink. + green: "#5ad1b0", + red: "#ff79c6", amber: "#ffb86c", blue: "#8be9fd", gitAdd: "#77c58c", diff --git a/internal/tui/transcript_selection.go b/internal/tui/transcript_selection.go index 0953a7aef..336dd4bca 100644 --- a/internal/tui/transcript_selection.go +++ b/internal/tui/transcript_selection.go @@ -1322,6 +1322,28 @@ 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 + // PERSISTED, so a click becomes the standing preference — the same + // way /recaps persists. A user who wants finished agents to stay + // clicks once and they stay every session, not just this one. + if err := m.persistKeepFinishedAgents(); err != nil { + m = m.appendPlansNotice(planControlNotice("warning", "Could not save the finished-agents preference: "+err.Error())) + } + 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 + // 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() diff --git a/internal/tui/view.go b/internal/tui/view.go index 44c9e3df1..4808211cd 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -173,13 +173,15 @@ func (m model) composerDividerLine(width int) string { meta := zeroTheme.muted.Render(model) metaWidth := lipgloss.Width(meta) if width < 8 { - return zeroTheme.lineStrong.Render(strings.Repeat("─", width)) + return m.postureBoxRule(strings.Repeat("─", width), 0, width) } if width < metaWidth+4 { - return zeroTheme.lineStrong.Render("╰" + strings.Repeat("─", width-2) + "╯") + return m.postureBoxRule("╰"+strings.Repeat("─", width-2)+"╯", 0, width) } + // The rule is painted in two column-accurate segments around the model + // label, so the gradient continues through the gap rather than restarting. rule := strings.Repeat("─", width-metaWidth-4) - return zeroTheme.lineStrong.Render("╰"+rule+" ") + meta + zeroTheme.lineStrong.Render(" ╯") + return m.postureBoxRule("╰"+rule+" ", 0, width) + meta + m.postureBoxRule(" ╯", width-2, width) } // statusLine renders the bottom readout as ` │ `-separated groups: the run-state @@ -221,6 +223,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 chip := m.zeromaxingGlowChip(); chip != "" { + left += zeroTheme.muted.Render(" · ") + chip + } if m.exitConfirmActive { left = prefix + btwChip + zeroTheme.amber.Render("●") + " " + zeroTheme.amber.Render(ctrlCExitConfirmText) } else if m.cancelConfirmActive { diff --git a/internal/tui/workers_view.go b/internal/tui/workers_view.go new file mode 100644 index 000000000..fca30a1e2 --- /dev/null +++ b/internal/tui/workers_view.go @@ -0,0 +1,122 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + "github.com/Gitlawb/zero/internal/specialist" +) + +// /workers — what this session has set going. +// +// READ FROM THE EVENT LOG, not from the sidebar's tracker. The tracker holds +// what is on screen: it drops finished agents past their linger and it starts +// empty on a resumed session. The event log is what actually happened, so a +// session resumed after a crash still answers "what did I start, and what did it +// cost". specialist.ReduceWorkers does the fold; this only renders it. + +func (m model) workersText() string { + if m.sessionStore == nil || m.activeSession.SessionID == "" { + return planControlNotice("warning", + "This session has no event log, so there is no record of what it started.") + } + events, err := m.sessionStore.ReadEvents(m.activeSession.SessionID) + if err != nil { + return planControlNotice("warning", "Could not read this session's events: "+err.Error()) + } + summary := specialist.ReduceWorkers(events) + if summary.Started == 0 { + return planControlNotice("info", + "This session has started no sub-agents. Delegated work and plan tasks both appear here once they run.") + } + + var b strings.Builder + fmt.Fprintf(&b, "%d sub-agent(s): %d running, %d done, %d failed\n", + summary.Started, summary.Running, summary.Done, summary.Failed) + // TOKENS ALWAYS, COST NEVER. Tokens are the one number every provider in this + // catalogue reports; rates are absent for most of its gateways, so a cost + // figure here would be right for some sessions and silently wrong for the + // rest. What it CANNOT account for is named instead of hidden. + fmt.Fprintf(&b, "%s tokens across the ones that reported usage", formatWorkerTokens(summary.Tokens)) + if summary.Unmeasured > 0 { + fmt.Fprintf(&b, "; %d reported none, so this total does not cover %s", + summary.Unmeasured, pluralWorkers(summary.Unmeasured)) + } + b.WriteString("\n\n") + + now := m.now() + for _, worker := range summary.Workers { + b.WriteString(" " + workerStatusGlyph(worker.Status) + " ") + b.WriteString(workerLabel(worker)) + if worker.Background { + b.WriteString(" · background") + } + fmt.Fprintf(&b, "\n %s", worker.Duration(now).Round(time.Second)) + if worker.TokensReported { + fmt.Fprintf(&b, " · %s tokens", formatWorkerTokens(worker.Tokens)) + } else { + b.WriteString(" · usage not reported") + } + if worker.Model != "" { + b.WriteString(" · " + worker.Model) + } + if worker.Err != "" { + b.WriteString("\n " + worker.Err) + } + b.WriteString("\n") + } + return planControlNotice("info", strings.TrimRight(b.String(), "\n")) +} + +func workerLabel(worker specialist.Worker) string { + label := strings.TrimSpace(worker.Description) + if label == "" { + label = strings.TrimSpace(worker.Specialist) + } + if label == "" { + label = worker.SessionID + } + if worker.Kind == specialist.WorkerPlanTask { + return label + } + if specialistName := strings.TrimSpace(worker.Specialist); specialistName != "" && specialistName != label { + return label + " (" + specialistName + ")" + } + return label +} + +func workerStatusGlyph(status specialist.WorkerStatus) string { + switch status { + case specialist.WorkerCompleted: + return "✓" + case specialist.WorkerFailed: + return "✗" + default: + return "•" + } +} + +func pluralWorkers(n int) string { + if n == 1 { + return "one of them" + } + return fmt.Sprintf("%d of them", n) +} + +// formatWorkerTokens groups thousands, because the numbers here run to millions +// and an ungrouped one cannot be read at a glance. +func formatWorkerTokens(n int) string { + text := fmt.Sprintf("%d", n) + if n < 0 { + return text + } + var out []byte + for i, digit := range []byte(text) { + if i > 0 && (len(text)-i)%3 == 0 { + out = append(out, ',') + } + out = append(out, digit) + } + return string(out) +} diff --git a/internal/tui/zeromaxing_chip_row_test.go b/internal/tui/zeromaxing_chip_row_test.go new file mode 100644 index 000000000..a8f82e1dd --- /dev/null +++ b/internal/tui/zeromaxing_chip_row_test.go @@ -0,0 +1,92 @@ +package tui + +import ( + "strings" + "testing" +) + +// THE CHIP IS ON THE STATUS LINE, so only the status line may be hit-tested. +// +// The hit test scanned every row footerView renders and took the FIRST one +// containing the word. But footerView is not only chips: it renders the plan +// panel, the idle hints, a queued-message preview and THE COMPOSER. So typing +// "zeromaxing" into the composer put a matching row above the chip's, and the +// hover target became the composer — a click meant for the chip landed on text +// the user was still writing, and the chip itself stopped responding. +// +// The composer is the reachable case, and a plan task prompt containing the word +// is the same defect arriving through the panel. +func TestTypingTheChipLabelInTheComposerDoesNotStealTheChipsRow(t *testing.T) { + m := newZeromaxingChipModel(t) + + before, ok := m.zeromaxingChipRow() + if !ok { + t.Fatal("setup: the chip is not in the footer, so this test measures nothing") + } + + typed := m + typed.input.SetValue("why is zeromaxing slow") + // The premise: the typed word really does reach the footer. Without this the + // test passes for the wrong reason on any build where the composer is hidden. + if !strings.Contains(ansiStripLine(typed.composerBox(typed.chatColumnWidth())), zeromaxingChipLabel) { + t.Skip("the composer does not render its own text in this configuration") + } + + after, ok := typed.zeromaxingChipRow() + if !ok { + t.Fatal("the chip's row vanished once the label was typed") + } + if after != before { + t.Fatalf("typing the label moved the chip's hit row from %d to %d: the composer became the click target", before, after) + } +} + +// The row the hit test reports must be the row the chip is actually drawn on — +// asserted against the rendered screen, not against the search that found it. +func TestTheReportedChipRowIsTheRowTheChipIsDrawnOn(t *testing.T) { + m := newZeromaxingChipModel(t) + m.input.SetValue("why is zeromaxing slow") + + row, ok := m.zeromaxingChipRow() + if !ok { + t.Fatal("the chip's row was not found") + } + footer := viewLines(m.footerView(m.chatColumnWidth())) + index := len(footer) - (m.height - row) + if index < 0 || index >= len(footer) { + t.Fatalf("reported row %d is outside the footer (%d rows, height %d)", row, len(footer), m.height) + } + line := ansiStripLine(footer[index]) + if !strings.Contains(line, zeromaxingChipLabel) { + t.Fatalf("row %d does not carry the chip: %q", row, line) + } + // And it is the STATUS line, not merely some row bearing the word: the + // composer draws the typed text inside a box, the status line does not. + if strings.Contains(line, "why is") { + t.Fatalf("the composer's own row was reported as the chip's: %q", line) + } +} + +// A click at the chip's reported position must still land on it. The narrowing +// must not have moved the span off the chip while making the row correct. +func TestTheChipStillAnswersAClickAfterNarrowingToTheStatusLine(t *testing.T) { + m := newZeromaxingChipModel(t) + m.input.SetValue("zeromaxing") + + row, ok := m.zeromaxingChipRow() + if !ok { + t.Fatal("the chip's row was not found") + } + start, end, ok := m.zeromaxingChipSpan() + if !ok { + t.Fatal("the chip's span was not found") + } + if start >= end { + t.Fatalf("the chip's span is empty: [%d,%d)", start, end) + } + footer := viewLines(m.footerView(m.chatColumnWidth())) + line := ansiStripLine(footer[len(footer)-(m.height-row)]) + if !strings.Contains(line, zeromaxingChipLabel) { + t.Fatalf("the span was taken from a row without the chip: %q", line) + } +} diff --git a/internal/tui/zeromaxing_chip_span_test.go b/internal/tui/zeromaxing_chip_span_test.go new file mode 100644 index 000000000..247e60aa9 --- /dev/null +++ b/internal/tui/zeromaxing_chip_span_test.go @@ -0,0 +1,63 @@ +package tui + +import ( + "strings" + "testing" + + "charm.land/lipgloss/v2" + + "github.com/Gitlawb/zero/internal/agent" +) + +// THE CHIP'S SPAN IS A SCREEN COORDINATE, so it must be measured in columns. +// +// strings.Index returns a BYTE offset. Every multi-byte rune earlier on the +// footer row — the "●" in the permission chip, an em dash, a non-ASCII branch +// name — makes that offset larger than the column the chip actually starts at, +// so the clickable span slides right of the chip the user can see. The bug is +// invisible on an all-ASCII footer, which is why it survived: this repo's own +// footer draws "●" before the chip in every ordinary session. +func TestTheChipSpanIsMeasuredInColumnsNotBytes(t *testing.T) { + m := newZeromaxingChipModel(t) + + start, end, ok := m.zeromaxingChipSpan() + if !ok { + t.Fatal("the chip is not in the footer, so this test measures nothing") + } + + row, ok := m.zeromaxingChipRow() + if !ok { + t.Fatal("the chip's row was not found") + } + footer := viewLines(m.footerView(m.chatColumnWidth())) + line := ansiStripLine(footer[len(footer)-(m.height-row)]) + + // The label must actually sit inside the reported span, measured the way a + // terminal measures: by column. + labelAt := strings.Index(line, zeromaxingChipLabel) + if labelAt < 0 { + t.Fatalf("the label is not on the row the span was taken from: %q", line) + } + labelColumn := lipgloss.Width(line[:labelAt]) + + if labelColumn < start || labelColumn >= end { + t.Fatalf("the label starts at column %d but the clickable span is [%d,%d): a click on the chip lands outside it.\n row: %q\n bytes before the label: %d, columns: %d", + labelColumn, start, end, line, labelAt, labelColumn) + } + // And the multi-byte content really is present, or the case above is not + // being exercised at all. + if labelAt == labelColumn { + t.Fatalf("nothing before the chip is multi-byte, so this test cannot detect the byte/column confusion: %q", line) + } +} + +func newZeromaxingChipModel(t *testing.T) model { + t.Helper() + m := sidebarDetailModel(t) + m.execProfileName = "zeromaxing" + m.zeromaxing = agent.ZeromaxingActive + if !m.zeromaxingActive() { + t.Fatal("setup: the posture must be active or the chip is never drawn") + } + return m +} diff --git a/internal/tui/zeromaxing_glow.go b/internal/tui/zeromaxing_glow.go new file mode 100644 index 000000000..c92165cca --- /dev/null +++ b/internal/tui/zeromaxing_glow.go @@ -0,0 +1,291 @@ +package tui + +import ( + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" +) + +// The zeromaxing posture is a LIVE WORD, not a filled box. +// +// It raises a cost multiplier — 480 turns per run, inherited by every sub-agent +// — 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 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 +// 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() + // 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. +// +// 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 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, hovered bool) string { + ramp := zeroTheme.spectrum + 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 lit { + style = style.Underline(true) + } + return style.Render(text) + } + if len(ramp) == 0 { + return paint(zeroTheme.accent, " "+marker+" "+zeromaxingChipLabel+" ", hovered) + } + offset := m.zeromaxingSpectrumOffset() + var out strings.Builder + 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, " ", 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 { + 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. +// Derived from the same string the renderer builds, so the two cannot drift. +// zeromaxingChipLeadColumns is the " ● " that precedes the label inside the +// badge, measured in COLUMNS. Three columns, five bytes — the distinction that +// put the chip's clickable span in the wrong place. +const zeromaxingChipLeadColumns = 3 + +func zeromaxingChipWidth() int { + return lipgloss.Width(" ● " + 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 +} + +// zeromaxingStatusRows returns the footer rows the STATUS LINE occupies, and the +// screen row the first of them sits on. +// +// THE SEARCH IS NARROWED TO THE STATUS LINE because the label is a word, and the +// footer is not only chips. footerView also renders the plan panel, the idle +// hints, a queued-message preview and THE COMPOSER — so scanning every footer row +// for "zeromaxing" meant typing the word into the composer put the composer's own +// row ahead of the chip's, and the hit test then answered with a row the chip is +// not on. A task prompt containing the word did the same through the plan panel. +// The chip renders in statusLine and nowhere else, so that is the only row range +// a hit test may consider. +// +// Derived from the SAME footer string the screen shows, and located by rendering +// the status line separately and taking that many rows off the end — the status +// line is the last thing footerView writes, on every branch. +func (m model) zeromaxingStatusRows() ([]string, int) { + width := m.chatColumnWidth() + footer := viewLines(m.footerView(width)) + status := viewLines(m.statusLine(width)) + if len(status) == 0 || len(status) > len(footer) { + return nil, 0 + } + return footer[len(footer)-len(status):], m.height - len(status) +} + +// zeromaxingChipRow is the status row the chip renders on. +func (m model) zeromaxingChipRow() (int, bool) { + rows, top := m.zeromaxingStatusRows() + for index, line := range rows { + if strings.Contains(ansiStripLine(line), zeromaxingChipLabel) { + // Status rows sit at the bottom of the screen. + return top + index, true + } + } + return 0, false +} + +// zeromaxingChipSpan is the chip's [start,end) column range on its row. +func (m model) zeromaxingChipSpan() (int, int, bool) { + rows, _ := m.zeromaxingStatusRows() + for _, line := range rows { + plain := ansiStripLine(line) + index := strings.Index(plain, zeromaxingChipLabel) + if index < 0 { + continue + } + // COLUMNS, NOT BYTES. strings.Index returns a byte offset, and this is a + // screen coordinate: every multi-byte rune earlier on the footer row — + // the "●" in the permission chip, an em dash, a non-ASCII branch or + // directory name — pushed the byte offset past the real column and moved + // the whole span right, so clicks landed beside the chip while the hover + // highlight sat on it. lipgloss.Width measures what the terminal draws. + // + // The label is preceded by " ● " inside the badge: three COLUMNS, five + // bytes, which is the same confusion in miniature. + start := maxInt(0, lipgloss.Width(plain[:index])-zeromaxingChipLeadColumns) + return start, start + zeromaxingChipWidth(), true + } + return 0, 0, false +} + +// 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" + +// 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 new file mode 100644 index 000000000..fe9a21634 --- /dev/null +++ b/internal/tui/zeromaxing_glow_test.go @@ -0,0 +1,513 @@ +package tui + +import ( + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "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 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: 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 + + 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)) + } + 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 len(distinct) < 3 { + t.Fatalf("the chip uses %d colours; the word is meant to be a spectrum: %q", len(distinct), chip) + } +} + +// 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") + } +} + +// 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") + } +} + +// 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 ") + 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") + } + if backgroundSequence(chip) != "" { + t.Fatalf("the posture chip paints a background box: %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] +} + +// 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 + + resting := m.zeromaxingGlowChip() + m.hover = hoverTarget{kind: hoverZeromaxingChip} + hovered := m.zeromaxingGlowChip() + + if resting == hovered { + t.Fatal("the chip renders identically hovered and not, so hovering it says nothing") + } + 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 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)) + } +} + +// 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) + } +} + +// 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") + } +} diff --git a/internal/tui/zeromaxing_guards_test.go b/internal/tui/zeromaxing_guards_test.go new file mode 100644 index 000000000..b619d0ff3 --- /dev/null +++ b/internal/tui/zeromaxing_guards_test.go @@ -0,0 +1,84 @@ +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. + // + // FATAL, NOT SKIP. This is the precondition for everything below — if the + // row arithmetic above stops matching the layout, the click lands somewhere + // else, orchestrateHeaderAtMouse returns false for the boring reason, and a + // skip would report the modal guard as covered while testing nothing at all. + // A test that cannot reach its subject has failed, not been excused. + if !base.orchestrateHeaderAtMouse(click) { + t.Fatalf("the computed click (%d,%d) does not resolve to the PLAN header, so the modal guard below is never exercised: fix the row arithmetic in this test", click.X, click.Y) + } + + // 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") + } +} diff --git a/internal/tui/zeromaxing_skin.go b/internal/tui/zeromaxing_skin.go new file mode 100644 index 000000000..a09f78a6c --- /dev/null +++ b/internal/tui/zeromaxing_skin.go @@ -0,0 +1,375 @@ +package tui + +// The zeromaxing SKIN: while the posture is on, the workspace wears it. +// +// The footer chip already marks the posture — but only the footer, and a +// posture that multiplies every sub-agent's budget deserves to be visible +// wherever the work is. So the always-on surfaces take the skin's ELECTRIC +// GRADIENT (blue → brand accent) while the posture is active: the composer's +// top border, the "Working" ripple, and the sidebar's section headers — +// AGENTS, PLAN, FILES, MODELS, ACTIVITY, TASK — each walking its colours on +// the clock the chip already breathes on while a turn runs, holding a static +// gradient when idle. +// +// THE SAME RULES THE CHIP LIVES BY, inherited wholesale: +// - Posture off renders BYTE-IDENTICAL output to before this file existed — +// the off path delegates to the plain renderers, it does not reimplement +// them. +// - Every colour is blended from named theme styles (postureSkinRamp), so +// the skin stays coherent on every theme and recolours with /theme. +// - No timer of its own: the walk reads zeromaxingSpectrumOffset, which +// advances on the spinner tick that already runs during a turn and pins to +// 0 when idle or under reduced motion. +// - Width is unchanged: colour adds ANSI bytes and no cells, so every layout +// and hit-test measurement sees the same plain text. + +import ( + "image/color" + "strings" + "time" + + "charm.land/lipgloss/v2" +) + +// postureSkinRamp is the skin's own gradient: theme blue blended into the +// brand accent — an ELECTRIC two-tone sweep, deliberately not the chip's +// full six-hue spectrum. Rainbowing every surface read as a flag rather than +// a mode; two hues read as voltage. Blended from named theme styles only +// (the same construction as swarmPulseStyles), so it recolours with /theme +// and no hex literal appears here. Falls back to a plain accent ramp when the +// theme's colours cannot be read. +func postureSkinRamp() []lipgloss.Style { + from := zeroTheme.blue.GetForeground() + to := zeroTheme.accent.GetForeground() + if from == nil || to == nil { + return []lipgloss.Style{zeroTheme.accent} + } + blend := lipgloss.Blend1D(8, from, to) + out := make([]lipgloss.Style, 0, len(blend)) + for _, c := range blend { + r, g, b, a := c.RGBA() + out = append(out, lipgloss.NewStyle().Foreground(color.RGBA{ + R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: uint8(a >> 8)})) + } + return out +} + +// postureSkinActive reports whether the skin should paint: the posture is on +// and a ramp exists. +func (m model) postureSkinActive() bool { + return m.zeromaxingActive() && len(postureSkinRamp()) > 0 +} + +// spectrumHeaderLabel paints an uppercase section label letter-by-letter from +// the skin's gradient, bold like the plain header it replaces. The offset +// walks during a turn and is pinned when idle — the same clock as the footer +// chip. +func (m model) spectrumHeaderLabel(label string) string { + ramp := postureSkinRamp() + offset := m.zeromaxingSpectrumOffset() + var out strings.Builder + for index, letter := range []rune(strings.ToUpper(label)) { + out.WriteString(ramp[(index+offset)%len(ramp)].Bold(true).Render(string(letter))) + } + return out.String() +} + +// postureHeader is sidebarHeader with the skin: plain muted-bold normally, +// gradient-painted while the posture is on. +func (m model) postureHeader(label string, width int) string { + if !m.postureSkinActive() { + return sidebarHeader(label, width) + } + return m.spectrumHeaderLabel(label) +} + +// postureHeaderWithCount is sidebarHeaderWithCount with the skin. The count +// keeps its own state colour either way — the skin dresses the label, never +// the data. +func (m model) postureHeaderWithCount(label, count string, countStyle lipgloss.Style, width int) string { + if !m.postureSkinActive() { + return sidebarHeaderWithCount(label, count, countStyle, width) + } + return sidebarHeaderAlign(m.spectrumHeaderLabel(label), count, countStyle, width) +} + +// postureComposerTop is the composer box's top border: the plain lineStrong +// rule normally, and the electric gradient while the posture is on — the one +// element of this skin that is on screen in every session, sidebar or not, +// from the first frame. Each ramp hue owns a band of the rule rather than +// cycling per character, so it reads as one gradient sweep; the offset walks +// it during a turn on the chip's own clock and pins it when idle. +// +// The characters are untouched — same runes, same width — so composerRect, +// mouse hit-testing and every layout measurement see the identical rule. +// postureGradientHue maps a column to a ramp hue with a MIRRORED sweep: blue +// at both edges rising to the accent in the middle, ping-ponging instead of +// wrapping. The modular wrap it replaces produced a hard seam where the ramp +// snapped from accent back to blue — beside the box's own blue border that +// read as "the gradient stops halfway". A mirror has no seam: every cell is +// unmistakably painted, and the offset slides the bright peak along the run. +func postureGradientHue(ramp []lipgloss.Style, position, span, offset int) lipgloss.Style { + if len(ramp) == 0 { + return zeroTheme.accent + } + steps := 2 * len(ramp) + p := (position*steps/maxInt(1, span) + offset) % steps + if p < 0 { + p += steps + } + if p >= len(ramp) { + p = steps - 1 - p + } + return ramp[p] +} + +// posturePaintRule paints each rune of a border segment by its COLUMN through +// the mirrored gradient, so segments drawn separately (the bottom rule around +// its model label, the two corners) still join into one continuous sweep. +func (m model) posturePaintRule(text string, startCol, span int) string { + ramp := postureSkinRamp() + offset := m.postureBarOffset() + var out strings.Builder + for index, r := range []rune(text) { + out.WriteString(postureGradientHue(ramp, startCol+index, span, offset).Render(string(r))) + } + return out.String() +} + +func (m model) postureComposerTop(width int) string { + plain := "╭" + strings.Repeat("─", maxInt(0, width-2)) + "╮" + if !m.postureSkinActive() { + return zeroTheme.lineStrong.Render(plain) + } + runes := []rune(plain) + scanHead := m.postureScanHead(len(runes)) + if scanHead < 0 { + return m.posturePaintRule(plain, 0, len(runes)) + } + ramp := postureSkinRamp() + offset := m.postureBarOffset() + var out strings.Builder + for index, r := range runes { + hue := postureGradientHue(ramp, index, len(runes), offset) + if index >= scanHead && index < scanHead+postureScanBand { + // The scanner burns over the gradient in the brand accent, bold — the + // brightest thing on the bar, unmistakably a moving eye. + hue = zeroTheme.accent.Bold(true) + } + out.WriteString(hue.Render(string(r))) + } + return out.String() +} + +// postureComposerSide paints one row of the box's side walls. The walls take +// the gradient's EDGE hue — the same colour the corners carry — so the whole +// frame reads as one lit box rather than a coloured lid on a grey crate. +func (m model) postureComposerSide(right bool) string { + if !m.postureSkinActive() { + if right { + return zeroTheme.lineStrong.Render(" │") + } + return zeroTheme.lineStrong.Render("│ ") + } + hue := postureGradientHue(postureSkinRamp(), 0, 1, m.postureBarOffset()) + if right { + return hue.Render(" │") + } + return hue.Render("│ ") +} + +// postureBoxRule paints a bottom-rule segment: plain lineStrong normally, the +// column-accurate gradient while the posture is on. +func (m model) postureBoxRule(text string, startCol, span int) string { + if !m.postureSkinActive() { + return zeroTheme.lineStrong.Render(text) + } + return m.posturePaintRule(text, startCol, span) +} + +// postureRipplePalette is the working line's colour ramp: the brand dim→lime +// ripple normally, the skin's gradient while the posture is on — so "Working" +// breathes the mode's colours exactly when the session spends at zeromaxing scale. +// Same length contract as ripplePalette (rippleText handles any), same clock, +// no new timer. +func (m model) postureRipplePalette() []lipgloss.Style { + if !m.postureSkinActive() { + return ripplePalette() + } + return postureSkinRamp() +} + +// postureActivity is the skin's read of WHAT KIND of work is in flight, so the +// animations can differ by state instead of one look for everything: +// +// "" — idle, everything static +// "thinking" — reasoning / waiting on the model +// "writing" — the answer is streaming +// "orchestrating"— a plan has tasks running RIGHT NOW +// +// Orchestrating outranks the other two: while sub-agents run, that is the story. +// Derived entirely from state the model already tracks — no new bookkeeping. +func (m model) postureActivity() string { + if !m.pending { + return "" + } + if _, _, _, _, running := m.orchestrate.counts(); running > 0 { + return "orchestrating" + } + return m.workingActivity() +} + +// postureRippleWaveLen shapes the "Working" ripple by state: a broad slow +// breath while thinking, a tight fast flow while writing, a mid pulse while +// orchestrating. Posture off returns exactly the historical 6, so the plain +// ripple is byte-identical. +func (m model) postureRippleWaveLen() int { + if !m.postureSkinActive() { + return 6 + } + switch m.postureActivity() { + case "writing": + return 3 + case "orchestrating": + return 4 + case "thinking": + return 10 + default: + return 6 + } +} + +// postureBarPeriod is how fast the composer bar's gradient travels, by state: +// thinking drifts, writing races, orchestrating holds the thinking drift and +// adds the scanner instead (postureScanHead) — speed marks throughput, the +// scanner marks fan-out. +func (m model) postureBarPeriod() time.Duration { + switch m.postureActivity() { + case "writing": + return 700 * time.Millisecond + case "thinking", "orchestrating": + return 2800 * time.Millisecond + default: + return 0 + } +} + +// postureBarOffset walks the composer gradient at the state's own speed. +// Pinned to 0 when idle and under reduced motion, like every animation here. +func (m model) postureBarOffset() int { + period := m.postureBarPeriod() + if m.reducedMotion || period <= 0 { + return 0 + } + step := m.now().UnixMilli() % period.Milliseconds() + return int(step * int64(len(postureSkinRamp())) / period.Milliseconds()) +} + +// postureScanPeriod is one full scanner crossing, edge to edge and back. +const postureScanPeriod = 1800 * time.Millisecond + +// postureScanBand is the scanner's width in cells. +const postureScanBand = 5 + +// postureScanHead is the scanner's leading cell on the composer bar while +// ORCHESTRATING: a bright band sweeping left-right-left — the Cylon eye that +// says "sub-agents are fanned out and being watched". Returns -1 (no scanner) +// in every other state, when idle, and under reduced motion. +func (m model) postureScanHead(width int) int { + if width <= postureScanBand || m.reducedMotion || m.postureActivity() != "orchestrating" { + return -1 + } + travel := width - postureScanBand + period := postureScanPeriod.Milliseconds() + step := m.now().UnixMilli() % period + // Triangle wave: 0 → travel in the first half, back in the second. + half := period / 2 + if step <= half { + return int(int64(travel) * step / half) + } + return int(int64(travel) * (period - step) / half) +} + +// postureDivider paints one row of the chat|sidebar divider: the plain quiet +// rule normally, and — while the posture is on — a cell of the ELECTRIC RAIL, +// a vertical gradient running the full height of the terminal, walking on the +// bar's clock. Same three cells (" │ ") either way, so column math and every +// hit test are untouched. +func (m model) postureDivider(row, rows int) string { + if !m.postureSkinActive() { + return " " + zeroTheme.line.Render("│") + " " + } + hue := postureGradientHue(postureSkinRamp(), row, rows, m.postureBarOffset()) + return " " + hue.Render("│") + " " +} + +// todoPlanBar gives the update_plan checklist a progress bar of its own, +// UNDER THE POSTURE ONLY. The orchestrate plan has always had a bar; the todo +// checklist showed only "0/4" in its header, so a session mid-plan read as +// barless. The steps map onto the same settled/running/failed/pending shape +// the orchestrate bar draws, and the render reuses posturePlanProgressBar so +// the two bars cannot drift apart. Posture off returns "" — the checklist +// renders exactly as it always has. +func (m model) todoPlanBar(width int) string { + if !m.postureSkinActive() || m.plan.isEmpty() { + return "" + } + tasks := make([]orchestrateTask, 0, len(m.plan.steps)) + for _, step := range m.plan.steps { + status := orchestratePending + switch step.status { + case "completed": + status = orchestrateDone + case "in_progress": + status = orchestrateRunning + case "failed": + status = orchestrateFailed + } + tasks = append(tasks, orchestrateTask{status: status}) + } + return m.posturePlanProgressBar(orchestratePanelState{tasks: tasks}, width) +} + +// posturePlanProgressBar is the sidebar's plan bar wearing the skin: an ENERGY +// FILL. Done work pours the electric gradient into the bar cell by cell — the +// filled run is one continuous blue→accent sweep, positioned so it joins up as +// the plan advances — the running head burns accent-bold at the fill's edge, +// and the pending track sits quiet in faint ▱. The historical solid green +// blocks read as a third loud colour beside the skin and were the thing being +// asked about; the gradient IS the skin, so the bar finally belongs to it. +// +// Failure stays red — that is data — but CALM red, dimmed a step: a defect +// must be findable at a glance without shouting over the whole column. +// Posture off delegates to the plain renderer byte-for-byte. +func (m model) posturePlanProgressBar(state orchestratePanelState, width int) string { + if !m.postureSkinActive() { + return sidebarProgressBar(state, width) + } + ramp := postureSkinRamp() + offset := m.postureBarOffset() + return sidebarProgressBarWith(state, width, progressBarSkin{ + settled: "▰", + running: "▰", + paint: func(kind string, startCell, n, cells int) string { + var out strings.Builder + for i := 0; i < n; i++ { + var hue lipgloss.Style + switch kind { + case "done": + hue = postureGradientHue(ramp, startCell+i, cells, offset) + case "failed": + hue = zeroTheme.red.Faint(true) + case "skipped": + hue = zeroTheme.muted + default: // running — the bright head of the fill + hue = zeroTheme.accent.Bold(true) + } + out.WriteString(hue.Render("▰")) + } + return out.String() + }, + pending: func(_, n, _ int) string { + return zeroTheme.faint.Render(strings.Repeat("▱", n)) + }, + }) +} diff --git a/internal/tui/zeromaxing_skin_test.go b/internal/tui/zeromaxing_skin_test.go new file mode 100644 index 000000000..e236b9ee1 --- /dev/null +++ b/internal/tui/zeromaxing_skin_test.go @@ -0,0 +1,441 @@ +package tui + +import ( + "strings" + "testing" + "time" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/Gitlawb/zero/internal/agent" +) + +// THE SKIN IS ADDITIVE OR ABSENT. Posture off, every header byte matches the +// plain renderer — the sidebar cannot know this file exists. Posture on, only +// the colours change: the stripped text, the width, and the count's column are +// identical to the plain header, so every layout and hit-test measurement is +// untouched. + +func skinModel(active bool) model { + m := model{now: func() time.Time { return time.Unix(1000, 0) }} + if active { + m.zeromaxing = agent.ZeromaxingActive + } + return m +} + +func TestPostureOffHeadersAreByteIdentical(t *testing.T) { + m := skinModel(false) + if got, want := m.postureHeader("AGENTS", 40), sidebarHeader("AGENTS", 40); got != want { + t.Fatalf("plain header changed with the posture off:\n got %q\nwant %q", got, want) + } + got := m.postureHeaderWithCount("PLAN", "2/5", zeroTheme.accent, 40) + want := sidebarHeaderWithCount("PLAN", "2/5", zeroTheme.accent, 40) + if got != want { + t.Fatalf("counted header changed with the posture off:\n got %q\nwant %q", got, want) + } +} + +func TestPostureOnPaintsTheLabelAndNothingElse(t *testing.T) { + m := skinModel(true) + painted := m.postureHeaderWithCount("MODELS", "3", zeroTheme.muted, 40) + plain := sidebarHeaderWithCount("MODELS", "3", zeroTheme.muted, 40) + if painted == plain { + t.Fatal("the active posture must repaint the header") + } + // ONLY the colours: stripped, the two lines are the same text in the same + // columns, so width math and hit tests cannot tell them apart. + if ansi.Strip(painted) != ansi.Strip(plain) { + t.Fatalf("the skin moved text, not just colour:\n got %q\nwant %q", + ansi.Strip(painted), ansi.Strip(plain)) + } + if ansi.Strip(m.postureHeader("AGENTS", 40)) != "AGENTS" { + t.Fatalf("plain text lost under the paint: %q", ansi.Strip(m.postureHeader("AGENTS", 40))) + } +} + +// Idle and reduced-motion renders are pinned: the same moment renders the same +// bytes, so an idle sidebar does not shimmer between frames. +func TestPostureSkinIsStableWhenIdle(t *testing.T) { + m := skinModel(true) + firstRender := m.postureHeader("FILES", 40) + secondRender := m.postureHeader("FILES", 40) + if firstRender != secondRender { + t.Fatal("two idle renders of the same header differ") + } + m.reducedMotion = true + m.pending = true + first := m.postureHeader("FILES", 40) + m.now = func() time.Time { return time.Unix(1000, 0).Add(300 * time.Millisecond) } + if got := m.postureHeader("FILES", 40); got != first { + t.Fatal("reduced motion must pin the ramp walk") + } +} + +// The walk is real: during a turn the ramp offset advances, so the same header +// renders differently as the clock moves — that is the "alive" part. +func TestPostureSkinWalksDuringATurn(t *testing.T) { + m := skinModel(true) + m.pending = true + first := m.postureHeader("AGENTS", 40) + m.now = func() time.Time { return time.Unix(1000, 0).Add(700 * time.Millisecond) } + second := m.postureHeader("AGENTS", 40) + if first == second { + t.Fatal("the ramp did not walk while a turn was in flight") + } + if ansi.Strip(first) != ansi.Strip(second) { + t.Fatal("the walk must move colours, never text") + } +} + +// THE COMPOSER'S TOP BORDER is the always-on surface: rainbow gradient with +// the posture, byte-identical lineStrong rule without it. +func TestComposerTopWearsTheGradientOnlyWithThePosture(t *testing.T) { + off := skinModel(false) + plain := zeroTheme.lineStrong.Render("╭" + strings.Repeat("─", 38) + "╮") + if got := off.postureComposerTop(40); got != plain { + t.Fatalf("posture off must render the plain rule byte-identically:\n got %q\nwant %q", got, plain) + } + on := skinModel(true) + painted := on.postureComposerTop(40) + if painted == plain { + t.Fatal("the active posture must paint the composer's top border") + } + if ansi.Strip(painted) != "╭"+strings.Repeat("─", 38)+"╮" { + t.Fatalf("the gradient changed the rule's characters: %q", ansi.Strip(painted)) + } +} + +// "Working" ripples through the SPECTRUM while the posture is on, and through +// the plain brand ramp when it is off — colour only, same clock, no new timer. +func TestWorkingRipplesTheSpectrumOnlyWithThePosture(t *testing.T) { + off := skinModel(false) + offSample := rippleText("Working", off.postureRipplePalette(), 0, 6) + plainSample := rippleText("Working", ripplePalette(), 0, 6) + if offSample != plainSample { + t.Fatal("posture off must ripple the plain brand palette byte-identically") + } + on := skinModel(true) + onSample := rippleText("Working", on.postureRipplePalette(), 0, 6) + if onSample == plainSample { + t.Fatal("the active posture must ripple the spectrum") + } + if ansi.Strip(onSample) != "Working" { + t.Fatalf("the spectrum ripple changed the word: %q", ansi.Strip(onSample)) + } +} + +// THE ANIMATION KNOWS WHAT KIND OF WORK IS IN FLIGHT. Idle, thinking, writing +// and orchestrating each get their own look; orchestrating outranks the rest +// because fanned-out sub-agents are the story. +func TestPostureActivityStates(t *testing.T) { + m := skinModel(true) + if got := m.postureActivity(); got != "" { + t.Fatalf("idle activity = %q, want empty", got) + } + m.pending = true + if got := m.postureActivity(); got != "thinking" { + t.Fatalf("pending activity = %q, want thinking", got) + } + m.orchestrate.tasks = []orchestrateTask{{id: "t", status: orchestrateRunning}} + if got := m.postureActivity(); got != "orchestrating" { + t.Fatalf("with a running plan task = %q, want orchestrating", got) + } +} + +// Each state shapes the "Working" ripple differently; posture off is exactly +// the historical wavelength so the plain ripple is byte-identical. +func TestPostureRippleWaveLenByState(t *testing.T) { + off := skinModel(false) + if got := off.postureRippleWaveLen(); got != 6 { + t.Fatalf("posture off wavelength = %d, want the historical 6", got) + } + on := skinModel(true) + on.pending = true + thinking := on.postureRippleWaveLen() + on.orchestrate.tasks = []orchestrateTask{{id: "t", status: orchestrateRunning}} + orchestrating := on.postureRippleWaveLen() + if thinking == 6 || thinking == orchestrating { + t.Fatalf("states must differ: thinking=%d orchestrating=%d", thinking, orchestrating) + } +} + +// Writing races, thinking drifts: at the same wall-clock moment the two states +// paint the bar differently, because their gradients travel at different speeds. +func TestComposerBarSpeedDiffersByState(t *testing.T) { + m := skinModel(true) + m.pending = true + m.now = func() time.Time { return time.Unix(1000, 0).Add(350 * time.Millisecond) } + thinking := m.postureComposerTop(60) + writing := m + writing.streamingText = []byte("streaming text") + if got := writing.postureActivity(); got != "writing" { + t.Fatalf("setup: streaming text must classify as writing, got %q", got) + } + if writing.postureComposerTop(60) == thinking { + t.Fatal("thinking and writing painted the bar identically at the same moment") + } +} + +// ORCHESTRATING GETS THE SCANNER: a bright band sweeping the bar. It exists +// only in that state, never under reduced motion, and never changes the runes. +func TestOrchestratingScannerSweepsTheBar(t *testing.T) { + m := skinModel(true) + m.pending = true + // Absolute milliseconds, so the sweep's phase is exactly the stated offset + // into its 1800ms period rather than wherever an arbitrary epoch lands. + m.now = func() time.Time { return time.UnixMilli(450) } + plainState := m.postureComposerTop(60) + m.orchestrate.tasks = []orchestrateTask{{id: "t", status: orchestrateRunning}} + withScanner := m.postureComposerTop(60) + if withScanner == plainState { + t.Fatal("orchestrating must paint the scanner over the bar") + } + if ansi.Strip(withScanner) != ansi.Strip(plainState) { + t.Fatal("the scanner changed the bar's characters") + } + // The eye moves: rising through the first half of the period… + first := m.postureScanHead(60) + m.now = func() time.Time { return time.UnixMilli(900) } + second := m.postureScanHead(60) + if first < 0 || second <= first { + t.Fatalf("the scanner is not sweeping forward: head %d then %d", first, second) + } + // …and bouncing back through the second half. + m.now = func() time.Time { return time.UnixMilli(1350) } + if got := m.postureScanHead(60); got >= second { + t.Fatalf("the scanner did not bounce: %d after peak %d", got, second) + } + m.reducedMotion = true + if m.postureScanHead(60) != -1 { + t.Fatal("reduced motion must disable the scanner") + } +} + +// THE RAIL: the chat|sidebar divider becomes a full-height gradient while the +// posture is on — same three cells, so column math is untouched — and stays +// the quiet single-hue rule byte-identically when it is off. +func TestTheDividerBecomesTheRailOnlyWithThePosture(t *testing.T) { + off := skinModel(false) + plain := " " + zeroTheme.line.Render("│") + " " + if got := off.postureDivider(0, 40); got != plain { + t.Fatalf("posture off divider changed:\n got %q\nwant %q", got, plain) + } + on := skinModel(true) + top := on.postureDivider(0, 40) + middle := on.postureDivider(20, 40) + if top == plain { + t.Fatal("the active posture must paint the rail") + } + // The sweep is MIRRORED — both ends share the edge hue, the middle burns + // brightest — so the gradient shows between an end and the centre. + if top == middle { + t.Fatal("the rail must be a gradient: its end and its middle are the same hue") + } + for _, cell := range []string{top, middle} { + if ansi.Strip(cell) != " │ " { + t.Fatalf("the rail changed the divider's cells: %q", ansi.Strip(cell)) + } + } +} + +// THE WHOLE BOX WEARS IT: sides and bottom rule paint with the gradient, not +// just the lid — and every surface is byte-identical with the posture off. +func TestTheFullComposerBoxWearsTheGradient(t *testing.T) { + off := skinModel(false) + if got, want := off.postureComposerSide(false), zeroTheme.lineStrong.Render("│ "); got != want { + t.Fatalf("posture off left wall changed: %q", got) + } + if got, want := off.postureComposerSide(true), zeroTheme.lineStrong.Render(" │"); got != want { + t.Fatalf("posture off right wall changed: %q", got) + } + if got, want := off.postureBoxRule("╰──╯", 0, 4), zeroTheme.lineStrong.Render("╰──╯"); got != want { + t.Fatalf("posture off bottom rule changed: %q", got) + } + on := skinModel(true) + if on.postureComposerSide(false) == off.postureComposerSide(false) { + t.Fatal("the active posture must paint the walls") + } + bottom := on.postureBoxRule("╰──────╯", 0, 8) + if bottom == off.postureBoxRule("╰──────╯", 0, 8) { + t.Fatal("the active posture must paint the bottom rule") + } + if ansi.Strip(bottom) != "╰──────╯" { + t.Fatalf("the paint changed the bottom rule's characters: %q", ansi.Strip(bottom)) + } +} + +// NO SEAM. The mirrored sweep means no column snaps from the accent back to +// blue mid-bar — the defect that read as "only half the box is coloured". The +// hue ramps up to the middle and back down: both edges share a hue, the middle +// differs, and adjacent columns never jump more than one ramp step. +func TestTheGradientHasNoSeam(t *testing.T) { + ramp := postureSkinRamp() + if len(ramp) < 2 { + t.Skip("theme built no ramp to sweep") + } + hueIndex := map[string]int{} + for i, style := range ramp { + hueIndex[style.Render("x")] = i + } + span := 60 + last := -1 + for col := 0; col < span; col++ { + rendered := postureGradientHue(ramp, col, span, 0).Render("x") + index, ok := hueIndex[rendered] + if !ok { + t.Fatalf("column %d rendered a hue not in the ramp", col) + } + if last >= 0 { + delta := index - last + if delta < -1 || delta > 1 { + t.Fatalf("hue jumped %d ramp steps at column %d — that is a seam", delta, col) + } + } + last = index + } + edge := postureGradientHue(ramp, 0, span, 0).Render("x") + if postureGradientHue(ramp, span-1, span, 0).Render("x") != edge { + t.Fatal("a mirrored sweep must end on the hue it started with") + } + if postureGradientHue(ramp, span/2, span, 0).Render("x") == edge { + t.Fatal("the middle must burn a different hue than the edges") + } +} + +// THE PLAN BAR JOINS THE SKIN: same layout and semantic colours, but the +// settled blocks speak ▰ and the pending track glows the gradient — and with +// the posture off it is the historical bar byte-for-byte. +func TestThePlanBarWearsTheSkinOnlyWithThePosture(t *testing.T) { + state := orchestratePanelState{tasks: []orchestrateTask{ + {id: "a", status: orchestrateDone}, + {id: "b", status: orchestrateRunning}, + {id: "c", status: orchestratePending}, + {id: "d", status: orchestratePending}, + }} + off := skinModel(false) + if got, want := off.posturePlanProgressBar(state, 36), sidebarProgressBar(state, 36); got != want { + t.Fatalf("posture off plan bar changed:\n got %q\nwant %q", got, want) + } + on := skinModel(true) + skinned := on.posturePlanProgressBar(state, 36) + if skinned == sidebarProgressBar(state, 36) { + t.Fatal("the active posture must dress the plan bar") + } + plain := ansi.Strip(skinned) + if !strings.Contains(plain, "▰") || !strings.Contains(plain, "▱") { + t.Fatalf("the skinned bar must speak the ▰/▱ language: %q", plain) + } + if !strings.Contains(plain, "1/4") { + t.Fatalf("the skinned bar lost its count: %q", plain) + } + if lipgloss.Width(plain) != lipgloss.Width(ansi.Strip(sidebarProgressBar(state, 36))) { + t.Fatal("the skin changed the bar's width") + } +} + +// THE TODO CHECKLIST GETS A BAR TOO. The orchestrate plan always had one; the +// update_plan checklist showed only "0/4" in its header, so a session mid-plan +// read as barless. Posture on, the checklist's first line is the skinned bar; +// posture off, the checklist renders exactly as it always has. +func TestTheTodoPlanGetsABarUnderThePosture(t *testing.T) { + steps := []planStep{ + {content: "one", status: "completed"}, + {content: "two", status: "in_progress"}, + {content: "three", status: "pending"}, + {content: "four", status: "pending"}, + } + off := skinModel(false) + off.plan.steps = steps + for _, line := range off.updatePlanStepLines(36) { + if strings.Contains(ansi.Strip(line), "▰") || strings.Contains(ansi.Strip(line), "1/4") { + t.Fatalf("posture off must not grow a bar: %q", ansi.Strip(line)) + } + } + on := skinModel(true) + on.plan.steps = steps + lines := on.updatePlanStepLines(36) + if len(lines) != len(steps)+1 { + t.Fatalf("expected the bar plus %d steps, got %d lines", len(steps), len(lines)) + } + first := ansi.Strip(lines[0]) + if !strings.Contains(first, "▰") || !strings.Contains(first, "1/4") { + t.Fatalf("the checklist's first line must be the skinned bar with its count: %q", first) + } +} + +// AND THE CLICKS STILL LAND. The bar is one inserted line; every step's click +// offset must move down with it, or selecting a step opens its neighbour. +func TestPlanStepClicksSurviveTheTodoBar(t *testing.T) { + m := sidebarTestModel() + m.zeromaxing = agent.ZeromaxingActive + m.plan.steps = []planStep{ + {content: "first step body", status: "in_progress"}, + {content: "second step body", status: "pending"}, + } + width := sidebarWidth(m.width) + if m.todoPlanBar(width) == "" { + t.Fatal("setup: the bar must render for this check to mean anything") + } + rendered := m.renderContextSidebar(width, 30) + hits := m.sidebarPlanSelectables(width) + if len(hits) != 2 { + t.Fatalf("expected 2 step hits, got %d", len(hits)) + } + for i, hit := range hits { + if hit.lineOffset >= len(rendered) { + t.Fatalf("hit %d offset %d beyond the rendered sidebar", i, hit.lineOffset) + } + row := ansi.Strip(rendered[hit.lineOffset]) + want := m.plan.steps[hit.stepIndex].content + if !strings.Contains(row, truncateStep(want, width)) && !strings.Contains(row, want[:8]) { + t.Fatalf("hit %d points at %q, not step %q — the bar shifted the clicks", i, row, want) + } + } +} + +// THE BAR BELONGS TO THE SKIN, NOT TO THE STOPLIGHT. Done work fills with the +// electric gradient — never the loud solid green that sat beside the skin as a +// third colour — the running head burns accent, and failure stays findable but +// CALM: dimmed red, not the shout. +func TestThePlanBarFillsWithTheGradientNotGreen(t *testing.T) { + state := orchestratePanelState{tasks: []orchestrateTask{ + {id: "a", status: orchestrateDone}, + {id: "b", status: orchestrateDone}, + {id: "c", status: orchestrateFailed}, + {id: "d", status: orchestrateRunning}, + {id: "e", status: orchestratePending}, + }} + on := skinModel(true) + bar := on.posturePlanProgressBar(state, 36) + if strings.Contains(bar, zeroTheme.green.Render("▰")) { + t.Fatal("done cells still render solid green — the exact colour being replaced") + } + if strings.Contains(bar, zeroTheme.red.Render("▰")) { + t.Fatal("failed cells still shout in full red") + } + if !strings.Contains(bar, zeroTheme.red.Faint(true).Render("▰")) { + t.Fatal("failure must stay findable: no calm-red cell in the bar") + } + if !strings.Contains(bar, zeroTheme.accent.Bold(true).Render("▰")) { + t.Fatal("the running head must burn accent at the fill's edge") + } + ramp := postureSkinRamp() + foundGradient := false + for i := 0; i < 36; i++ { + if strings.Contains(bar, postureGradientHue(ramp, i, 28, 0).Render("▰")) { + foundGradient = true + break + } + } + if !foundGradient { + t.Fatal("done cells must pour the gradient into the bar") + } + // The plain bar is untouched: posture off still renders the historical + // semantic colours byte-for-byte. + off := skinModel(false) + if off.posturePlanProgressBar(state, 36) != sidebarProgressBar(state, 36) { + t.Fatal("posture off must keep the historical bar byte-identically") + } +} diff --git a/internal/tui/zeromaxing_test.go b/internal/tui/zeromaxing_test.go new file mode 100644 index 000000000..428b0a855 --- /dev/null +++ b/internal/tui/zeromaxing_test.go @@ -0,0 +1,935 @@ +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/specialist" + "github.com/Gitlawb/zero/internal/tools" + "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 != 480 { + t.Fatalf("the posture must raise the budget to 480, 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 != 480 || 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) { + // 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: "openai", + ModelName: "gpt-4.1", + Provider: &fakeProvider{}, + 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("gpt-4.1 gained an effort ring; the fixture no longer exercises the known-lacking 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 != 480 { + 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) + } + + // 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 openai 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") + + delta := execprofile.Delta(execprofile.DeltaState{ + CurrentMaxTurns: m.execProfileDisplacedMaxTurns, + Effort: m.effortTransition(), + SelfCorrect: m.selfCorrectTransition(), + }) + // THE SAME FACTS, EACH SURFACE'S OWN GRAMMAR. /effort keeps the shared + // resolved-state line + the delta sentence; /profile renders key-value rows + // with the transition as a clause on the row it qualifies ("raised by the + // posture") — the stacked three-renderer card said every fact three times + // and was called irritating to its face. What both must still guarantee: + // the resolved values, the real delta facts, and exactly one effort + // transition claim with no contradictions. + resolved := map[string][]string{ + "/effort": {"effort: high", "profile: " + execprofile.Name, "turns: 480", "turn budget:", "reasoning effort:", "self-correct:"}, + "/profile status": {"profile", execprofile.Name, "high", "480", "raised by the posture", "verify"}, + } + for surface, text := range map[string]string{"/effort": effortStatus, "/profile status": profileStatus} { + for _, want := range resolved[surface] { + if !strings.Contains(text, want) { + t.Fatalf("%s must show %q in its resolved state:\n%s", surface, want, text) + } + } + for _, want := range []string{"480", "sub-agents"} { + 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, on either surface's + // grammar — that duplication is what let "unchanged" and "NOT raised" + // coexist. + transitions := strings.Count(text, "raised to") + strings.Count(text, "raised by the posture") + + strings.Count(text, "NOT raised") + strings.Count(text, "reasoning effort: unchanged") + if transitions != 1 { + t.Fatalf("%s must carry exactly one effort-transition claim, found %d:\n%s", surface, transitions, text) + } + } + // /effort still carries the shared delta sentence VERBATIM, exactly once — + // it is the one line the posture-switch notice and the status agree on. + if n := strings.Count(effortStatus, delta); n != 1 { + t.Fatalf("/effort must carry the delta exactly once, found %d:\n%s", n, effortStatus) + } + // Other profiles must NOT carry the posture's delta text. + other := zeromaxingTestModel(t, false) + _, otherText := other.handleProfileCommand("thorough") + if strings.Contains(otherText, "turn budget:") { + 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") + } + // 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(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(ansiStripLine(m.statusLine(120)), zeromaxingChipLabel) { + t.Fatalf("the footer must drop the chip once off:\n%s", ansiStripLine(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, "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, "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) + } +} + +// 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 != 480 { + 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) + } +} + +// 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) +} + +// 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 +} + +// THE POSTURE'S SPEND BUDGET MUST REACH THE RUN, and leave when it does. +// +// Asserted on agentOptions — the struct a run is actually launched with — +// because a profile field nothing carries is the defect this codebase has hit +// repeatedly: a value that exists at one layer, is read at another, and is +// carried by neither. +func TestThePostureSpendBudgetReachesAndLeavesTheRun(t *testing.T) { + m := zeromaxingTestModel(t, false) + m, _ = m.handleEffortCommand(execprofile.Name) + if got := m.agentOptions.MaxTokens; got != execprofile.Zeromaxing.MaxTokens { + t.Fatalf("agentOptions.MaxTokens = %d, want the posture's %d", got, execprofile.Zeromaxing.MaxTokens) + } + reverted := m.revertExecProfile() + if got := reverted.agentOptions.MaxTokens; got != 0 { + t.Errorf("removing the posture left a spend budget of %d behind", got) + } +} + +// LEAVING THE POSTURE MID-RUN IS THE SAME MUTATION AS ENTERING IT, IN REVERSE. +// /effort zeromaxing is guarded by the idle-session rule; /effort auto reached +// revertExecProfile — moving the turn budget, self-correct and the shared +// orchestrate gate — with no guard at all, leaving one turn running under two +// different budgets. +func TestEffortAutoCannotLeaveThePostureMidRun(t *testing.T) { + m := zeromaxingTestModel(t, false) + m, _ = m.handleEffortCommand(execprofile.Name) + if m.agentOptions.MaxTurns != 480 { + t.Fatalf("setup: posture not applied, turns=%d", m.agentOptions.MaxTurns) + } + m.pending = true + m, text := m.handleEffortCommand("auto") + if m.execProfileName != execprofile.Name || m.agentOptions.MaxTurns != 480 { + t.Fatalf("a pending turn must block the revert: profile=%q turns=%d", + m.execProfileName, m.agentOptions.MaxTurns) + } + if !strings.Contains(text, "Finish or stop the current run") { + t.Fatalf("the refusal must say why: %q", text) + } + // Idle again: the same command reverts normally. + m.pending = false + m, _ = m.handleEffortCommand("auto") + if m.execProfileName == execprofile.Name { + t.Fatal("an idle session must still be able to leave the posture") + } + // Plain /effort auto OUTSIDE the posture keeps working mid-run: it only + // clears the effort selection, which mutates no shared budget. + plain := zeromaxingTestModel(t, false) + plain.pending = true + plain, _ = plain.handleEffortCommand("auto") + if plain.reasoningEffort != "" { + t.Fatalf("plain auto mid-run must still clear the effort, got %q", plain.reasoningEffort) + } +} 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