Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions cmd/agentop/tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
45 changes: 42 additions & 3 deletions cmd/agentop/tui/timeline_pane.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>". 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) == "" {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: The gate keys on the description rather than on whether the harvest found the agent at all. A meta file that has agentType but no description has a Subagents[id] entry, so further harvests can't add anything for it. Even so, harvestedLabel returns "" for it. The row stays subagent <id>, and on a live session this asks for a harvest every 15 s for as long as the pane is open. That's the same cost as the teammate "known limit", but here it comes from an agent the harvest did find. subagentLabel in events_blocks.go already falls back from Description to AgentType. Two changes would fix this: have the gate test key presence (_, ok := m.sessionsData[s].Subagents[a.ID]), and have the label use the same Description → AgentType fallback, so both panes name such an agent the same way. Not blocking: the 15 s floor already bounds the cost.

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
}
Expand Down Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions cmd/agentop/tui/timeline_pane_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 <id>". 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")
}
}
38 changes: 28 additions & 10 deletions core/observe/claude/harvest.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
"maps"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: After this change, a skipped session can appear in the returned map. The ReadSessions doc comment above still says "Skipped sessions are absent from the returned map rather than copied into it", so it needs an update. A related effect: such a session now counts in both res.Skipped and res.Harvested. That's harmless today, because Kept is derived and the Incremental caller in main.go prints neither. But it no longer matches Harvested's doc ("entries this run read from transcripts"), so one sentence on each would keep the counters honest.

out[id] = kept
}
skipped++
continue
}
Expand All @@ -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
// (<id>/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.
// (<id>/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")),
}
}
Expand All @@ -492,19 +498,31 @@ func ReadSessions(configDir string, since map[string]SessionMetadata) (map[strin
}

// readSubagents reads one session's subagents/agent-<id>.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/<run>/agent-<id>.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-<id>.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") {
Expand Down
39 changes: 39 additions & 0 deletions core/observe/claude/harvest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2995,6 +2995,45 @@ func TestHarvest_ReadsSubagentMetaFiles(t *testing.T) {
}
}

// The Workflow tool's agents keep their meta files in subagents/workflows/<run>/. 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) {
Expand Down
Loading