diff --git a/cmd/agentop/tui/app.go b/cmd/agentop/tui/app.go index b0de98b34..6a86879be 100644 --- a/cmd/agentop/tui/app.go +++ b/cmd/agentop/tui/app.go @@ -479,8 +479,9 @@ type model struct { // version is the build agentop was started as, shown after its name on every title bar. // Empty renders the bare name, which is what tests construct. version string - // lastHarvest is when the most recent harvest was STARTED. Read by ONE caller: the sessions - // pane's backoff gate, which asks whether untitledBackoff has elapsed since then. The picker + // lastHarvest is when the most recent harvest was STARTED. Read by TWO callers: the sessions + // pane's backoff gate, which asks whether untitledBackoff has elapsed since then, and the + // timeline pane's gate for unlabeled subagents (timelineLabelHarvestEvery). The picker // no longer has a cadence to measure — it harvests on arrival — so nothing there reads this. // // STAMPED BY EVERY PATH, THOUGH, INCLUDING THE ONES THAT NEVER READ IT, and that is a real @@ -1635,7 +1636,7 @@ func (m *model) update(msg tea.Msg) (tea.Model, tea.Cmd) { case timelineLoadedMsg: m.handleTimelineLoaded(msg) - return m, nil + return m, m.harvestForTimeline() case timelineTickMsg: return m, m.handleTimelineTick(msg) diff --git a/cmd/agentop/tui/timeline_pane.go b/cmd/agentop/tui/timeline_pane.go index 0c6aca154..a442d491f 100644 --- a/cmd/agentop/tui/timeline_pane.go +++ b/cmd/agentop/tui/timeline_pane.go @@ -762,8 +762,46 @@ func axisLabels(lay tlLayout) string { return strings.TrimRight(string(line[:min(len(line), w+8)]), " ") } +// timelineLabelHarvestEvery is the shortest time between two harvests that the timeline pane +// starts for subagents with no label. +const timelineLabelHarvestEvery = 15 * time.Second + +// harvestedLabel is the description Claude Code wrote for a subagent with no spawn call on +// the wire, or "". An agent that Claude Code's Workflow tool starts has no Agent tool call, so +// the server can only label it "subagent ". Its meta file on disk holds the real label. +func (m *model) harvestedLabel(a timeline.Agent) string { + if a.SpawnTool != "" { + return "" + } + return m.sessionsData[m.timeline.session].Subagents[a.ID].Description +} + +func (m *model) timelineLabel(a timeline.Agent) string { + if l := m.harvestedLabel(a); l != "" { + return l + } + return a.Label +} + +// harvestForTimeline starts a harvest when the timeline shows a subagent that has no label +// yet. A workflow starts agents while its main transcript stays unchanged, so nothing else +// starts a harvest during the run. +func (m *model) harvestForTimeline() tea.Cmd { + if m.harvest == nil || m.harvesting || m.timeline.tl == nil || time.Since(m.lastHarvest) < timelineLabelHarvestEvery { + return nil + } + for _, a := range m.timeline.tl.Agents { + if a.SpawnTool == "" && a.ID != timeline.MainAgent && a.ID != timeline.UnseparatedAgent && m.harvestedLabel(a) == "" { + m.harvesting = true + m.lastHarvest = time.Now() + return harvestCmd(m.harvest) + } + } + return nil +} + func (m *model) agentLine(r tlAgentRow, lay tlLayout) string { - label := r.agent.Label + label := m.timelineLabel(r.agent) if r.depth > 0 { label = strings.Repeat(" ", r.depth) + label } @@ -907,11 +945,12 @@ func (m *model) shortAgent(id string) string { } for _, a := range m.timeline.tl.Agents { if a.ID == id { - l := []rune(a.Label) + label := m.timelineLabel(a) + l := []rune(label) if len(l) > 14 { return string(l[:13]) + "…" } - return a.Label + return label } } return id diff --git a/cmd/agentop/tui/timeline_pane_test.go b/cmd/agentop/tui/timeline_pane_test.go index 872b34e41..ea7b45fed 100644 --- a/cmd/agentop/tui/timeline_pane_test.go +++ b/cmd/agentop/tui/timeline_pane_test.go @@ -11,6 +11,7 @@ import ( "github.com/rossoctl/cortex/cmd/agentop/tui/table" "github.com/rossoctl/cortex/cmd/agentop/apiclient" + "github.com/rossoctl/cortex/core/observe/claude" "github.com/rossoctl/cortex/core/pipeline" "github.com/rossoctl/cortex/core/session" "github.com/rossoctl/cortex/core/session/timeline" @@ -1380,3 +1381,47 @@ func TestTimelineStrip_NamesTheTurnModel(t *testing.T) { t.Errorf("strip = %q, want the turn's model named", strip) } } + +// A workflow agent has no spawn call on the wire, so the server labels it "subagent ". The +// pane uses the description Claude Code wrote to disk, and asks for a harvest while one is +// missing. An agent that a spawn call names keeps its wire label. +func TestTimeline_WorkflowAgentUsesHarvestedLabel(t *testing.T) { + m := timelineModel(t, 160, 40) + m.timeline.tl.Agents = append(m.timeline.tl.Agents, + timeline.Agent{ID: "wf1", Parent: timeline.MainAgent, Label: "subagent wf1"}) + m.timeline.tl.Turns = append(m.timeline.tl.Turns, + timeline.Turn{Agent: "wf1", Start: tlAt(700), End: tlPtr(800), Finish: "end_turn", Seq: 25}) + harvests := 0 + m.harvest = func() (map[string]SessionMetadata, error) { harvests++; return nil, nil } + + if !strings.Contains(stripANSI(m.renderTimeline()), "subagent wf1") { + t.Fatalf("no harvest yet: want the server's label") + } + cmd := m.harvestForTimeline() + if cmd == nil || !m.harvesting { + t.Fatalf("unlabeled agent: want a harvest, got cmd=%v harvesting=%v", cmd != nil, m.harvesting) + } + cmd() + if harvests != 1 { + t.Fatalf("harvests = %d, want 1", harvests) + } + if m.harvestForTimeline() != nil { + t.Errorf("a harvest in flight: want no second one") + } + + m.harvesting, m.lastHarvest = false, time.Time{} + m.sessionsData = map[string]SessionMetadata{"sess-1": {Subagents: map[string]claude.SubagentInfo{ + "wf1": {AgentType: "workflow-subagent", Description: "implement T1: x"}, + "a1": {Description: "not the wire label"}, + }}} + out := stripANSI(m.renderTimeline()) + if !strings.Contains(out, "implement T1: x") || strings.Contains(out, "subagent wf1") { + t.Errorf("want the harvested label for wf1:\n%s", out) + } + if !strings.Contains(out, "Survey the code") || strings.Contains(out, "not the wire label") { + t.Errorf("a spawned agent keeps its wire label:\n%s", out) + } + if m.harvestForTimeline() != nil { + t.Errorf("every agent labeled: want no harvest") + } +} diff --git a/core/observe/claude/harvest.go b/core/observe/claude/harvest.go index c93ed29f6..8c4a07fc5 100644 --- a/core/observe/claude/harvest.go +++ b/core/observe/claude/harvest.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "maps" "os" "path/filepath" "strings" @@ -460,6 +461,14 @@ func ReadSessions(configDir string, since map[string]SessionMetadata) (map[strin // converging. won[id] = st.ModTime() delete(out, id) + // The subagent labels are re-read anyway. A workflow (Claude Code's Workflow + // tool) starts its agents without a write to the main transcript, so the + // mtime above does not move while new agents start. The meta files are small. + if subs := readSubagents(filepath.Join(projDir, id, "subagents")); !maps.Equal(subs, since[id].Subagents) { + kept := since[id] + kept.Subagents = subs + out[id] = kept + } skipped++ continue } @@ -479,11 +488,8 @@ func ReadSessions(configDir string, since map[string]SessionMetadata) (map[strin LogFile: path, LogModTime: st.ModTime(), // The subagents' labels live in a sibling DIRECTORY of the transcript - // (/subagents), whose mtime the skip above knows nothing about. That - // is fine, because a session only gains a subagent by appending the Agent - // tool call that spawned it to the transcript — which moves the - // transcript's mtime and re-parses the session, meta files and all. A - // skipped session keeps the Subagents its `since` entry already carried. + // (/subagents), whose mtime the skip above knows nothing about, so the + // skip re-reads them on its own. Subagents: readSubagents(filepath.Join(projDir, id, "subagents")), } } @@ -492,19 +498,31 @@ func ReadSessions(configDir string, since map[string]SessionMetadata) (map[strin } // readSubagents reads one session's subagents/agent-.meta.json files into a map keyed -// by the bare id. Nil when the directory is missing or holds nothing harvestable — a -// session that never spawned a subagent, which is most of them, must not carry an empty -// map the JSON would then spell out. +// by the bare id. It also reads subagents/workflows//agent-.meta.json, where +// Claude Code puts the agents that its Workflow tool starts. Nil when the directory is +// missing or holds nothing harvestable — a session that never spawned a subagent, which +// is most of them, must not carry an empty map the JSON would then spell out. // // Best-effort per file, for the same reason titleFromTranscript is: one unparseable meta // file must not cost the other labels. The transcripts sitting beside the meta files are // deliberately not read — see SessionMetadata.Subagents. func readSubagents(dir string) map[string]SubagentInfo { + out := readSubagentDir(dir, nil) + runs, _ := os.ReadDir(filepath.Join(dir, "workflows")) + for _, r := range runs { + if r.IsDir() { + out = readSubagentDir(filepath.Join(dir, "workflows", r.Name()), out) + } + } + return out +} + +// readSubagentDir adds the agent-.meta.json files in dir to out, and returns out. +func readSubagentDir(dir string, out map[string]SubagentInfo) map[string]SubagentInfo { entries, err := os.ReadDir(dir) if err != nil { - return nil + return out } - var out map[string]SubagentInfo for _, e := range entries { name := e.Name() if !strings.HasPrefix(name, "agent-") || !strings.HasSuffix(name, ".meta.json") { diff --git a/core/observe/claude/harvest_test.go b/core/observe/claude/harvest_test.go index 13a29976b..1bedf998b 100644 --- a/core/observe/claude/harvest_test.go +++ b/core/observe/claude/harvest_test.go @@ -2995,6 +2995,45 @@ func TestHarvest_ReadsSubagentMetaFiles(t *testing.T) { } } +// The Workflow tool's agents keep their meta files in subagents/workflows//. They +// start without a write to the main transcript, so an incremental harvest that skips the +// unchanged transcript must still find an agent that started after the last harvest. +func TestHarvest_ReadsWorkflowSubagentsWhenTranscriptIsUnchanged(t *testing.T) { + metadataHome(t) + cfg := filepath.Join(t.TempDir(), "claude") + proj := filepath.Join(cfg, "projects", "-p") + writeSessionTranscript(t, proj, "s1.jsonl", `{"type":"ai-title","aiTitle":"t"}`) + subs := filepath.Join(proj, "s1", "subagents") + writeSessionTranscript(t, subs, "agent-a1.meta.json", `{"description":"Agent tool child"}`) + run := filepath.Join(subs, "workflows", "wf_1") + writeSessionTranscript(t, run, "agent-b1.meta.json", `{"agentType":"workflow-subagent","description":"implement T1: x"}`) + + if _, err := Harvest(Options{ConfigDir: cfg, Merge: true, Incremental: true}); err != nil { + t.Fatal(err) + } + writeSessionTranscript(t, run, "agent-b2.meta.json", `{"description":"merge T1: x"}`) + res, err := Harvest(Options{ConfigDir: cfg, Merge: true, Incremental: true}) + if err != nil { + t.Fatal(err) + } + if res.Skipped != 1 { + t.Fatalf("Skipped = %d, want 1: the transcript did not change", res.Skipped) + } + got := readMetadataFile(t, res.Path)["s1"] + want := map[string]string{"a1": "Agent tool child", "b1": "implement T1: x", "b2": "merge T1: x"} + if len(got.Subagents) != len(want) { + t.Fatalf("Subagents = %v, want %v", got.Subagents, want) + } + for id, d := range want { + if got.Subagents[id].Description != d { + t.Errorf("Subagents[%s] = %+v, want description %q", id, got.Subagents[id], d) + } + } + if got.Title != "t" { + t.Errorf("Title = %q, want the kept title", got.Title) + } +} + // A session with no subagents carries no Subagents map at all, so the JSON spells nothing // out for the sessions that have none — which is most of them. func TestHarvest_SessionWithoutSubagentsHasNoMap(t *testing.T) {