fix(subagents): address trace review findings
- commit
- ab332873be5d307e09bbe7c5e2e993f1aaa27816
- author
- brice · · 2026-08-12 05:41 UTC
- parent
- 81ad31b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
15 files · +112 -29
M README.md +1 -1
| @@ -421,7 +421,7 @@ Background subagents that run alongside your main session. Each one is a separat | |||
| 421 | 421 | ||
| 422 | 422 | **Persistence across zut restarts** — every spawn writes durable metadata, a session file, and structured `result.json` under `$ZUT_HOME/subagents/agents/<id>/`. A local execution trace is an optional separate diagnostic bundle. On the next `zut` launch, retained session and result data can be used with `R` or `/subagents resume-session <id>` to continue the existing session. `/subagents restart-task <id>` is the explicit operation that intentionally replays the stored task. | |
| 423 | 423 | ||
| 424 | - | **Where state lives** — per-agent metadata, session files, results, and patches live under `$ZUT_HOME/subagents/agents/<id>/`; inbox sockets are runtime-only and permission-restricted. When enabled, trace bundles live below `ZUT_SUBAGENT_TRACE_DIR`. Shared-mode edits land directly in the repo. Worktree-mode edits are captured as durable patches and changed-file summaries before cleanup. | |
| 424 | + | **Where state lives** — per-agent metadata, session files, results, and patches live under `$ZUT_HOME/subagents/agents/<id>/`; inbox sockets are runtime-only and permission-restricted. When enabled, trace bundles live below `ZUT_SUBAGENT_TRACE_DIR`; `zut debug trace <bundle>` reports the open operation, terminal fact, or last event. Shared-mode edits land directly in the repo. Worktree-mode edits are captured as durable patches and changed-file summaries before cleanup. | |
| 425 | 425 | ||
| 426 | 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 | ||
| @@ -421,7 +421,7 @@ Background subagents that run alongside your main session. Each one is a separat | |||||
| 421 | 421 | ||||
| 422 | **Persistence across zut restarts** — every spawn writes durable metadata, a session file, and structured `result.json` under `$ZUT_HOME/subagents/agents/<id>/`. A local execution trace is an optional separate diagnostic bundle. On the next `zut` launch, retained session and result data can be used with `R` or `/subagents resume-session <id>` to continue the existing session. `/subagents restart-task <id>` is the explicit operation that intentionally replays the stored task. | 422 | **Persistence across zut restarts** — every spawn writes durable metadata, a session file, and structured `result.json` under `$ZUT_HOME/subagents/agents/<id>/`. A local execution trace is an optional separate diagnostic bundle. On the next `zut` launch, retained session and result data can be used with `R` or `/subagents resume-session <id>` to continue the existing session. `/subagents restart-task <id>` is the explicit operation that intentionally replays the stored task. | ||
| 423 | 423 | ||||
| 424 | - | **Where state lives** — per-agent metadata, session files, results, and patches live under `$ZUT_HOME/subagents/agents/<id>/`; inbox sockets are runtime-only and permission-restricted. When enabled, trace bundles live below `ZUT_SUBAGENT_TRACE_DIR`. Shared-mode edits land directly in the repo. Worktree-mode edits are captured as durable patches and changed-file summaries before cleanup. | 424 | + | **Where state lives** — per-agent metadata, session files, results, and patches live under `$ZUT_HOME/subagents/agents/<id>/`; inbox sockets are runtime-only and permission-restricted. When enabled, trace bundles live below `ZUT_SUBAGENT_TRACE_DIR`; `zut debug trace <bundle>` reports the open operation, terminal fact, or last event. Shared-mode edits land directly in the repo. Worktree-mode edits are captured as durable patches and changed-file summaries before cleanup. |
| 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 | ||||
M docs/subagents.md +1 -1
| @@ -164,7 +164,7 @@ When the relevant manager tool is permitted by launch-time policy, the model can | |||
| 164 | 164 | ||
| 165 | 165 | ## Local execution trace | |
| 166 | 166 | ||
| 167 | - | Set `ZUT_SUBAGENT_TRACE_DIR` to a private local directory before launching zut to retain an execution-trace bundle for each supervisor. Each bundle contains `manifest.json`, ordered `trace.jsonl`, and (only in detailed mode) private `payloads/` files. The default normal mode records redacted metadata; set `ZUT_SUBAGENT_TRACE_MODE=detailed` only when investigating locally and when payload capture is appropriate for the session. Trace bundles are not uploaded, have no automatic retention policy, and should be removed manually after the investigation. | |
| 167 | + | Set `ZUT_SUBAGENT_TRACE_DIR` to a private local directory before launching zut to retain an execution-trace bundle for each supervisor. Each bundle contains `manifest.json`, ordered `trace.jsonl`, and (only in detailed mode) private `payloads/` files. The default normal mode records redacted metadata. `ZUT_SUBAGENT_TRACE_MODE=detailed` retains unredacted prompts, tool arguments, and tool output in local trace files; enable it only for an appropriate investigation. Trace bundles are not uploaded or deleted automatically, and should be removed manually after the investigation. | |
| 168 | 168 | ||
| 169 | 169 | Inspect a retained bundle with: | |
| 170 | 170 | ||
| @@ -164,7 +164,7 @@ When the relevant manager tool is permitted by launch-time policy, the model can | |||||
| 164 | 164 | ||||
| 165 | ## Local execution trace | 165 | ## Local execution trace | ||
| 166 | 166 | ||||
| 167 | - | Set `ZUT_SUBAGENT_TRACE_DIR` to a private local directory before launching zut to retain an execution-trace bundle for each supervisor. Each bundle contains `manifest.json`, ordered `trace.jsonl`, and (only in detailed mode) private `payloads/` files. The default normal mode records redacted metadata; set `ZUT_SUBAGENT_TRACE_MODE=detailed` only when investigating locally and when payload capture is appropriate for the session. Trace bundles are not uploaded, have no automatic retention policy, and should be removed manually after the investigation. | 167 | + | Set `ZUT_SUBAGENT_TRACE_DIR` to a private local directory before launching zut to retain an execution-trace bundle for each supervisor. Each bundle contains `manifest.json`, ordered `trace.jsonl`, and (only in detailed mode) private `payloads/` files. The default normal mode records redacted metadata. `ZUT_SUBAGENT_TRACE_MODE=detailed` retains unredacted prompts, tool arguments, and tool output in local trace files; enable it only for an appropriate investigation. Trace bundles are not uploaded or deleted automatically, and should be removed manually after the investigation. |
| 168 | 168 | ||||
| 169 | Inspect a retained bundle with: | 169 | Inspect a retained bundle with: | ||
| 170 | 170 | ||||
M packages/agent/subagents/persist.go +1 -0
| @@ -1325,6 +1325,7 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo | |||
| 1325 | 1325 | a.setOnTurnIdle(func() { f.dispatchQueuedResumeWithTimeout(a) }) | |
| 1326 | 1326 | a.workspaceCleanup = func() error { return workspace.Cleanup(context.Background()) } | |
| 1327 | 1327 | a.workspaceCapture = func() (WorkspaceCapture, error) { return workspace.Capture(context.Background()) } | |
| 1328 | + | a.trace = f.trace | |
| 1328 | 1329 | a.runner = f.cfg.NewRunner(a) | |
| 1329 | 1330 | if err := writeAgentMeta(a.stateDirectory(f.cfg.Root), a); err != nil { | |
| 1330 | 1331 | if a.cancel != nil { | |
| @@ -1325,6 +1325,7 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo | |||||
| 1325 | a.setOnTurnIdle(func() { f.dispatchQueuedResumeWithTimeout(a) }) | 1325 | a.setOnTurnIdle(func() { f.dispatchQueuedResumeWithTimeout(a) }) | ||
| 1326 | a.workspaceCleanup = func() error { return workspace.Cleanup(context.Background()) } | 1326 | a.workspaceCleanup = func() error { return workspace.Cleanup(context.Background()) } | ||
| 1327 | a.workspaceCapture = func() (WorkspaceCapture, error) { return workspace.Capture(context.Background()) } | 1327 | a.workspaceCapture = func() (WorkspaceCapture, error) { return workspace.Capture(context.Background()) } | ||
| 1328 | + | a.trace = f.trace | |||
| 1328 | a.runner = f.cfg.NewRunner(a) | 1329 | a.runner = f.cfg.NewRunner(a) | ||
| 1329 | if err := writeAgentMeta(a.stateDirectory(f.cfg.Root), a); err != nil { | 1330 | if err := writeAgentMeta(a.stateDirectory(f.cfg.Root), a); err != nil { | ||
| 1330 | if a.cancel != nil { | 1331 | if a.cancel != nil { | ||
M packages/agent/subagents/projection.go +20 -4
| @@ -1,6 +1,9 @@ | |||
| 1 | 1 | package subagents | |
| 2 | 2 | ||
| 3 | - | import "time" | |
| 3 | + | import ( | |
| 4 | + | "sort" | |
| 5 | + | "time" | |
| 6 | + | ) | |
| 4 | 7 | ||
| 5 | 8 | // Operation is a factual operation inferred from paired trace boundaries. An | |
| 6 | 9 | // empty FinishedAt means that no terminal boundary has been observed yet. | |
| @@ -76,26 +79,39 @@ func ProjectTrace(events []TraceEvent) map[string]AgentTraceView { | |||
| 76 | 79 | for _, operation := range operations { | |
| 77 | 80 | view.OpenOperations = append(view.OpenOperations, operation) | |
| 78 | 81 | } | |
| 82 | + | sort.Slice(view.OpenOperations, func(i, j int) bool { | |
| 83 | + | left, right := view.OpenOperations[i], view.OpenOperations[j] | |
| 84 | + | if !left.StartedAt.Equal(right.StartedAt) { | |
| 85 | + | return left.StartedAt.Before(right.StartedAt) | |
| 86 | + | } | |
| 87 | + | if left.Type != right.Type { | |
| 88 | + | return left.Type < right.Type | |
| 89 | + | } | |
| 90 | + | return left.TurnID < right.TurnID | |
| 91 | + | }) | |
| 79 | 92 | views[agentID] = view | |
| 80 | 93 | } | |
| 81 | 94 | return views | |
| 82 | 95 | } | |
| 83 | 96 | ||
| 84 | 97 | func traceBoundary(event TraceEvent) (key string, starts, ends bool, terminal string) { | |
| 85 | - | key = event.Type + ":" + event.TurnID | |
| 98 | + | callID, _ := event.Data["call_id"].(string) | |
| 99 | + | key = event.Type + ":" + event.TurnID + ":" + callID | |
| 86 | 100 | switch event.Type { | |
| 87 | 101 | case "turn.started": | |
| 88 | 102 | return "turn:" + event.TurnID, true, false, "" | |
| 89 | 103 | case "turn.finished", "turn.failed": | |
| 90 | 104 | return "turn:" + event.TurnID, false, true, "" | |
| 91 | 105 | case "tool.started": | |
| 92 | - | return "tool:" + event.TurnID, true, false, "" | |
| 106 | + | return "tool:" + event.TurnID + ":" + callID, true, false, "" | |
| 93 | 107 | case "tool.finished": | |
| 94 | - | return "tool:" + event.TurnID, false, true, "" | |
| 108 | + | return "tool:" + event.TurnID + ":" + callID, false, true, "" | |
| 95 | 109 | case "agent.finished": | |
| 96 | 110 | return key, false, false, "completed" | |
| 97 | 111 | case "agent.failed": | |
| 98 | 112 | return key, false, false, "failed" | |
| 113 | + | case "agent.cancelled": | |
| 114 | + | return key, false, false, "cancelled" | |
| 99 | 115 | } | |
| 100 | 116 | return key, false, false, "" | |
| 101 | 117 | } | |
| @@ -1,6 +1,9 @@ | |||||
| 1 | package subagents | 1 | package subagents | ||
| 2 | 2 | ||||
| 3 | - | import "time" | 3 | + | import ( |
| 4 | + | "sort" | |||
| 5 | + | "time" | |||
| 6 | + | ) | |||
| 4 | 7 | ||||
| 5 | // Operation is a factual operation inferred from paired trace boundaries. An | 8 | // Operation is a factual operation inferred from paired trace boundaries. An | ||
| 6 | // empty FinishedAt means that no terminal boundary has been observed yet. | 9 | // empty FinishedAt means that no terminal boundary has been observed yet. | ||
| @@ -76,26 +79,39 @@ func ProjectTrace(events []TraceEvent) map[string]AgentTraceView { | |||||
| 76 | for _, operation := range operations { | 79 | for _, operation := range operations { | ||
| 77 | view.OpenOperations = append(view.OpenOperations, operation) | 80 | view.OpenOperations = append(view.OpenOperations, operation) | ||
| 78 | } | 81 | } | ||
| 82 | + | sort.Slice(view.OpenOperations, func(i, j int) bool { | |||
| 83 | + | left, right := view.OpenOperations[i], view.OpenOperations[j] | |||
| 84 | + | if !left.StartedAt.Equal(right.StartedAt) { | |||
| 85 | + | return left.StartedAt.Before(right.StartedAt) | |||
| 86 | + | } | |||
| 87 | + | if left.Type != right.Type { | |||
| 88 | + | return left.Type < right.Type | |||
| 89 | + | } | |||
| 90 | + | return left.TurnID < right.TurnID | |||
| 91 | + | }) | |||
| 79 | views[agentID] = view | 92 | views[agentID] = view | ||
| 80 | } | 93 | } | ||
| 81 | return views | 94 | return views | ||
| 82 | } | 95 | } | ||
| 83 | 96 | ||||
| 84 | func traceBoundary(event TraceEvent) (key string, starts, ends bool, terminal string) { | 97 | func traceBoundary(event TraceEvent) (key string, starts, ends bool, terminal string) { | ||
| 85 | - | key = event.Type + ":" + event.TurnID | 98 | + | callID, _ := event.Data["call_id"].(string) |
| 99 | + | key = event.Type + ":" + event.TurnID + ":" + callID | |||
| 86 | switch event.Type { | 100 | switch event.Type { | ||
| 87 | case "turn.started": | 101 | case "turn.started": | ||
| 88 | return "turn:" + event.TurnID, true, false, "" | 102 | return "turn:" + event.TurnID, true, false, "" | ||
| 89 | case "turn.finished", "turn.failed": | 103 | case "turn.finished", "turn.failed": | ||
| 90 | return "turn:" + event.TurnID, false, true, "" | 104 | return "turn:" + event.TurnID, false, true, "" | ||
| 91 | case "tool.started": | 105 | case "tool.started": | ||
| 92 | - | return "tool:" + event.TurnID, true, false, "" | 106 | + | return "tool:" + event.TurnID + ":" + callID, true, false, "" |
| 93 | case "tool.finished": | 107 | case "tool.finished": | ||
| 94 | - | return "tool:" + event.TurnID, false, true, "" | 108 | + | return "tool:" + event.TurnID + ":" + callID, false, true, "" |
| 95 | case "agent.finished": | 109 | case "agent.finished": | ||
| 96 | return key, false, false, "completed" | 110 | return key, false, false, "completed" | ||
| 97 | case "agent.failed": | 111 | case "agent.failed": | ||
| 98 | return key, false, false, "failed" | 112 | return key, false, false, "failed" | ||
| 113 | + | case "agent.cancelled": | |||
| 114 | + | return key, false, false, "cancelled" | |||
| 99 | } | 115 | } | ||
| 100 | return key, false, false, "" | 116 | return key, false, false, "" | ||
| 101 | } | 117 | } | ||
M packages/agent/subagents/projection_test.go +6 -3
| @@ -30,11 +30,14 @@ func TestProjectTraceClosesPairedOperationsAndKeepsTerminalFact(t *testing.T) { | |||
| 30 | 30 | views := ProjectTrace([]TraceEvent{ | |
| 31 | 31 | {Seq: 1, Timestamp: started, Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"}, | |
| 32 | 32 | {Seq: 2, Timestamp: started.Add(time.Second), Type: "turn.finished", AgentID: "agent-1", TurnID: "turn-1"}, | |
| 33 | - | {Seq: 3, Timestamp: started.Add(2 * time.Second), Type: "agent.finished", AgentID: "agent-1"}, | |
| 33 | + | {Seq: 3, Timestamp: started.Add(2 * time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}}, | |
| 34 | + | {Seq: 4, Timestamp: started.Add(3 * time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "two"}}, | |
| 35 | + | {Seq: 5, Timestamp: started.Add(4 * time.Second), Type: "tool.finished", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}}, | |
| 36 | + | {Seq: 6, Timestamp: started.Add(5 * time.Second), Type: "agent.finished", AgentID: "agent-1"}, | |
| 34 | 37 | }) | |
| 35 | 38 | view := views["agent-1"] | |
| 36 | - | if len(view.OpenOperations) != 0 { | |
| 37 | - | t.Fatalf("open operations = %#v, want none", view.OpenOperations) | |
| 39 | + | if len(view.OpenOperations) != 1 || view.OpenOperations[0].Type != "tool.started" { | |
| 40 | + | t.Fatalf("open operations = %#v, want unmatched tool", view.OpenOperations) | |
| 38 | 41 | } | |
| 39 | 42 | if view.Terminal != "completed" { | |
| 40 | 43 | t.Fatalf("terminal = %q, want completed", view.Terminal) | |
| @@ -30,11 +30,14 @@ func TestProjectTraceClosesPairedOperationsAndKeepsTerminalFact(t *testing.T) { | |||||
| 30 | views := ProjectTrace([]TraceEvent{ | 30 | views := ProjectTrace([]TraceEvent{ | ||
| 31 | {Seq: 1, Timestamp: started, Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"}, | 31 | {Seq: 1, Timestamp: started, Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"}, | ||
| 32 | {Seq: 2, Timestamp: started.Add(time.Second), Type: "turn.finished", AgentID: "agent-1", TurnID: "turn-1"}, | 32 | {Seq: 2, Timestamp: started.Add(time.Second), Type: "turn.finished", AgentID: "agent-1", TurnID: "turn-1"}, | ||
| 33 | - | {Seq: 3, Timestamp: started.Add(2 * time.Second), Type: "agent.finished", AgentID: "agent-1"}, | 33 | + | {Seq: 3, Timestamp: started.Add(2 * time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}}, |
| 34 | + | {Seq: 4, Timestamp: started.Add(3 * time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "two"}}, | |||
| 35 | + | {Seq: 5, Timestamp: started.Add(4 * time.Second), Type: "tool.finished", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}}, | |||
| 36 | + | {Seq: 6, Timestamp: started.Add(5 * time.Second), Type: "agent.finished", AgentID: "agent-1"}, | |||
| 34 | }) | 37 | }) | ||
| 35 | view := views["agent-1"] | 38 | view := views["agent-1"] | ||
| 36 | - | if len(view.OpenOperations) != 0 { | 39 | + | if len(view.OpenOperations) != 1 || view.OpenOperations[0].Type != "tool.started" { |
| 37 | - | t.Fatalf("open operations = %#v, want none", view.OpenOperations) | 40 | + | t.Fatalf("open operations = %#v, want unmatched tool", view.OpenOperations) |
| 38 | } | 41 | } | ||
| 39 | if view.Terminal != "completed" { | 42 | if view.Terminal != "completed" { | ||
| 40 | t.Fatalf("terminal = %q, want completed", view.Terminal) | 43 | t.Fatalf("terminal = %q, want completed", view.Terminal) | ||
M packages/agent/subagents/runner.go +3 -0
| @@ -928,6 +928,9 @@ func recordWorkerTrace(agent *Agent, ev Event) { | |||
| 928 | 928 | if status, ok := ev.Data["status"].(string); ok && status != "" { | |
| 929 | 929 | data["status"] = status | |
| 930 | 930 | } | |
| 931 | + | if callID, ok := ev.Data["call_id"].(string); ok && callID != "" { | |
| 932 | + | data["call_id"] = callID | |
| 933 | + | } | |
| 931 | 934 | agent.recordTrace(TraceEvent{Type: traceType, Timestamp: ev.Time, TurnID: ev.TurnID, Data: data}) | |
| 932 | 935 | } | |
| 933 | 936 | ||
| @@ -928,6 +928,9 @@ func recordWorkerTrace(agent *Agent, ev Event) { | |||||
| 928 | if status, ok := ev.Data["status"].(string); ok && status != "" { | 928 | if status, ok := ev.Data["status"].(string); ok && status != "" { | ||
| 929 | data["status"] = status | 929 | data["status"] = status | ||
| 930 | } | 930 | } | ||
| 931 | + | if callID, ok := ev.Data["call_id"].(string); ok && callID != "" { | |||
| 932 | + | data["call_id"] = callID | |||
| 933 | + | } | |||
| 931 | agent.recordTrace(TraceEvent{Type: traceType, Timestamp: ev.Time, TurnID: ev.TurnID, Data: data}) | 934 | agent.recordTrace(TraceEvent{Type: traceType, Timestamp: ev.Time, TurnID: ev.TurnID, Data: data}) | ||
| 932 | } | 935 | } | ||
| 933 | 936 | ||||
M packages/agent/subagents/supervisor.go +7 -1
| @@ -179,6 +179,8 @@ func New(cfg Config) *Supervisor { | |||
| 179 | 179 | // in-memory trace remains the sole status projection in that case. | |
| 180 | 180 | if bundle, err := NewTraceWriter(filepath.Join(cfg.TraceDir, uuid.NewString()), cfg.TraceMode); err == nil { | |
| 181 | 181 | trace = bundle | |
| 182 | + | } else { | |
| 183 | + | trace.Record(TraceEvent{Type: "trace.bundle.failed", Data: map[string]any{"error_kind": "create"}}) | |
| 182 | 184 | } | |
| 183 | 185 | } | |
| 184 | 186 | lifetimeParent := cfg.Context | |
| @@ -734,7 +736,10 @@ func (f *Supervisor) run(a *Agent) { | |||
| 734 | 736 | finalStatus := a.status | |
| 735 | 737 | a.mu.Unlock() | |
| 736 | 738 | terminalType := "agent.finished" | |
| 737 | - | if terminalErr != nil { | |
| 739 | + | switch { | |
| 740 | + | case finalStatus == StatusKilled: | |
| 741 | + | terminalType = "agent.cancelled" | |
| 742 | + | case terminalErr != nil: | |
| 738 | 743 | terminalType = "agent.failed" | |
| 739 | 744 | } | |
| 740 | 745 | f.recordTrace(TraceEvent{Type: terminalType, AgentID: a.ID, Data: map[string]any{"status": string(finalStatus), "error": traceErrorKind(terminalErr)}}) | |
| @@ -1105,6 +1110,7 @@ func (f *Supervisor) stopAgents(ctx context.Context, agents []*Agent, origin Shu | |||
| 1105 | 1110 | // StopAll cancels every running agent. Used on shutdown. | |
| 1106 | 1111 | func (f *Supervisor) StopAll() { | |
| 1107 | 1112 | _ = f.stopAll(context.Background()) | |
| 1113 | + | _ = f.Close() | |
| 1108 | 1114 | } | |
| 1109 | 1115 | ||
| 1110 | 1116 | // StopAllContext is the context-aware shutdown variant. It returns the | |
| @@ -179,6 +179,8 @@ func New(cfg Config) *Supervisor { | |||||
| 179 | // in-memory trace remains the sole status projection in that case. | 179 | // in-memory trace remains the sole status projection in that case. | ||
| 180 | if bundle, err := NewTraceWriter(filepath.Join(cfg.TraceDir, uuid.NewString()), cfg.TraceMode); err == nil { | 180 | if bundle, err := NewTraceWriter(filepath.Join(cfg.TraceDir, uuid.NewString()), cfg.TraceMode); err == nil { | ||
| 181 | trace = bundle | 181 | trace = bundle | ||
| 182 | + | } else { | |||
| 183 | + | trace.Record(TraceEvent{Type: "trace.bundle.failed", Data: map[string]any{"error_kind": "create"}}) | |||
| 182 | } | 184 | } | ||
| 183 | } | 185 | } | ||
| 184 | lifetimeParent := cfg.Context | 186 | lifetimeParent := cfg.Context | ||
| @@ -734,7 +736,10 @@ func (f *Supervisor) run(a *Agent) { | |||||
| 734 | finalStatus := a.status | 736 | finalStatus := a.status | ||
| 735 | a.mu.Unlock() | 737 | a.mu.Unlock() | ||
| 736 | terminalType := "agent.finished" | 738 | terminalType := "agent.finished" | ||
| 737 | - | if terminalErr != nil { | 739 | + | switch { |
| 740 | + | case finalStatus == StatusKilled: | |||
| 741 | + | terminalType = "agent.cancelled" | |||
| 742 | + | case terminalErr != nil: | |||
| 738 | terminalType = "agent.failed" | 743 | terminalType = "agent.failed" | ||
| 739 | } | 744 | } | ||
| 740 | f.recordTrace(TraceEvent{Type: terminalType, AgentID: a.ID, Data: map[string]any{"status": string(finalStatus), "error": traceErrorKind(terminalErr)}}) | 745 | f.recordTrace(TraceEvent{Type: terminalType, AgentID: a.ID, Data: map[string]any{"status": string(finalStatus), "error": traceErrorKind(terminalErr)}}) | ||
| @@ -1105,6 +1110,7 @@ func (f *Supervisor) stopAgents(ctx context.Context, agents []*Agent, origin Shu | |||||
| 1105 | // StopAll cancels every running agent. Used on shutdown. | 1110 | // StopAll cancels every running agent. Used on shutdown. | ||
| 1106 | func (f *Supervisor) StopAll() { | 1111 | func (f *Supervisor) StopAll() { | ||
| 1107 | _ = f.stopAll(context.Background()) | 1112 | _ = f.stopAll(context.Background()) | ||
| 1113 | + | _ = f.Close() | |||
| 1108 | } | 1114 | } | ||
| 1109 | 1115 | ||||
| 1110 | // StopAllContext is the context-aware shutdown variant. It returns the | 1116 | // StopAllContext is the context-aware shutdown variant. It returns the | ||
M packages/agent/subagents/trace.go +9 -1
| @@ -101,7 +101,10 @@ type traceRecord struct { | |||
| 101 | 101 | barrier chan struct{} | |
| 102 | 102 | } | |
| 103 | 103 | ||
| 104 | - | const traceQueueInitialCapacity = 64 | |
| 104 | + | const ( | |
| 105 | + | traceQueueInitialCapacity = 64 | |
| 106 | + | memoryTraceEventLimit = 4096 | |
| 107 | + | ) | |
| 105 | 108 | ||
| 106 | 109 | // NewMemoryTraceWriter creates the non-persistent projection source used when | |
| 107 | 110 | // bundle capture is disabled. It retains only normal-mode event metadata. | |
| @@ -301,6 +304,10 @@ func (w *TraceWriter) write(record traceRecord) { | |||
| 301 | 304 | if w.memory { | |
| 302 | 305 | w.mu.Lock() | |
| 303 | 306 | w.events = append(w.events, event) | |
| 307 | + | if overflow := len(w.events) - memoryTraceEventLimit; overflow > 0 { | |
| 308 | + | copy(w.events, w.events[overflow:]) | |
| 309 | + | w.events = w.events[:memoryTraceEventLimit] | |
| 310 | + | } | |
| 304 | 311 | w.mu.Unlock() | |
| 305 | 312 | return | |
| 306 | 313 | } | |
| @@ -383,6 +390,7 @@ func (w *TraceWriter) Flush() error { | |||
| 383 | 390 | <-barrier | |
| 384 | 391 | } else { | |
| 385 | 392 | w.mu.Unlock() | |
| 393 | + | <-w.finished | |
| 386 | 394 | } | |
| 387 | 395 | return w.recordingError() | |
| 388 | 396 | } | |
| @@ -101,7 +101,10 @@ type traceRecord struct { | |||||
| 101 | barrier chan struct{} | 101 | barrier chan struct{} | ||
| 102 | } | 102 | } | ||
| 103 | 103 | ||||
| 104 | - | const traceQueueInitialCapacity = 64 | 104 | + | const ( |
| 105 | + | traceQueueInitialCapacity = 64 | |||
| 106 | + | memoryTraceEventLimit = 4096 | |||
| 107 | + | ) | |||
| 105 | 108 | ||||
| 106 | // NewMemoryTraceWriter creates the non-persistent projection source used when | 109 | // NewMemoryTraceWriter creates the non-persistent projection source used when | ||
| 107 | // bundle capture is disabled. It retains only normal-mode event metadata. | 110 | // bundle capture is disabled. It retains only normal-mode event metadata. | ||
| @@ -301,6 +304,10 @@ func (w *TraceWriter) write(record traceRecord) { | |||||
| 301 | if w.memory { | 304 | if w.memory { | ||
| 302 | w.mu.Lock() | 305 | w.mu.Lock() | ||
| 303 | w.events = append(w.events, event) | 306 | w.events = append(w.events, event) | ||
| 307 | + | if overflow := len(w.events) - memoryTraceEventLimit; overflow > 0 { | |||
| 308 | + | copy(w.events, w.events[overflow:]) | |||
| 309 | + | w.events = w.events[:memoryTraceEventLimit] | |||
| 310 | + | } | |||
| 304 | w.mu.Unlock() | 311 | w.mu.Unlock() | ||
| 305 | return | 312 | return | ||
| 306 | } | 313 | } | ||
| @@ -383,6 +390,7 @@ func (w *TraceWriter) Flush() error { | |||||
| 383 | <-barrier | 390 | <-barrier | ||
| 384 | } else { | 391 | } else { | ||
| 385 | w.mu.Unlock() | 392 | w.mu.Unlock() | ||
| 393 | + | <-w.finished | |||
| 386 | } | 394 | } | ||
| 387 | return w.recordingError() | 395 | return w.recordingError() | ||
| 388 | } | 396 | } | ||
M packages/agent/subagents/trace_test.go +19 -0
| @@ -69,6 +69,25 @@ func TestTraceWriterCreatesPrivateBundleAndDetailedPayload(t *testing.T) { | |||
| 69 | 69 | } | |
| 70 | 70 | } | |
| 71 | 71 | ||
| 72 | + | func TestMemoryTraceWriterRetainsOrderedEventsWithoutBundle(t *testing.T) { | |
| 73 | + | writer := NewMemoryTraceWriter() | |
| 74 | + | writer.Record(TraceEvent{Type: "agent.started"}) | |
| 75 | + | writer.Record(TraceEvent{Type: "turn.started", TurnID: "turn-1"}) | |
| 76 | + | if err := writer.Flush(); err != nil { | |
| 77 | + | t.Fatal(err) | |
| 78 | + | } | |
| 79 | + | if got := writer.Dir(); got != "" { | |
| 80 | + | t.Fatalf("memory trace dir = %q, want empty", got) | |
| 81 | + | } | |
| 82 | + | events := writer.Events() | |
| 83 | + | if len(events) != 2 || events[0].Seq != 1 || events[1].Seq != 2 { | |
| 84 | + | t.Fatalf("memory events = %#v, want contiguous sequence", events) | |
| 85 | + | } | |
| 86 | + | if err := writer.Close(); err != nil { | |
| 87 | + | t.Fatal(err) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 72 | 91 | func TestTraceWriterNormalRedactsSensitiveValues(t *testing.T) { | |
| 73 | 92 | writer, err := NewTraceWriter(filepath.Join(t.TempDir(), "trace"), TraceModeNormal) | |
| 74 | 93 | if err != nil { | |
| @@ -69,6 +69,25 @@ func TestTraceWriterCreatesPrivateBundleAndDetailedPayload(t *testing.T) { | |||||
| 69 | } | 69 | } | ||
| 70 | } | 70 | } | ||
| 71 | 71 | ||||
| 72 | + | func TestMemoryTraceWriterRetainsOrderedEventsWithoutBundle(t *testing.T) { | |||
| 73 | + | writer := NewMemoryTraceWriter() | |||
| 74 | + | writer.Record(TraceEvent{Type: "agent.started"}) | |||
| 75 | + | writer.Record(TraceEvent{Type: "turn.started", TurnID: "turn-1"}) | |||
| 76 | + | if err := writer.Flush(); err != nil { | |||
| 77 | + | t.Fatal(err) | |||
| 78 | + | } | |||
| 79 | + | if got := writer.Dir(); got != "" { | |||
| 80 | + | t.Fatalf("memory trace dir = %q, want empty", got) | |||
| 81 | + | } | |||
| 82 | + | events := writer.Events() | |||
| 83 | + | if len(events) != 2 || events[0].Seq != 1 || events[1].Seq != 2 { | |||
| 84 | + | t.Fatalf("memory events = %#v, want contiguous sequence", events) | |||
| 85 | + | } | |||
| 86 | + | if err := writer.Close(); err != nil { | |||
| 87 | + | t.Fatal(err) | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 72 | func TestTraceWriterNormalRedactsSensitiveValues(t *testing.T) { | 91 | func TestTraceWriterNormalRedactsSensitiveValues(t *testing.T) { | ||
| 73 | writer, err := NewTraceWriter(filepath.Join(t.TempDir(), "trace"), TraceModeNormal) | 92 | writer, err := NewTraceWriter(filepath.Join(t.TempDir(), "trace"), TraceModeNormal) | ||
| 74 | if err != nil { | 93 | if err != nil { | ||
M packages/agent/tools/subagent_resume.go +1 -1
| @@ -125,5 +125,5 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||
| 125 | 125 | if !ok { | |
| 126 | 126 | return core.ToolResult{}, fmt.Errorf("%s: resumed agent disappeared from supervisor", prefix) | |
| 127 | 127 | } | |
| 128 | - | return renderSubagentAction("resumed", snapshot) | |
| 128 | + | return renderSubagentAction("resumed", snapshot, t.Supervisor.TraceViews()[snapshot.ID]) | |
| 129 | 129 | } | |
| @@ -125,5 +125,5 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||||
| 125 | if !ok { | 125 | if !ok { | ||
| 126 | return core.ToolResult{}, fmt.Errorf("%s: resumed agent disappeared from supervisor", prefix) | 126 | return core.ToolResult{}, fmt.Errorf("%s: resumed agent disappeared from supervisor", prefix) | ||
| 127 | } | 127 | } | ||
| 128 | - | return renderSubagentAction("resumed", snapshot) | 128 | + | return renderSubagentAction("resumed", snapshot, t.Supervisor.TraceViews()[snapshot.ID]) |
| 129 | } | 129 | } | ||
M packages/agent/tools/subagent_status.go +1 -5
| @@ -148,11 +148,7 @@ func findSubagentStatusSnapshot(snapshots []subagents.AgentSnapshot, id string) | |||
| 148 | 148 | return match, hits == 1 | |
| 149 | 149 | } | |
| 150 | 150 | ||
| 151 | - | func publicSubagentStatus(snapshot subagents.AgentSnapshot, traceViews ...subagents.AgentTraceView) subagentStatusEntry { | |
| 152 | - | var view subagents.AgentTraceView | |
| 153 | - | if len(traceViews) != 0 { | |
| 154 | - | view = traceViews[0] | |
| 155 | - | } | |
| 151 | + | func publicSubagentStatus(snapshot subagents.AgentSnapshot, view subagents.AgentTraceView) subagentStatusEntry { | |
| 156 | 152 | entry := subagentStatusEntry{ | |
| 157 | 153 | ID: snapshot.ID, | |
| 158 | 154 | Terminal: view.Terminal, | |
| @@ -148,11 +148,7 @@ func findSubagentStatusSnapshot(snapshots []subagents.AgentSnapshot, id string) | |||||
| 148 | return match, hits == 1 | 148 | return match, hits == 1 | ||
| 149 | } | 149 | } | ||
| 150 | 150 | ||||
| 151 | - | func publicSubagentStatus(snapshot subagents.AgentSnapshot, traceViews ...subagents.AgentTraceView) subagentStatusEntry { | 151 | + | func publicSubagentStatus(snapshot subagents.AgentSnapshot, view subagents.AgentTraceView) subagentStatusEntry { |
| 152 | - | var view subagents.AgentTraceView | |||
| 153 | - | if len(traceViews) != 0 { | |||
| 154 | - | view = traceViews[0] | |||
| 155 | - | } | |||
| 156 | entry := subagentStatusEntry{ | 152 | entry := subagentStatusEntry{ | ||
| 157 | ID: snapshot.ID, | 153 | ID: snapshot.ID, | ||
| 158 | Terminal: view.Terminal, | 154 | Terminal: view.Terminal, | ||
M packages/agent/tools/subagent_status_test.go +20 -8
| @@ -44,7 +44,7 @@ func TestSubagentStatusIncludesTurnCounters(t *testing.T) { | |||
| 44 | 44 | ID: "counter-agent", | |
| 45 | 45 | LifetimeTurns: 7, | |
| 46 | 46 | CurrentRunTurns: 2, | |
| 47 | - | }) | |
| 47 | + | }, subagents.AgentTraceView{}) | |
| 48 | 48 | if entry.LifetimeTurns != 7 || entry.CurrentRunTurns != 2 { | |
| 49 | 49 | t.Fatalf("status counters = (%d, %d), want (7, 2)", entry.LifetimeTurns, entry.CurrentRunTurns) | |
| 50 | 50 | } | |
| @@ -198,7 +198,8 @@ func TestSubagentStatusReportsQueuedWorkerWithoutLifecycleState(t *testing.T) { | |||
| 198 | 198 | } | |
| 199 | 199 | var got struct { | |
| 200 | 200 | Agent struct { | |
| 201 | - | ID string `json:"agent_id"` | |
| 201 | + | ID string `json:"agent_id"` | |
| 202 | + | Operation json.RawMessage `json:"operation"` | |
| 202 | 203 | } `json:"agent"` | |
| 203 | 204 | } | |
| 204 | 205 | if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil { | |
| @@ -207,6 +208,9 @@ func TestSubagentStatusReportsQueuedWorkerWithoutLifecycleState(t *testing.T) { | |||
| 207 | 208 | if got.Agent.ID != queued.ID { | |
| 208 | 209 | t.Fatalf("agent id = %q, want %q", got.Agent.ID, queued.ID) | |
| 209 | 210 | } | |
| 211 | + | if len(got.Agent.Operation) != 0 { | |
| 212 | + | t.Fatalf("queued operation = %s, want omitted", got.Agent.Operation) | |
| 213 | + | } | |
| 210 | 214 | ||
| 211 | 215 | close(release) | |
| 212 | 216 | active.Wait() | |
| @@ -308,12 +312,13 @@ func TestSubagentStatusRejectsUnknownWorker(t *testing.T) { | |||
| 308 | 312 | ||
| 309 | 313 | func TestSubagentStatusOmitsLifecycleStateForFailedAndCancelledWorkers(t *testing.T) { | |
| 310 | 314 | for _, tc := range []struct { | |
| 311 | - | name string | |
| 312 | - | runnerErr error | |
| 313 | - | stop bool | |
| 315 | + | name string | |
| 316 | + | runnerErr error | |
| 317 | + | stop bool | |
| 318 | + | wantTerminal string | |
| 314 | 319 | }{ | |
| 315 | - | {name: "failed", runnerErr: errors.New("runner failed")}, | |
| 316 | - | {name: "cancelled", stop: true}, | |
| 320 | + | {name: "failed", runnerErr: errors.New("runner failed"), wantTerminal: "failed"}, | |
| 321 | + | {name: "cancelled", stop: true, wantTerminal: "cancelled"}, | |
| 317 | 322 | } { | |
| 318 | 323 | t.Run(tc.name, func(t *testing.T) { | |
| 319 | 324 | root := t.TempDir() | |
| @@ -356,7 +361,8 @@ func TestSubagentStatusOmitsLifecycleStateForFailedAndCancelledWorkers(t *testin | |||
| 356 | 361 | } | |
| 357 | 362 | var got struct { | |
| 358 | 363 | Agent struct { | |
| 359 | - | ID string `json:"agent_id"` | |
| 364 | + | ID string `json:"agent_id"` | |
| 365 | + | Terminal string `json:"terminal"` | |
| 360 | 366 | } `json:"agent"` | |
| 361 | 367 | } | |
| 362 | 368 | if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil { | |
| @@ -365,6 +371,12 @@ func TestSubagentStatusOmitsLifecycleStateForFailedAndCancelledWorkers(t *testin | |||
| 365 | 371 | if got.Agent.ID != agent.ID { | |
| 366 | 372 | t.Fatalf("agent id = %q, want %q", got.Agent.ID, agent.ID) | |
| 367 | 373 | } | |
| 374 | + | if got.Agent.Terminal != tc.wantTerminal { | |
| 375 | + | t.Fatalf("terminal = %q, want %q", got.Agent.Terminal, tc.wantTerminal) | |
| 376 | + | } | |
| 377 | + | if tc.stop && got.Agent.Terminal == "completed" { | |
| 378 | + | t.Fatalf("cancelled worker reported completed terminal") | |
| 379 | + | } | |
| 368 | 380 | }) | |
| 369 | 381 | } | |
| 370 | 382 | } | |
| @@ -44,7 +44,7 @@ func TestSubagentStatusIncludesTurnCounters(t *testing.T) { | |||||
| 44 | ID: "counter-agent", | 44 | ID: "counter-agent", | ||
| 45 | LifetimeTurns: 7, | 45 | LifetimeTurns: 7, | ||
| 46 | CurrentRunTurns: 2, | 46 | CurrentRunTurns: 2, | ||
| 47 | - | }) | 47 | + | }, subagents.AgentTraceView{}) |
| 48 | if entry.LifetimeTurns != 7 || entry.CurrentRunTurns != 2 { | 48 | if entry.LifetimeTurns != 7 || entry.CurrentRunTurns != 2 { | ||
| 49 | t.Fatalf("status counters = (%d, %d), want (7, 2)", entry.LifetimeTurns, entry.CurrentRunTurns) | 49 | t.Fatalf("status counters = (%d, %d), want (7, 2)", entry.LifetimeTurns, entry.CurrentRunTurns) | ||
| 50 | } | 50 | } | ||
| @@ -198,7 +198,8 @@ func TestSubagentStatusReportsQueuedWorkerWithoutLifecycleState(t *testing.T) { | |||||
| 198 | } | 198 | } | ||
| 199 | var got struct { | 199 | var got struct { | ||
| 200 | Agent struct { | 200 | Agent struct { | ||
| 201 | - | ID string `json:"agent_id"` | 201 | + | ID string `json:"agent_id"` |
| 202 | + | Operation json.RawMessage `json:"operation"` | |||
| 202 | } `json:"agent"` | 203 | } `json:"agent"` | ||
| 203 | } | 204 | } | ||
| 204 | if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil { | 205 | if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil { | ||
| @@ -207,6 +208,9 @@ func TestSubagentStatusReportsQueuedWorkerWithoutLifecycleState(t *testing.T) { | |||||
| 207 | if got.Agent.ID != queued.ID { | 208 | if got.Agent.ID != queued.ID { | ||
| 208 | t.Fatalf("agent id = %q, want %q", got.Agent.ID, queued.ID) | 209 | t.Fatalf("agent id = %q, want %q", got.Agent.ID, queued.ID) | ||
| 209 | } | 210 | } | ||
| 211 | + | if len(got.Agent.Operation) != 0 { | |||
| 212 | + | t.Fatalf("queued operation = %s, want omitted", got.Agent.Operation) | |||
| 213 | + | } | |||
| 210 | 214 | ||||
| 211 | close(release) | 215 | close(release) | ||
| 212 | active.Wait() | 216 | active.Wait() | ||
| @@ -308,12 +312,13 @@ func TestSubagentStatusRejectsUnknownWorker(t *testing.T) { | |||||
| 308 | 312 | ||||
| 309 | func TestSubagentStatusOmitsLifecycleStateForFailedAndCancelledWorkers(t *testing.T) { | 313 | func TestSubagentStatusOmitsLifecycleStateForFailedAndCancelledWorkers(t *testing.T) { | ||
| 310 | for _, tc := range []struct { | 314 | for _, tc := range []struct { | ||
| 311 | - | name string | 315 | + | name string |
| 312 | - | runnerErr error | 316 | + | runnerErr error |
| 313 | - | stop bool | 317 | + | stop bool |
| 318 | + | wantTerminal string | |||
| 314 | }{ | 319 | }{ | ||
| 315 | - | {name: "failed", runnerErr: errors.New("runner failed")}, | 320 | + | {name: "failed", runnerErr: errors.New("runner failed"), wantTerminal: "failed"}, |
| 316 | - | {name: "cancelled", stop: true}, | 321 | + | {name: "cancelled", stop: true, wantTerminal: "cancelled"}, |
| 317 | } { | 322 | } { | ||
| 318 | t.Run(tc.name, func(t *testing.T) { | 323 | t.Run(tc.name, func(t *testing.T) { | ||
| 319 | root := t.TempDir() | 324 | root := t.TempDir() | ||
| @@ -356,7 +361,8 @@ func TestSubagentStatusOmitsLifecycleStateForFailedAndCancelledWorkers(t *testin | |||||
| 356 | } | 361 | } | ||
| 357 | var got struct { | 362 | var got struct { | ||
| 358 | Agent struct { | 363 | Agent struct { | ||
| 359 | - | ID string `json:"agent_id"` | 364 | + | ID string `json:"agent_id"` |
| 365 | + | Terminal string `json:"terminal"` | |||
| 360 | } `json:"agent"` | 366 | } `json:"agent"` | ||
| 361 | } | 367 | } | ||
| 362 | if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil { | 368 | if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil { | ||
| @@ -365,6 +371,12 @@ func TestSubagentStatusOmitsLifecycleStateForFailedAndCancelledWorkers(t *testin | |||||
| 365 | if got.Agent.ID != agent.ID { | 371 | if got.Agent.ID != agent.ID { | ||
| 366 | t.Fatalf("agent id = %q, want %q", got.Agent.ID, agent.ID) | 372 | t.Fatalf("agent id = %q, want %q", got.Agent.ID, agent.ID) | ||
| 367 | } | 373 | } | ||
| 374 | + | if got.Agent.Terminal != tc.wantTerminal { | |||
| 375 | + | t.Fatalf("terminal = %q, want %q", got.Agent.Terminal, tc.wantTerminal) | |||
| 376 | + | } | |||
| 377 | + | if tc.stop && got.Agent.Terminal == "completed" { | |||
| 378 | + | t.Fatalf("cancelled worker reported completed terminal") | |||
| 379 | + | } | |||
| 368 | }) | 380 | }) | ||
| 369 | } | 381 | } | ||
| 370 | } | 382 | } | ||
M packages/agent/tools/subagent_stop.go +3 -3
| @@ -98,7 +98,7 @@ func (t *SubagentStopTool) Execute(ctx context.Context, raw json.RawMessage, _ f | |||
| 98 | 98 | if !ok { | |
| 99 | 99 | return core.ToolResult{}, fmt.Errorf("%s: terminated agent disappeared from supervisor", prefix) | |
| 100 | 100 | } | |
| 101 | - | return renderSubagentAction("stop_requested", snapshot) | |
| 101 | + | return renderSubagentAction("stop_requested", snapshot, t.Supervisor.TraceViews()[snapshot.ID]) | |
| 102 | 102 | } | |
| 103 | 103 | ||
| 104 | 104 | func subagentCanStop(snapshot subagents.AgentSnapshot) bool { | |
| @@ -107,10 +107,10 @@ func subagentCanStop(snapshot subagents.AgentSnapshot) bool { | |||
| 107 | 107 | (snapshot.Status == subagents.StatusDetached && snapshot.ProcessState == subagents.ProcessAlive) | |
| 108 | 108 | } | |
| 109 | 109 | ||
| 110 | - | func renderSubagentAction(action string, snapshot subagents.AgentSnapshot) (core.ToolResult, error) { | |
| 110 | + | func renderSubagentAction(action string, snapshot subagents.AgentSnapshot, view subagents.AgentTraceView) (core.ToolResult, error) { | |
| 111 | 111 | response := subagentActionResponse{ | |
| 112 | 112 | Action: action, | |
| 113 | - | Agent: publicSubagentStatus(snapshot), | |
| 113 | + | Agent: publicSubagentStatus(snapshot, view), | |
| 114 | 114 | } | |
| 115 | 115 | data, err := json.Marshal(response) | |
| 116 | 116 | if err != nil { | |
| @@ -98,7 +98,7 @@ func (t *SubagentStopTool) Execute(ctx context.Context, raw json.RawMessage, _ f | |||||
| 98 | if !ok { | 98 | if !ok { | ||
| 99 | return core.ToolResult{}, fmt.Errorf("%s: terminated agent disappeared from supervisor", prefix) | 99 | return core.ToolResult{}, fmt.Errorf("%s: terminated agent disappeared from supervisor", prefix) | ||
| 100 | } | 100 | } | ||
| 101 | - | return renderSubagentAction("stop_requested", snapshot) | 101 | + | return renderSubagentAction("stop_requested", snapshot, t.Supervisor.TraceViews()[snapshot.ID]) |
| 102 | } | 102 | } | ||
| 103 | 103 | ||||
| 104 | func subagentCanStop(snapshot subagents.AgentSnapshot) bool { | 104 | func subagentCanStop(snapshot subagents.AgentSnapshot) bool { | ||
| @@ -107,10 +107,10 @@ func subagentCanStop(snapshot subagents.AgentSnapshot) bool { | |||||
| 107 | (snapshot.Status == subagents.StatusDetached && snapshot.ProcessState == subagents.ProcessAlive) | 107 | (snapshot.Status == subagents.StatusDetached && snapshot.ProcessState == subagents.ProcessAlive) | ||
| 108 | } | 108 | } | ||
| 109 | 109 | ||||
| 110 | - | func renderSubagentAction(action string, snapshot subagents.AgentSnapshot) (core.ToolResult, error) { | 110 | + | func renderSubagentAction(action string, snapshot subagents.AgentSnapshot, view subagents.AgentTraceView) (core.ToolResult, error) { |
| 111 | response := subagentActionResponse{ | 111 | response := subagentActionResponse{ | ||
| 112 | Action: action, | 112 | Action: action, | ||
| 113 | - | Agent: publicSubagentStatus(snapshot), | 113 | + | Agent: publicSubagentStatus(snapshot, view), |
| 114 | } | 114 | } | ||
| 115 | data, err := json.Marshal(response) | 115 | data, err := json.Marshal(response) | ||
| 116 | if err != nil { | 116 | if err != nil { | ||
M packages/agent/tracecmd.go +6 -1
| @@ -46,7 +46,12 @@ func renderTraceInspection(w io.Writer, views map[string]subagents.AgentTraceVie | |||
| 46 | 46 | } | |
| 47 | 47 | continue | |
| 48 | 48 | } | |
| 49 | - | if _, err := fmt.Fprintf(w, "%s no observable operation; last event %s %s ago\n", id, view.LastEvent.Type, now.Sub(view.LastEvent.Timestamp).Round(time.Second)); err != nil { | |
| 49 | + | age := now.Sub(view.LastEvent.Timestamp).Round(time.Second) | |
| 50 | + | when := age.String() + " ago" | |
| 51 | + | if age < 0 { | |
| 52 | + | when = "in " + (-age).String() | |
| 53 | + | } | |
| 54 | + | if _, err := fmt.Fprintf(w, "%s no observable operation; last event %s %s\n", id, view.LastEvent.Type, when); err != nil { | |
| 50 | 55 | return err | |
| 51 | 56 | } | |
| 52 | 57 | } | |
| @@ -46,7 +46,12 @@ func renderTraceInspection(w io.Writer, views map[string]subagents.AgentTraceVie | |||||
| 46 | } | 46 | } | ||
| 47 | continue | 47 | continue | ||
| 48 | } | 48 | } | ||
| 49 | - | if _, err := fmt.Fprintf(w, "%s no observable operation; last event %s %s ago\n", id, view.LastEvent.Type, now.Sub(view.LastEvent.Timestamp).Round(time.Second)); err != nil { | 49 | + | age := now.Sub(view.LastEvent.Timestamp).Round(time.Second) |
| 50 | + | when := age.String() + " ago" | |||
| 51 | + | if age < 0 { | |||
| 52 | + | when = "in " + (-age).String() | |||
| 53 | + | } | |||
| 54 | + | if _, err := fmt.Fprintf(w, "%s no observable operation; last event %s %s\n", id, view.LastEvent.Type, when); err != nil { | |||
| 50 | return err | 55 | return err | ||
| 51 | } | 56 | } | ||
| 52 | } | 57 | } | ||
M packages/agent/tracecmd_test.go +14 -0
| @@ -21,3 +21,17 @@ func TestRenderTraceInspectionUsesFactualOperations(t *testing.T) { | |||
| 21 | 21 | t.Fatalf("inspection = %q, want %q", got, want) | |
| 22 | 22 | } | |
| 23 | 23 | } | |
| 24 | + | ||
| 25 | + | func TestRenderTraceInspectionUsesFutureOrientedLastEventTime(t *testing.T) { | |
| 26 | + | now := time.Date(2026, 8, 12, 7, 0, 30, 0, time.UTC) | |
| 27 | + | views := map[string]subagents.AgentTraceView{ | |
| 28 | + | "agent-a": {AgentID: "agent-a", LastEvent: subagents.TraceEvent{Type: "agent.ready", Timestamp: now.Add(time.Minute)}}, | |
| 29 | + | } | |
| 30 | + | var out bytes.Buffer | |
| 31 | + | if err := renderTraceInspection(&out, views, now); err != nil { | |
| 32 | + | t.Fatal(err) | |
| 33 | + | } | |
| 34 | + | if got, want := out.String(), "agent-a no observable operation; last event agent.ready in 1m0s\n"; got != want { | |
| 35 | + | t.Fatalf("inspection = %q, want %q", got, want) | |
| 36 | + | } | |
| 37 | + | } | |
| @@ -21,3 +21,17 @@ func TestRenderTraceInspectionUsesFactualOperations(t *testing.T) { | |||||
| 21 | t.Fatalf("inspection = %q, want %q", got, want) | 21 | t.Fatalf("inspection = %q, want %q", got, want) | ||
| 22 | } | 22 | } | ||
| 23 | } | 23 | } | ||
| 24 | + | ||||
| 25 | + | func TestRenderTraceInspectionUsesFutureOrientedLastEventTime(t *testing.T) { | |||
| 26 | + | now := time.Date(2026, 8, 12, 7, 0, 30, 0, time.UTC) | |||
| 27 | + | views := map[string]subagents.AgentTraceView{ | |||
| 28 | + | "agent-a": {AgentID: "agent-a", LastEvent: subagents.TraceEvent{Type: "agent.ready", Timestamp: now.Add(time.Minute)}}, | |||
| 29 | + | } | |||
| 30 | + | var out bytes.Buffer | |||
| 31 | + | if err := renderTraceInspection(&out, views, now); err != nil { | |||
| 32 | + | t.Fatal(err) | |||
| 33 | + | } | |||
| 34 | + | if got, want := out.String(), "agent-a no observable operation; last event agent.ready in 1m0s\n"; got != want { | |||
| 35 | + | t.Fatalf("inspection = %q, want %q", got, want) | |||
| 36 | + | } | |||
| 37 | + | } | |||