detached f87ad92

fix(subagents): finish observable unlimited execution

commit
f87ad92006e33a1e2f6bde35a6f019fea8f24e81
author
brice · · 2026-08-13 03:32 UTC
parent
3e8568c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
38 files · +478 -333
M README.md +1 -1
@@ -425,7 +425,7 @@ Background subagents that run alongside your main session. Each one is a separat
425425
426426 **`/session export` does NOT bundle subagents.** A `.zutsession` is just the main chat transcript; per-agent state (session file, unix-socket inbox) is machine-local and doesn't round-trip through a JSONL file. To share what an agent said, copy it out of the transcript view manually.
427427
428-**Subagent Orchestrator.** With `/settings` → **Subagent Orchestrator** on (or `/orchestrator`), the interactive primary agent enters strict orchestrator mode and gets whichever of the canonical `subagent_spawn` and `subagent_status` tools are permitted by launch-time policy. It must delegate all implementation, debugging/testing, and code-review work to an appropriately named profile, or to a clearly described general worker when no profile fits. It must not write or edit code, make direct implementation tool calls, inspect or review code, or apply worker patches itself. It may decompose requests, select and spawn workers, check status, coordinate follow-ups, and synthesize results. Give workers self-contained tasks: shared-worktree workers edit the same working directory and need coordination, while `isolation:"worktree"` is for parallel coding in separate trees; a worktree patch must be integrated by a worker rather than applied by the primary agent. Child workers cannot recursively spawn more sub-agents in v1. The `subagent_status` tool accepts an optional `agent_id`: omit it to list visible workers, or pass a full id / unique prefix to query one without waiting for completion. It returns bounded JSON metadata with the worker id, lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), underlying process/turn state, timestamps, a bounded first-line task summary, required state/target/unmet metadata when applicable, and terminal-result metadata when available; it omits full prompts, transcripts, result output, provider settings, and filesystem paths. Treat the bounded task summary as session data. The spawn tool always returns the child id and initial state immediately. Set `required:true` when the parent or workflow depends on the result: the worker remains asynchronous, its outcome is persisted and delivered through a host completion update, and failure, timeout, or cancellation remains recoverable until a retry succeeds or the user explicitly removes the terminal worker. An outcome not durably observed before host restart becomes indeterminate and requires the user to inspect side effects before explicitly resuming, restarting, or removing the worker. Completion is a durable `turn.result` with a `subagent://<id>/result` reference. The tool accepts named profiles and shared/worktree isolation; deadline, turn, and step budgets are manager-owned and are not exposed in its schema. Delegated turns, run turns, and model/tool loops are unlimited by default unless positive `subagents.default_timeout`, `subagents.max_turns`, or `subagents.max_steps` values are configured. Global concurrency limits apply equally to slash commands, tool calls, and batches. Completion is host-event-driven for every worker through `[auto-subagents update]`, with required state also supplied as high-priority `[required-subagents update]` context. Never use `bash sleep`, `watch`, `tail -f`, polling loops, repeated `subagent_status`, or dashboard/metadata/event-log/file checks solely to wait; work on unrelated independent tasks or end/yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests remain allowed. Off by default; toggle with `/orchestrator` or from `/settings`. When off, the permitted canonical subagent tools and profile manifest remain available for user-requested delegation or when an active skill workflow requires it; the primary otherwise performs work itself without the strict orchestrator contract.
428+**Subagent Orchestrator.** With `/settings` → **Subagent Orchestrator** on (or `/orchestrator`), the interactive primary agent enters strict orchestrator mode and gets whichever of the canonical `subagent_spawn` and `subagent_status` tools are permitted by launch-time policy. It must delegate all implementation, debugging/testing, and code-review work to an appropriately named profile, or to a clearly described general worker when no profile fits. It must not write or edit code, make direct implementation tool calls, inspect or review code, or apply worker patches itself. It may decompose requests, select and spawn workers, check status, coordinate follow-ups, and synthesize results. Give workers self-contained tasks: shared-worktree workers edit the same working directory and need coordination, while `isolation:"worktree"` is for parallel coding in separate trees; a worktree patch must be integrated by a worker rather than applied by the primary agent. Child workers cannot recursively spawn more sub-agents in v1. The `subagent_status` tool accepts an optional `agent_id`: omit it to list visible workers, or pass a full id / unique prefix to query one without waiting for completion. It returns bounded factual metadata: the worker id, current primary operation, last safe observation/event, terminal fact, timestamps, turn counters, a bounded first-line task summary, requirement state, and terminal-result delivery metadata. It omits full prompts, transcripts, result output, provider settings, and filesystem paths. Treat the bounded task summary as session data. The spawn tool always returns the child id and initial state immediately. Set `required:true` when the parent or workflow depends on the result: the worker remains asynchronous, its outcome is persisted and delivered through a host completion update, and failure, timeout, or cancellation remains recoverable until a retry succeeds or the user explicitly removes the terminal worker. An outcome not durably observed before host restart becomes indeterminate and requires the user to inspect side effects before explicitly resuming, restarting, or removing the worker. Completion is a durable `turn.result` with a `subagent://<id>/result` reference. The tool accepts named profiles and shared/worktree isolation. Model steps and delegated turns are counted for observability but are never capped; only an optional manager-owned `subagents.default_timeout` bounds active turn duration. Global concurrency limits apply equally to slash commands, tool calls, and batches. Completion is host-event-driven for every worker through `[auto-subagents update]`, with required state also supplied as high-priority `[required-subagents update]` context. Never use `bash sleep`, `watch`, `tail -f`, polling loops, repeated `subagent_status`, or dashboard/metadata/event-log/file checks solely to wait; work on unrelated independent tasks or end/yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests remain allowed. Off by default; toggle with `/orchestrator` or from `/settings`. When off, the permitted canonical subagent tools and profile manifest remain available for user-requested delegation or when an active skill workflow requires it; the primary otherwise performs work itself without the strict orchestrator contract.
429429
430430 ### Named subagent profiles
431431
@@ -425,7 +425,7 @@ Background subagents that run alongside your main session. Each one is a separat
425 425
426 **`/session export` does NOT bundle subagents.** A `.zutsession` is just the main chat transcript; per-agent state (session file, unix-socket inbox) is machine-local and doesn't round-trip through a JSONL file. To share what an agent said, copy it out of the transcript view manually.426 **`/session export` does NOT bundle subagents.** A `.zutsession` is just the main chat transcript; per-agent state (session file, unix-socket inbox) is machine-local and doesn't round-trip through a JSONL file. To share what an agent said, copy it out of the transcript view manually.
427 427
428-**Subagent Orchestrator.** With `/settings` → **Subagent Orchestrator** on (or `/orchestrator`), the interactive primary agent enters strict orchestrator mode and gets whichever of the canonical `subagent_spawn` and `subagent_status` tools are permitted by launch-time policy. It must delegate all implementation, debugging/testing, and code-review work to an appropriately named profile, or to a clearly described general worker when no profile fits. It must not write or edit code, make direct implementation tool calls, inspect or review code, or apply worker patches itself. It may decompose requests, select and spawn workers, check status, coordinate follow-ups, and synthesize results. Give workers self-contained tasks: shared-worktree workers edit the same working directory and need coordination, while `isolation:"worktree"` is for parallel coding in separate trees; a worktree patch must be integrated by a worker rather than applied by the primary agent. Child workers cannot recursively spawn more sub-agents in v1. The `subagent_status` tool accepts an optional `agent_id`: omit it to list visible workers, or pass a full id / unique prefix to query one without waiting for completion. It returns bounded JSON metadata with the worker id, lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), underlying process/turn state, timestamps, a bounded first-line task summary, required state/target/unmet metadata when applicable, and terminal-result metadata when available; it omits full prompts, transcripts, result output, provider settings, and filesystem paths. Treat the bounded task summary as session data. The spawn tool always returns the child id and initial state immediately. Set `required:true` when the parent or workflow depends on the result: the worker remains asynchronous, its outcome is persisted and delivered through a host completion update, and failure, timeout, or cancellation remains recoverable until a retry succeeds or the user explicitly removes the terminal worker. An outcome not durably observed before host restart becomes indeterminate and requires the user to inspect side effects before explicitly resuming, restarting, or removing the worker. Completion is a durable `turn.result` with a `subagent://<id>/result` reference. The tool accepts named profiles and shared/worktree isolation; deadline, turn, and step budgets are manager-owned and are not exposed in its schema. Delegated turns, run turns, and model/tool loops are unlimited by default unless positive `subagents.default_timeout`, `subagents.max_turns`, or `subagents.max_steps` values are configured. Global concurrency limits apply equally to slash commands, tool calls, and batches. Completion is host-event-driven for every worker through `[auto-subagents update]`, with required state also supplied as high-priority `[required-subagents update]` context. Never use `bash sleep`, `watch`, `tail -f`, polling loops, repeated `subagent_status`, or dashboard/metadata/event-log/file checks solely to wait; work on unrelated independent tasks or end/yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests remain allowed. Off by default; toggle with `/orchestrator` or from `/settings`. When off, the permitted canonical subagent tools and profile manifest remain available for user-requested delegation or when an active skill workflow requires it; the primary otherwise performs work itself without the strict orchestrator contract.428+**Subagent Orchestrator.** With `/settings` → **Subagent Orchestrator** on (or `/orchestrator`), the interactive primary agent enters strict orchestrator mode and gets whichever of the canonical `subagent_spawn` and `subagent_status` tools are permitted by launch-time policy. It must delegate all implementation, debugging/testing, and code-review work to an appropriately named profile, or to a clearly described general worker when no profile fits. It must not write or edit code, make direct implementation tool calls, inspect or review code, or apply worker patches itself. It may decompose requests, select and spawn workers, check status, coordinate follow-ups, and synthesize results. Give workers self-contained tasks: shared-worktree workers edit the same working directory and need coordination, while `isolation:"worktree"` is for parallel coding in separate trees; a worktree patch must be integrated by a worker rather than applied by the primary agent. Child workers cannot recursively spawn more sub-agents in v1. The `subagent_status` tool accepts an optional `agent_id`: omit it to list visible workers, or pass a full id / unique prefix to query one without waiting for completion. It returns bounded factual metadata: the worker id, current primary operation, last safe observation/event, terminal fact, timestamps, turn counters, a bounded first-line task summary, requirement state, and terminal-result delivery metadata. It omits full prompts, transcripts, result output, provider settings, and filesystem paths. Treat the bounded task summary as session data. The spawn tool always returns the child id and initial state immediately. Set `required:true` when the parent or workflow depends on the result: the worker remains asynchronous, its outcome is persisted and delivered through a host completion update, and failure, timeout, or cancellation remains recoverable until a retry succeeds or the user explicitly removes the terminal worker. An outcome not durably observed before host restart becomes indeterminate and requires the user to inspect side effects before explicitly resuming, restarting, or removing the worker. Completion is a durable `turn.result` with a `subagent://<id>/result` reference. The tool accepts named profiles and shared/worktree isolation. Model steps and delegated turns are counted for observability but are never capped; only an optional manager-owned `subagents.default_timeout` bounds active turn duration. Global concurrency limits apply equally to slash commands, tool calls, and batches. Completion is host-event-driven for every worker through `[auto-subagents update]`, with required state also supplied as high-priority `[required-subagents update]` context. Never use `bash sleep`, `watch`, `tail -f`, polling loops, repeated `subagent_status`, or dashboard/metadata/event-log/file checks solely to wait; work on unrelated independent tasks or end/yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests remain allowed. Off by default; toggle with `/orchestrator` or from `/settings`. When off, the permitted canonical subagent tools and profile manifest remain available for user-requested delegation or when an active skill workflow requires it; the primary otherwise performs work itself without the strict orchestrator contract.
429 429
430 ### Named subagent profiles430 ### Named subagent profiles
431 431
M docs/subagents.md +1 -1
@@ -178,4 +178,4 @@ Use `subagent_stop` with an `agent_id` to request termination of a stuck worker
178178
179179 ## Resource policy
180180
181-The persisted `subagents` config object supports `max_concurrent`, `max_concurrent_per_parent`, `queue_timeout`, `startup_timeout`, `default_timeout`, `max_turns`, `max_steps`, output caps, allowed tools/roots, heartbeat and idle timeouts. Duration strings use Go duration syntax and malformed values fail configuration loading with the field name. A worker fails with an actionable queue-timeout error when no execution slot becomes available before `queue_timeout` (5 minutes by default). Once admitted, it must report `agent_ready` before `startup_timeout` (1 minute by default) or fails with guidance to inspect worker output and retry. Missing or non-positive `default_timeout`, `max_turns`, and `max_steps` leave delegated turns, active-run turns, and provider/model/tool-loop iterations unlimited. Positive values opt into those manager-owned budgets; they are not fields in the model-facing `subagent_spawn` schema, and stale calls that still send them are rejected explicitly. Exhausting `max_steps` produces a failed result with error code `step_limit`; partial output and session history remain available for an explicit resume. `max_turns` bounds one active worker run; lifetime and current-run turn counters remain persisted and visible in worker status. An explicit `subagent_resume` starts a new run with fresh turn and step budgets without replaying the original task or losing session context. Previously saved `max_total_spawned` values are accepted but ignored. Concurrency limits apply to slash commands, `subagent_spawn`, and batch operations. A child cannot create descendants in v1. Per-worker deadline mode and explicitly configured limits are retained across reload/resume. The shared HTTP streaming providers retry transient request-open failures four times; after opening, a stream may reconnect five times and is canceled after five minutes without an event. Packaged `zut run` agents keep their declared capability ceiling by disabling subagent delegation; profiles are not a substitute for that permission boundary. A primary whose launch-time policy disables delegation still receives the strict orchestrator contract, but does not receive actionable profile metadata or a replacement implementation path.
181+The persisted `subagents` config object supports `max_concurrent`, `max_concurrent_per_parent`, `queue_timeout`, `startup_timeout`, `default_timeout`, output caps, allowed tools/roots, heartbeat and idle timeouts. Duration strings use Go duration syntax and malformed values fail configuration loading with the field name. A worker fails with an actionable queue-timeout error when no execution slot becomes available before `queue_timeout` (5 minutes by default). Once admitted, it must report `agent_ready` before `startup_timeout` (1 minute by default) or fails with guidance to inspect worker output and retry. Model steps and delegated turns are counted for observability but never capped. A missing or non-positive `default_timeout` leaves each active delegated turn unlimited; a positive value opts into a manager-owned deadline retained across reload and resume. An explicit `subagent_resume` starts a new observable run without replaying the original task or losing session context. Previously saved `max_total_spawned` values are accepted but ignored. Concurrency limits apply to slash commands, `subagent_spawn`, and batch operations. A child cannot create descendants in v1. The shared HTTP streaming providers retry transient request-open failures four times; after opening, a stream may reconnect five times and is canceled after five minutes without an event. Packaged `zut run` agents keep their declared capability ceiling by disabling subagent delegation; profiles are not a substitute for that permission boundary. A primary whose launch-time policy disables delegation still receives the strict orchestrator contract, but does not receive actionable profile metadata or a replacement implementation path.
@@ -178,4 +178,4 @@ Use `subagent_stop` with an `agent_id` to request termination of a stuck worker
178 178
179 ## Resource policy179 ## Resource policy
180 180
181-The persisted `subagents` config object supports `max_concurrent`, `max_concurrent_per_parent`, `queue_timeout`, `startup_timeout`, `default_timeout`, `max_turns`, `max_steps`, output caps, allowed tools/roots, heartbeat and idle timeouts. Duration strings use Go duration syntax and malformed values fail configuration loading with the field name. A worker fails with an actionable queue-timeout error when no execution slot becomes available before `queue_timeout` (5 minutes by default). Once admitted, it must report `agent_ready` before `startup_timeout` (1 minute by default) or fails with guidance to inspect worker output and retry. Missing or non-positive `default_timeout`, `max_turns`, and `max_steps` leave delegated turns, active-run turns, and provider/model/tool-loop iterations unlimited. Positive values opt into those manager-owned budgets; they are not fields in the model-facing `subagent_spawn` schema, and stale calls that still send them are rejected explicitly. Exhausting `max_steps` produces a failed result with error code `step_limit`; partial output and session history remain available for an explicit resume. `max_turns` bounds one active worker run; lifetime and current-run turn counters remain persisted and visible in worker status. An explicit `subagent_resume` starts a new run with fresh turn and step budgets without replaying the original task or losing session context. Previously saved `max_total_spawned` values are accepted but ignored. Concurrency limits apply to slash commands, `subagent_spawn`, and batch operations. A child cannot create descendants in v1. Per-worker deadline mode and explicitly configured limits are retained across reload/resume. The shared HTTP streaming providers retry transient request-open failures four times; after opening, a stream may reconnect five times and is canceled after five minutes without an event. Packaged `zut run` agents keep their declared capability ceiling by disabling subagent delegation; profiles are not a substitute for that permission boundary. A primary whose launch-time policy disables delegation still receives the strict orchestrator contract, but does not receive actionable profile metadata or a replacement implementation path.181+The persisted `subagents` config object supports `max_concurrent`, `max_concurrent_per_parent`, `queue_timeout`, `startup_timeout`, `default_timeout`, output caps, allowed tools/roots, heartbeat and idle timeouts. Duration strings use Go duration syntax and malformed values fail configuration loading with the field name. A worker fails with an actionable queue-timeout error when no execution slot becomes available before `queue_timeout` (5 minutes by default). Once admitted, it must report `agent_ready` before `startup_timeout` (1 minute by default) or fails with guidance to inspect worker output and retry. Model steps and delegated turns are counted for observability but never capped. A missing or non-positive `default_timeout` leaves each active delegated turn unlimited; a positive value opts into a manager-owned deadline retained across reload and resume. An explicit `subagent_resume` starts a new observable run without replaying the original task or losing session context. Previously saved `max_total_spawned` values are accepted but ignored. Concurrency limits apply to slash commands, `subagent_spawn`, and batch operations. A child cannot create descendants in v1. The shared HTTP streaming providers retry transient request-open failures four times; after opening, a stream may reconnect five times and is canceled after five minutes without an event. Packaged `zut run` agents keep their declared capability ceiling by disabling subagent delegation; profiles are not a substitute for that permission boundary. A primary whose launch-time policy disables delegation still receives the strict orchestrator contract, but does not receive actionable profile metadata or a replacement implementation path.
M packages/agent/args.go +0 -12
@@ -147,8 +147,6 @@ type Args struct {
147147 // --subagent-worker.
148148 SubagentWorker string
149149
150- // SubagentMaxTurns limits message turns in one worker run.
151- SubagentMaxTurns int
152150 // SubagentTurnTimeout limits each active delegated turn while leaving the
153151 // worker process alive between turns for explicit follow-ups.
154152 SubagentTurnTimeout time.Duration
@@ -387,16 +385,6 @@ func ParseArgs(in []string) (Args, error) {
387385 return a, fmt.Errorf("--max-steps must be a positive integer")
388386 }
389387 a.MaxSteps = n
390- case "--max-turns":
391- v, err := want(&i, arg)
392- if err != nil {
393- return a, err
394- }
395- var n int
396- if _, err := fmt.Sscanf(v, "%d", &n); err != nil || n <= 0 {
397- return a, fmt.Errorf("--max-turns must be a positive integer")
398- }
399- a.SubagentMaxTurns = n
400388 case "--subagent-turn-timeout":
401389 v, err := want(&i, arg)
402390 if err != nil {
@@ -147,8 +147,6 @@ type Args struct {
147 // --subagent-worker.147 // --subagent-worker.
148 SubagentWorker string148 SubagentWorker string
149 149
150- // SubagentMaxTurns limits message turns in one worker run.
151- SubagentMaxTurns int
152 // SubagentTurnTimeout limits each active delegated turn while leaving the150 // SubagentTurnTimeout limits each active delegated turn while leaving the
153 // worker process alive between turns for explicit follow-ups.151 // worker process alive between turns for explicit follow-ups.
154 SubagentTurnTimeout time.Duration152 SubagentTurnTimeout time.Duration
@@ -387,16 +385,6 @@ func ParseArgs(in []string) (Args, error) {
387 return a, fmt.Errorf("--max-steps must be a positive integer")385 return a, fmt.Errorf("--max-steps must be a positive integer")
388 }386 }
389 a.MaxSteps = n387 a.MaxSteps = n
390- case "--max-turns":
391- v, err := want(&i, arg)
392- if err != nil {
393- return a, err
394- }
395- var n int
396- if _, err := fmt.Sscanf(v, "%d", &n); err != nil || n <= 0 {
397- return a, fmt.Errorf("--max-turns must be a positive integer")
398- }
399- a.SubagentMaxTurns = n
400 case "--subagent-turn-timeout":388 case "--subagent-turn-timeout":
401 v, err := want(&i, arg)389 v, err := want(&i, arg)
402 if err != nil {390 if err != nil {
M packages/agent/config.go +0 -2
@@ -30,8 +30,6 @@ type SubagentsConfig struct {
3030 QueueTimeout string `json:"queue_timeout,omitempty"`
3131 StartupTimeout string `json:"startup_timeout,omitempty"`
3232 DefaultTimeout string `json:"default_timeout,omitempty"`
33- MaxTurns int `json:"max_turns,omitempty"`
34- MaxSteps int `json:"max_steps,omitempty"`
3533 MaxOutputBytes int `json:"max_output_bytes,omitempty"`
3634 MaxOutputLines int `json:"max_output_lines,omitempty"`
3735 AllowedTools []string `json:"allowed_tools,omitempty"`
@@ -30,8 +30,6 @@ type SubagentsConfig struct {
30 QueueTimeout string `json:"queue_timeout,omitempty"`30 QueueTimeout string `json:"queue_timeout,omitempty"`
31 StartupTimeout string `json:"startup_timeout,omitempty"`31 StartupTimeout string `json:"startup_timeout,omitempty"`
32 DefaultTimeout string `json:"default_timeout,omitempty"`32 DefaultTimeout string `json:"default_timeout,omitempty"`
33- MaxTurns int `json:"max_turns,omitempty"`
34- MaxSteps int `json:"max_steps,omitempty"`
35 MaxOutputBytes int `json:"max_output_bytes,omitempty"`33 MaxOutputBytes int `json:"max_output_bytes,omitempty"`
36 MaxOutputLines int `json:"max_output_lines,omitempty"`34 MaxOutputLines int `json:"max_output_lines,omitempty"`
37 AllowedTools []string `json:"allowed_tools,omitempty"`35 AllowedTools []string `json:"allowed_tools,omitempty"`
M packages/agent/modes/subagent_indicator.go +24 -8
@@ -10,6 +10,11 @@ import (
1010 "github.com/mattn/go-runewidth"
1111 )
1212
13+var providerWaitingSpinner = tui.NewStringSpinner(
14+ []string{"Zzzz", "zZzz", "zzZz", "zzzZ"},
15+ time.Second,
16+)
17+
1318 // renderSubagentActivityLines renders only trace-observed open operations.
1419 // A living process, generic lifecycle state, or heartbeat never starts a
1520 // spinner because none proves that an operation is progressing.
@@ -17,14 +22,11 @@ func renderSubagentActivityLines(th tui.Theme, spinnerGlyph string, snapshots []
1722 lines := make([]string, 0, len(snapshots))
1823 for _, snapshot := range snapshots {
1924 view := views[snapshot.ID]
20- operation := view.PrimaryOperation
21- if operation == nil && len(view.OpenOperations) != 0 {
22- operation = &view.OpenOperations[0]
23- }
24- if operation == nil {
25+ operation, ok := view.Primary()
26+ if !ok {
2527 continue
2628 }
27- line := renderSubagentActivityLine(th, spinnerGlyph, snapshot, *operation, view.TurnStartedAt(*operation), view.ObservationFor(*operation), width, now)
29+ line := renderSubagentActivityLine(th, spinnerGlyph, snapshot, operation, view.TurnStartedAt(operation), view.ObservationFor(operation), width, now)
2830 if line != "" {
2931 lines = append(lines, line)
3032 }
@@ -44,7 +46,7 @@ func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot suba
4446 if spinnerGlyph == "" {
4547 spinnerGlyph = "."
4648 }
47- activity := operation.Label()
49+ activity := subagentOperationActivity(operation, now)
4850 if observation != nil {
4951 activity += " · " + observation.Label() + " " + formatSubagentActivityAge(observation.At, now) + " ago"
5052 }
@@ -55,6 +57,13 @@ func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot suba
5557 turnStartedAt = operation.StartedAt
5658 }
5759 age := formatSubagentActivityAge(turnStartedAt, now)
60+ if operation.Step > 0 {
61+ label := "steps"
62+ if operation.Step == 1 {
63+ label = "step"
64+ }
65+ age = fmt.Sprintf("%d %s · %s", operation.Step, label, age)
66+ }
5867 plain, layout := fitSubagentActivityLine(spinnerGlyph, name, activity, age, width-2)
5968 if plain == "" {
6069 return ""
@@ -83,6 +92,13 @@ func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot suba
8392 }
8493 }
8594
95+func subagentOperationActivity(operation subagents.Operation, now time.Time) string {
96+ if operation.Type == "provider.request.started" {
97+ return providerWaitingSpinner.FrameAt(operation.StartedAt, now)
98+ }
99+ return operation.Label()
100+}
101+
86102 func subagentOperationColor(th tui.Theme, operation subagents.Operation) tui.TerminalColor {
87103 switch operation.Type {
88104 case "provider.request.started":
@@ -192,7 +208,7 @@ func (i *Interactive) activeSubagentActivitySnapshots() ([]subagents.AgentSnapsh
192208 continue
193209 }
194210 view := views[agent.ID]
195- if view.PrimaryOperation == nil && len(view.OpenOperations) == 0 {
211+ if _, ok := view.Primary(); !ok {
196212 continue
197213 }
198214 out = append(out, subagents.AgentSnapshot{ID: agent.ID, Started: agent.Started, Subagent: agent.Subagent})
@@ -10,6 +10,11 @@ import (
10 "github.com/mattn/go-runewidth"10 "github.com/mattn/go-runewidth"
11 )11 )
12 12
13+var providerWaitingSpinner = tui.NewStringSpinner(
14+ []string{"Zzzz", "zZzz", "zzZz", "zzzZ"},
15+ time.Second,
16+)
17+
13 // renderSubagentActivityLines renders only trace-observed open operations.18 // renderSubagentActivityLines renders only trace-observed open operations.
14 // A living process, generic lifecycle state, or heartbeat never starts a19 // A living process, generic lifecycle state, or heartbeat never starts a
15 // spinner because none proves that an operation is progressing.20 // spinner because none proves that an operation is progressing.
@@ -17,14 +22,11 @@ func renderSubagentActivityLines(th tui.Theme, spinnerGlyph string, snapshots []
17 lines := make([]string, 0, len(snapshots))22 lines := make([]string, 0, len(snapshots))
18 for _, snapshot := range snapshots {23 for _, snapshot := range snapshots {
19 view := views[snapshot.ID]24 view := views[snapshot.ID]
20- operation := view.PrimaryOperation25+ operation, ok := view.Primary()
21- if operation == nil && len(view.OpenOperations) != 0 {26+ if !ok {
22- operation = &view.OpenOperations[0]
23- }
24- if operation == nil {
25 continue27 continue
26 }28 }
27- line := renderSubagentActivityLine(th, spinnerGlyph, snapshot, *operation, view.TurnStartedAt(*operation), view.ObservationFor(*operation), width, now)29+ line := renderSubagentActivityLine(th, spinnerGlyph, snapshot, operation, view.TurnStartedAt(operation), view.ObservationFor(operation), width, now)
28 if line != "" {30 if line != "" {
29 lines = append(lines, line)31 lines = append(lines, line)
30 }32 }
@@ -44,7 +46,7 @@ func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot suba
44 if spinnerGlyph == "" {46 if spinnerGlyph == "" {
45 spinnerGlyph = "."47 spinnerGlyph = "."
46 }48 }
47- activity := operation.Label()49+ activity := subagentOperationActivity(operation, now)
48 if observation != nil {50 if observation != nil {
49 activity += " · " + observation.Label() + " " + formatSubagentActivityAge(observation.At, now) + " ago"51 activity += " · " + observation.Label() + " " + formatSubagentActivityAge(observation.At, now) + " ago"
50 }52 }
@@ -55,6 +57,13 @@ func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot suba
55 turnStartedAt = operation.StartedAt57 turnStartedAt = operation.StartedAt
56 }58 }
57 age := formatSubagentActivityAge(turnStartedAt, now)59 age := formatSubagentActivityAge(turnStartedAt, now)
60+ if operation.Step > 0 {
61+ label := "steps"
62+ if operation.Step == 1 {
63+ label = "step"
64+ }
65+ age = fmt.Sprintf("%d %s · %s", operation.Step, label, age)
66+ }
58 plain, layout := fitSubagentActivityLine(spinnerGlyph, name, activity, age, width-2)67 plain, layout := fitSubagentActivityLine(spinnerGlyph, name, activity, age, width-2)
59 if plain == "" {68 if plain == "" {
60 return ""69 return ""
@@ -83,6 +92,13 @@ func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot suba
83 }92 }
84 }93 }
85 94
95+func subagentOperationActivity(operation subagents.Operation, now time.Time) string {
96+ if operation.Type == "provider.request.started" {
97+ return providerWaitingSpinner.FrameAt(operation.StartedAt, now)
98+ }
99+ return operation.Label()
100+}
101+
86 func subagentOperationColor(th tui.Theme, operation subagents.Operation) tui.TerminalColor {102 func subagentOperationColor(th tui.Theme, operation subagents.Operation) tui.TerminalColor {
87 switch operation.Type {103 switch operation.Type {
88 case "provider.request.started":104 case "provider.request.started":
@@ -192,7 +208,7 @@ func (i *Interactive) activeSubagentActivitySnapshots() ([]subagents.AgentSnapsh
192 continue208 continue
193 }209 }
194 view := views[agent.ID]210 view := views[agent.ID]
195- if view.PrimaryOperation == nil && len(view.OpenOperations) == 0 {211+ if _, ok := view.Primary(); !ok {
196 continue212 continue
197 }213 }
198 out = append(out, subagents.AgentSnapshot{ID: agent.ID, Started: agent.Started, Subagent: agent.Subagent})214 out = append(out, subagents.AgentSnapshot{ID: agent.ID, Started: agent.Started, Subagent: agent.Subagent})
M packages/agent/modes/subagent_indicator_test.go +32 -7
@@ -30,7 +30,7 @@ func TestRenderSubagentActivityLinesShowsOnlyTraceOpenOperations(t *testing.T) {
3030 func TestRenderSubagentActivityLinesShowsSafeLastObservation(t *testing.T) {
3131 now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
3232 lines := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{
33- "review-123": {PrimaryOperation: &subagents.Operation{Type: "turn.started", TurnID: "turn-1", StartedAt: now.Add(-time.Minute)}, LastObservation: &subagents.LiveObservation{Type: "assistant.stream.observed", TurnID: "turn-1", At: now.Add(-time.Second)}},
33+ "review-123": {OpenOperations: []subagents.Operation{{Type: "turn.started", TurnID: "turn-1", StartedAt: now.Add(-time.Minute)}}, LastObservation: &subagents.LiveObservation{Type: "assistant.stream.observed", TurnID: "turn-1", At: now.Add(-time.Second)}},
3434 }, 100, now))
3535 if got := strings.Join(lines, "\n"); !strings.Contains(got, "processing turn · assistant streaming 1s ago") || strings.Contains(got, "delta") {
3636 t.Fatalf("activity lines = %#v", lines)
@@ -40,10 +40,10 @@ func TestRenderSubagentActivityLinesShowsSafeLastObservation(t *testing.T) {
4040 func TestRenderSubagentActivityLinesShowsRecentCompletedToolUnderProviderRequest(t *testing.T) {
4141 now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
4242 lines := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{
43- "review-123": {PrimaryOperation: &subagents.Operation{Type: "provider.request.started", TurnID: "turn-1", StartedAt: now.Add(-2 * time.Second)}, LastObservation: &subagents.LiveObservation{Type: "tool.finished", Name: "read", CallID: "call-1", TurnID: "turn-1", At: now.Add(-3 * time.Second)}},
43+ "review-123": {OpenOperations: []subagents.Operation{{Type: "provider.request.started", TurnID: "turn-1", StartedAt: now.Add(-2 * time.Second)}}, LastObservation: &subagents.LiveObservation{Type: "tool.finished", Name: "read", CallID: "call-1", TurnID: "turn-1", At: now.Add(-3 * time.Second)}},
4444 }, 100, now))
4545 got := strings.Join(lines, "\n")
46- if !strings.Contains(got, "waiting for model response · finished read 3s ago · 2s") {
46+ if !strings.Contains(got, "zzZz · finished read 3s ago · 2s") {
4747 t.Fatalf("activity lines = %#v", lines)
4848 }
4949 }
@@ -52,15 +52,40 @@ func TestRenderSubagentActivityLineColorsProviderWaitAsActive(t *testing.T) {
5252 now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
5353 operation := subagents.Operation{Type: "provider.request.started", TurnID: "turn-1", StartedAt: now.Add(-2 * time.Second)}
5454 line := renderSubagentActivityLine(tui.Dark, "/", subagents.AgentSnapshot{ID: "review-123", Subagent: "reviewer"}, operation, operation.StartedAt, nil, 100, now)
55- if !strings.Contains(line, tui.Dark.FGColor(tui.Dark.Spinner, "waiting for model response")) {
55+ if !strings.Contains(line, tui.Dark.FGColor(tui.Dark.Spinner, "zzZz")) {
5656 t.Fatalf("provider wait is not rendered with the active color: %q", line)
5757 }
5858 }
5959
60+func TestRenderSubagentActivityLineAnimatesProviderWaitAtOneHertz(t *testing.T) {
61+ started := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
62+ operation := subagents.Operation{Type: "provider.request.started", TurnID: "turn-1", StartedAt: started}
63+ snapshot := subagents.AgentSnapshot{ID: "review-123", Subagent: "reviewer"}
64+
65+ wants := []string{"Zzzz", "zZzz", "zzZz", "zzzZ", "Zzzz"}
66+ for second, want := range wants {
67+ line := stripANSIBytes(renderSubagentActivityLine(tui.Dark, "/", snapshot, operation, started, nil, 100, started.Add(time.Duration(second)*time.Second)))
68+ if !strings.Contains(line, "reviewer · "+want+" · ") {
69+ t.Fatalf("provider wait at %ds = %q, want frame %q", second, line, want)
70+ }
71+ }
72+}
73+
74+func TestRenderSubagentActivityLineShowsModelStepBesideElapsedTurnTime(t *testing.T) {
75+ started := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
76+ operation := subagents.Operation{Type: "provider.request.started", TurnID: "turn-1", Step: 3, StartedAt: started.Add(8 * time.Second)}
77+ snapshot := subagents.AgentSnapshot{ID: "review-123", Subagent: "reviewer"}
78+
79+ line := stripANSIBytes(renderSubagentActivityLine(tui.Dark, "/", snapshot, operation, started, nil, 100, started.Add(12*time.Second)))
80+ if !strings.Contains(line, "reviewer · Zzzz · 3 steps · 12s") {
81+ t.Fatalf("provider wait line = %q, want model step beside delegated-turn elapsed time", line)
82+ }
83+}
84+
6085 func TestRenderSubagentActivityLinesOmitsPriorTurnObservation(t *testing.T) {
6186 now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
6287 lines := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{
63- "review-123": {PrimaryOperation: &subagents.Operation{Type: "tool.started", TurnID: "turn-2", StartedAt: now.Add(-time.Minute)}, LastObservation: &subagents.LiveObservation{Type: "assistant.stream.observed", TurnID: "turn-1", At: now.Add(-time.Second)}},
88+ "review-123": {OpenOperations: []subagents.Operation{{Type: "tool.started", TurnID: "turn-2", StartedAt: now.Add(-time.Minute)}}, LastObservation: &subagents.LiveObservation{Type: "assistant.stream.observed", TurnID: "turn-1", At: now.Add(-time.Second)}},
6489 }, 100, now))
6590 if got := strings.Join(lines, "\n"); strings.Contains(got, "assistant streaming") || !strings.Contains(got, "running tool") || !strings.Contains(got, "1m") {
6691 t.Fatalf("activity lines = %#v", lines)
@@ -103,10 +128,10 @@ func TestRenderSubagentActivityLinesUsesStableDelegatedTurnElapsedTime(t *testin
103128 turn := subagents.Operation{Type: "turn.started", TurnID: "turn-1", StartedAt: now.Add(-12 * time.Second)}
104129 request := subagents.Operation{Type: "provider.request.started", TurnID: "turn-1", StartedAt: now.Add(-time.Second)}
105130 lines := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer", Started: now.Add(-5*time.Hour - 16*time.Minute)}}, map[string]subagents.AgentTraceView{
106- "review-123": {PrimaryOperation: &request, OpenOperations: []subagents.Operation{request, turn}},
131+ "review-123": {OpenOperations: []subagents.Operation{request, turn}},
107132 }, 100, now))
108133 got := strings.Join(lines, "\n")
109- if !strings.Contains(got, "waiting for model response · 12s") || strings.Contains(got, "5h16m") {
134+ if !strings.Contains(got, "zZzz · 12s") || strings.Contains(got, "5h16m") {
110135 t.Fatalf("activity lines = %#v, want current delegated-turn elapsed time", lines)
111136 }
112137 }
@@ -30,7 +30,7 @@ func TestRenderSubagentActivityLinesShowsOnlyTraceOpenOperations(t *testing.T) {
30 func TestRenderSubagentActivityLinesShowsSafeLastObservation(t *testing.T) {30 func TestRenderSubagentActivityLinesShowsSafeLastObservation(t *testing.T) {
31 now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)31 now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
32 lines := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{32 lines := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{
33- "review-123": {PrimaryOperation: &subagents.Operation{Type: "turn.started", TurnID: "turn-1", StartedAt: now.Add(-time.Minute)}, LastObservation: &subagents.LiveObservation{Type: "assistant.stream.observed", TurnID: "turn-1", At: now.Add(-time.Second)}},33+ "review-123": {OpenOperations: []subagents.Operation{{Type: "turn.started", TurnID: "turn-1", StartedAt: now.Add(-time.Minute)}}, LastObservation: &subagents.LiveObservation{Type: "assistant.stream.observed", TurnID: "turn-1", At: now.Add(-time.Second)}},
34 }, 100, now))34 }, 100, now))
35 if got := strings.Join(lines, "\n"); !strings.Contains(got, "processing turn · assistant streaming 1s ago") || strings.Contains(got, "delta") {35 if got := strings.Join(lines, "\n"); !strings.Contains(got, "processing turn · assistant streaming 1s ago") || strings.Contains(got, "delta") {
36 t.Fatalf("activity lines = %#v", lines)36 t.Fatalf("activity lines = %#v", lines)
@@ -40,10 +40,10 @@ func TestRenderSubagentActivityLinesShowsSafeLastObservation(t *testing.T) {
40 func TestRenderSubagentActivityLinesShowsRecentCompletedToolUnderProviderRequest(t *testing.T) {40 func TestRenderSubagentActivityLinesShowsRecentCompletedToolUnderProviderRequest(t *testing.T) {
41 now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)41 now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
42 lines := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{42 lines := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{
43- "review-123": {PrimaryOperation: &subagents.Operation{Type: "provider.request.started", TurnID: "turn-1", StartedAt: now.Add(-2 * time.Second)}, LastObservation: &subagents.LiveObservation{Type: "tool.finished", Name: "read", CallID: "call-1", TurnID: "turn-1", At: now.Add(-3 * time.Second)}},43+ "review-123": {OpenOperations: []subagents.Operation{{Type: "provider.request.started", TurnID: "turn-1", StartedAt: now.Add(-2 * time.Second)}}, LastObservation: &subagents.LiveObservation{Type: "tool.finished", Name: "read", CallID: "call-1", TurnID: "turn-1", At: now.Add(-3 * time.Second)}},
44 }, 100, now))44 }, 100, now))
45 got := strings.Join(lines, "\n")45 got := strings.Join(lines, "\n")
46- if !strings.Contains(got, "waiting for model response · finished read 3s ago · 2s") {46+ if !strings.Contains(got, "zzZz · finished read 3s ago · 2s") {
47 t.Fatalf("activity lines = %#v", lines)47 t.Fatalf("activity lines = %#v", lines)
48 }48 }
49 }49 }
@@ -52,15 +52,40 @@ func TestRenderSubagentActivityLineColorsProviderWaitAsActive(t *testing.T) {
52 now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)52 now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
53 operation := subagents.Operation{Type: "provider.request.started", TurnID: "turn-1", StartedAt: now.Add(-2 * time.Second)}53 operation := subagents.Operation{Type: "provider.request.started", TurnID: "turn-1", StartedAt: now.Add(-2 * time.Second)}
54 line := renderSubagentActivityLine(tui.Dark, "/", subagents.AgentSnapshot{ID: "review-123", Subagent: "reviewer"}, operation, operation.StartedAt, nil, 100, now)54 line := renderSubagentActivityLine(tui.Dark, "/", subagents.AgentSnapshot{ID: "review-123", Subagent: "reviewer"}, operation, operation.StartedAt, nil, 100, now)
55- if !strings.Contains(line, tui.Dark.FGColor(tui.Dark.Spinner, "waiting for model response")) {55+ if !strings.Contains(line, tui.Dark.FGColor(tui.Dark.Spinner, "zzZz")) {
56 t.Fatalf("provider wait is not rendered with the active color: %q", line)56 t.Fatalf("provider wait is not rendered with the active color: %q", line)
57 }57 }
58 }58 }
59 59
60+func TestRenderSubagentActivityLineAnimatesProviderWaitAtOneHertz(t *testing.T) {
61+ started := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
62+ operation := subagents.Operation{Type: "provider.request.started", TurnID: "turn-1", StartedAt: started}
63+ snapshot := subagents.AgentSnapshot{ID: "review-123", Subagent: "reviewer"}
64+
65+ wants := []string{"Zzzz", "zZzz", "zzZz", "zzzZ", "Zzzz"}
66+ for second, want := range wants {
67+ line := stripANSIBytes(renderSubagentActivityLine(tui.Dark, "/", snapshot, operation, started, nil, 100, started.Add(time.Duration(second)*time.Second)))
68+ if !strings.Contains(line, "reviewer · "+want+" · ") {
69+ t.Fatalf("provider wait at %ds = %q, want frame %q", second, line, want)
70+ }
71+ }
72+}
73+
74+func TestRenderSubagentActivityLineShowsModelStepBesideElapsedTurnTime(t *testing.T) {
75+ started := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
76+ operation := subagents.Operation{Type: "provider.request.started", TurnID: "turn-1", Step: 3, StartedAt: started.Add(8 * time.Second)}
77+ snapshot := subagents.AgentSnapshot{ID: "review-123", Subagent: "reviewer"}
78+
79+ line := stripANSIBytes(renderSubagentActivityLine(tui.Dark, "/", snapshot, operation, started, nil, 100, started.Add(12*time.Second)))
80+ if !strings.Contains(line, "reviewer · Zzzz · 3 steps · 12s") {
81+ t.Fatalf("provider wait line = %q, want model step beside delegated-turn elapsed time", line)
82+ }
83+}
84+
60 func TestRenderSubagentActivityLinesOmitsPriorTurnObservation(t *testing.T) {85 func TestRenderSubagentActivityLinesOmitsPriorTurnObservation(t *testing.T) {
61 now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)86 now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
62 lines := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{87 lines := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{
63- "review-123": {PrimaryOperation: &subagents.Operation{Type: "tool.started", TurnID: "turn-2", StartedAt: now.Add(-time.Minute)}, LastObservation: &subagents.LiveObservation{Type: "assistant.stream.observed", TurnID: "turn-1", At: now.Add(-time.Second)}},88+ "review-123": {OpenOperations: []subagents.Operation{{Type: "tool.started", TurnID: "turn-2", StartedAt: now.Add(-time.Minute)}}, LastObservation: &subagents.LiveObservation{Type: "assistant.stream.observed", TurnID: "turn-1", At: now.Add(-time.Second)}},
64 }, 100, now))89 }, 100, now))
65 if got := strings.Join(lines, "\n"); strings.Contains(got, "assistant streaming") || !strings.Contains(got, "running tool") || !strings.Contains(got, "1m") {90 if got := strings.Join(lines, "\n"); strings.Contains(got, "assistant streaming") || !strings.Contains(got, "running tool") || !strings.Contains(got, "1m") {
66 t.Fatalf("activity lines = %#v", lines)91 t.Fatalf("activity lines = %#v", lines)
@@ -103,10 +128,10 @@ func TestRenderSubagentActivityLinesUsesStableDelegatedTurnElapsedTime(t *testin
103 turn := subagents.Operation{Type: "turn.started", TurnID: "turn-1", StartedAt: now.Add(-12 * time.Second)}128 turn := subagents.Operation{Type: "turn.started", TurnID: "turn-1", StartedAt: now.Add(-12 * time.Second)}
104 request := subagents.Operation{Type: "provider.request.started", TurnID: "turn-1", StartedAt: now.Add(-time.Second)}129 request := subagents.Operation{Type: "provider.request.started", TurnID: "turn-1", StartedAt: now.Add(-time.Second)}
105 lines := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer", Started: now.Add(-5*time.Hour - 16*time.Minute)}}, map[string]subagents.AgentTraceView{130 lines := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer", Started: now.Add(-5*time.Hour - 16*time.Minute)}}, map[string]subagents.AgentTraceView{
106- "review-123": {PrimaryOperation: &request, OpenOperations: []subagents.Operation{request, turn}},131+ "review-123": {OpenOperations: []subagents.Operation{request, turn}},
107 }, 100, now))132 }, 100, now))
108 got := strings.Join(lines, "\n")133 got := strings.Join(lines, "\n")
109- if !strings.Contains(got, "waiting for model response · 12s") || strings.Contains(got, "5h16m") {134+ if !strings.Contains(got, "zZzz · 12s") || strings.Contains(got, "5h16m") {
110 t.Fatalf("activity lines = %#v, want current delegated-turn elapsed time", lines)135 t.Fatalf("activity lines = %#v, want current delegated-turn elapsed time", lines)
111 }136 }
112 }137 }
M packages/agent/modes/subagents_dialog.go +2 -6
@@ -1641,12 +1641,8 @@ func (d *subagentsDialog) traceView(id string) subagents.AgentTraceView {
16411641 // completed write-tests-67890 1h add coverage
16421642 func formatSupervisorRow(r subagents.AgentSnapshot, view subagents.AgentTraceView, maxWidth int) string {
16431643 fact := view.Summary()
1644- primary := view.PrimaryOperation
1645- if primary == nil && len(view.OpenOperations) != 0 {
1646- primary = &view.OpenOperations[0]
1647- }
1648- if primary != nil {
1649- if observation := view.ObservationFor(*primary); observation != nil {
1644+ if primary, ok := view.Primary(); ok {
1645+ if observation := view.ObservationFor(primary); observation != nil {
16501646 fact += " " + formatAge(observation.At)
16511647 } else {
16521648 fact += " " + formatAge(primary.StartedAt)
@@ -1641,12 +1641,8 @@ func (d *subagentsDialog) traceView(id string) subagents.AgentTraceView {
1641 // completed write-tests-67890 1h add coverage1641 // completed write-tests-67890 1h add coverage
1642 func formatSupervisorRow(r subagents.AgentSnapshot, view subagents.AgentTraceView, maxWidth int) string {1642 func formatSupervisorRow(r subagents.AgentSnapshot, view subagents.AgentTraceView, maxWidth int) string {
1643 fact := view.Summary()1643 fact := view.Summary()
1644- primary := view.PrimaryOperation1644+ if primary, ok := view.Primary(); ok {
1645- if primary == nil && len(view.OpenOperations) != 0 {1645+ if observation := view.ObservationFor(primary); observation != nil {
1646- primary = &view.OpenOperations[0]
1647- }
1648- if primary != nil {
1649- if observation := view.ObservationFor(*primary); observation != nil {
1650 fact += " " + formatAge(observation.At)1646 fact += " " + formatAge(observation.At)
1651 } else {1647 } else {
1652 fact += " " + formatAge(primary.StartedAt)1648 fact += " " + formatAge(primary.StartedAt)
M packages/agent/modes/subagents_dialog_test.go +2 -2
@@ -38,8 +38,8 @@ func TestFormatSupervisorRowShowsTerminalResultDelivery(t *testing.T) {
3838 func TestFormatSupervisorRowOmitsPriorTurnObservationAge(t *testing.T) {
3939 now := time.Now()
4040 got := formatSupervisorRow(subagents.AgentSnapshot{ID: "review-1", Started: now.Add(-time.Hour)}, subagents.AgentTraceView{
41- PrimaryOperation: &subagents.Operation{Type: "tool.started", TurnID: "turn-2", StartedAt: now.Add(-time.Minute)},
42- LastObservation: &subagents.LiveObservation{Type: "assistant.stream.observed", TurnID: "turn-1", At: now.Add(-time.Second)},
41+ OpenOperations: []subagents.Operation{{Type: "tool.started", TurnID: "turn-2", StartedAt: now.Add(-time.Minute)}},
42+ LastObservation: &subagents.LiveObservation{Type: "assistant.stream.observed", TurnID: "turn-1", At: now.Add(-time.Second)},
4343 }, 120)
4444 if strings.Contains(got, "assistant streaming") || !strings.Contains(got, "running tool") || !strings.Contains(got, "1m") {
4545 t.Fatalf("row = %q", got)
@@ -38,8 +38,8 @@ func TestFormatSupervisorRowShowsTerminalResultDelivery(t *testing.T) {
38 func TestFormatSupervisorRowOmitsPriorTurnObservationAge(t *testing.T) {38 func TestFormatSupervisorRowOmitsPriorTurnObservationAge(t *testing.T) {
39 now := time.Now()39 now := time.Now()
40 got := formatSupervisorRow(subagents.AgentSnapshot{ID: "review-1", Started: now.Add(-time.Hour)}, subagents.AgentTraceView{40 got := formatSupervisorRow(subagents.AgentSnapshot{ID: "review-1", Started: now.Add(-time.Hour)}, subagents.AgentTraceView{
41- PrimaryOperation: &subagents.Operation{Type: "tool.started", TurnID: "turn-2", StartedAt: now.Add(-time.Minute)},41+ OpenOperations: []subagents.Operation{{Type: "tool.started", TurnID: "turn-2", StartedAt: now.Add(-time.Minute)}},
42- LastObservation: &subagents.LiveObservation{Type: "assistant.stream.observed", TurnID: "turn-1", At: now.Add(-time.Second)},42+ LastObservation: &subagents.LiveObservation{Type: "assistant.stream.observed", TurnID: "turn-1", At: now.Add(-time.Second)},
43 }, 120)43 }, 120)
44 if strings.Contains(got, "assistant streaming") || !strings.Contains(got, "running tool") || !strings.Contains(got, "1m") {44 if strings.Contains(got, "assistant streaming") || !strings.Contains(got, "running tool") || !strings.Contains(got, "1m") {
45 t.Fatalf("row = %q", got)45 t.Fatalf("row = %q", got)
M packages/agent/subagent_runtime.go +0 -2
@@ -499,8 +499,6 @@ func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy {
499499 QueueTimeout: parseDuration(cfg.QueueTimeout),
500500 StartupTimeout: parseDuration(cfg.StartupTimeout),
501501 DefaultTimeout: parseDuration(cfg.DefaultTimeout),
502- MaxTurns: cfg.MaxTurns,
503- MaxSteps: cfg.MaxSteps,
504502 MaxOutputBytes: cfg.MaxOutputBytes,
505503 MaxOutputLines: cfg.MaxOutputLines,
506504 AllowedTools: append([]string(nil), cfg.AllowedTools...),
@@ -499,8 +499,6 @@ func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy {
499 QueueTimeout: parseDuration(cfg.QueueTimeout),499 QueueTimeout: parseDuration(cfg.QueueTimeout),
500 StartupTimeout: parseDuration(cfg.StartupTimeout),500 StartupTimeout: parseDuration(cfg.StartupTimeout),
501 DefaultTimeout: parseDuration(cfg.DefaultTimeout),501 DefaultTimeout: parseDuration(cfg.DefaultTimeout),
502- MaxTurns: cfg.MaxTurns,
503- MaxSteps: cfg.MaxSteps,
504 MaxOutputBytes: cfg.MaxOutputBytes,502 MaxOutputBytes: cfg.MaxOutputBytes,
505 MaxOutputLines: cfg.MaxOutputLines,503 MaxOutputLines: cfg.MaxOutputLines,
506 AllowedTools: append([]string(nil), cfg.AllowedTools...),504 AllowedTools: append([]string(nil), cfg.AllowedTools...),
M packages/agent/subagent_runtime_test.go +0 -1
@@ -59,7 +59,6 @@ func newRuntimeTestConfig(root, repoRoot string, runner func(*subagents.Agent) s
5959 FastMode: true,
6060 Policy: subagents.SubagentPolicy{
6161 MaxConcurrent: 2,
62- MaxTurns: 4,
6362 DefaultTimeout: time.Minute,
6463 CancelGracePeriod: time.Second,
6564 },
@@ -59,7 +59,6 @@ func newRuntimeTestConfig(root, repoRoot string, runner func(*subagents.Agent) s
59 FastMode: true,59 FastMode: true,
60 Policy: subagents.SubagentPolicy{60 Policy: subagents.SubagentPolicy{
61 MaxConcurrent: 2,61 MaxConcurrent: 2,
62- MaxTurns: 4,
63 DefaultTimeout: time.Minute,62 DefaultTimeout: time.Minute,
64 CancelGracePeriod: time.Second,63 CancelGracePeriod: time.Second,
65 },64 },
M packages/agent/subagent_worker.go +8 -33
@@ -34,10 +34,9 @@ import (
3434 // production; tests use the stubchild binary under
3535 // packages/agent/subagents/testdata/cmd/stubchild instead of the real model
3636 // loop.
37-// workerTurnBudget is deliberately independent of the model loop: one
38-// admitted message turn consumes one current-run and lifetime turn, while
39-// retries, compaction, and provider/tool loops remain inside that turn.
40-type workerTurnBudget struct {
37+// workerTurnCounters track stable delegated-turn identity and observability.
38+// Retries, compaction, and provider/tool loops remain inside one message turn.
39+type workerTurnCounters struct {
4140 sequence int
4241 lifetime int
4342 current int
@@ -71,17 +70,14 @@ func (s *sessionPersistenceState) err() error {
7170 return s.errValue
7271 }
7372
74-func (b *workerTurnBudget) start(maxTurns int, newRun bool) (step, lifetime, current int, admitted bool) {
73+func (b *workerTurnCounters) start(newRun bool) (step, lifetime, current int) {
7574 if newRun {
7675 b.current = 0
7776 }
7877 b.sequence++
79- if maxTurns > 0 && b.current >= maxTurns {
80- return b.sequence, b.lifetime, b.current, false
81- }
8278 b.lifetime++
8379 b.current++
84- return b.sequence, b.lifetime, b.current, true
80+ return b.sequence, b.lifetime, b.current
8581 }
8682
8783 func subagentTurnContext(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
@@ -188,7 +184,6 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
188184 "model": r.Model,
189185 "lifetime_turns": args.SubagentLifetimeTurns,
190186 "current_run_turns": args.SubagentRunTurns,
191- "max_turns": args.SubagentMaxTurns,
192187 })
193188
194189 // Keep a per-turn cancel so the "cancel" inbox message can interrupt
@@ -197,7 +192,8 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
197192 mu sync.Mutex
198193 cancelFn context.CancelFunc
199194 busyTurn bool
200- budget = workerTurnBudget{
195+ counters = workerTurnCounters{
196+ sequence: args.SubagentLifetimeTurns,
201197 lifetime: args.SubagentLifetimeTurns,
202198 current: args.SubagentRunTurns,
203199 }
@@ -225,25 +221,7 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
225221 }
226222 busyTurn = true
227223 turnPending = false
228- step, lifetime, currentRun, admitted := budget.start(args.SubagentMaxTurns, newRun)
229- if !admitted {
230- busyTurn = false
231- mu.Unlock()
232- turnID := fmt.Sprintf("turn-%d", step)
233- errPayload := map[string]any{"code": "turn_rejected", "reason": "max_turns", "command_id": commandID, "message": "maximum subagent turns reached"}
234- em.emit("error", errPayload)
235- em.emit("turn.result", map[string]any{"status": "failed", "turn_id": turnID, "error": errPayload})
236- em.emit("turn.failed", map[string]any{"turn_id": turnID, "error": errPayload})
237- em.emit("turn_end", map[string]any{"step": step, "turn_id": turnID, "error": errPayload["message"]})
238- // Max-turn rejection ends the current run, but the worker remains
239- // alive and can accept a queued follow-up as a fresh run.
240- em.emit("agent.idle", map[string]any{
241- "turn_id": turnID,
242- "lifetime_turns": lifetime,
243- "current_run_turns": currentRun,
244- })
245- return
246- }
224+ step, lifetime, currentRun := counters.start(newRun)
247225 turnID := fmt.Sprintf("turn-%d", step)
248226 em.setTurnID(turnID)
249227 c, cancel := subagentTurnContext(ctx, args.SubagentTurnTimeout)
@@ -707,9 +685,6 @@ func resultErrorPayload(err error, shutdownOrigin subagents.ShutdownOrigin) map[
707685 if errors.Is(err, core.ErrStreamIdleTimeout) {
708686 return map[string]any{"code": "stream_idle_timeout", "message": "provider stream produced no events for five minutes; the delegated turn stopped safely"}
709687 }
710- if errors.Is(err, core.ErrMaxSteps) {
711- return map[string]any{"code": "step_limit", "message": "subagent model-step limit reached; partial output is preserved and the worker can be resumed"}
712- }
713688 if errors.Is(err, context.DeadlineExceeded) || shutdownOrigin == subagents.ShutdownOriginDeadline {
714689 return map[string]any{"code": "deadline_exceeded", "message": "subagent turn deadline exceeded; partial output is preserved in the result and history"}
715690 }
@@ -34,10 +34,9 @@ import (
34 // production; tests use the stubchild binary under34 // production; tests use the stubchild binary under
35 // packages/agent/subagents/testdata/cmd/stubchild instead of the real model35 // packages/agent/subagents/testdata/cmd/stubchild instead of the real model
36 // loop.36 // loop.
37-// workerTurnBudget is deliberately independent of the model loop: one37+// workerTurnCounters track stable delegated-turn identity and observability.
38-// admitted message turn consumes one current-run and lifetime turn, while38+// Retries, compaction, and provider/tool loops remain inside one message turn.
39-// retries, compaction, and provider/tool loops remain inside that turn.39+type workerTurnCounters struct {
40-type workerTurnBudget struct {
41 sequence int40 sequence int
42 lifetime int41 lifetime int
43 current int42 current int
@@ -71,17 +70,14 @@ func (s *sessionPersistenceState) err() error {
71 return s.errValue70 return s.errValue
72 }71 }
73 72
74-func (b *workerTurnBudget) start(maxTurns int, newRun bool) (step, lifetime, current int, admitted bool) {73+func (b *workerTurnCounters) start(newRun bool) (step, lifetime, current int) {
75 if newRun {74 if newRun {
76 b.current = 075 b.current = 0
77 }76 }
78 b.sequence++77 b.sequence++
79- if maxTurns > 0 && b.current >= maxTurns {
80- return b.sequence, b.lifetime, b.current, false
81- }
82 b.lifetime++78 b.lifetime++
83 b.current++79 b.current++
84- return b.sequence, b.lifetime, b.current, true80+ return b.sequence, b.lifetime, b.current
85 }81 }
86 82
87 func subagentTurnContext(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {83 func subagentTurnContext(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
@@ -188,7 +184,6 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
188 "model": r.Model,184 "model": r.Model,
189 "lifetime_turns": args.SubagentLifetimeTurns,185 "lifetime_turns": args.SubagentLifetimeTurns,
190 "current_run_turns": args.SubagentRunTurns,186 "current_run_turns": args.SubagentRunTurns,
191- "max_turns": args.SubagentMaxTurns,
192 })187 })
193 188
194 // Keep a per-turn cancel so the "cancel" inbox message can interrupt189 // Keep a per-turn cancel so the "cancel" inbox message can interrupt
@@ -197,7 +192,8 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
197 mu sync.Mutex192 mu sync.Mutex
198 cancelFn context.CancelFunc193 cancelFn context.CancelFunc
199 busyTurn bool194 busyTurn bool
200- budget = workerTurnBudget{195+ counters = workerTurnCounters{
196+ sequence: args.SubagentLifetimeTurns,
201 lifetime: args.SubagentLifetimeTurns,197 lifetime: args.SubagentLifetimeTurns,
202 current: args.SubagentRunTurns,198 current: args.SubagentRunTurns,
203 }199 }
@@ -225,25 +221,7 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
225 }221 }
226 busyTurn = true222 busyTurn = true
227 turnPending = false223 turnPending = false
228- step, lifetime, currentRun, admitted := budget.start(args.SubagentMaxTurns, newRun)224+ step, lifetime, currentRun := counters.start(newRun)
229- if !admitted {
230- busyTurn = false
231- mu.Unlock()
232- turnID := fmt.Sprintf("turn-%d", step)
233- errPayload := map[string]any{"code": "turn_rejected", "reason": "max_turns", "command_id": commandID, "message": "maximum subagent turns reached"}
234- em.emit("error", errPayload)
235- em.emit("turn.result", map[string]any{"status": "failed", "turn_id": turnID, "error": errPayload})
236- em.emit("turn.failed", map[string]any{"turn_id": turnID, "error": errPayload})
237- em.emit("turn_end", map[string]any{"step": step, "turn_id": turnID, "error": errPayload["message"]})
238- // Max-turn rejection ends the current run, but the worker remains
239- // alive and can accept a queued follow-up as a fresh run.
240- em.emit("agent.idle", map[string]any{
241- "turn_id": turnID,
242- "lifetime_turns": lifetime,
243- "current_run_turns": currentRun,
244- })
245- return
246- }
247 turnID := fmt.Sprintf("turn-%d", step)225 turnID := fmt.Sprintf("turn-%d", step)
248 em.setTurnID(turnID)226 em.setTurnID(turnID)
249 c, cancel := subagentTurnContext(ctx, args.SubagentTurnTimeout)227 c, cancel := subagentTurnContext(ctx, args.SubagentTurnTimeout)
@@ -707,9 +685,6 @@ func resultErrorPayload(err error, shutdownOrigin subagents.ShutdownOrigin) map[
707 if errors.Is(err, core.ErrStreamIdleTimeout) {685 if errors.Is(err, core.ErrStreamIdleTimeout) {
708 return map[string]any{"code": "stream_idle_timeout", "message": "provider stream produced no events for five minutes; the delegated turn stopped safely"}686 return map[string]any{"code": "stream_idle_timeout", "message": "provider stream produced no events for five minutes; the delegated turn stopped safely"}
709 }687 }
710- if errors.Is(err, core.ErrMaxSteps) {
711- return map[string]any{"code": "step_limit", "message": "subagent model-step limit reached; partial output is preserved and the worker can be resumed"}
712- }
713 if errors.Is(err, context.DeadlineExceeded) || shutdownOrigin == subagents.ShutdownOriginDeadline {688 if errors.Is(err, context.DeadlineExceeded) || shutdownOrigin == subagents.ShutdownOriginDeadline {
714 return map[string]any{"code": "deadline_exceeded", "message": "subagent turn deadline exceeded; partial output is preserved in the result and history"}689 return map[string]any{"code": "deadline_exceeded", "message": "subagent turn deadline exceeded; partial output is preserved in the result and history"}
715 }690 }
M packages/agent/subagent_worker_test.go +14 -21
@@ -66,13 +66,6 @@ func TestResultErrorPayloadClassifiesTurnDeadline(t *testing.T) {
6666 }
6767 }
6868
69-func TestResultErrorPayloadClassifiesModelStepLimit(t *testing.T) {
70- payload := resultErrorPayload(core.ErrMaxSteps, "")
71- if payload["code"] != "step_limit" {
72- t.Fatalf("step-limit error code = %v, want step_limit", payload["code"])
73- }
74-}
75-
7669 func TestResultErrorPayloadAttributesShutdownCancellation(t *testing.T) {
7770 payload := resultErrorPayload(context.Canceled, subagents.ShutdownOriginSession)
7871 if payload["code"] != "shutdown" || payload["message"] != "subagent stopped during session shutdown" {
@@ -865,24 +858,24 @@ func TestSupervisorEmitterStdoutShapeMatchesSupervisorParser(t *testing.T) {
865858 }
866859 }
867860
868-func TestWorkerTurnBudgetFreshAllowanceOnlyForExplicitRun(t *testing.T) {
869- var budget workerTurnBudget
861+func TestWorkerTurnCountersRemainObservableWithoutAdmissionLimit(t *testing.T) {
862+ var counters workerTurnCounters
870863
871- if _, lifetime, current, admitted := budget.start(2, false); !admitted || lifetime != 1 || current != 1 {
872- t.Fatalf("first turn = (%d, %d, %v), want (1, 1, true)", lifetime, current, admitted)
864+ if step, lifetime, current := counters.start(false); step != 1 || lifetime != 1 || current != 1 {
865+ t.Fatalf("first turn = (%d, %d, %d), want (1, 1, 1)", step, lifetime, current)
873866 }
874- // Provider retries, model loops, and compaction stay inside the admitted
875- // turn and therefore do not call start or consume another allowance.
876- if budget.lifetime != 1 || budget.current != 1 {
877- t.Fatalf("nested work changed budget to (lifetime=%d, current=%d)", budget.lifetime, budget.current)
867+ // Provider retries, model loops, and compaction stay inside the delegated
868+ // turn and therefore do not advance the message-turn counters.
869+ if counters.lifetime != 1 || counters.current != 1 {
870+ t.Fatalf("nested work changed counters to (lifetime=%d, current=%d)", counters.lifetime, counters.current)
878871 }
879- if _, lifetime, current, admitted := budget.start(2, false); !admitted || lifetime != 2 || current != 2 {
880- t.Fatalf("second turn = (%d, %d, %v), want (2, 2, true)", lifetime, current, admitted)
872+ if step, lifetime, current := counters.start(false); step != 2 || lifetime != 2 || current != 2 {
873+ t.Fatalf("second turn = (%d, %d, %d), want (2, 2, 2)", step, lifetime, current)
881874 }
882- if _, lifetime, current, admitted := budget.start(2, false); admitted || lifetime != 2 || current != 2 {
883- t.Fatalf("exhausted turn = (%d, %d, %v), want (2, 2, false)", lifetime, current, admitted)
875+ if step, lifetime, current := counters.start(false); step != 3 || lifetime != 3 || current != 3 {
876+ t.Fatalf("third turn = (%d, %d, %d), want (3, 3, 3)", step, lifetime, current)
884877 }
885- if _, lifetime, current, admitted := budget.start(2, true); !admitted || lifetime != 3 || current != 1 {
886- t.Fatalf("explicit resumed turn = (%d, %d, %v), want (3, 1, true)", lifetime, current, admitted)
878+ if step, lifetime, current := counters.start(true); step != 4 || lifetime != 4 || current != 1 {
879+ t.Fatalf("explicit resumed turn = (%d, %d, %d), want (4, 4, 1)", step, lifetime, current)
887880 }
888881 }
@@ -66,13 +66,6 @@ func TestResultErrorPayloadClassifiesTurnDeadline(t *testing.T) {
66 }66 }
67 }67 }
68 68
69-func TestResultErrorPayloadClassifiesModelStepLimit(t *testing.T) {
70- payload := resultErrorPayload(core.ErrMaxSteps, "")
71- if payload["code"] != "step_limit" {
72- t.Fatalf("step-limit error code = %v, want step_limit", payload["code"])
73- }
74-}
75-
76 func TestResultErrorPayloadAttributesShutdownCancellation(t *testing.T) {69 func TestResultErrorPayloadAttributesShutdownCancellation(t *testing.T) {
77 payload := resultErrorPayload(context.Canceled, subagents.ShutdownOriginSession)70 payload := resultErrorPayload(context.Canceled, subagents.ShutdownOriginSession)
78 if payload["code"] != "shutdown" || payload["message"] != "subagent stopped during session shutdown" {71 if payload["code"] != "shutdown" || payload["message"] != "subagent stopped during session shutdown" {
@@ -865,24 +858,24 @@ func TestSupervisorEmitterStdoutShapeMatchesSupervisorParser(t *testing.T) {
865 }858 }
866 }859 }
867 860
868-func TestWorkerTurnBudgetFreshAllowanceOnlyForExplicitRun(t *testing.T) {861+func TestWorkerTurnCountersRemainObservableWithoutAdmissionLimit(t *testing.T) {
869- var budget workerTurnBudget862+ var counters workerTurnCounters
870 863
871- if _, lifetime, current, admitted := budget.start(2, false); !admitted || lifetime != 1 || current != 1 {864+ if step, lifetime, current := counters.start(false); step != 1 || lifetime != 1 || current != 1 {
872- t.Fatalf("first turn = (%d, %d, %v), want (1, 1, true)", lifetime, current, admitted)865+ t.Fatalf("first turn = (%d, %d, %d), want (1, 1, 1)", step, lifetime, current)
873 }866 }
874- // Provider retries, model loops, and compaction stay inside the admitted867+ // Provider retries, model loops, and compaction stay inside the delegated
875- // turn and therefore do not call start or consume another allowance.868+ // turn and therefore do not advance the message-turn counters.
876- if budget.lifetime != 1 || budget.current != 1 {869+ if counters.lifetime != 1 || counters.current != 1 {
877- t.Fatalf("nested work changed budget to (lifetime=%d, current=%d)", budget.lifetime, budget.current)870+ t.Fatalf("nested work changed counters to (lifetime=%d, current=%d)", counters.lifetime, counters.current)
878 }871 }
879- if _, lifetime, current, admitted := budget.start(2, false); !admitted || lifetime != 2 || current != 2 {872+ if step, lifetime, current := counters.start(false); step != 2 || lifetime != 2 || current != 2 {
880- t.Fatalf("second turn = (%d, %d, %v), want (2, 2, true)", lifetime, current, admitted)873+ t.Fatalf("second turn = (%d, %d, %d), want (2, 2, 2)", step, lifetime, current)
881 }874 }
882- if _, lifetime, current, admitted := budget.start(2, false); admitted || lifetime != 2 || current != 2 {875+ if step, lifetime, current := counters.start(false); step != 3 || lifetime != 3 || current != 3 {
883- t.Fatalf("exhausted turn = (%d, %d, %v), want (2, 2, false)", lifetime, current, admitted)876+ t.Fatalf("third turn = (%d, %d, %d), want (3, 3, 3)", step, lifetime, current)
884 }877 }
885- if _, lifetime, current, admitted := budget.start(2, true); !admitted || lifetime != 3 || current != 1 {878+ if step, lifetime, current := counters.start(true); step != 4 || lifetime != 4 || current != 1 {
886- t.Fatalf("explicit resumed turn = (%d, %d, %v), want (3, 1, true)", lifetime, current, admitted)879+ t.Fatalf("explicit resumed turn = (%d, %d, %d), want (4, 4, 1)", step, lifetime, current)
887 }880 }
888 }881 }
M packages/agent/subagents/agent.go +28 -2
@@ -49,8 +49,6 @@ type Agent struct {
4949 WorkspacePath string
5050 WorkspaceBase string
5151 WorkspaceCapture CaptureMode
52- MaxTurns int
53- MaxSteps int
5452
5553 // LifetimeTurns counts every accepted message turn for this worker across
5654 // all explicit runs. CurrentRunTurns resets only when an explicit
@@ -211,6 +209,8 @@ type Agent struct {
211209 // status (done / failed / killed). Wait blocks on this so
212210 // callers don't have to poll.
213211 done chan struct{}
212+ results map[string]*TurnResult
213+ resultWaiters map[string]int
214214 resultChanged chan struct{}
215215 doneOnce sync.Once
216216 }
@@ -460,6 +460,12 @@ func (a *Agent) markActivity(now time.Time) {
460460 func (a *Agent) setResult(result *TurnResult) {
461461 a.lifecycleMu.Lock()
462462 a.result = cloneTurnResult(result)
463+ if result != nil && result.TurnID != "" && a.resultWaiters[result.TurnID] > 0 {
464+ if a.results == nil {
465+ a.results = make(map[string]*TurnResult)
466+ }
467+ a.results[result.TurnID] = cloneTurnResult(result)
468+ }
463469 a.updatedAt = time.Now()
464470 if a.resultChanged != nil {
465471 close(a.resultChanged)
@@ -537,9 +543,29 @@ func (a *Agent) WaitTurnResult(ctx context.Context, turnID string) (*TurnResult,
537543 if ctx == nil {
538544 ctx = context.Background()
539545 }
546+ if turnID != "" {
547+ a.lifecycleMu.Lock()
548+ if a.resultWaiters == nil {
549+ a.resultWaiters = make(map[string]int)
550+ }
551+ a.resultWaiters[turnID]++
552+ a.lifecycleMu.Unlock()
553+ defer func() {
554+ a.lifecycleMu.Lock()
555+ a.resultWaiters[turnID]--
556+ if a.resultWaiters[turnID] == 0 {
557+ delete(a.resultWaiters, turnID)
558+ delete(a.results, turnID)
559+ }
560+ a.lifecycleMu.Unlock()
561+ }()
562+ }
540563 for {
541564 a.lifecycleMu.Lock()
542565 result := cloneTurnResult(a.result)
566+ if turnID != "" && a.results != nil {
567+ result = cloneTurnResult(a.results[turnID])
568+ }
543569 if result != nil && (turnID == "" || result.TurnID == turnID) {
544570 a.lifecycleMu.Unlock()
545571 return result, nil
@@ -49,8 +49,6 @@ type Agent struct {
49 WorkspacePath string49 WorkspacePath string
50 WorkspaceBase string50 WorkspaceBase string
51 WorkspaceCapture CaptureMode51 WorkspaceCapture CaptureMode
52- MaxTurns int
53- MaxSteps int
54 52
55 // LifetimeTurns counts every accepted message turn for this worker across53 // LifetimeTurns counts every accepted message turn for this worker across
56 // all explicit runs. CurrentRunTurns resets only when an explicit54 // all explicit runs. CurrentRunTurns resets only when an explicit
@@ -211,6 +209,8 @@ type Agent struct {
211 // status (done / failed / killed). Wait blocks on this so209 // status (done / failed / killed). Wait blocks on this so
212 // callers don't have to poll.210 // callers don't have to poll.
213 done chan struct{}211 done chan struct{}
212+ results map[string]*TurnResult
213+ resultWaiters map[string]int
214 resultChanged chan struct{}214 resultChanged chan struct{}
215 doneOnce sync.Once215 doneOnce sync.Once
216 }216 }
@@ -460,6 +460,12 @@ func (a *Agent) markActivity(now time.Time) {
460 func (a *Agent) setResult(result *TurnResult) {460 func (a *Agent) setResult(result *TurnResult) {
461 a.lifecycleMu.Lock()461 a.lifecycleMu.Lock()
462 a.result = cloneTurnResult(result)462 a.result = cloneTurnResult(result)
463+ if result != nil && result.TurnID != "" && a.resultWaiters[result.TurnID] > 0 {
464+ if a.results == nil {
465+ a.results = make(map[string]*TurnResult)
466+ }
467+ a.results[result.TurnID] = cloneTurnResult(result)
468+ }
463 a.updatedAt = time.Now()469 a.updatedAt = time.Now()
464 if a.resultChanged != nil {470 if a.resultChanged != nil {
465 close(a.resultChanged)471 close(a.resultChanged)
@@ -537,9 +543,29 @@ func (a *Agent) WaitTurnResult(ctx context.Context, turnID string) (*TurnResult,
537 if ctx == nil {543 if ctx == nil {
538 ctx = context.Background()544 ctx = context.Background()
539 }545 }
546+ if turnID != "" {
547+ a.lifecycleMu.Lock()
548+ if a.resultWaiters == nil {
549+ a.resultWaiters = make(map[string]int)
550+ }
551+ a.resultWaiters[turnID]++
552+ a.lifecycleMu.Unlock()
553+ defer func() {
554+ a.lifecycleMu.Lock()
555+ a.resultWaiters[turnID]--
556+ if a.resultWaiters[turnID] == 0 {
557+ delete(a.resultWaiters, turnID)
558+ delete(a.results, turnID)
559+ }
560+ a.lifecycleMu.Unlock()
561+ }()
562+ }
540 for {563 for {
541 a.lifecycleMu.Lock()564 a.lifecycleMu.Lock()
542 result := cloneTurnResult(a.result)565 result := cloneTurnResult(a.result)
566+ if turnID != "" && a.results != nil {
567+ result = cloneTurnResult(a.results[turnID])
568+ }
543 if result != nil && (turnID == "" || result.TurnID == turnID) {569 if result != nil && (turnID == "" || result.TurnID == turnID) {
544 a.lifecycleMu.Unlock()570 a.lifecycleMu.Unlock()
545 return result, nil571 return result, nil
M packages/agent/subagents/agent_test.go +40 -0
@@ -2,6 +2,7 @@ package subagents
22
33 import (
44 "context"
5+ "runtime"
56 "sync"
67 "testing"
78 "time"
@@ -106,6 +107,45 @@ func TestWaitTurnResultBroadcastsToMultipleObservers(t *testing.T) {
106107 }
107108 }
108109
110+func TestWaitTurnResultRetainsEarlierCompletedTurn(t *testing.T) {
111+ a := &Agent{ID: "worker-1", done: make(chan struct{})}
112+ resultReady := make(chan *TurnResult, 1)
113+ errReady := make(chan error, 1)
114+ go func() {
115+ result, err := a.WaitTurnResult(context.Background(), "turn-1")
116+ resultReady <- result
117+ errReady <- err
118+ }()
119+ deadline := time.Now().Add(time.Second)
120+ for {
121+ a.lifecycleMu.Lock()
122+ registered := a.resultWaiters["turn-1"] > 0
123+ a.lifecycleMu.Unlock()
124+ if registered {
125+ break
126+ }
127+ if time.Now().After(deadline) {
128+ t.Fatal("turn-1 waiter did not register")
129+ }
130+ runtime.Gosched()
131+ }
132+ a.setResult(&TurnResult{Version: ProtocolVersion, AgentID: a.ID, TurnID: "turn-1", Status: ResultSucceeded})
133+ a.setResult(&TurnResult{Version: ProtocolVersion, AgentID: a.ID, TurnID: "turn-2", Status: ResultSucceeded})
134+
135+ if err := <-errReady; err != nil {
136+ t.Fatal(err)
137+ }
138+ result := <-resultReady
139+ if result == nil || result.TurnID != "turn-1" {
140+ t.Fatalf("wait result = %#v, want retained turn-1", result)
141+ }
142+ a.lifecycleMu.Lock()
143+ defer a.lifecycleMu.Unlock()
144+ if len(a.results) != 0 || len(a.resultWaiters) != 0 {
145+ t.Fatalf("completed waiter state leaked: results=%d waiters=%d", len(a.results), len(a.resultWaiters))
146+ }
147+}
148+
109149 func TestWaitTargetTurnIDAdvancesQueuedResume(t *testing.T) {
110150 a := &Agent{LifetimeTurns: 1, turnState: TurnQueued, currentTurnID: "turn-1"}
111151 if got := a.WaitTargetTurnID(); got != "turn-2" {
@@ -2,6 +2,7 @@ package subagents
2 2
3 import (3 import (
4 "context"4 "context"
5+ "runtime"
5 "sync"6 "sync"
6 "testing"7 "testing"
7 "time"8 "time"
@@ -106,6 +107,45 @@ func TestWaitTurnResultBroadcastsToMultipleObservers(t *testing.T) {
106 }107 }
107 }108 }
108 109
110+func TestWaitTurnResultRetainsEarlierCompletedTurn(t *testing.T) {
111+ a := &Agent{ID: "worker-1", done: make(chan struct{})}
112+ resultReady := make(chan *TurnResult, 1)
113+ errReady := make(chan error, 1)
114+ go func() {
115+ result, err := a.WaitTurnResult(context.Background(), "turn-1")
116+ resultReady <- result
117+ errReady <- err
118+ }()
119+ deadline := time.Now().Add(time.Second)
120+ for {
121+ a.lifecycleMu.Lock()
122+ registered := a.resultWaiters["turn-1"] > 0
123+ a.lifecycleMu.Unlock()
124+ if registered {
125+ break
126+ }
127+ if time.Now().After(deadline) {
128+ t.Fatal("turn-1 waiter did not register")
129+ }
130+ runtime.Gosched()
131+ }
132+ a.setResult(&TurnResult{Version: ProtocolVersion, AgentID: a.ID, TurnID: "turn-1", Status: ResultSucceeded})
133+ a.setResult(&TurnResult{Version: ProtocolVersion, AgentID: a.ID, TurnID: "turn-2", Status: ResultSucceeded})
134+
135+ if err := <-errReady; err != nil {
136+ t.Fatal(err)
137+ }
138+ result := <-resultReady
139+ if result == nil || result.TurnID != "turn-1" {
140+ t.Fatalf("wait result = %#v, want retained turn-1", result)
141+ }
142+ a.lifecycleMu.Lock()
143+ defer a.lifecycleMu.Unlock()
144+ if len(a.results) != 0 || len(a.resultWaiters) != 0 {
145+ t.Fatalf("completed waiter state leaked: results=%d waiters=%d", len(a.results), len(a.resultWaiters))
146+ }
147+}
148+
109 func TestWaitTargetTurnIDAdvancesQueuedResume(t *testing.T) {149 func TestWaitTargetTurnIDAdvancesQueuedResume(t *testing.T) {
110 a := &Agent{LifetimeTurns: 1, turnState: TurnQueued, currentTurnID: "turn-1"}150 a := &Agent{LifetimeTurns: 1, turnState: TurnQueued, currentTurnID: "turn-1"}
111 if got := a.WaitTargetTurnID(); got != "turn-2" {151 if got := a.WaitTargetTurnID(); got != "turn-2" {
M packages/agent/subagents/batch_test.go +29 -7
@@ -139,23 +139,45 @@ func TestBatchSpawnWaitAndCollect(t *testing.T) {
139139 }
140140
141141 func TestBatchResultWaitCancellationDoesNotCancelWorker(t *testing.T) {
142- f := New(Config{Root: t.TempDir(), RepoRoot: t.TempDir()})
143- a := &Agent{
144- ID: "worker-1",
145- status: StatusRunning,
142+ started := make(chan struct{})
143+ release := make(chan struct{})
144+ f := New(Config{
145+ Root: t.TempDir(), RepoRoot: t.TempDir(),
146+ NewRunner: func(*Agent) Runner {
147+ return RunnerFunc(func(context.Context, Sink) error {
148+ close(started)
149+ <-release
150+ return nil
151+ })
152+ },
153+ })
154+ t.Cleanup(f.StopAll)
155+ batch, err := f.SpawnBatch(context.Background(), BatchRequest{Tasks: []string{"one"}})
156+ if err != nil {
157+ t.Fatal(err)
146158 }
159+ <-started
147160 ctx, cancel := context.WithCancel(context.Background())
148161 cancel()
149162
150- result, err := f.waitForBatchResult(ctx, a)
163+ result, err := f.WaitBatchContext(ctx, batch.ID)
151164 if !errors.Is(err, context.Canceled) {
152165 t.Fatalf("wait error = %v, want context.Canceled", err)
153166 }
154167 if result != nil {
155168 t.Fatalf("wait result = %#v, want nil", result)
156169 }
157- if got := a.Status(); got != StatusRunning {
158- t.Fatalf("worker status = %s, want running", got)
170+ child := f.Get(batch.ChildIDs[0])
171+ if child == nil || child.Status() != StatusRunning {
172+ t.Fatalf("worker after observer cancellation = %#v, want running", child)
173+ }
174+ close(release)
175+ completed, err := f.WaitBatch(batch.ID)
176+ if err != nil {
177+ t.Fatal(err)
178+ }
179+ if completed.Status != BatchSucceeded || completed.Results[child.ID] == nil {
180+ t.Fatalf("completed batch = %#v, want successful retained worker result", completed)
159181 }
160182 }
161183
@@ -139,23 +139,45 @@ func TestBatchSpawnWaitAndCollect(t *testing.T) {
139 }139 }
140 140
141 func TestBatchResultWaitCancellationDoesNotCancelWorker(t *testing.T) {141 func TestBatchResultWaitCancellationDoesNotCancelWorker(t *testing.T) {
142- f := New(Config{Root: t.TempDir(), RepoRoot: t.TempDir()})142+ started := make(chan struct{})
143- a := &Agent{143+ release := make(chan struct{})
144- ID: "worker-1",144+ f := New(Config{
145- status: StatusRunning,145+ Root: t.TempDir(), RepoRoot: t.TempDir(),
146+ NewRunner: func(*Agent) Runner {
147+ return RunnerFunc(func(context.Context, Sink) error {
148+ close(started)
149+ <-release
150+ return nil
151+ })
152+ },
153+ })
154+ t.Cleanup(f.StopAll)
155+ batch, err := f.SpawnBatch(context.Background(), BatchRequest{Tasks: []string{"one"}})
156+ if err != nil {
157+ t.Fatal(err)
146 }158 }
159+ <-started
147 ctx, cancel := context.WithCancel(context.Background())160 ctx, cancel := context.WithCancel(context.Background())
148 cancel()161 cancel()
149 162
150- result, err := f.waitForBatchResult(ctx, a)163+ result, err := f.WaitBatchContext(ctx, batch.ID)
151 if !errors.Is(err, context.Canceled) {164 if !errors.Is(err, context.Canceled) {
152 t.Fatalf("wait error = %v, want context.Canceled", err)165 t.Fatalf("wait error = %v, want context.Canceled", err)
153 }166 }
154 if result != nil {167 if result != nil {
155 t.Fatalf("wait result = %#v, want nil", result)168 t.Fatalf("wait result = %#v, want nil", result)
156 }169 }
157- if got := a.Status(); got != StatusRunning {170+ child := f.Get(batch.ChildIDs[0])
158- t.Fatalf("worker status = %s, want running", got)171+ if child == nil || child.Status() != StatusRunning {
172+ t.Fatalf("worker after observer cancellation = %#v, want running", child)
173+ }
174+ close(release)
175+ completed, err := f.WaitBatch(batch.ID)
176+ if err != nil {
177+ t.Fatal(err)
178+ }
179+ if completed.Status != BatchSucceeded || completed.Results[child.ID] == nil {
180+ t.Fatalf("completed batch = %#v, want successful retained worker result", completed)
159 }181 }
160 }182 }
161 183
M packages/agent/subagents/model.go +3 -11
@@ -107,17 +107,15 @@ const (
107107
108108 // SubagentPolicy contains manager-owned safety and resource limits. normalize
109109 // replaces zero MaxOutputBytes, MaxOutputLines, QueueTimeout, and StartupTimeout
110-// values with positive manager defaults. Zero DefaultTimeout, MaxTurns, and
111-// MaxSteps leave delegated turns and model loops unlimited; queue, startup,
112-// stream-idle, and idle-process limits remain independently bounded.
110+// values with positive manager defaults. Zero DefaultTimeout leaves delegated
111+// turns unlimited; queue, startup, stream-idle, and idle-process limits remain
112+// independently bounded.
113113 type SubagentPolicy struct {
114114 MaxConcurrent int
115115 MaxConcurrentPerParent int
116116 QueueTimeout time.Duration
117117 StartupTimeout time.Duration
118118 DefaultTimeout time.Duration
119- MaxTurns int
120- MaxSteps int
121119 MaxOutputBytes int
122120 MaxOutputLines int
123121 AllowedTools []string
@@ -132,12 +130,6 @@ func (p *SubagentPolicy) normalize() {
132130 if p.DefaultTimeout < 0 {
133131 p.DefaultTimeout = 0
134132 }
135- if p.MaxTurns < 0 {
136- p.MaxTurns = 0
137- }
138- if p.MaxSteps < 0 {
139- p.MaxSteps = 0
140- }
141133 if p.MaxConcurrent <= 0 {
142134 p.MaxConcurrent = 8
143135 }
@@ -107,17 +107,15 @@ const (
107 107
108 // SubagentPolicy contains manager-owned safety and resource limits. normalize108 // SubagentPolicy contains manager-owned safety and resource limits. normalize
109 // replaces zero MaxOutputBytes, MaxOutputLines, QueueTimeout, and StartupTimeout109 // replaces zero MaxOutputBytes, MaxOutputLines, QueueTimeout, and StartupTimeout
110-// values with positive manager defaults. Zero DefaultTimeout, MaxTurns, and110+// values with positive manager defaults. Zero DefaultTimeout leaves delegated
111-// MaxSteps leave delegated turns and model loops unlimited; queue, startup,111+// turns unlimited; queue, startup, stream-idle, and idle-process limits remain
112-// stream-idle, and idle-process limits remain independently bounded.112+// independently bounded.
113 type SubagentPolicy struct {113 type SubagentPolicy struct {
114 MaxConcurrent int114 MaxConcurrent int
115 MaxConcurrentPerParent int115 MaxConcurrentPerParent int
116 QueueTimeout time.Duration116 QueueTimeout time.Duration
117 StartupTimeout time.Duration117 StartupTimeout time.Duration
118 DefaultTimeout time.Duration118 DefaultTimeout time.Duration
119- MaxTurns int
120- MaxSteps int
121 MaxOutputBytes int119 MaxOutputBytes int
122 MaxOutputLines int120 MaxOutputLines int
123 AllowedTools []string121 AllowedTools []string
@@ -132,12 +130,6 @@ func (p *SubagentPolicy) normalize() {
132 if p.DefaultTimeout < 0 {130 if p.DefaultTimeout < 0 {
133 p.DefaultTimeout = 0131 p.DefaultTimeout = 0
134 }132 }
135- if p.MaxTurns < 0 {
136- p.MaxTurns = 0
137- }
138- if p.MaxSteps < 0 {
139- p.MaxSteps = 0
140- }
141 if p.MaxConcurrent <= 0 {133 if p.MaxConcurrent <= 0 {
142 p.MaxConcurrent = 8134 p.MaxConcurrent = 8
143 }135 }
M packages/agent/subagents/persist.go +2 -21
@@ -57,8 +57,6 @@ type agentMeta struct {
5757 WorkspacePath string `json:"workspace_path,omitempty"`
5858 WorkspaceBase string `json:"workspace_base,omitempty"`
5959 WorkspaceCapture CaptureMode `json:"workspace_capture,omitempty"`
60- MaxTurns int `json:"max_turns,omitempty"`
61- MaxSteps int `json:"max_steps,omitempty"`
6260 LifetimeTurns int `json:"lifetime_turns,omitempty"`
6361 CurrentRunTurns int `json:"current_run_turns,omitempty"`
6462 Timeout time.Duration `json:"timeout,omitempty"`
@@ -128,13 +126,6 @@ func resolvePersistedTurnTimeout(m agentMeta, fallback time.Duration) time.Durat
128126 }
129127 }
130128
131-func resolvePersistedMaxSteps(value, fallback int) int {
132- if value > 0 {
133- return value
134- }
135- return fallback
136-}
137-
138129 func metaPath(stateDir string) string { return filepath.Join(stateDir, "meta.json") }
139130
140131 // writeAgentMeta serialises a's identity into stateDir/meta.json. The
@@ -198,8 +189,6 @@ func writeAgentMeta(stateDir string, a *Agent) error {
198189 WorkspacePath: a.WorkspacePath,
199190 WorkspaceBase: a.WorkspaceBase,
200191 WorkspaceCapture: a.WorkspaceCapture,
201- MaxTurns: a.MaxTurns,
202- MaxSteps: a.MaxSteps,
203192 LifetimeTurns: lifetimeTurns,
204193 CurrentRunTurns: currentRunTurns,
205194 Timeout: a.Timeout,
@@ -542,8 +531,6 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) (*Agent, bool) {
542531 WorkspacePath: m.WorkspacePath,
543532 WorkspaceBase: m.WorkspaceBase,
544533 WorkspaceCapture: m.WorkspaceCapture,
545- MaxTurns: m.MaxTurns,
546- MaxSteps: resolvePersistedMaxSteps(m.MaxSteps, f.cfg.Policy.MaxSteps),
547534 LifetimeTurns: m.LifetimeTurns,
548535 CurrentRunTurns: m.CurrentRunTurns,
549536 Timeout: resolvePersistedTurnTimeout(m, f.cfg.Policy.DefaultTimeout),
@@ -1240,7 +1227,7 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo
12401227 Subagent: existing.Subagent,
12411228 WorkspaceMode: existing.WorkspaceMode, WorkspacePath: existing.WorkspacePath,
12421229 WorkspaceBase: existing.WorkspaceBase, WorkspaceCapture: existing.WorkspaceCapture,
1243- MaxTurns: existing.MaxTurns, MaxSteps: existing.MaxSteps, LifetimeTurns: lifetimeTurns, CurrentRunTurns: currentRunTurns, Timeout: existing.Timeout, TurnTimeoutMode: timeoutModeFor(existing.Timeout), Tools: append([]string(nil), existing.Tools...),
1230+ LifetimeTurns: lifetimeTurns, CurrentRunTurns: currentRunTurns, Timeout: existing.Timeout, TurnTimeoutMode: timeoutModeFor(existing.Timeout), Tools: append([]string(nil), existing.Tools...),
12441231 WebSearchPolicy: childWebSearchPolicy(existing.WebSearchPolicy, existing.Subagent, existing.Tools),
12451232 CurrentTurnID: existingSnapshot.CurrentTurnID, Attempt: existingSnapshot.Attempt,
12461233 Requirement: existingSnapshot.Requirement,
@@ -1256,7 +1243,7 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo
12561243 if len(m.Tools) == 0 && len(f.cfg.Policy.AllowedTools) > 0 {
12571244 m.Tools = append([]string(nil), f.cfg.Policy.AllowedTools...)
12581245 }
1259- if err := f.validateSpawnOptions(SpawnRequest{MaxTurns: m.MaxTurns, Tools: m.Tools}); err != nil {
1246+ if err := f.validateSpawnOptions(SpawnRequest{Tools: m.Tools}); err != nil {
12601247 return nil, err
12611248 }
12621249 workspaceMode := m.WorkspaceMode
@@ -1276,10 +1263,6 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo
12761263 if err := validateWorkspaceRoot(repositoryRoot, workspaceMode, allowedRoots); err != nil {
12771264 return nil, err
12781265 }
1279- maxTurns := m.MaxTurns
1280- if maxTurns <= 0 {
1281- maxTurns = f.cfg.Policy.MaxTurns
1282- }
12831266 existingWorkspacePath := ""
12841267 if workspaceMode == WorkspaceWorktree {
12851268 existingWorkspacePath = m.WorkspacePath
@@ -1325,8 +1308,6 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo
13251308 WorkspacePath: m.WorkspacePath,
13261309 WorkspaceBase: m.WorkspaceBase,
13271310 WorkspaceCapture: m.WorkspaceCapture,
1328- MaxTurns: maxTurns,
1329- MaxSteps: resolvePersistedMaxSteps(m.MaxSteps, f.cfg.Policy.MaxSteps),
13301311 LifetimeTurns: m.LifetimeTurns,
13311312 CurrentRunTurns: m.CurrentRunTurns,
13321313 Timeout: timeout,
@@ -57,8 +57,6 @@ type agentMeta struct {
57 WorkspacePath string `json:"workspace_path,omitempty"`57 WorkspacePath string `json:"workspace_path,omitempty"`
58 WorkspaceBase string `json:"workspace_base,omitempty"`58 WorkspaceBase string `json:"workspace_base,omitempty"`
59 WorkspaceCapture CaptureMode `json:"workspace_capture,omitempty"`59 WorkspaceCapture CaptureMode `json:"workspace_capture,omitempty"`
60- MaxTurns int `json:"max_turns,omitempty"`
61- MaxSteps int `json:"max_steps,omitempty"`
62 LifetimeTurns int `json:"lifetime_turns,omitempty"`60 LifetimeTurns int `json:"lifetime_turns,omitempty"`
63 CurrentRunTurns int `json:"current_run_turns,omitempty"`61 CurrentRunTurns int `json:"current_run_turns,omitempty"`
64 Timeout time.Duration `json:"timeout,omitempty"`62 Timeout time.Duration `json:"timeout,omitempty"`
@@ -128,13 +126,6 @@ func resolvePersistedTurnTimeout(m agentMeta, fallback time.Duration) time.Durat
128 }126 }
129 }127 }
130 128
131-func resolvePersistedMaxSteps(value, fallback int) int {
132- if value > 0 {
133- return value
134- }
135- return fallback
136-}
137-
138 func metaPath(stateDir string) string { return filepath.Join(stateDir, "meta.json") }129 func metaPath(stateDir string) string { return filepath.Join(stateDir, "meta.json") }
139 130
140 // writeAgentMeta serialises a's identity into stateDir/meta.json. The131 // writeAgentMeta serialises a's identity into stateDir/meta.json. The
@@ -198,8 +189,6 @@ func writeAgentMeta(stateDir string, a *Agent) error {
198 WorkspacePath: a.WorkspacePath,189 WorkspacePath: a.WorkspacePath,
199 WorkspaceBase: a.WorkspaceBase,190 WorkspaceBase: a.WorkspaceBase,
200 WorkspaceCapture: a.WorkspaceCapture,191 WorkspaceCapture: a.WorkspaceCapture,
201- MaxTurns: a.MaxTurns,
202- MaxSteps: a.MaxSteps,
203 LifetimeTurns: lifetimeTurns,192 LifetimeTurns: lifetimeTurns,
204 CurrentRunTurns: currentRunTurns,193 CurrentRunTurns: currentRunTurns,
205 Timeout: a.Timeout,194 Timeout: a.Timeout,
@@ -542,8 +531,6 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) (*Agent, bool) {
542 WorkspacePath: m.WorkspacePath,531 WorkspacePath: m.WorkspacePath,
543 WorkspaceBase: m.WorkspaceBase,532 WorkspaceBase: m.WorkspaceBase,
544 WorkspaceCapture: m.WorkspaceCapture,533 WorkspaceCapture: m.WorkspaceCapture,
545- MaxTurns: m.MaxTurns,
546- MaxSteps: resolvePersistedMaxSteps(m.MaxSteps, f.cfg.Policy.MaxSteps),
547 LifetimeTurns: m.LifetimeTurns,534 LifetimeTurns: m.LifetimeTurns,
548 CurrentRunTurns: m.CurrentRunTurns,535 CurrentRunTurns: m.CurrentRunTurns,
549 Timeout: resolvePersistedTurnTimeout(m, f.cfg.Policy.DefaultTimeout),536 Timeout: resolvePersistedTurnTimeout(m, f.cfg.Policy.DefaultTimeout),
@@ -1240,7 +1227,7 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo
1240 Subagent: existing.Subagent,1227 Subagent: existing.Subagent,
1241 WorkspaceMode: existing.WorkspaceMode, WorkspacePath: existing.WorkspacePath,1228 WorkspaceMode: existing.WorkspaceMode, WorkspacePath: existing.WorkspacePath,
1242 WorkspaceBase: existing.WorkspaceBase, WorkspaceCapture: existing.WorkspaceCapture,1229 WorkspaceBase: existing.WorkspaceBase, WorkspaceCapture: existing.WorkspaceCapture,
1243- MaxTurns: existing.MaxTurns, MaxSteps: existing.MaxSteps, LifetimeTurns: lifetimeTurns, CurrentRunTurns: currentRunTurns, Timeout: existing.Timeout, TurnTimeoutMode: timeoutModeFor(existing.Timeout), Tools: append([]string(nil), existing.Tools...),1230+ LifetimeTurns: lifetimeTurns, CurrentRunTurns: currentRunTurns, Timeout: existing.Timeout, TurnTimeoutMode: timeoutModeFor(existing.Timeout), Tools: append([]string(nil), existing.Tools...),
1244 WebSearchPolicy: childWebSearchPolicy(existing.WebSearchPolicy, existing.Subagent, existing.Tools),1231 WebSearchPolicy: childWebSearchPolicy(existing.WebSearchPolicy, existing.Subagent, existing.Tools),
1245 CurrentTurnID: existingSnapshot.CurrentTurnID, Attempt: existingSnapshot.Attempt,1232 CurrentTurnID: existingSnapshot.CurrentTurnID, Attempt: existingSnapshot.Attempt,
1246 Requirement: existingSnapshot.Requirement,1233 Requirement: existingSnapshot.Requirement,
@@ -1256,7 +1243,7 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo
1256 if len(m.Tools) == 0 && len(f.cfg.Policy.AllowedTools) > 0 {1243 if len(m.Tools) == 0 && len(f.cfg.Policy.AllowedTools) > 0 {
1257 m.Tools = append([]string(nil), f.cfg.Policy.AllowedTools...)1244 m.Tools = append([]string(nil), f.cfg.Policy.AllowedTools...)
1258 }1245 }
1259- if err := f.validateSpawnOptions(SpawnRequest{MaxTurns: m.MaxTurns, Tools: m.Tools}); err != nil {1246+ if err := f.validateSpawnOptions(SpawnRequest{Tools: m.Tools}); err != nil {
1260 return nil, err1247 return nil, err
1261 }1248 }
1262 workspaceMode := m.WorkspaceMode1249 workspaceMode := m.WorkspaceMode
@@ -1276,10 +1263,6 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo
1276 if err := validateWorkspaceRoot(repositoryRoot, workspaceMode, allowedRoots); err != nil {1263 if err := validateWorkspaceRoot(repositoryRoot, workspaceMode, allowedRoots); err != nil {
1277 return nil, err1264 return nil, err
1278 }1265 }
1279- maxTurns := m.MaxTurns
1280- if maxTurns <= 0 {
1281- maxTurns = f.cfg.Policy.MaxTurns
1282- }
1283 existingWorkspacePath := ""1266 existingWorkspacePath := ""
1284 if workspaceMode == WorkspaceWorktree {1267 if workspaceMode == WorkspaceWorktree {
1285 existingWorkspacePath = m.WorkspacePath1268 existingWorkspacePath = m.WorkspacePath
@@ -1325,8 +1308,6 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo
1325 WorkspacePath: m.WorkspacePath,1308 WorkspacePath: m.WorkspacePath,
1326 WorkspaceBase: m.WorkspaceBase,1309 WorkspaceBase: m.WorkspaceBase,
1327 WorkspaceCapture: m.WorkspaceCapture,1310 WorkspaceCapture: m.WorkspaceCapture,
1328- MaxTurns: maxTurns,
1329- MaxSteps: resolvePersistedMaxSteps(m.MaxSteps, f.cfg.Policy.MaxSteps),
1330 LifetimeTurns: m.LifetimeTurns,1311 LifetimeTurns: m.LifetimeTurns,
1331 CurrentRunTurns: m.CurrentRunTurns,1312 CurrentRunTurns: m.CurrentRunTurns,
1332 Timeout: timeout,1313 Timeout: timeout,
M packages/agent/subagents/projection.go +21 -20
@@ -17,6 +17,7 @@ type Operation struct {
1717 Name string
1818 Provider string
1919 Model string
20+ Step int
2021 Attempt int
2122 MaxAttempts int
2223 StartedAt time.Time
@@ -115,14 +116,13 @@ type RequestFact struct {
115116 // contains operations and terminal facts rather than lifecycle guesses such as
116117 // working, idle, or alive.
117118 type AgentTraceView struct {
118- AgentID string
119- LastEvent TraceEvent
120- LastObservation *LiveObservation
121- OpenOperations []Operation
122- PrimaryOperation *Operation
123- Terminal string
124- Result *ResultFact
125- LastRequest *RequestFact
119+ AgentID string
120+ LastEvent TraceEvent
121+ LastObservation *LiveObservation
122+ OpenOperations []Operation
123+ Terminal string
124+ Result *ResultFact
125+ LastRequest *RequestFact
126126 }
127127
128128 // Summary returns the most specific factual activity or terminal result fact.
@@ -139,12 +139,8 @@ func (v AgentTraceView) Summary() string {
139139 }
140140 return v.Terminal
141141 }
142- primary := v.PrimaryOperation
143- if primary == nil && len(v.OpenOperations) != 0 {
144- primary = &v.OpenOperations[0]
145- }
146- if primary != nil {
147- if observation := v.ObservationFor(*primary); observation != nil {
142+ if primary, ok := v.Primary(); ok {
143+ if observation := v.ObservationFor(primary); observation != nil {
148144 return primary.Label() + " · " + observation.Label()
149145 }
150146 return primary.Label()
@@ -208,7 +204,8 @@ func (p *traceProjection) apply(event TraceEvent) {
208204 model, _ := event.Data["model"].(string)
209205 attempt := traceInt(event.Data["attempt"])
210206 maxAttempts := traceInt(event.Data["max_attempts"])
211- p.open[event.AgentID][key] = Operation{Type: event.Type, AgentID: event.AgentID, TurnID: event.TurnID, CallID: callID, Name: name, Provider: provider, Model: model, Attempt: attempt, MaxAttempts: maxAttempts, StartedAt: event.Timestamp}
207+ step := traceInt(event.Data["step"])
208+ p.open[event.AgentID][key] = Operation{Type: event.Type, AgentID: event.AgentID, TurnID: event.TurnID, CallID: callID, Name: name, Provider: provider, Model: model, Step: step, Attempt: attempt, MaxAttempts: maxAttempts, StartedAt: event.Timestamp}
212209 }
213210 if ends {
214211 if event.Type == "provider.request.finished" || event.Type == "provider.request.failed" || event.Type == "provider.request.cancelled" || event.Type == "provider.request.retry.scheduled" {
@@ -272,10 +269,6 @@ func (p *traceProjection) snapshot() map[string]AgentTraceView {
272269 }
273270 return left.CallID < right.CallID
274271 })
275- if len(view.OpenOperations) != 0 {
276- primary := view.OpenOperations[0]
277- view.PrimaryOperation = &primary
278- }
279272 views[agentID] = view
280273 }
281274 return views
@@ -283,7 +276,6 @@ func (p *traceProjection) snapshot() map[string]AgentTraceView {
283276
284277 func cloneAgentTraceView(view AgentTraceView) AgentTraceView {
285278 view.OpenOperations = nil
286- view.PrimaryOperation = nil
287279 if view.LastObservation != nil {
288280 observation := *view.LastObservation
289281 view.LastObservation = &observation
@@ -299,6 +291,15 @@ func cloneAgentTraceView(view AgentTraceView) AgentTraceView {
299291 return view
300292 }
301293
294+// Primary returns the highest-priority factual operation from the ordered
295+// open-operation projection.
296+func (v AgentTraceView) Primary() (Operation, bool) {
297+ if len(v.OpenOperations) == 0 {
298+ return Operation{}, false
299+ }
300+ return v.OpenOperations[0], true
301+}
302+
302303 // TurnStartedAt returns the factual start of the open delegated turn owning
303304 // operation. It is stable across nested provider requests and tool calls.
304305 func (v AgentTraceView) TurnStartedAt(operation Operation) time.Time {
@@ -17,6 +17,7 @@ type Operation struct {
17 Name string17 Name string
18 Provider string18 Provider string
19 Model string19 Model string
20+ Step int
20 Attempt int21 Attempt int
21 MaxAttempts int22 MaxAttempts int
22 StartedAt time.Time23 StartedAt time.Time
@@ -115,14 +116,13 @@ type RequestFact struct {
115 // contains operations and terminal facts rather than lifecycle guesses such as116 // contains operations and terminal facts rather than lifecycle guesses such as
116 // working, idle, or alive.117 // working, idle, or alive.
117 type AgentTraceView struct {118 type AgentTraceView struct {
118- AgentID string119+ AgentID string
119- LastEvent TraceEvent120+ LastEvent TraceEvent
120- LastObservation *LiveObservation121+ LastObservation *LiveObservation
121- OpenOperations []Operation122+ OpenOperations []Operation
122- PrimaryOperation *Operation123+ Terminal string
123- Terminal string124+ Result *ResultFact
124- Result *ResultFact125+ LastRequest *RequestFact
125- LastRequest *RequestFact
126 }126 }
127 127
128 // Summary returns the most specific factual activity or terminal result fact.128 // Summary returns the most specific factual activity or terminal result fact.
@@ -139,12 +139,8 @@ func (v AgentTraceView) Summary() string {
139 }139 }
140 return v.Terminal140 return v.Terminal
141 }141 }
142- primary := v.PrimaryOperation142+ if primary, ok := v.Primary(); ok {
143- if primary == nil && len(v.OpenOperations) != 0 {143+ if observation := v.ObservationFor(primary); observation != nil {
144- primary = &v.OpenOperations[0]
145- }
146- if primary != nil {
147- if observation := v.ObservationFor(*primary); observation != nil {
148 return primary.Label() + " · " + observation.Label()144 return primary.Label() + " · " + observation.Label()
149 }145 }
150 return primary.Label()146 return primary.Label()
@@ -208,7 +204,8 @@ func (p *traceProjection) apply(event TraceEvent) {
208 model, _ := event.Data["model"].(string)204 model, _ := event.Data["model"].(string)
209 attempt := traceInt(event.Data["attempt"])205 attempt := traceInt(event.Data["attempt"])
210 maxAttempts := traceInt(event.Data["max_attempts"])206 maxAttempts := traceInt(event.Data["max_attempts"])
211- p.open[event.AgentID][key] = Operation{Type: event.Type, AgentID: event.AgentID, TurnID: event.TurnID, CallID: callID, Name: name, Provider: provider, Model: model, Attempt: attempt, MaxAttempts: maxAttempts, StartedAt: event.Timestamp}207+ step := traceInt(event.Data["step"])
208+ p.open[event.AgentID][key] = Operation{Type: event.Type, AgentID: event.AgentID, TurnID: event.TurnID, CallID: callID, Name: name, Provider: provider, Model: model, Step: step, Attempt: attempt, MaxAttempts: maxAttempts, StartedAt: event.Timestamp}
212 }209 }
213 if ends {210 if ends {
214 if event.Type == "provider.request.finished" || event.Type == "provider.request.failed" || event.Type == "provider.request.cancelled" || event.Type == "provider.request.retry.scheduled" {211 if event.Type == "provider.request.finished" || event.Type == "provider.request.failed" || event.Type == "provider.request.cancelled" || event.Type == "provider.request.retry.scheduled" {
@@ -272,10 +269,6 @@ func (p *traceProjection) snapshot() map[string]AgentTraceView {
272 }269 }
273 return left.CallID < right.CallID270 return left.CallID < right.CallID
274 })271 })
275- if len(view.OpenOperations) != 0 {
276- primary := view.OpenOperations[0]
277- view.PrimaryOperation = &primary
278- }
279 views[agentID] = view272 views[agentID] = view
280 }273 }
281 return views274 return views
@@ -283,7 +276,6 @@ func (p *traceProjection) snapshot() map[string]AgentTraceView {
283 276
284 func cloneAgentTraceView(view AgentTraceView) AgentTraceView {277 func cloneAgentTraceView(view AgentTraceView) AgentTraceView {
285 view.OpenOperations = nil278 view.OpenOperations = nil
286- view.PrimaryOperation = nil
287 if view.LastObservation != nil {279 if view.LastObservation != nil {
288 observation := *view.LastObservation280 observation := *view.LastObservation
289 view.LastObservation = &observation281 view.LastObservation = &observation
@@ -299,6 +291,15 @@ func cloneAgentTraceView(view AgentTraceView) AgentTraceView {
299 return view291 return view
300 }292 }
301 293
294+// Primary returns the highest-priority factual operation from the ordered
295+// open-operation projection.
296+func (v AgentTraceView) Primary() (Operation, bool) {
297+ if len(v.OpenOperations) == 0 {
298+ return Operation{}, false
299+ }
300+ return v.OpenOperations[0], true
301+}
302+
302 // TurnStartedAt returns the factual start of the open delegated turn owning303 // TurnStartedAt returns the factual start of the open delegated turn owning
303 // operation. It is stable across nested provider requests and tool calls.304 // operation. It is stable across nested provider requests and tool calls.
304 func (v AgentTraceView) TurnStartedAt(operation Operation) time.Time {305 func (v AgentTraceView) TurnStartedAt(operation Operation) time.Time {
M packages/agent/subagents/projection_test.go +26 -17
@@ -8,6 +8,15 @@ import (
88 "time"
99 )
1010
11+func primaryOperation(t testing.TB, view AgentTraceView) *Operation {
12+ t.Helper()
13+ operation, ok := view.Primary()
14+ if !ok {
15+ t.Fatal("trace view has no primary operation")
16+ }
17+ return &operation
18+}
19+
1120 func TestProjectTraceRetainsJSONDecodedRequestAttempts(t *testing.T) {
1221 bundle := t.TempDir()
1322 if err := os.WriteFile(filepath.Join(bundle, "manifest.json"), []byte(`{"version":1,"trace_file":"trace.jsonl"}`), 0o600); err != nil {
@@ -21,7 +30,7 @@ func TestProjectTraceRetainsJSONDecodedRequestAttempts(t *testing.T) {
2130 if err != nil {
2231 t.Fatal(err)
2332 }
24- operation := ProjectTrace(events)["agent-1"].PrimaryOperation
33+ operation := primaryOperation(t, ProjectTrace(events)["agent-1"])
2534 if operation == nil || operation.Attempt != 2 || operation.MaxAttempts != 6 {
2635 t.Fatalf("projected operation = %#v, want attempt 2/6", operation)
2736 }
@@ -36,10 +45,10 @@ func TestProjectTraceIgnoresProtocolFactsAndShowsCompletedToolDuringNextProvider
3645 {Timestamp: started.Add(2500 * time.Millisecond), Type: "worker.protocol.observed", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"source_event": "tool.use.args"}},
3746 {Timestamp: started.Add(3 * time.Second), Type: "provider.request.started", AgentID: "agent-1", TurnID: "turn-1"},
3847 })["agent-1"]
39- if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "provider.request.started" {
40- t.Fatalf("primary operation = %#v", view.PrimaryOperation)
48+ if primaryOperation(t, view) == nil || primaryOperation(t, view).Type != "provider.request.started" {
49+ t.Fatalf("primary operation = %#v", primaryOperation(t, view))
4150 }
42- observation := view.ObservationFor(*view.PrimaryOperation)
51+ observation := view.ObservationFor(*primaryOperation(t, view))
4352 if observation == nil || observation.Label() != "finished read" {
4453 t.Fatalf("observation = %#v, want finished read", observation)
4554 }
@@ -65,10 +74,10 @@ func TestProjectTraceKeepsShortCompletedToolVisibleBeforeNextRequest(t *testing.
6574 {Timestamp: started.Add(time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "call-1", "name": "read"}},
6675 {Timestamp: started.Add(time.Second + time.Millisecond), Type: "tool.finished", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "call-1", "name": "read"}},
6776 })["agent-1"]
68- if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "turn.started" {
69- t.Fatalf("primary operation = %#v, want turn", view.PrimaryOperation)
77+ if primaryOperation(t, view) == nil || primaryOperation(t, view).Type != "turn.started" {
78+ t.Fatalf("primary operation = %#v, want turn", primaryOperation(t, view))
7079 }
71- if observation := view.ObservationFor(*view.PrimaryOperation); observation == nil || observation.Label() != "finished read" {
80+ if observation := view.ObservationFor(*primaryOperation(t, view)); observation == nil || observation.Label() != "finished read" {
7281 t.Fatalf("completed tool observation = %#v, want finished read", observation)
7382 }
7483 }
@@ -80,10 +89,10 @@ func TestProjectTraceDoesNotDecorateToolWithProviderStream(t *testing.T) {
8089 {Timestamp: started.Add(time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "call-1", "name": "read"}},
8190 {Timestamp: started.Add(2 * time.Second), Type: "assistant.stream.observed", AgentID: "agent-1", TurnID: "turn-1"},
8291 })["agent-1"]
83- if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "tool.started" {
84- t.Fatalf("primary operation = %#v, want tool", view.PrimaryOperation)
92+ if primaryOperation(t, view) == nil || primaryOperation(t, view).Type != "tool.started" {
93+ t.Fatalf("primary operation = %#v, want tool", primaryOperation(t, view))
8594 }
86- if observation := view.ObservationFor(*view.PrimaryOperation); observation != nil {
95+ if observation := view.ObservationFor(*primaryOperation(t, view)); observation != nil {
8796 t.Fatalf("provider stream decorated tool operation: %#v", observation)
8897 }
8998 }
@@ -161,8 +170,8 @@ func TestProjectTraceSelectsSpecificActivityAndProjectsResultDelivery(t *testing
161170 {Timestamp: started.Add(3 * time.Second), Type: "result.available", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"ref": "subagent://agent-1/result"}},
162171 {Timestamp: started.Add(4 * time.Second), Type: "result.delivered", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"ref": "subagent://agent-1/result"}},
163172 })["agent-1"]
164- if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "tool.started" || view.PrimaryOperation.Name != "bash" {
165- t.Fatalf("primary operation = %#v, want bash tool", view.PrimaryOperation)
173+ if primaryOperation(t, view) == nil || primaryOperation(t, view).Type != "tool.started" || primaryOperation(t, view).Name != "bash" {
174+ t.Fatalf("primary operation = %#v, want bash tool", primaryOperation(t, view))
166175 }
167176 if view.LastObservation == nil || view.LastObservation.Type != "assistant.stream.observed" || view.LastObservation.Label() != "assistant streaming" {
168177 t.Fatalf("last observation = %#v", view.LastObservation)
@@ -213,10 +222,10 @@ func TestProjectTraceOmitsObservationForAnotherToolInSameTurn(t *testing.T) {
213222 {Timestamp: started.Add(2 * time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "two", "name": "read"}},
214223 {Timestamp: started.Add(3 * time.Second), Type: "tool.output.observed", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "two", "name": "read"}},
215224 })["agent-1"]
216- if view.PrimaryOperation == nil || view.PrimaryOperation.CallID != "one" {
217- t.Fatalf("primary operation = %#v, want call one", view.PrimaryOperation)
225+ if primaryOperation(t, view) == nil || primaryOperation(t, view).CallID != "one" {
226+ t.Fatalf("primary operation = %#v, want call one", primaryOperation(t, view))
218227 }
219- if observation := view.ObservationFor(*view.PrimaryOperation); observation != nil {
228+ if observation := view.ObservationFor(*primaryOperation(t, view)); observation != nil {
220229 t.Fatalf("another tool's observation = %#v, want omitted", observation)
221230 }
222231 if got, want := view.Summary(), "running bash"; got != want {
@@ -233,10 +242,10 @@ func TestProjectTraceOmitsPriorTurnObservation(t *testing.T) {
233242 if view.LastObservation == nil {
234243 t.Fatal("last observation missing")
235244 }
236- if view.PrimaryOperation == nil {
245+ if primaryOperation(t, view) == nil {
237246 t.Fatal("primary operation missing")
238247 }
239- if observation := view.ObservationFor(*view.PrimaryOperation); observation != nil {
248+ if observation := view.ObservationFor(*primaryOperation(t, view)); observation != nil {
240249 t.Fatalf("prior turn observation = %#v, want omitted", observation)
241250 }
242251 if got, want := view.Summary(), "processing turn"; got != want {
@@ -8,6 +8,15 @@ import (
8 "time"8 "time"
9 )9 )
10 10
11+func primaryOperation(t testing.TB, view AgentTraceView) *Operation {
12+ t.Helper()
13+ operation, ok := view.Primary()
14+ if !ok {
15+ t.Fatal("trace view has no primary operation")
16+ }
17+ return &operation
18+}
19+
11 func TestProjectTraceRetainsJSONDecodedRequestAttempts(t *testing.T) {20 func TestProjectTraceRetainsJSONDecodedRequestAttempts(t *testing.T) {
12 bundle := t.TempDir()21 bundle := t.TempDir()
13 if err := os.WriteFile(filepath.Join(bundle, "manifest.json"), []byte(`{"version":1,"trace_file":"trace.jsonl"}`), 0o600); err != nil {22 if err := os.WriteFile(filepath.Join(bundle, "manifest.json"), []byte(`{"version":1,"trace_file":"trace.jsonl"}`), 0o600); err != nil {
@@ -21,7 +30,7 @@ func TestProjectTraceRetainsJSONDecodedRequestAttempts(t *testing.T) {
21 if err != nil {30 if err != nil {
22 t.Fatal(err)31 t.Fatal(err)
23 }32 }
24- operation := ProjectTrace(events)["agent-1"].PrimaryOperation33+ operation := primaryOperation(t, ProjectTrace(events)["agent-1"])
25 if operation == nil || operation.Attempt != 2 || operation.MaxAttempts != 6 {34 if operation == nil || operation.Attempt != 2 || operation.MaxAttempts != 6 {
26 t.Fatalf("projected operation = %#v, want attempt 2/6", operation)35 t.Fatalf("projected operation = %#v, want attempt 2/6", operation)
27 }36 }
@@ -36,10 +45,10 @@ func TestProjectTraceIgnoresProtocolFactsAndShowsCompletedToolDuringNextProvider
36 {Timestamp: started.Add(2500 * time.Millisecond), Type: "worker.protocol.observed", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"source_event": "tool.use.args"}},45 {Timestamp: started.Add(2500 * time.Millisecond), Type: "worker.protocol.observed", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"source_event": "tool.use.args"}},
37 {Timestamp: started.Add(3 * time.Second), Type: "provider.request.started", AgentID: "agent-1", TurnID: "turn-1"},46 {Timestamp: started.Add(3 * time.Second), Type: "provider.request.started", AgentID: "agent-1", TurnID: "turn-1"},
38 })["agent-1"]47 })["agent-1"]
39- if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "provider.request.started" {48+ if primaryOperation(t, view) == nil || primaryOperation(t, view).Type != "provider.request.started" {
40- t.Fatalf("primary operation = %#v", view.PrimaryOperation)49+ t.Fatalf("primary operation = %#v", primaryOperation(t, view))
41 }50 }
42- observation := view.ObservationFor(*view.PrimaryOperation)51+ observation := view.ObservationFor(*primaryOperation(t, view))
43 if observation == nil || observation.Label() != "finished read" {52 if observation == nil || observation.Label() != "finished read" {
44 t.Fatalf("observation = %#v, want finished read", observation)53 t.Fatalf("observation = %#v, want finished read", observation)
45 }54 }
@@ -65,10 +74,10 @@ func TestProjectTraceKeepsShortCompletedToolVisibleBeforeNextRequest(t *testing.
65 {Timestamp: started.Add(time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "call-1", "name": "read"}},74 {Timestamp: started.Add(time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "call-1", "name": "read"}},
66 {Timestamp: started.Add(time.Second + time.Millisecond), Type: "tool.finished", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "call-1", "name": "read"}},75 {Timestamp: started.Add(time.Second + time.Millisecond), Type: "tool.finished", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "call-1", "name": "read"}},
67 })["agent-1"]76 })["agent-1"]
68- if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "turn.started" {77+ if primaryOperation(t, view) == nil || primaryOperation(t, view).Type != "turn.started" {
69- t.Fatalf("primary operation = %#v, want turn", view.PrimaryOperation)78+ t.Fatalf("primary operation = %#v, want turn", primaryOperation(t, view))
70 }79 }
71- if observation := view.ObservationFor(*view.PrimaryOperation); observation == nil || observation.Label() != "finished read" {80+ if observation := view.ObservationFor(*primaryOperation(t, view)); observation == nil || observation.Label() != "finished read" {
72 t.Fatalf("completed tool observation = %#v, want finished read", observation)81 t.Fatalf("completed tool observation = %#v, want finished read", observation)
73 }82 }
74 }83 }
@@ -80,10 +89,10 @@ func TestProjectTraceDoesNotDecorateToolWithProviderStream(t *testing.T) {
80 {Timestamp: started.Add(time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "call-1", "name": "read"}},89 {Timestamp: started.Add(time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "call-1", "name": "read"}},
81 {Timestamp: started.Add(2 * time.Second), Type: "assistant.stream.observed", AgentID: "agent-1", TurnID: "turn-1"},90 {Timestamp: started.Add(2 * time.Second), Type: "assistant.stream.observed", AgentID: "agent-1", TurnID: "turn-1"},
82 })["agent-1"]91 })["agent-1"]
83- if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "tool.started" {92+ if primaryOperation(t, view) == nil || primaryOperation(t, view).Type != "tool.started" {
84- t.Fatalf("primary operation = %#v, want tool", view.PrimaryOperation)93+ t.Fatalf("primary operation = %#v, want tool", primaryOperation(t, view))
85 }94 }
86- if observation := view.ObservationFor(*view.PrimaryOperation); observation != nil {95+ if observation := view.ObservationFor(*primaryOperation(t, view)); observation != nil {
87 t.Fatalf("provider stream decorated tool operation: %#v", observation)96 t.Fatalf("provider stream decorated tool operation: %#v", observation)
88 }97 }
89 }98 }
@@ -161,8 +170,8 @@ func TestProjectTraceSelectsSpecificActivityAndProjectsResultDelivery(t *testing
161 {Timestamp: started.Add(3 * time.Second), Type: "result.available", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"ref": "subagent://agent-1/result"}},170 {Timestamp: started.Add(3 * time.Second), Type: "result.available", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"ref": "subagent://agent-1/result"}},
162 {Timestamp: started.Add(4 * time.Second), Type: "result.delivered", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"ref": "subagent://agent-1/result"}},171 {Timestamp: started.Add(4 * time.Second), Type: "result.delivered", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"ref": "subagent://agent-1/result"}},
163 })["agent-1"]172 })["agent-1"]
164- if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "tool.started" || view.PrimaryOperation.Name != "bash" {173+ if primaryOperation(t, view) == nil || primaryOperation(t, view).Type != "tool.started" || primaryOperation(t, view).Name != "bash" {
165- t.Fatalf("primary operation = %#v, want bash tool", view.PrimaryOperation)174+ t.Fatalf("primary operation = %#v, want bash tool", primaryOperation(t, view))
166 }175 }
167 if view.LastObservation == nil || view.LastObservation.Type != "assistant.stream.observed" || view.LastObservation.Label() != "assistant streaming" {176 if view.LastObservation == nil || view.LastObservation.Type != "assistant.stream.observed" || view.LastObservation.Label() != "assistant streaming" {
168 t.Fatalf("last observation = %#v", view.LastObservation)177 t.Fatalf("last observation = %#v", view.LastObservation)
@@ -213,10 +222,10 @@ func TestProjectTraceOmitsObservationForAnotherToolInSameTurn(t *testing.T) {
213 {Timestamp: started.Add(2 * time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "two", "name": "read"}},222 {Timestamp: started.Add(2 * time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "two", "name": "read"}},
214 {Timestamp: started.Add(3 * time.Second), Type: "tool.output.observed", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "two", "name": "read"}},223 {Timestamp: started.Add(3 * time.Second), Type: "tool.output.observed", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "two", "name": "read"}},
215 })["agent-1"]224 })["agent-1"]
216- if view.PrimaryOperation == nil || view.PrimaryOperation.CallID != "one" {225+ if primaryOperation(t, view) == nil || primaryOperation(t, view).CallID != "one" {
217- t.Fatalf("primary operation = %#v, want call one", view.PrimaryOperation)226+ t.Fatalf("primary operation = %#v, want call one", primaryOperation(t, view))
218 }227 }
219- if observation := view.ObservationFor(*view.PrimaryOperation); observation != nil {228+ if observation := view.ObservationFor(*primaryOperation(t, view)); observation != nil {
220 t.Fatalf("another tool's observation = %#v, want omitted", observation)229 t.Fatalf("another tool's observation = %#v, want omitted", observation)
221 }230 }
222 if got, want := view.Summary(), "running bash"; got != want {231 if got, want := view.Summary(), "running bash"; got != want {
@@ -233,10 +242,10 @@ func TestProjectTraceOmitsPriorTurnObservation(t *testing.T) {
233 if view.LastObservation == nil {242 if view.LastObservation == nil {
234 t.Fatal("last observation missing")243 t.Fatal("last observation missing")
235 }244 }
236- if view.PrimaryOperation == nil {245+ if primaryOperation(t, view) == nil {
237 t.Fatal("primary operation missing")246 t.Fatal("primary operation missing")
238 }247 }
239- if observation := view.ObservationFor(*view.PrimaryOperation); observation != nil {248+ if observation := view.ObservationFor(*primaryOperation(t, view)); observation != nil {
240 t.Fatalf("prior turn observation = %#v, want omitted", observation)249 t.Fatalf("prior turn observation = %#v, want omitted", observation)
241 }250 }
242 if got, want := view.Summary(), "processing turn"; got != want {251 if got, want := view.Summary(), "processing turn"; got != want {
M packages/agent/subagents/protocol.go +1 -1
@@ -600,7 +600,7 @@ type AgentHeartbeatPayload struct {
600600 }
601601 type TurnStartPayload struct {
602602 Prompt string `json:"prompt"`
603- // NewRun starts a fresh max_turns budget while retaining the worker's
603+ // NewRun starts a fresh observable run while retaining the worker's
604604 // existing session and transcript. It is set only for explicit
605605 // subagent_resume follow-ups.
606606 NewRun bool `json:"new_run,omitempty"`
@@ -600,7 +600,7 @@ type AgentHeartbeatPayload struct {
600 }600 }
601 type TurnStartPayload struct {601 type TurnStartPayload struct {
602 Prompt string `json:"prompt"`602 Prompt string `json:"prompt"`
603- // NewRun starts a fresh max_turns budget while retaining the worker's603+ // NewRun starts a fresh observable run while retaining the worker's
604 // existing session and transcript. It is set only for explicit604 // existing session and transcript. It is set only for explicit
605 // subagent_resume follow-ups.605 // subagent_resume follow-ups.
606 NewRun bool `json:"new_run,omitempty"`606 NewRun bool `json:"new_run,omitempty"`
M packages/agent/subagents/result.go +5 -1
@@ -571,13 +571,17 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
571571 result.Summary = strings.Split(strings.TrimSpace(result.Output), "\n")[0]
572572 }
573573 result = result.Bounded(a.maxOutputBytes, a.maxOutputLines)
574- a.setResult(result)
575574 stateDir := a.stateDirectory(f.cfg.Root)
576575 if err := writeTurnResult(stateDir, result); err != nil {
577576 a.recordPersistenceError(fmt.Errorf("write turn result: %w", err))
577+ failed := cloneTurnResult(result)
578+ failed.Status = ResultFailed
579+ failed.Error = &ResultError{Code: "result_persistence_failed", Message: "failed to persist delegated turn result"}
580+ a.setResult(failed)
578581 a.resolveRequirement(0, nil, fmt.Sprintf("write turn result: %v", err), true)
579582 return
580583 }
584+ a.setResult(result)
581585 a.lifecycleMu.Lock()
582586 a.resultRef = ResultRef(a.ID)
583587 a.lifecycleMu.Unlock()
@@ -571,13 +571,17 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
571 result.Summary = strings.Split(strings.TrimSpace(result.Output), "\n")[0]571 result.Summary = strings.Split(strings.TrimSpace(result.Output), "\n")[0]
572 }572 }
573 result = result.Bounded(a.maxOutputBytes, a.maxOutputLines)573 result = result.Bounded(a.maxOutputBytes, a.maxOutputLines)
574- a.setResult(result)
575 stateDir := a.stateDirectory(f.cfg.Root)574 stateDir := a.stateDirectory(f.cfg.Root)
576 if err := writeTurnResult(stateDir, result); err != nil {575 if err := writeTurnResult(stateDir, result); err != nil {
577 a.recordPersistenceError(fmt.Errorf("write turn result: %w", err))576 a.recordPersistenceError(fmt.Errorf("write turn result: %w", err))
577+ failed := cloneTurnResult(result)
578+ failed.Status = ResultFailed
579+ failed.Error = &ResultError{Code: "result_persistence_failed", Message: "failed to persist delegated turn result"}
580+ a.setResult(failed)
578 a.resolveRequirement(0, nil, fmt.Sprintf("write turn result: %v", err), true)581 a.resolveRequirement(0, nil, fmt.Sprintf("write turn result: %v", err), true)
579 return582 return
580 }583 }
584+ a.setResult(result)
581 a.lifecycleMu.Lock()585 a.lifecycleMu.Lock()
582 a.resultRef = ResultRef(a.ID)586 a.resultRef = ResultRef(a.ID)
583 a.lifecycleMu.Unlock()587 a.lifecycleMu.Unlock()
M packages/agent/subagents/runner.go +4 -28
@@ -78,8 +78,6 @@ type subagentWorkerArgsOpts struct {
7878 FastMode bool
7979 FastModeSet bool
8080 Subagent string
81- MaxTurns int
82- MaxSteps int
8381 TurnTimeout time.Duration
8482 LifetimeTurns int
8583 RunTurns int
@@ -118,8 +116,6 @@ func defaultChildArgs(exe string, a *Agent, sessionPath, inboxPath string) []str
118116 FastMode: a.FastMode,
119117 FastModeSet: true,
120118 Subagent: a.Subagent,
121- MaxTurns: a.MaxTurns,
122- MaxSteps: a.MaxSteps,
123119 TurnTimeout: a.Timeout,
124120 LifetimeTurns: a.LifetimeTurnsValue(),
125121 RunTurns: a.CurrentRunTurnsValue(),
@@ -164,12 +160,6 @@ func subagentWorkerArgs(opts subagentWorkerArgsOpts) []string {
164160 if opts.Subagent != "" {
165161 args = append(args, "--subagent", opts.Subagent)
166162 }
167- if opts.MaxTurns > 0 {
168- args = append(args, "--max-turns", fmt.Sprint(opts.MaxTurns))
169- }
170- if opts.MaxSteps > 0 {
171- args = append(args, "--max-steps", fmt.Sprint(opts.MaxSteps))
172- }
173163 if opts.TurnTimeout > 0 {
174164 args = append(args, "--subagent-turn-timeout", opts.TurnTimeout.String())
175165 }
@@ -814,13 +804,8 @@ func updateAgentFromEvent(a *Agent, ev Event) error {
814804 case "error":
815805 if code, _ := ev.Data["code"].(string); code == "turn_rejected" && eventMatchesPendingResume(a, ev) {
816806 if a.rejectActiveResumePrompt() {
817- reason, _ := ev.Data["reason"].(string)
818- if reason == "max_turns" {
819- a.setTurnState(TurnFailed, ev.TurnID)
820- } else {
821- a.setTurnState(TurnIdle, ev.TurnID)
822- notifyIdle = true
823- }
807+ a.setTurnState(TurnIdle, ev.TurnID)
808+ notifyIdle = true
824809 persist = true
825810 }
826811 }
@@ -926,7 +911,7 @@ func recordWorkerTrace(agent *Agent, ev Event) {
926911 traceType := "worker.protocol.observed"
927912 nestedTurn, _ := ev.Data["nested_turn"].(bool)
928913 switch ev.Type {
929- case "turn_start":
914+ case "turn_start", EventTurnStarted:
930915 if nestedTurn {
931916 traceType = "provider.request.started"
932917 } else {
@@ -941,15 +926,6 @@ func recordWorkerTrace(agent *Agent, ev Event) {
941926 } else {
942927 traceType = "provider.request.finished"
943928 }
944- case EventTurnStarted:
945- // The worker canonicalizes core turn_start to turn.started. Its
946- // nested_turn marker keeps that provider-loop boundary distinct from
947- // the delegated worker turn, which also uses turn.started.
948- if nestedTurn {
949- traceType = "provider.request.started"
950- } else {
951- traceType = "turn.started"
952- }
953929 case EventTurnResult:
954930 traceType = "turn.finished"
955931 if status, _ := ev.Data["status"].(string); status == "failed" {
@@ -991,7 +967,7 @@ func recordWorkerTrace(agent *Agent, ev Event) {
991967 data[key] = value
992968 }
993969 }
994- for _, key := range []string{"attempt", "max_attempts"} {
970+ for _, key := range []string{"step", "attempt", "max_attempts"} {
995971 if value, ok := ev.Data[key].(float64); ok && value > 0 {
996972 data[key] = int(value)
997973 } else if value, ok := ev.Data[key].(int); ok && value > 0 {
@@ -78,8 +78,6 @@ type subagentWorkerArgsOpts struct {
78 FastMode bool78 FastMode bool
79 FastModeSet bool79 FastModeSet bool
80 Subagent string80 Subagent string
81- MaxTurns int
82- MaxSteps int
83 TurnTimeout time.Duration81 TurnTimeout time.Duration
84 LifetimeTurns int82 LifetimeTurns int
85 RunTurns int83 RunTurns int
@@ -118,8 +116,6 @@ func defaultChildArgs(exe string, a *Agent, sessionPath, inboxPath string) []str
118 FastMode: a.FastMode,116 FastMode: a.FastMode,
119 FastModeSet: true,117 FastModeSet: true,
120 Subagent: a.Subagent,118 Subagent: a.Subagent,
121- MaxTurns: a.MaxTurns,
122- MaxSteps: a.MaxSteps,
123 TurnTimeout: a.Timeout,119 TurnTimeout: a.Timeout,
124 LifetimeTurns: a.LifetimeTurnsValue(),120 LifetimeTurns: a.LifetimeTurnsValue(),
125 RunTurns: a.CurrentRunTurnsValue(),121 RunTurns: a.CurrentRunTurnsValue(),
@@ -164,12 +160,6 @@ func subagentWorkerArgs(opts subagentWorkerArgsOpts) []string {
164 if opts.Subagent != "" {160 if opts.Subagent != "" {
165 args = append(args, "--subagent", opts.Subagent)161 args = append(args, "--subagent", opts.Subagent)
166 }162 }
167- if opts.MaxTurns > 0 {
168- args = append(args, "--max-turns", fmt.Sprint(opts.MaxTurns))
169- }
170- if opts.MaxSteps > 0 {
171- args = append(args, "--max-steps", fmt.Sprint(opts.MaxSteps))
172- }
173 if opts.TurnTimeout > 0 {163 if opts.TurnTimeout > 0 {
174 args = append(args, "--subagent-turn-timeout", opts.TurnTimeout.String())164 args = append(args, "--subagent-turn-timeout", opts.TurnTimeout.String())
175 }165 }
@@ -814,13 +804,8 @@ func updateAgentFromEvent(a *Agent, ev Event) error {
814 case "error":804 case "error":
815 if code, _ := ev.Data["code"].(string); code == "turn_rejected" && eventMatchesPendingResume(a, ev) {805 if code, _ := ev.Data["code"].(string); code == "turn_rejected" && eventMatchesPendingResume(a, ev) {
816 if a.rejectActiveResumePrompt() {806 if a.rejectActiveResumePrompt() {
817- reason, _ := ev.Data["reason"].(string)807+ a.setTurnState(TurnIdle, ev.TurnID)
818- if reason == "max_turns" {808+ notifyIdle = true
819- a.setTurnState(TurnFailed, ev.TurnID)
820- } else {
821- a.setTurnState(TurnIdle, ev.TurnID)
822- notifyIdle = true
823- }
824 persist = true809 persist = true
825 }810 }
826 }811 }
@@ -926,7 +911,7 @@ func recordWorkerTrace(agent *Agent, ev Event) {
926 traceType := "worker.protocol.observed"911 traceType := "worker.protocol.observed"
927 nestedTurn, _ := ev.Data["nested_turn"].(bool)912 nestedTurn, _ := ev.Data["nested_turn"].(bool)
928 switch ev.Type {913 switch ev.Type {
929- case "turn_start":914+ case "turn_start", EventTurnStarted:
930 if nestedTurn {915 if nestedTurn {
931 traceType = "provider.request.started"916 traceType = "provider.request.started"
932 } else {917 } else {
@@ -941,15 +926,6 @@ func recordWorkerTrace(agent *Agent, ev Event) {
941 } else {926 } else {
942 traceType = "provider.request.finished"927 traceType = "provider.request.finished"
943 }928 }
944- case EventTurnStarted:
945- // The worker canonicalizes core turn_start to turn.started. Its
946- // nested_turn marker keeps that provider-loop boundary distinct from
947- // the delegated worker turn, which also uses turn.started.
948- if nestedTurn {
949- traceType = "provider.request.started"
950- } else {
951- traceType = "turn.started"
952- }
953 case EventTurnResult:929 case EventTurnResult:
954 traceType = "turn.finished"930 traceType = "turn.finished"
955 if status, _ := ev.Data["status"].(string); status == "failed" {931 if status, _ := ev.Data["status"].(string); status == "failed" {
@@ -991,7 +967,7 @@ func recordWorkerTrace(agent *Agent, ev Event) {
991 data[key] = value967 data[key] = value
992 }968 }
993 }969 }
994- for _, key := range []string{"attempt", "max_attempts"} {970+ for _, key := range []string{"step", "attempt", "max_attempts"} {
995 if value, ok := ev.Data[key].(float64); ok && value > 0 {971 if value, ok := ev.Data[key].(float64); ok && value > 0 {
996 data[key] = int(value)972 data[key] = int(value)
997 } else if value, ok := ev.Data[key].(int); ok && value > 0 {973 } else if value, ok := ev.Data[key].(int); ok && value > 0 {
M packages/agent/subagents/runner_test.go +25 -5
@@ -360,10 +360,12 @@ func TestDefaultChildArgsPropagatesWebSearchPolicy(t *testing.T) {
360360 }
361361 }
362362
363-func TestDefaultChildArgsPropagatesModelStepBudget(t *testing.T) {
364- args := defaultChildArgs("zut", &Agent{Task: "task", MaxSteps: 128}, "/session", "/inbox")
365- if i := indexOf(args, "--max-steps"); i < 0 || safeAt(args, i+1) != "128" {
366- t.Fatalf("argv = %v, want --max-steps 128", args)
363+func TestDefaultChildArgsDoNotImposeModelStepOrTurnLimits(t *testing.T) {
364+ args := defaultChildArgs("zut", &Agent{Task: "task"}, "/session", "/inbox")
365+ for _, flag := range []string{"--max-steps", "--max-turns"} {
366+ if indexOf(args, flag) >= 0 {
367+ t.Fatalf("argv = %v, contains removed subagent limit %s", args, flag)
368+ }
367369 }
368370 }
369371
@@ -911,7 +913,7 @@ func TestRecordWorkerTraceNormalizesSafeLiveObservations(t *testing.T) {
911913 }
912914 }
913915 view := ProjectTrace(append([]TraceEvent{{Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"}}, events...))["agent-1"]
914- if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "tool.started" || view.PrimaryOperation.Name != "bash" {
916+ if primaryOperation(t, view) == nil || primaryOperation(t, view).Type != "tool.started" || primaryOperation(t, view).Name != "bash" {
915917 t.Fatalf("current-turn tool activity was not projected: %#v", view)
916918 }
917919 }
@@ -934,6 +936,24 @@ func TestRecordWorkerTraceClosesProviderRequestWhenTurnEnds(t *testing.T) {
934936 }
935937 }
936938
939+func TestRecordWorkerTraceProjectsCurrentModelStep(t *testing.T) {
940+ trace := NewMemoryTraceWriter()
941+ t.Cleanup(func() { _ = trace.Close() })
942+ agent := &Agent{ID: "agent-1", trace: trace}
943+ recordWorkerTrace(agent, Event{
944+ Type: EventTurnStarted,
945+ TurnID: "turn-1",
946+ Data: map[string]any{"nested_turn": true, "step": float64(3)},
947+ })
948+ if err := trace.Flush(); err != nil {
949+ t.Fatal(err)
950+ }
951+ operation := primaryOperation(t, ProjectTrace(trace.Events())["agent-1"])
952+ if operation.Type != "provider.request.started" || operation.Step != 3 {
953+ t.Fatalf("provider operation = %#v, want model step 3", operation)
954+ }
955+}
956+
937957 func TestApplyEventToSinkProjectsToolActivityIntoTranscript(t *testing.T) {
938958 events := []Event{
939959 {Type: EventToolStarted, Data: map[string]any{"name": "bash"}},
@@ -360,10 +360,12 @@ func TestDefaultChildArgsPropagatesWebSearchPolicy(t *testing.T) {
360 }360 }
361 }361 }
362 362
363-func TestDefaultChildArgsPropagatesModelStepBudget(t *testing.T) {363+func TestDefaultChildArgsDoNotImposeModelStepOrTurnLimits(t *testing.T) {
364- args := defaultChildArgs("zut", &Agent{Task: "task", MaxSteps: 128}, "/session", "/inbox")364+ args := defaultChildArgs("zut", &Agent{Task: "task"}, "/session", "/inbox")
365- if i := indexOf(args, "--max-steps"); i < 0 || safeAt(args, i+1) != "128" {365+ for _, flag := range []string{"--max-steps", "--max-turns"} {
366- t.Fatalf("argv = %v, want --max-steps 128", args)366+ if indexOf(args, flag) >= 0 {
367+ t.Fatalf("argv = %v, contains removed subagent limit %s", args, flag)
368+ }
367 }369 }
368 }370 }
369 371
@@ -911,7 +913,7 @@ func TestRecordWorkerTraceNormalizesSafeLiveObservations(t *testing.T) {
911 }913 }
912 }914 }
913 view := ProjectTrace(append([]TraceEvent{{Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"}}, events...))["agent-1"]915 view := ProjectTrace(append([]TraceEvent{{Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"}}, events...))["agent-1"]
914- if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "tool.started" || view.PrimaryOperation.Name != "bash" {916+ if primaryOperation(t, view) == nil || primaryOperation(t, view).Type != "tool.started" || primaryOperation(t, view).Name != "bash" {
915 t.Fatalf("current-turn tool activity was not projected: %#v", view)917 t.Fatalf("current-turn tool activity was not projected: %#v", view)
916 }918 }
917 }919 }
@@ -934,6 +936,24 @@ func TestRecordWorkerTraceClosesProviderRequestWhenTurnEnds(t *testing.T) {
934 }936 }
935 }937 }
936 938
939+func TestRecordWorkerTraceProjectsCurrentModelStep(t *testing.T) {
940+ trace := NewMemoryTraceWriter()
941+ t.Cleanup(func() { _ = trace.Close() })
942+ agent := &Agent{ID: "agent-1", trace: trace}
943+ recordWorkerTrace(agent, Event{
944+ Type: EventTurnStarted,
945+ TurnID: "turn-1",
946+ Data: map[string]any{"nested_turn": true, "step": float64(3)},
947+ })
948+ if err := trace.Flush(); err != nil {
949+ t.Fatal(err)
950+ }
951+ operation := primaryOperation(t, ProjectTrace(trace.Events())["agent-1"])
952+ if operation.Type != "provider.request.started" || operation.Step != 3 {
953+ t.Fatalf("provider operation = %#v, want model step 3", operation)
954+ }
955+}
956+
937 func TestApplyEventToSinkProjectsToolActivityIntoTranscript(t *testing.T) {957 func TestApplyEventToSinkProjectsToolActivityIntoTranscript(t *testing.T) {
938 events := []Event{958 events := []Event{
939 {Type: EventToolStarted, Data: map[string]any{"name": "bash"}},959 {Type: EventToolStarted, Data: map[string]any{"name": "bash"}},
M packages/agent/subagents/scheduler.go +0 -3
@@ -63,9 +63,6 @@ func (f *Supervisor) validateSpawnRequest(req SpawnRequest) error {
6363
6464 func (f *Supervisor) validateSpawnOptions(req SpawnRequest) error {
6565 p := f.cfg.Policy
66- if req.MaxTurns < 0 || (p.MaxTurns > 0 && req.MaxTurns > p.MaxTurns) {
67- return fmt.Errorf("subagents: max_turns must be 0 through %d (zero uses the policy default)", p.MaxTurns)
68- }
6966 if req.Timeout < 0 {
7067 return errorsInvalid("timeout must not be negative")
7168 }
@@ -63,9 +63,6 @@ func (f *Supervisor) validateSpawnRequest(req SpawnRequest) error {
63 63
64 func (f *Supervisor) validateSpawnOptions(req SpawnRequest) error {64 func (f *Supervisor) validateSpawnOptions(req SpawnRequest) error {
65 p := f.cfg.Policy65 p := f.cfg.Policy
66- if req.MaxTurns < 0 || (p.MaxTurns > 0 && req.MaxTurns > p.MaxTurns) {
67- return fmt.Errorf("subagents: max_turns must be 0 through %d (zero uses the policy default)", p.MaxTurns)
68- }
69 if req.Timeout < 0 {66 if req.Timeout < 0 {
70 return errorsInvalid("timeout must not be negative")67 return errorsInvalid("timeout must not be negative")
71 }68 }
M packages/agent/subagents/supervisor.go +0 -15
@@ -228,14 +228,6 @@ func (f *Supervisor) RepoRoot() string {
228228 return f.cfg.RepoRoot
229229 }
230230
231-// MaxTurns returns the maximum prompt-level turns allowed for subsequently
232-// spawned agents.
233-func (f *Supervisor) MaxTurns() int {
234- f.mu.Lock()
235- defer f.mu.Unlock()
236- return f.cfg.Policy.MaxTurns
237-}
238-
239231 // SetFastMode updates the fast-mode setting for subsequently spawned
240232 // children. Existing agents keep the setting captured at their spawn
241233 // boundary so resumes remain deterministic.
@@ -382,7 +374,6 @@ type SpawnRequest struct {
382374 // supervisor persists its outcome while the manager remains asynchronous.
383375 Required bool
384376 Timeout time.Duration
385- MaxTurns int
386377 Tools []string
387378 WorkspaceMode WorkspaceMode
388379 WorkspaceBase string
@@ -502,10 +493,6 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er
502493 return nil, err
503494 }
504495 dir = workspace.Dir()
505- maxTurns := req.MaxTurns
506- if maxTurns <= 0 {
507- maxTurns = f.cfg.Policy.MaxTurns
508- }
509496 timeout := req.Timeout
510497 if timeout <= 0 {
511498 timeout = f.cfg.Policy.DefaultTimeout
@@ -539,8 +526,6 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er
539526 WorkspaceMode: workspaceMode,
540527 WorkspaceBase: strings.TrimSpace(req.WorkspaceBase),
541528 WorkspaceCapture: req.WorkspaceCapture,
542- MaxTurns: maxTurns,
543- MaxSteps: f.cfg.Policy.MaxSteps,
544529 Timeout: timeout,
545530 HeartbeatInterval: f.cfg.Policy.HeartbeatInterval,
546531 Tools: tools,
@@ -228,14 +228,6 @@ func (f *Supervisor) RepoRoot() string {
228 return f.cfg.RepoRoot228 return f.cfg.RepoRoot
229 }229 }
230 230
231-// MaxTurns returns the maximum prompt-level turns allowed for subsequently
232-// spawned agents.
233-func (f *Supervisor) MaxTurns() int {
234- f.mu.Lock()
235- defer f.mu.Unlock()
236- return f.cfg.Policy.MaxTurns
237-}
238-
239 // SetFastMode updates the fast-mode setting for subsequently spawned231 // SetFastMode updates the fast-mode setting for subsequently spawned
240 // children. Existing agents keep the setting captured at their spawn232 // children. Existing agents keep the setting captured at their spawn
241 // boundary so resumes remain deterministic.233 // boundary so resumes remain deterministic.
@@ -382,7 +374,6 @@ type SpawnRequest struct {
382 // supervisor persists its outcome while the manager remains asynchronous.374 // supervisor persists its outcome while the manager remains asynchronous.
383 Required bool375 Required bool
384 Timeout time.Duration376 Timeout time.Duration
385- MaxTurns int
386 Tools []string377 Tools []string
387 WorkspaceMode WorkspaceMode378 WorkspaceMode WorkspaceMode
388 WorkspaceBase string379 WorkspaceBase string
@@ -502,10 +493,6 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er
502 return nil, err493 return nil, err
503 }494 }
504 dir = workspace.Dir()495 dir = workspace.Dir()
505- maxTurns := req.MaxTurns
506- if maxTurns <= 0 {
507- maxTurns = f.cfg.Policy.MaxTurns
508- }
509 timeout := req.Timeout496 timeout := req.Timeout
510 if timeout <= 0 {497 if timeout <= 0 {
511 timeout = f.cfg.Policy.DefaultTimeout498 timeout = f.cfg.Policy.DefaultTimeout
@@ -539,8 +526,6 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er
539 WorkspaceMode: workspaceMode,526 WorkspaceMode: workspaceMode,
540 WorkspaceBase: strings.TrimSpace(req.WorkspaceBase),527 WorkspaceBase: strings.TrimSpace(req.WorkspaceBase),
541 WorkspaceCapture: req.WorkspaceCapture,528 WorkspaceCapture: req.WorkspaceCapture,
542- MaxTurns: maxTurns,
543- MaxSteps: f.cfg.Policy.MaxSteps,
544 Timeout: timeout,529 Timeout: timeout,
545 HeartbeatInterval: f.cfg.Policy.HeartbeatInterval,530 HeartbeatInterval: f.cfg.Policy.HeartbeatInterval,
546 Tools: tools,531 Tools: tools,
M packages/agent/subagents/supervisor_test.go +3 -19
@@ -86,29 +86,13 @@ func TestSpawnWithoutTimeoutUsesUnlimitedTurns(t *testing.T) {
8686 a.Wait()
8787 }
8888
89-func TestSpawnLeavesModelAndTurnBudgetsUnlimitedByDefault(t *testing.T) {
90- f := newTestSupervisor(t, func(*Agent) Runner {
91- return RunnerFunc(func(context.Context, Sink) error { return nil })
92- })
93- a, err := f.Spawn(context.Background(), "unlimited model loop")
94- if err != nil {
95- t.Fatal(err)
96- }
97- if a.MaxSteps != 0 || a.MaxTurns != 0 {
98- t.Fatalf("model/turn budgets = %d/%d, want unlimited", a.MaxSteps, a.MaxTurns)
99- }
100- a.Wait()
101-}
102-
103-func TestSubagentPolicyCanonicalizesNegativeUnlimitedBudgets(t *testing.T) {
89+func TestSubagentPolicyCanonicalizesNegativeUnlimitedTimeout(t *testing.T) {
10490 policy := SubagentPolicy{
10591 DefaultTimeout: -time.Second,
106- MaxTurns: -1,
107- MaxSteps: -1,
10892 }
10993 policy.normalize()
110- if policy.DefaultTimeout != 0 || policy.MaxTurns != 0 || policy.MaxSteps != 0 {
111- t.Fatalf("normalized budgets = %s/%d/%d, want unlimited zeros", policy.DefaultTimeout, policy.MaxTurns, policy.MaxSteps)
94+ if policy.DefaultTimeout != 0 {
95+ t.Fatalf("normalized timeout = %s, want unlimited zero", policy.DefaultTimeout)
11296 }
11397 }
11498
@@ -86,29 +86,13 @@ func TestSpawnWithoutTimeoutUsesUnlimitedTurns(t *testing.T) {
86 a.Wait()86 a.Wait()
87 }87 }
88 88
89-func TestSpawnLeavesModelAndTurnBudgetsUnlimitedByDefault(t *testing.T) {89+func TestSubagentPolicyCanonicalizesNegativeUnlimitedTimeout(t *testing.T) {
90- f := newTestSupervisor(t, func(*Agent) Runner {
91- return RunnerFunc(func(context.Context, Sink) error { return nil })
92- })
93- a, err := f.Spawn(context.Background(), "unlimited model loop")
94- if err != nil {
95- t.Fatal(err)
96- }
97- if a.MaxSteps != 0 || a.MaxTurns != 0 {
98- t.Fatalf("model/turn budgets = %d/%d, want unlimited", a.MaxSteps, a.MaxTurns)
99- }
100- a.Wait()
101-}
102-
103-func TestSubagentPolicyCanonicalizesNegativeUnlimitedBudgets(t *testing.T) {
104 policy := SubagentPolicy{90 policy := SubagentPolicy{
105 DefaultTimeout: -time.Second,91 DefaultTimeout: -time.Second,
106- MaxTurns: -1,
107- MaxSteps: -1,
108 }92 }
109 policy.normalize()93 policy.normalize()
110- if policy.DefaultTimeout != 0 || policy.MaxTurns != 0 || policy.MaxSteps != 0 {94+ if policy.DefaultTimeout != 0 {
111- t.Fatalf("normalized budgets = %s/%d/%d, want unlimited zeros", policy.DefaultTimeout, policy.MaxTurns, policy.MaxSteps)95+ t.Fatalf("normalized timeout = %s, want unlimited zero", policy.DefaultTimeout)
112 }96 }
113 }97 }
114 98
M packages/agent/subagents/trace_test.go +9 -9
@@ -144,11 +144,11 @@ func TestSupervisorTraceViewsRetainOpenTurnAcrossEventEviction(t *testing.T) {
144144 }
145145
146146 view := supervisor.TraceViews()["agent-1"]
147- if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "turn.started" {
148- t.Fatalf("primary operation after request = %#v, want open delegated turn", view.PrimaryOperation)
147+ if primaryOperation(t, view) == nil || primaryOperation(t, view).Type != "turn.started" {
148+ t.Fatalf("primary operation after request = %#v, want open delegated turn", primaryOperation(t, view))
149149 }
150- if !view.PrimaryOperation.StartedAt.Equal(started) {
151- t.Fatalf("delegated turn started at %v, want %v", view.PrimaryOperation.StartedAt, started)
150+ if !primaryOperation(t, view).StartedAt.Equal(started) {
151+ t.Fatalf("delegated turn started at %v, want %v", primaryOperation(t, view).StartedAt, started)
152152 }
153153
154154 nextRequestStarted := requestStarted.Add(2 * time.Second)
@@ -162,10 +162,10 @@ func TestSupervisorTraceViewsRetainOpenTurnAcrossEventEviction(t *testing.T) {
162162 t.Fatal(err)
163163 }
164164 view = supervisor.TraceViews()["agent-1"]
165- if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "provider.request.started" {
166- t.Fatalf("primary operation during next request = %#v, want provider request", view.PrimaryOperation)
165+ if primaryOperation(t, view) == nil || primaryOperation(t, view).Type != "provider.request.started" {
166+ t.Fatalf("primary operation during next request = %#v, want provider request", primaryOperation(t, view))
167167 }
168- if got := view.TurnStartedAt(*view.PrimaryOperation); !got.Equal(started) {
168+ if got := view.TurnStartedAt(*primaryOperation(t, view)); !got.Equal(started) {
169169 t.Fatalf("elapsed timer anchor = %v, want delegated turn start %v", got, started)
170170 }
171171 }
@@ -183,8 +183,8 @@ func TestTraceViewsObserveIngestedBoundariesBeforeAsyncWrite(t *testing.T) {
183183 })
184184
185185 view := writer.Views()["agent-1"]
186- if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "turn.started" {
187- t.Fatalf("primary operation before async write = %#v, want delegated turn", view.PrimaryOperation)
186+ if primaryOperation(t, view) == nil || primaryOperation(t, view).Type != "turn.started" {
187+ t.Fatalf("primary operation before async write = %#v, want delegated turn", primaryOperation(t, view))
188188 }
189189 }
190190
@@ -144,11 +144,11 @@ func TestSupervisorTraceViewsRetainOpenTurnAcrossEventEviction(t *testing.T) {
144 }144 }
145 145
146 view := supervisor.TraceViews()["agent-1"]146 view := supervisor.TraceViews()["agent-1"]
147- if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "turn.started" {147+ if primaryOperation(t, view) == nil || primaryOperation(t, view).Type != "turn.started" {
148- t.Fatalf("primary operation after request = %#v, want open delegated turn", view.PrimaryOperation)148+ t.Fatalf("primary operation after request = %#v, want open delegated turn", primaryOperation(t, view))
149 }149 }
150- if !view.PrimaryOperation.StartedAt.Equal(started) {150+ if !primaryOperation(t, view).StartedAt.Equal(started) {
151- t.Fatalf("delegated turn started at %v, want %v", view.PrimaryOperation.StartedAt, started)151+ t.Fatalf("delegated turn started at %v, want %v", primaryOperation(t, view).StartedAt, started)
152 }152 }
153 153
154 nextRequestStarted := requestStarted.Add(2 * time.Second)154 nextRequestStarted := requestStarted.Add(2 * time.Second)
@@ -162,10 +162,10 @@ func TestSupervisorTraceViewsRetainOpenTurnAcrossEventEviction(t *testing.T) {
162 t.Fatal(err)162 t.Fatal(err)
163 }163 }
164 view = supervisor.TraceViews()["agent-1"]164 view = supervisor.TraceViews()["agent-1"]
165- if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "provider.request.started" {165+ if primaryOperation(t, view) == nil || primaryOperation(t, view).Type != "provider.request.started" {
166- t.Fatalf("primary operation during next request = %#v, want provider request", view.PrimaryOperation)166+ t.Fatalf("primary operation during next request = %#v, want provider request", primaryOperation(t, view))
167 }167 }
168- if got := view.TurnStartedAt(*view.PrimaryOperation); !got.Equal(started) {168+ if got := view.TurnStartedAt(*primaryOperation(t, view)); !got.Equal(started) {
169 t.Fatalf("elapsed timer anchor = %v, want delegated turn start %v", got, started)169 t.Fatalf("elapsed timer anchor = %v, want delegated turn start %v", got, started)
170 }170 }
171 }171 }
@@ -183,8 +183,8 @@ func TestTraceViewsObserveIngestedBoundariesBeforeAsyncWrite(t *testing.T) {
183 })183 })
184 184
185 view := writer.Views()["agent-1"]185 view := writer.Views()["agent-1"]
186- if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "turn.started" {186+ if primaryOperation(t, view) == nil || primaryOperation(t, view).Type != "turn.started" {
187- t.Fatalf("primary operation before async write = %#v, want delegated turn", view.PrimaryOperation)187+ t.Fatalf("primary operation before async write = %#v, want delegated turn", primaryOperation(t, view))
188 }188 }
189 }189 }
190 190
M packages/agent/tools/subagent_resume.go +2 -2
@@ -11,8 +11,8 @@ import (
1111 )
1212
1313 // SubagentResumeTool gives a sub-agent a follow-up turn while preserving its
14-// existing session context. Every explicit follow-up starts a fresh max_turns
15-// budget. For an idle live worker, the follow-up is delivered directly; for an
14+// existing session context. Every explicit follow-up starts a fresh observable
15+// run. For an idle live worker, the follow-up is delivered directly; for an
1616 // active live worker, it is queued for the next available message turn; for a
1717 // terminal worker, the supervisor restarts the retained session with the
1818 // follow-up as its initial turn.
@@ -11,8 +11,8 @@ import (
11 )11 )
12 12
13 // SubagentResumeTool gives a sub-agent a follow-up turn while preserving its13 // SubagentResumeTool gives a sub-agent a follow-up turn while preserving its
14-// existing session context. Every explicit follow-up starts a fresh max_turns14+// existing session context. Every explicit follow-up starts a fresh observable
15-// budget. For an idle live worker, the follow-up is delivered directly; for an15+// run. For an idle live worker, the follow-up is delivered directly; for an
16 // active live worker, it is queued for the next available message turn; for a16 // active live worker, it is queued for the next available message turn; for a
17 // terminal worker, the supervisor restarts the retained session with the17 // terminal worker, the supervisor restarts the retained session with the
18 // follow-up as its initial turn.18 // follow-up as its initial turn.
M packages/agent/tools/subagent_spawn.go +7 -10
@@ -1,9 +1,11 @@
11 package tools
22
33 import (
4+ "bytes"
45 "context"
56 "encoding/json"
67 "fmt"
8+ "io"
79 "strings"
810
911 "github.com/bnema/zut/packages/agent/subagents"
@@ -61,11 +63,6 @@ type subagentSpawnArgs struct {
6163 FastMode *bool `json:"fast_mode,omitempty"`
6264 Required bool `json:"required,omitempty"`
6365 Isolation string `json:"isolation,omitempty"`
64- // Decode removed fields so stale callers receive an explicit policy error
65- // instead of silently spawning with a different, unlimited budget.
66- Timeout json.RawMessage `json:"timeout,omitempty"`
67- MaxTurns json.RawMessage `json:"max_turns,omitempty"`
68- MaxSteps json.RawMessage `json:"max_steps,omitempty"`
6966 }
7067
7168 const subagentSpawnSchemaTemplate = `{
@@ -126,11 +123,13 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
126123 return protocolToolError(prefix + ": subagent delegation is unavailable in this mode")
127124 }
128125 var a subagentSpawnArgs
129- if err := json.Unmarshal(raw, &a); err != nil {
126+ decoder := json.NewDecoder(bytes.NewReader(raw))
127+ decoder.DisallowUnknownFields()
128+ if err := decoder.Decode(&a); err != nil {
130129 return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
131130 }
132- if len(a.Timeout) != 0 || len(a.MaxTurns) != 0 || len(a.MaxSteps) != 0 {
133- return protocolToolError(prefix + ": timeout, max_turns, and max_steps are manager-owned budgets and cannot be set by this tool")
131+ if err := decoder.Decode(&struct{}{}); err != io.EOF {
132+ return core.ToolResult{}, fmt.Errorf("invalid args: trailing JSON value")
134133 }
135134 task := strings.TrimSpace(a.Task)
136135 if task == "" {
@@ -275,8 +274,6 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
275274 "required": a.Required,
276275 "isolation": string(workspaceMode),
277276 "timeout": timeoutDetail,
278- "max_turns": agent.MaxTurns,
279- "max_steps": agent.MaxSteps,
280277 "state": agent.Status(),
281278 "process_state": string(agent.ProcessState()),
282279 "turn_state": string(agent.TurnState()),
@@ -1,9 +1,11 @@
1 package tools1 package tools
2 2
3 import (3 import (
4+ "bytes"
4 "context"5 "context"
5 "encoding/json"6 "encoding/json"
6 "fmt"7 "fmt"
8+ "io"
7 "strings"9 "strings"
8 10
9 "github.com/bnema/zut/packages/agent/subagents"11 "github.com/bnema/zut/packages/agent/subagents"
@@ -61,11 +63,6 @@ type subagentSpawnArgs struct {
61 FastMode *bool `json:"fast_mode,omitempty"`63 FastMode *bool `json:"fast_mode,omitempty"`
62 Required bool `json:"required,omitempty"`64 Required bool `json:"required,omitempty"`
63 Isolation string `json:"isolation,omitempty"`65 Isolation string `json:"isolation,omitempty"`
64- // Decode removed fields so stale callers receive an explicit policy error
65- // instead of silently spawning with a different, unlimited budget.
66- Timeout json.RawMessage `json:"timeout,omitempty"`
67- MaxTurns json.RawMessage `json:"max_turns,omitempty"`
68- MaxSteps json.RawMessage `json:"max_steps,omitempty"`
69 }66 }
70 67
71 const subagentSpawnSchemaTemplate = `{68 const subagentSpawnSchemaTemplate = `{
@@ -126,11 +123,13 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
126 return protocolToolError(prefix + ": subagent delegation is unavailable in this mode")123 return protocolToolError(prefix + ": subagent delegation is unavailable in this mode")
127 }124 }
128 var a subagentSpawnArgs125 var a subagentSpawnArgs
129- if err := json.Unmarshal(raw, &a); err != nil {126+ decoder := json.NewDecoder(bytes.NewReader(raw))
127+ decoder.DisallowUnknownFields()
128+ if err := decoder.Decode(&a); err != nil {
130 return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)129 return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
131 }130 }
132- if len(a.Timeout) != 0 || len(a.MaxTurns) != 0 || len(a.MaxSteps) != 0 {131+ if err := decoder.Decode(&struct{}{}); err != io.EOF {
133- return protocolToolError(prefix + ": timeout, max_turns, and max_steps are manager-owned budgets and cannot be set by this tool")132+ return core.ToolResult{}, fmt.Errorf("invalid args: trailing JSON value")
134 }133 }
135 task := strings.TrimSpace(a.Task)134 task := strings.TrimSpace(a.Task)
136 if task == "" {135 if task == "" {
@@ -275,8 +274,6 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
275 "required": a.Required,274 "required": a.Required,
276 "isolation": string(workspaceMode),275 "isolation": string(workspaceMode),
277 "timeout": timeoutDetail,276 "timeout": timeoutDetail,
278- "max_turns": agent.MaxTurns,
279- "max_steps": agent.MaxSteps,
280 "state": agent.Status(),277 "state": agent.Status(),
281 "process_state": string(agent.ProcessState()),278 "process_state": string(agent.ProcessState()),
282 "turn_state": string(agent.TurnState()),279 "turn_state": string(agent.TurnState()),
M packages/agent/tools/subagent_spawn_test.go +12 -36
@@ -128,9 +128,6 @@ func TestSubagentSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) {
128128 if got := details["timeout"]; got != "unlimited" {
129129 t.Fatalf("timeout detail = %v, want unlimited", got)
130130 }
131- if got := details["max_steps"]; got != 0 {
132- t.Fatalf("max_steps detail = %v, want unlimited", got)
133- }
134131 text := textResult(res.Content)
135132 if !strings.Contains(text, "model: gpt-5") || !strings.Contains(text, "provider: openai-codex") || !strings.Contains(text, "reasoning: medium") {
136133 t.Fatalf("result text missing inherited model/provider:\n%s", text)
@@ -143,20 +140,15 @@ func TestSubagentSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) {
143140 if agents[0].Model != "gpt-5" || agents[0].Provider != "openai-codex" || agents[0].Reasoning != "medium" {
144141 t.Fatalf("agent model/provider/reasoning = %q/%q/%q, want gpt-5/openai-codex/medium", agents[0].Model, agents[0].Provider, agents[0].Reasoning)
145142 }
146- if agents[0].MaxTurns != 0 {
147- t.Fatalf("omitted max_turns = %d, want unlimited", agents[0].MaxTurns)
148- }
149143 }
150144
151-func TestSubagentSpawnDetailsUseEffectiveTimeoutAndTurns(t *testing.T) {
145+func TestSubagentSpawnDetailsUseEffectiveTimeout(t *testing.T) {
152146 root := t.TempDir()
153147 manager := subagents.New(subagents.Config{
154148 Root: filepath.Join(root, "subagents"),
155149 RepoRoot: root,
156150 Policy: subagents.SubagentPolicy{
157151 DefaultTimeout: 37 * time.Minute,
158- MaxTurns: 7,
159- MaxSteps: 99,
160152 },
161153 NewRunner: func(*subagents.Agent) subagents.Runner {
162154 return noopSupervisorRunner{}
@@ -182,41 +174,25 @@ func TestSubagentSpawnDetailsUseEffectiveTimeoutAndTurns(t *testing.T) {
182174 if got := details["timeout"]; got != (37 * time.Minute).String() {
183175 t.Fatalf("omitted timeout detail = %v, want %s", got, (37 * time.Minute).String())
184176 }
185- if got := details["max_turns"]; got != 7 {
186- t.Fatalf("max_turns detail = %v, want 7", got)
187- }
188- if got := details["max_steps"]; got != 99 {
189- t.Fatalf("max_steps detail = %v, want 99", got)
190- }
191-}
192-
193-func TestSubagentSpawnRejectsLegacyModelTimeoutOverride(t *testing.T) {
194- manager := newTestSupervisor(t)
195- tool := &SubagentSpawnTool{Supervisor: manager, Enabled: func() bool { return true }}
196- res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs","timeout":"20m"}`), nil)
197- if err != nil {
198- t.Fatal(err)
199- }
200- if !res.IsError || !strings.Contains(textResult(res.Content), "manager-owned") {
201- t.Fatalf("result = %#v, want manager-owned budget error", res)
177+ if _, found := details["max_turns"]; found {
178+ t.Fatalf("details retain removed max_turns: %#v", details)
202179 }
203- if agents := manager.List(); len(agents) != 0 {
204- t.Fatalf("legacy timeout spawned agents: %#v", agents)
180+ if _, found := details["max_steps"]; found {
181+ t.Fatalf("details retain removed max_steps: %#v", details)
205182 }
206183 }
207184
208-func TestSubagentSpawnRejectsLegacyModelMaxTurnsOverride(t *testing.T) {
185+func TestSubagentSpawnRejectsUnknownExecutionLimitFields(t *testing.T) {
209186 manager := newTestSupervisor(t)
210187 tool := &SubagentSpawnTool{Supervisor: manager, Enabled: func() bool { return true }}
211- res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs","max_turns":1}`), nil)
212- if err != nil {
213- t.Fatal(err)
214- }
215- if !res.IsError || !strings.Contains(textResult(res.Content), "manager-owned") {
216- t.Fatalf("result = %#v, want manager-owned budget error", res)
188+ for _, field := range []string{"timeout", "max_turns", "max_steps"} {
189+ _, err := tool.Execute(context.Background(), json.RawMessage(fmt.Sprintf(`{"task":"research docs",%q:1}`, field)), nil)
190+ if err == nil || !strings.Contains(err.Error(), "unknown field") {
191+ t.Fatalf("field %q error = %v, want unknown field", field, err)
192+ }
217193 }
218194 if agents := manager.List(); len(agents) != 0 {
219- t.Fatalf("legacy max_turns spawned agents: %#v", agents)
195+ t.Fatalf("unknown execution limits spawned agents: %#v", agents)
220196 }
221197 }
222198
@@ -128,9 +128,6 @@ func TestSubagentSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) {
128 if got := details["timeout"]; got != "unlimited" {128 if got := details["timeout"]; got != "unlimited" {
129 t.Fatalf("timeout detail = %v, want unlimited", got)129 t.Fatalf("timeout detail = %v, want unlimited", got)
130 }130 }
131- if got := details["max_steps"]; got != 0 {
132- t.Fatalf("max_steps detail = %v, want unlimited", got)
133- }
134 text := textResult(res.Content)131 text := textResult(res.Content)
135 if !strings.Contains(text, "model: gpt-5") || !strings.Contains(text, "provider: openai-codex") || !strings.Contains(text, "reasoning: medium") {132 if !strings.Contains(text, "model: gpt-5") || !strings.Contains(text, "provider: openai-codex") || !strings.Contains(text, "reasoning: medium") {
136 t.Fatalf("result text missing inherited model/provider:\n%s", text)133 t.Fatalf("result text missing inherited model/provider:\n%s", text)
@@ -143,20 +140,15 @@ func TestSubagentSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) {
143 if agents[0].Model != "gpt-5" || agents[0].Provider != "openai-codex" || agents[0].Reasoning != "medium" {140 if agents[0].Model != "gpt-5" || agents[0].Provider != "openai-codex" || agents[0].Reasoning != "medium" {
144 t.Fatalf("agent model/provider/reasoning = %q/%q/%q, want gpt-5/openai-codex/medium", agents[0].Model, agents[0].Provider, agents[0].Reasoning)141 t.Fatalf("agent model/provider/reasoning = %q/%q/%q, want gpt-5/openai-codex/medium", agents[0].Model, agents[0].Provider, agents[0].Reasoning)
145 }142 }
146- if agents[0].MaxTurns != 0 {
147- t.Fatalf("omitted max_turns = %d, want unlimited", agents[0].MaxTurns)
148- }
149 }143 }
150 144
151-func TestSubagentSpawnDetailsUseEffectiveTimeoutAndTurns(t *testing.T) {145+func TestSubagentSpawnDetailsUseEffectiveTimeout(t *testing.T) {
152 root := t.TempDir()146 root := t.TempDir()
153 manager := subagents.New(subagents.Config{147 manager := subagents.New(subagents.Config{
154 Root: filepath.Join(root, "subagents"),148 Root: filepath.Join(root, "subagents"),
155 RepoRoot: root,149 RepoRoot: root,
156 Policy: subagents.SubagentPolicy{150 Policy: subagents.SubagentPolicy{
157 DefaultTimeout: 37 * time.Minute,151 DefaultTimeout: 37 * time.Minute,
158- MaxTurns: 7,
159- MaxSteps: 99,
160 },152 },
161 NewRunner: func(*subagents.Agent) subagents.Runner {153 NewRunner: func(*subagents.Agent) subagents.Runner {
162 return noopSupervisorRunner{}154 return noopSupervisorRunner{}
@@ -182,41 +174,25 @@ func TestSubagentSpawnDetailsUseEffectiveTimeoutAndTurns(t *testing.T) {
182 if got := details["timeout"]; got != (37 * time.Minute).String() {174 if got := details["timeout"]; got != (37 * time.Minute).String() {
183 t.Fatalf("omitted timeout detail = %v, want %s", got, (37 * time.Minute).String())175 t.Fatalf("omitted timeout detail = %v, want %s", got, (37 * time.Minute).String())
184 }176 }
185- if got := details["max_turns"]; got != 7 {177+ if _, found := details["max_turns"]; found {
186- t.Fatalf("max_turns detail = %v, want 7", got)178+ t.Fatalf("details retain removed max_turns: %#v", details)
187- }
188- if got := details["max_steps"]; got != 99 {
189- t.Fatalf("max_steps detail = %v, want 99", got)
190- }
191-}
192-
193-func TestSubagentSpawnRejectsLegacyModelTimeoutOverride(t *testing.T) {
194- manager := newTestSupervisor(t)
195- tool := &SubagentSpawnTool{Supervisor: manager, Enabled: func() bool { return true }}
196- res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs","timeout":"20m"}`), nil)
197- if err != nil {
198- t.Fatal(err)
199- }
200- if !res.IsError || !strings.Contains(textResult(res.Content), "manager-owned") {
201- t.Fatalf("result = %#v, want manager-owned budget error", res)
202 }179 }
203- if agents := manager.List(); len(agents) != 0 {180+ if _, found := details["max_steps"]; found {
204- t.Fatalf("legacy timeout spawned agents: %#v", agents)181+ t.Fatalf("details retain removed max_steps: %#v", details)
205 }182 }
206 }183 }
207 184
208-func TestSubagentSpawnRejectsLegacyModelMaxTurnsOverride(t *testing.T) {185+func TestSubagentSpawnRejectsUnknownExecutionLimitFields(t *testing.T) {
209 manager := newTestSupervisor(t)186 manager := newTestSupervisor(t)
210 tool := &SubagentSpawnTool{Supervisor: manager, Enabled: func() bool { return true }}187 tool := &SubagentSpawnTool{Supervisor: manager, Enabled: func() bool { return true }}
211- res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs","max_turns":1}`), nil)188+ for _, field := range []string{"timeout", "max_turns", "max_steps"} {
212- if err != nil {189+ _, err := tool.Execute(context.Background(), json.RawMessage(fmt.Sprintf(`{"task":"research docs",%q:1}`, field)), nil)
213- t.Fatal(err)190+ if err == nil || !strings.Contains(err.Error(), "unknown field") {
214- }191+ t.Fatalf("field %q error = %v, want unknown field", field, err)
215- if !res.IsError || !strings.Contains(textResult(res.Content), "manager-owned") {192+ }
216- t.Fatalf("result = %#v, want manager-owned budget error", res)
217 }193 }
218 if agents := manager.List(); len(agents) != 0 {194 if agents := manager.List(); len(agents) != 0 {
219- t.Fatalf("legacy max_turns spawned agents: %#v", agents)195+ t.Fatalf("unknown execution limits spawned agents: %#v", agents)
220 }196 }
221 }197 }
222 198
M packages/agent/tools/subagent_status.go +4 -7
@@ -160,11 +160,7 @@ func publicSubagentStatus(snapshot subagents.AgentSnapshot, view subagents.Agent
160160 CurrentRunTurns: snapshot.CurrentRunTurns,
161161 TaskSummary: summarizeSubagentTask(snapshot.Task),
162162 }
163- primary := view.PrimaryOperation
164- if primary == nil && len(view.OpenOperations) != 0 {
165- primary = &view.OpenOperations[0]
166- }
167- if primary != nil {
163+ if primary, ok := view.Primary(); ok {
168164 entry.PrimaryOperation = &statusOperation{Type: primary.Type, StartedAt: primary.StartedAt}
169165 }
170166 if view.LastObservation != nil {
@@ -186,6 +182,7 @@ func publicSubagentStatus(snapshot subagents.AgentSnapshot, view subagents.Agent
186182 State: publicResultState(snapshot.Result.Status),
187183 Available: snapshot.ResultRef != "",
188184 Ref: snapshot.ResultRef,
185+ Delivered: snapshot.Requirement.Notified,
189186 }
190187 }
191188 if view.Result != nil {
@@ -196,8 +193,8 @@ func publicSubagentStatus(snapshot subagents.AgentSnapshot, view subagents.Agent
196193 if view.Result.Ref != "" {
197194 entry.Result.Ref = view.Result.Ref
198195 }
199- entry.Result.Delivered = view.Result.Delivered
200- entry.Result.DeliveryFailed = view.Result.Failed
196+ entry.Result.Delivered = entry.Result.Delivered || view.Result.Delivered
197+ entry.Result.DeliveryFailed = entry.Result.DeliveryFailed || view.Result.Failed
201198 }
202199 return entry
203200 }
@@ -160,11 +160,7 @@ func publicSubagentStatus(snapshot subagents.AgentSnapshot, view subagents.Agent
160 CurrentRunTurns: snapshot.CurrentRunTurns,160 CurrentRunTurns: snapshot.CurrentRunTurns,
161 TaskSummary: summarizeSubagentTask(snapshot.Task),161 TaskSummary: summarizeSubagentTask(snapshot.Task),
162 }162 }
163- primary := view.PrimaryOperation163+ if primary, ok := view.Primary(); ok {
164- if primary == nil && len(view.OpenOperations) != 0 {
165- primary = &view.OpenOperations[0]
166- }
167- if primary != nil {
168 entry.PrimaryOperation = &statusOperation{Type: primary.Type, StartedAt: primary.StartedAt}164 entry.PrimaryOperation = &statusOperation{Type: primary.Type, StartedAt: primary.StartedAt}
169 }165 }
170 if view.LastObservation != nil {166 if view.LastObservation != nil {
@@ -186,6 +182,7 @@ func publicSubagentStatus(snapshot subagents.AgentSnapshot, view subagents.Agent
186 State: publicResultState(snapshot.Result.Status),182 State: publicResultState(snapshot.Result.Status),
187 Available: snapshot.ResultRef != "",183 Available: snapshot.ResultRef != "",
188 Ref: snapshot.ResultRef,184 Ref: snapshot.ResultRef,
185+ Delivered: snapshot.Requirement.Notified,
189 }186 }
190 }187 }
191 if view.Result != nil {188 if view.Result != nil {
@@ -196,8 +193,8 @@ func publicSubagentStatus(snapshot subagents.AgentSnapshot, view subagents.Agent
196 if view.Result.Ref != "" {193 if view.Result.Ref != "" {
197 entry.Result.Ref = view.Result.Ref194 entry.Result.Ref = view.Result.Ref
198 }195 }
199- entry.Result.Delivered = view.Result.Delivered196+ entry.Result.Delivered = entry.Result.Delivered || view.Result.Delivered
200- entry.Result.DeliveryFailed = view.Result.Failed197+ entry.Result.DeliveryFailed = entry.Result.DeliveryFailed || view.Result.Failed
201 }198 }
202 return entry199 return entry
203 }200 }
M packages/agent/tools/subagent_status_test.go +17 -3
@@ -61,11 +61,13 @@ func TestSubagentStatusIncludesTurnCounters(t *testing.T) {
6161 }
6262 }
6363
64-func TestPublicSubagentStatusUsesOnlyPrimaryOperation(t *testing.T) {
64+func TestPublicSubagentStatusUsesOrderedPrimaryOperation(t *testing.T) {
6565 at := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
6666 entry := publicSubagentStatus(subagents.AgentSnapshot{ID: "agent-1"}, subagents.AgentTraceView{
67- OpenOperations: []subagents.Operation{{Type: "turn.started", StartedAt: at}},
68- PrimaryOperation: &subagents.Operation{Type: "tool.started", StartedAt: at.Add(time.Second)},
67+ OpenOperations: []subagents.Operation{
68+ {Type: "tool.started", StartedAt: at.Add(time.Second)},
69+ {Type: "turn.started", StartedAt: at},
70+ },
6971 })
7072 if entry.PrimaryOperation == nil || entry.PrimaryOperation.Type != "tool.started" {
7173 t.Fatalf("primary operation = %#v", entry.PrimaryOperation)
@@ -120,6 +122,18 @@ func TestPublicSubagentStatusIncludesResultDeliveryFacts(t *testing.T) {
120122 }
121123 }
122124
125+func TestPublicSubagentStatusKeepsDurableDeliveryAfterTraceRestart(t *testing.T) {
126+ entry := publicSubagentStatus(subagents.AgentSnapshot{
127+ ID: "agent-1",
128+ Result: &subagents.TurnResult{Status: subagents.ResultSucceeded},
129+ ResultRef: "subagent://agent-1/result",
130+ Requirement: subagents.RequirementSnapshot{Required: true, Notified: true},
131+ }, subagents.AgentTraceView{})
132+ if entry.Result == nil || !entry.Result.Available || !entry.Result.Delivered {
133+ t.Fatalf("result = %#v, want durable delivered fact without transient trace", entry.Result)
134+ }
135+}
136+
123137 func TestSubagentStatusSchemaHasOptionalAgentID(t *testing.T) {
124138 var schema struct {
125139 Type string `json:"type"`
@@ -61,11 +61,13 @@ func TestSubagentStatusIncludesTurnCounters(t *testing.T) {
61 }61 }
62 }62 }
63 63
64-func TestPublicSubagentStatusUsesOnlyPrimaryOperation(t *testing.T) {64+func TestPublicSubagentStatusUsesOrderedPrimaryOperation(t *testing.T) {
65 at := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)65 at := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
66 entry := publicSubagentStatus(subagents.AgentSnapshot{ID: "agent-1"}, subagents.AgentTraceView{66 entry := publicSubagentStatus(subagents.AgentSnapshot{ID: "agent-1"}, subagents.AgentTraceView{
67- OpenOperations: []subagents.Operation{{Type: "turn.started", StartedAt: at}},67+ OpenOperations: []subagents.Operation{
68- PrimaryOperation: &subagents.Operation{Type: "tool.started", StartedAt: at.Add(time.Second)},68+ {Type: "tool.started", StartedAt: at.Add(time.Second)},
69+ {Type: "turn.started", StartedAt: at},
70+ },
69 })71 })
70 if entry.PrimaryOperation == nil || entry.PrimaryOperation.Type != "tool.started" {72 if entry.PrimaryOperation == nil || entry.PrimaryOperation.Type != "tool.started" {
71 t.Fatalf("primary operation = %#v", entry.PrimaryOperation)73 t.Fatalf("primary operation = %#v", entry.PrimaryOperation)
@@ -120,6 +122,18 @@ func TestPublicSubagentStatusIncludesResultDeliveryFacts(t *testing.T) {
120 }122 }
121 }123 }
122 124
125+func TestPublicSubagentStatusKeepsDurableDeliveryAfterTraceRestart(t *testing.T) {
126+ entry := publicSubagentStatus(subagents.AgentSnapshot{
127+ ID: "agent-1",
128+ Result: &subagents.TurnResult{Status: subagents.ResultSucceeded},
129+ ResultRef: "subagent://agent-1/result",
130+ Requirement: subagents.RequirementSnapshot{Required: true, Notified: true},
131+ }, subagents.AgentTraceView{})
132+ if entry.Result == nil || !entry.Result.Available || !entry.Result.Delivered {
133+ t.Fatalf("result = %#v, want durable delivered fact without transient trace", entry.Result)
134+ }
135+}
136+
123 func TestSubagentStatusSchemaHasOptionalAgentID(t *testing.T) {137 func TestSubagentStatusSchemaHasOptionalAgentID(t *testing.T) {
124 var schema struct {138 var schema struct {
125 Type string `json:"type"`139 Type string `json:"type"`
M packages/core/agent.go +9 -0
@@ -963,6 +963,10 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext
963963 if err != nil {
964964 return provider.StopError, provider.Message{}, &requestOpenError{err: err}
965965 }
966+ // A provider may already have queued events when cancellation wins. Keep a
967+ // consumer attached after this turn returns so bounded producer channels
968+ // cannot strand the provider goroutine and its response body.
969+ defer func() { go drainProviderStream(stream) }()
966970
967971 sink(EvAssistantStart{})
968972
@@ -1097,6 +1101,11 @@ streamDone:
10971101 return stop, finalMsg, finalErr
10981102 }
10991103
1104+func drainProviderStream(stream <-chan provider.Event) {
1105+ for range stream {
1106+ }
1107+}
1108+
11001109 // executeTools runs every tool call in the assistant message and returns
11011110 // a single tool-role message carrying all results.
11021111 func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink func(AgentEvent)) (provider.Message, bool) {
@@ -963,6 +963,10 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext
963 if err != nil {963 if err != nil {
964 return provider.StopError, provider.Message{}, &requestOpenError{err: err}964 return provider.StopError, provider.Message{}, &requestOpenError{err: err}
965 }965 }
966+ // A provider may already have queued events when cancellation wins. Keep a
967+ // consumer attached after this turn returns so bounded producer channels
968+ // cannot strand the provider goroutine and its response body.
969+ defer func() { go drainProviderStream(stream) }()
966 970
967 sink(EvAssistantStart{})971 sink(EvAssistantStart{})
968 972
@@ -1097,6 +1101,11 @@ streamDone:
1097 return stop, finalMsg, finalErr1101 return stop, finalMsg, finalErr
1098 }1102 }
1099 1103
1104+func drainProviderStream(stream <-chan provider.Event) {
1105+ for range stream {
1106+ }
1107+}
1108+
1100 // executeTools runs every tool call in the assistant message and returns1109 // executeTools runs every tool call in the assistant message and returns
1101 // a single tool-role message carrying all results.1110 // a single tool-role message carrying all results.
1102 func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink func(AgentEvent)) (provider.Message, bool) {1111 func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink func(AgentEvent)) (provider.Message, bool) {
M packages/core/agent_retry_test.go +42 -2
@@ -61,6 +61,18 @@ func (silentStreamClient) Stream(ctx context.Context, _ provider.Request) (<-cha
6161 return out, nil
6262 }
6363
64+type activeStreamClient struct{}
65+
66+func (activeStreamClient) Name() string { return "active-stream" }
67+
68+func (activeStreamClient) Stream(context.Context, provider.Request) (<-chan provider.Event, error) {
69+ out := make(chan provider.Event, 2)
70+ out <- provider.EventStart{}
71+ out <- provider.EventDone{Stop: provider.StopEnd}
72+ close(out)
73+ return out, nil
74+}
75+
6476 type requestOpenFailureClient struct{ calls int32 }
6577
6678 func (c *requestOpenFailureClient) Name() string { return "request-open-failure" }
@@ -71,7 +83,8 @@ func (c *requestOpenFailureClient) Stream(context.Context, provider.Request) (<-
7183 }
7284
7385 type readableAfterCancellationClient struct {
74- cancel context.CancelFunc
86+ cancel context.CancelFunc
87+ drained chan struct{}
7588 }
7689
7790 func (c readableAfterCancellationClient) Name() string { return "readable-after-cancellation" }
@@ -80,6 +93,11 @@ func (c readableAfterCancellationClient) Stream(_ context.Context, _ provider.Re
8093 out := make(chan provider.Event, 1)
8194 out <- provider.EventStart{}
8295 c.cancel()
96+ go func() {
97+ out <- provider.EventTextDelta{Delta: "discard after cancellation"}
98+ close(out)
99+ close(c.drained)
100+ }()
83101 return out, nil
84102 }
85103
@@ -98,11 +116,17 @@ func TestNewAgentUsesCodexStreamGuardDefaults(t *testing.T) {
98116
99117 func TestAgentStopsReadingStreamAfterCancellation(t *testing.T) {
100118 ctx, cancel := context.WithCancel(context.Background())
101- a := NewAgent(readableAfterCancellationClient{cancel: cancel}, "fake-model", "system", Registry{})
119+ drained := make(chan struct{})
120+ a := NewAgent(readableAfterCancellationClient{cancel: cancel, drained: drained}, "fake-model", "system", Registry{})
102121 a.MaxRetries = 0
103122 if err := a.Prompt(ctx, "hello", nil, nil); !errors.Is(err, context.Canceled) {
104123 t.Fatalf("Prompt error = %v, want context.Canceled", err)
105124 }
125+ select {
126+ case <-drained:
127+ case <-time.After(time.Second):
128+ t.Fatal("provider producer remained blocked after turn cancellation")
129+ }
106130 }
107131
108132 func TestAgentStopsSilentStreamAtIdleDeadline(t *testing.T) {
@@ -119,6 +143,22 @@ func TestAgentStopsSilentStreamAtIdleDeadline(t *testing.T) {
119143 }
120144 }
121145
146+func TestAgentResetsIdleDeadlineForEveryStreamEvent(t *testing.T) {
147+ a := NewAgent(activeStreamClient{}, "fake-model", "system", Registry{})
148+ a.MaxRetries = 0
149+ a.StreamIdleTimeout = time.Minute
150+ var resets atomic.Int32
151+ a.streamIdleTimer = func(time.Duration) (<-chan time.Time, func(), func()) {
152+ return make(chan time.Time), func() {}, func() { resets.Add(1) }
153+ }
154+ if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
155+ t.Fatal(err)
156+ }
157+ if got := resets.Load(); got != 2 {
158+ t.Fatalf("idle timer resets = %d, want one for each stream event", got)
159+ }
160+}
161+
122162 func TestAgentRetriesSilentStreamWithoutEndingTurn(t *testing.T) {
123163 client := &retryFakeClient{firstErr: ErrStreamIdleTimeout}
124164 a := NewAgent(client, "fake-model", "system", Registry{})
@@ -61,6 +61,18 @@ func (silentStreamClient) Stream(ctx context.Context, _ provider.Request) (<-cha
61 return out, nil61 return out, nil
62 }62 }
63 63
64+type activeStreamClient struct{}
65+
66+func (activeStreamClient) Name() string { return "active-stream" }
67+
68+func (activeStreamClient) Stream(context.Context, provider.Request) (<-chan provider.Event, error) {
69+ out := make(chan provider.Event, 2)
70+ out <- provider.EventStart{}
71+ out <- provider.EventDone{Stop: provider.StopEnd}
72+ close(out)
73+ return out, nil
74+}
75+
64 type requestOpenFailureClient struct{ calls int32 }76 type requestOpenFailureClient struct{ calls int32 }
65 77
66 func (c *requestOpenFailureClient) Name() string { return "request-open-failure" }78 func (c *requestOpenFailureClient) Name() string { return "request-open-failure" }
@@ -71,7 +83,8 @@ func (c *requestOpenFailureClient) Stream(context.Context, provider.Request) (<-
71 }83 }
72 84
73 type readableAfterCancellationClient struct {85 type readableAfterCancellationClient struct {
74- cancel context.CancelFunc86+ cancel context.CancelFunc
87+ drained chan struct{}
75 }88 }
76 89
77 func (c readableAfterCancellationClient) Name() string { return "readable-after-cancellation" }90 func (c readableAfterCancellationClient) Name() string { return "readable-after-cancellation" }
@@ -80,6 +93,11 @@ func (c readableAfterCancellationClient) Stream(_ context.Context, _ provider.Re
80 out := make(chan provider.Event, 1)93 out := make(chan provider.Event, 1)
81 out <- provider.EventStart{}94 out <- provider.EventStart{}
82 c.cancel()95 c.cancel()
96+ go func() {
97+ out <- provider.EventTextDelta{Delta: "discard after cancellation"}
98+ close(out)
99+ close(c.drained)
100+ }()
83 return out, nil101 return out, nil
84 }102 }
85 103
@@ -98,11 +116,17 @@ func TestNewAgentUsesCodexStreamGuardDefaults(t *testing.T) {
98 116
99 func TestAgentStopsReadingStreamAfterCancellation(t *testing.T) {117 func TestAgentStopsReadingStreamAfterCancellation(t *testing.T) {
100 ctx, cancel := context.WithCancel(context.Background())118 ctx, cancel := context.WithCancel(context.Background())
101- a := NewAgent(readableAfterCancellationClient{cancel: cancel}, "fake-model", "system", Registry{})119+ drained := make(chan struct{})
120+ a := NewAgent(readableAfterCancellationClient{cancel: cancel, drained: drained}, "fake-model", "system", Registry{})
102 a.MaxRetries = 0121 a.MaxRetries = 0
103 if err := a.Prompt(ctx, "hello", nil, nil); !errors.Is(err, context.Canceled) {122 if err := a.Prompt(ctx, "hello", nil, nil); !errors.Is(err, context.Canceled) {
104 t.Fatalf("Prompt error = %v, want context.Canceled", err)123 t.Fatalf("Prompt error = %v, want context.Canceled", err)
105 }124 }
125+ select {
126+ case <-drained:
127+ case <-time.After(time.Second):
128+ t.Fatal("provider producer remained blocked after turn cancellation")
129+ }
106 }130 }
107 131
108 func TestAgentStopsSilentStreamAtIdleDeadline(t *testing.T) {132 func TestAgentStopsSilentStreamAtIdleDeadline(t *testing.T) {
@@ -119,6 +143,22 @@ func TestAgentStopsSilentStreamAtIdleDeadline(t *testing.T) {
119 }143 }
120 }144 }
121 145
146+func TestAgentResetsIdleDeadlineForEveryStreamEvent(t *testing.T) {
147+ a := NewAgent(activeStreamClient{}, "fake-model", "system", Registry{})
148+ a.MaxRetries = 0
149+ a.StreamIdleTimeout = time.Minute
150+ var resets atomic.Int32
151+ a.streamIdleTimer = func(time.Duration) (<-chan time.Time, func(), func()) {
152+ return make(chan time.Time), func() {}, func() { resets.Add(1) }
153+ }
154+ if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
155+ t.Fatal(err)
156+ }
157+ if got := resets.Load(); got != 2 {
158+ t.Fatalf("idle timer resets = %d, want one for each stream event", got)
159+ }
160+}
161+
122 func TestAgentRetriesSilentStreamWithoutEndingTurn(t *testing.T) {162 func TestAgentRetriesSilentStreamWithoutEndingTurn(t *testing.T) {
123 client := &retryFakeClient{firstErr: ErrStreamIdleTimeout}163 client := &retryFakeClient{firstErr: ErrStreamIdleTimeout}
124 a := NewAgent(client, "fake-model", "system", Registry{})164 a := NewAgent(client, "fake-model", "system", Registry{})
M packages/provider/amazon_bedrock.go +14 -18
@@ -585,28 +585,24 @@ func (c *bedrockClient) Stream(ctx context.Context, req Request) (<-chan Event,
585585 }
586586 modelID := resolveBedrockInferenceProfileID(req.Model, c.region)
587587 url := c.baseURL + "/model/" + modelID + "/converse-stream"
588- httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
589- if err != nil {
590- return nil, err
588+ if c.bearerToken == "" && c.sigv4 == nil {
589+ return nil, fmt.Errorf("bedrock: no auth configured")
591590 }
592- httpReq.Header.Set("content-type", "application/json")
593- httpReq.Header.Set("accept", "application/vnd.amazon.eventstream")
594- if c.bearerToken != "" {
595- httpReq.Header.Set("authorization", "Bearer "+c.bearerToken)
596- } else if c.sigv4 != nil {
597- if err := signSigV4(httpReq, body, "bedrock", c.region, c.sigv4, time.Now().UTC()); err != nil {
591+ newRequest := func() (*http.Request, error) {
592+ httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
593+ if err != nil {
594+ return nil, err
595+ }
596+ httpReq.Header.Set("content-type", "application/json")
597+ httpReq.Header.Set("accept", "application/vnd.amazon.eventstream")
598+ if c.bearerToken != "" {
599+ httpReq.Header.Set("authorization", "Bearer "+c.bearerToken)
600+ } else if err := signSigV4(httpReq, body, "bedrock", c.region, c.sigv4, time.Now().UTC()); err != nil {
598601 return nil, fmt.Errorf("bedrock: sigv4 sign: %w", err)
599602 }
600- } else {
601- return nil, fmt.Errorf("bedrock: no auth configured")
602- }
603- if err := ctx.Err(); err != nil {
604- return nil, err
605- }
606- if req.Lifecycle != nil {
607- req.Lifecycle.RequestAttempt(1, 1)
603+ return httpReq, nil
608604 }
609- resp, err := c.http.Do(httpReq)
605+ resp, err := doStreamWithRetry(ctx, c.http, newRequest, req.Lifecycle)
610606 if err != nil {
611607 return nil, fmt.Errorf("bedrock: %w", err)
612608 }
@@ -585,28 +585,24 @@ func (c *bedrockClient) Stream(ctx context.Context, req Request) (<-chan Event,
585 }585 }
586 modelID := resolveBedrockInferenceProfileID(req.Model, c.region)586 modelID := resolveBedrockInferenceProfileID(req.Model, c.region)
587 url := c.baseURL + "/model/" + modelID + "/converse-stream"587 url := c.baseURL + "/model/" + modelID + "/converse-stream"
588- httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))588+ if c.bearerToken == "" && c.sigv4 == nil {
589- if err != nil {589+ return nil, fmt.Errorf("bedrock: no auth configured")
590- return nil, err
591 }590 }
592- httpReq.Header.Set("content-type", "application/json")591+ newRequest := func() (*http.Request, error) {
593- httpReq.Header.Set("accept", "application/vnd.amazon.eventstream")592+ httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
594- if c.bearerToken != "" {593+ if err != nil {
595- httpReq.Header.Set("authorization", "Bearer "+c.bearerToken)594+ return nil, err
596- } else if c.sigv4 != nil {595+ }
597- if err := signSigV4(httpReq, body, "bedrock", c.region, c.sigv4, time.Now().UTC()); err != nil {596+ httpReq.Header.Set("content-type", "application/json")
597+ httpReq.Header.Set("accept", "application/vnd.amazon.eventstream")
598+ if c.bearerToken != "" {
599+ httpReq.Header.Set("authorization", "Bearer "+c.bearerToken)
600+ } else if err := signSigV4(httpReq, body, "bedrock", c.region, c.sigv4, time.Now().UTC()); err != nil {
598 return nil, fmt.Errorf("bedrock: sigv4 sign: %w", err)601 return nil, fmt.Errorf("bedrock: sigv4 sign: %w", err)
599 }602 }
600- } else {603+ return httpReq, nil
601- return nil, fmt.Errorf("bedrock: no auth configured")
602- }
603- if err := ctx.Err(); err != nil {
604- return nil, err
605- }
606- if req.Lifecycle != nil {
607- req.Lifecycle.RequestAttempt(1, 1)
608 }604 }
609- resp, err := c.http.Do(httpReq)605+ resp, err := doStreamWithRetry(ctx, c.http, newRequest, req.Lifecycle)
610 if err != nil {606 if err != nil {
611 return nil, fmt.Errorf("bedrock: %w", err)607 return nil, fmt.Errorf("bedrock: %w", err)
612 }608 }
M packages/provider/amazon_bedrock_test.go +31 -0
@@ -2,13 +2,44 @@ package provider
22
33 import (
44 "bytes"
5+ "context"
56 "encoding/json"
67 "net/http"
8+ "net/http/httptest"
79 "strings"
10+ "sync/atomic"
811 "testing"
912 "time"
1013 )
1114
15+func TestBedrockRetriesTransientRequestOpenFailures(t *testing.T) {
16+ var attempts atomic.Int32
17+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
18+ if attempts.Add(1) < 5 {
19+ http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable)
20+ return
21+ }
22+ w.WriteHeader(http.StatusOK)
23+ }))
24+ t.Cleanup(server.Close)
25+
26+ client := &bedrockClient{
27+ bearerToken: "synthetic-token",
28+ region: "us-east-1",
29+ baseURL: server.URL,
30+ http: server.Client(),
31+ }
32+ events, err := client.Stream(context.Background(), Request{Model: "anthropic.claude-sonnet-4-5"})
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ for range events {
37+ }
38+ if got := attempts.Load(); got != 5 {
39+ t.Fatalf("request attempts = %d, want initial request plus four retries", got)
40+ }
41+}
42+
1243 // TestSigV4Known checks our signer against AWS's published test vector
1344 // from the SigV4 documentation. The expected Authorization header below
1445 // is taken from "get-vanilla" in the suite (adapted: we use POST with
@@ -2,13 +2,44 @@ package provider
2 2
3 import (3 import (
4 "bytes"4 "bytes"
5+ "context"
5 "encoding/json"6 "encoding/json"
6 "net/http"7 "net/http"
8+ "net/http/httptest"
7 "strings"9 "strings"
10+ "sync/atomic"
8 "testing"11 "testing"
9 "time"12 "time"
10 )13 )
11 14
15+func TestBedrockRetriesTransientRequestOpenFailures(t *testing.T) {
16+ var attempts atomic.Int32
17+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
18+ if attempts.Add(1) < 5 {
19+ http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable)
20+ return
21+ }
22+ w.WriteHeader(http.StatusOK)
23+ }))
24+ t.Cleanup(server.Close)
25+
26+ client := &bedrockClient{
27+ bearerToken: "synthetic-token",
28+ region: "us-east-1",
29+ baseURL: server.URL,
30+ http: server.Client(),
31+ }
32+ events, err := client.Stream(context.Background(), Request{Model: "anthropic.claude-sonnet-4-5"})
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ for range events {
37+ }
38+ if got := attempts.Load(); got != 5 {
39+ t.Fatalf("request attempts = %d, want initial request plus four retries", got)
40+ }
41+}
42+
12 // TestSigV4Known checks our signer against AWS's published test vector43 // TestSigV4Known checks our signer against AWS's published test vector
13 // from the SigV4 documentation. The expected Authorization header below44 // from the SigV4 documentation. The expected Authorization header below
14 // is taken from "get-vanilla" in the suite (adapted: we use POST with45 // is taken from "get-vanilla" in the suite (adapted: we use POST with
A packages/tui/string_spinner.go +32 -0
@@ -0,0 +1,32 @@
1+package tui
2+
3+import "time"
4+
5+// StringSpinner selects one text frame for each configured interval. It owns
6+// no ticker or goroutine; callers render it from their existing UI clock.
7+type StringSpinner struct {
8+ frames []string
9+ interval time.Duration
10+}
11+
12+// NewStringSpinner creates a reusable text animation. Empty frames render an
13+// empty string, and a non-positive interval keeps the first frame stable.
14+func NewStringSpinner(frames []string, interval time.Duration) StringSpinner {
15+ return StringSpinner{
16+ frames: append([]string(nil), frames...),
17+ interval: interval,
18+ }
19+}
20+
21+// FrameAt returns the frame for now relative to startedAt. Times before the
22+// start clamp to the first frame.
23+func (s StringSpinner) FrameAt(startedAt, now time.Time) string {
24+ if len(s.frames) == 0 {
25+ return ""
26+ }
27+ if s.interval <= 0 || startedAt.IsZero() || now.IsZero() || now.Before(startedAt) {
28+ return s.frames[0]
29+ }
30+ index := int(now.Sub(startedAt)/s.interval) % len(s.frames)
31+ return s.frames[index]
32+}
@@ -0,0 +1,32 @@
1+package tui
2+
3+import "time"
4+
5+// StringSpinner selects one text frame for each configured interval. It owns
6+// no ticker or goroutine; callers render it from their existing UI clock.
7+type StringSpinner struct {
8+ frames []string
9+ interval time.Duration
10+}
11+
12+// NewStringSpinner creates a reusable text animation. Empty frames render an
13+// empty string, and a non-positive interval keeps the first frame stable.
14+func NewStringSpinner(frames []string, interval time.Duration) StringSpinner {
15+ return StringSpinner{
16+ frames: append([]string(nil), frames...),
17+ interval: interval,
18+ }
19+}
20+
21+// FrameAt returns the frame for now relative to startedAt. Times before the
22+// start clamp to the first frame.
23+func (s StringSpinner) FrameAt(startedAt, now time.Time) string {
24+ if len(s.frames) == 0 {
25+ return ""
26+ }
27+ if s.interval <= 0 || startedAt.IsZero() || now.IsZero() || now.Before(startedAt) {
28+ return s.frames[0]
29+ }
30+ index := int(now.Sub(startedAt)/s.interval) % len(s.frames)
31+ return s.frames[index]
32+}
A packages/tui/string_spinner_test.go +28 -0
@@ -0,0 +1,28 @@
1+package tui
2+
3+import (
4+ "testing"
5+ "time"
6+)
7+
8+func TestStringSpinnerAdvancesAtConfiguredInterval(t *testing.T) {
9+ spinner := NewStringSpinner([]string{"Zzzz", "zZzz", "zzZz", "zzzZ"}, time.Second)
10+ started := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
11+
12+ tests := []struct {
13+ elapsed time.Duration
14+ want string
15+ }{
16+ {elapsed: 0, want: "Zzzz"},
17+ {elapsed: 999 * time.Millisecond, want: "Zzzz"},
18+ {elapsed: time.Second, want: "zZzz"},
19+ {elapsed: 2 * time.Second, want: "zzZz"},
20+ {elapsed: 3 * time.Second, want: "zzzZ"},
21+ {elapsed: 4 * time.Second, want: "Zzzz"},
22+ }
23+ for _, test := range tests {
24+ if got := spinner.FrameAt(started, started.Add(test.elapsed)); got != test.want {
25+ t.Fatalf("FrameAt(%s) = %q, want %q", test.elapsed, got, test.want)
26+ }
27+ }
28+}
@@ -0,0 +1,28 @@
1+package tui
2+
3+import (
4+ "testing"
5+ "time"
6+)
7+
8+func TestStringSpinnerAdvancesAtConfiguredInterval(t *testing.T) {
9+ spinner := NewStringSpinner([]string{"Zzzz", "zZzz", "zzZz", "zzzZ"}, time.Second)
10+ started := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
11+
12+ tests := []struct {
13+ elapsed time.Duration
14+ want string
15+ }{
16+ {elapsed: 0, want: "Zzzz"},
17+ {elapsed: 999 * time.Millisecond, want: "Zzzz"},
18+ {elapsed: time.Second, want: "zZzz"},
19+ {elapsed: 2 * time.Second, want: "zzZz"},
20+ {elapsed: 3 * time.Second, want: "zzzZ"},
21+ {elapsed: 4 * time.Second, want: "Zzzz"},
22+ }
23+ for _, test := range tests {
24+ if got := spinner.FrameAt(started, started.Add(test.elapsed)); got != test.want {
25+ t.Fatalf("FrameAt(%s) = %q, want %q", test.elapsed, got, test.want)
26+ }
27+ }
28+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close