Load full detached swarm history only on demand
- commit
- d45d6efe29e7f104a428a58bbbe69e6a62235e69
- author
- patriceckhart · · 2026-09-24 12:05 UTC
- parent
- 01a4224
- signature
- unsigned
5 files · +62 -42
M README.md +1 -1
| @@ -448,7 +448,7 @@ Background subagents that run alongside your main session. Each one is a separat | |||
| 448 | 448 | ||
| 449 | 449 | **Session scoping** — each agent is stamped with the host session that spawned it and only shows up in that session's dashboard. Swap sessions with `/sessions` and the dashboard re-narrows accordingly. Agents from other sessions keep running in the background and reappear when you switch back. | |
| 450 | 450 | ||
| 451 | - | **Persistence across zot restarts** — every spawn writes a `meta.json` next to its event log and session file under `$ZOT_HOME/swarm/agents/<id>/`. On the next `zot` launch they show up in the dashboard as **detached**; press `R` (or `/swarm resume <id>`) to bring one back. Resumed agents reattach to the same session and inbox socket, so the conversation continues from where it left off. | |
| 451 | + | **Persistence across zot restarts** — every spawn writes a `meta.json` next to its event log and session file under `$ZOT_HOME/swarm/agents/<id>/`. On the next `zot` launch they show up in the dashboard as **detached**; press `R` (or `/swarm resume <id>`) to bring one back. Startup reads only the last 4 MiB of each large event log. Opening `/swarm` or resuming an agent loads its full log on demand, which can briefly delay that action for very large logs. The displayed transcript still retains at most 2000 lines. Resumed agents reattach to the same session and inbox socket, so the conversation continues from where it left off. | |
| 452 | 452 | ||
| 453 | 453 | **Where state lives** — everything per-agent (session file, events log, inbox socket, meta) lives under `$ZOT_HOME/swarm/agents/<id>/`. The agent's actual code edits land directly in your repo; track them with normal `git status` / `git diff`. | |
| 454 | 454 | ||
| @@ -448,7 +448,7 @@ Background subagents that run alongside your main session. Each one is a separat | |||||
| 448 | 448 | ||||
| 449 | **Session scoping** — each agent is stamped with the host session that spawned it and only shows up in that session's dashboard. Swap sessions with `/sessions` and the dashboard re-narrows accordingly. Agents from other sessions keep running in the background and reappear when you switch back. | 449 | **Session scoping** — each agent is stamped with the host session that spawned it and only shows up in that session's dashboard. Swap sessions with `/sessions` and the dashboard re-narrows accordingly. Agents from other sessions keep running in the background and reappear when you switch back. | ||
| 450 | 450 | ||||
| 451 | - | **Persistence across zot restarts** — every spawn writes a `meta.json` next to its event log and session file under `$ZOT_HOME/swarm/agents/<id>/`. On the next `zot` launch they show up in the dashboard as **detached**; press `R` (or `/swarm resume <id>`) to bring one back. Resumed agents reattach to the same session and inbox socket, so the conversation continues from where it left off. | 451 | + | **Persistence across zot restarts** — every spawn writes a `meta.json` next to its event log and session file under `$ZOT_HOME/swarm/agents/<id>/`. On the next `zot` launch they show up in the dashboard as **detached**; press `R` (or `/swarm resume <id>`) to bring one back. Startup reads only the last 4 MiB of each large event log. Opening `/swarm` or resuming an agent loads its full log on demand, which can briefly delay that action for very large logs. The displayed transcript still retains at most 2000 lines. Resumed agents reattach to the same session and inbox socket, so the conversation continues from where it left off. |
| 452 | 452 | ||||
| 453 | **Where state lives** — everything per-agent (session file, events log, inbox socket, meta) lives under `$ZOT_HOME/swarm/agents/<id>/`. The agent's actual code edits land directly in your repo; track them with normal `git status` / `git diff`. | 453 | **Where state lives** — everything per-agent (session file, events log, inbox socket, meta) lives under `$ZOT_HOME/swarm/agents/<id>/`. The agent's actual code edits land directly in your repo; track them with normal `git status` / `git diff`. | ||
| 454 | 454 | ||||
M packages/agent/swarm/agent.go +4 -2
| @@ -69,8 +69,10 @@ type Agent struct { | |||
| 69 | 69 | activity string | |
| 70 | 70 | transcript []string | |
| 71 | 71 | lastAssistant string | |
| 72 | - | finished time.Time | |
| 73 | - | lastErr error | |
| 72 | + | // Large detached logs are loaded in full only when their transcript is viewed or resumed. | |
| 73 | + | needsFullReplay bool | |
| 74 | + | finished time.Time | |
| 75 | + | lastErr error | |
| 74 | 76 | ||
| 75 | 77 | // OnTurnEnd, if set, fires once per prompt-level turn_end event | |
| 76 | 78 | // emitted by the swarm daemon wrapper. Provider/tool-loop | |
| @@ -69,8 +69,10 @@ type Agent struct { | |||||
| 69 | activity string | 69 | activity string | ||
| 70 | transcript []string | 70 | transcript []string | ||
| 71 | lastAssistant string | 71 | lastAssistant string | ||
| 72 | - | finished time.Time | 72 | + | // Large detached logs are loaded in full only when their transcript is viewed or resumed. |
| 73 | - | lastErr error | 73 | + | needsFullReplay bool |
| 74 | + | finished time.Time | |||
| 75 | + | lastErr error | |||
| 74 | 76 | ||||
| 75 | // OnTurnEnd, if set, fires once per prompt-level turn_end event | 77 | // OnTurnEnd, if set, fires once per prompt-level turn_end event | ||
| 76 | // emitted by the swarm daemon wrapper. Provider/tool-loop | 78 | // emitted by the swarm daemon wrapper. Provider/tool-loop | ||
M packages/agent/swarm/persist.go +24 -32
| @@ -180,8 +180,8 @@ func (f *Swarm) Reload() (loaded int, errs []error) { | |||
| 180 | 180 | return loaded, errs | |
| 181 | 181 | } | |
| 182 | 182 | ||
| 183 | - | // detachedReplayBytes is the initial replay window. Expand it when a | |
| 184 | - | // sparse transcript or a large event leaves relevant history outside it. | |
| 183 | + | // detachedReplayBytes bounds startup replay; full history is loaded | |
| 184 | + | // when the dashboard is opened or the agent is resumed. | |
| 185 | 185 | const detachedReplayBytes = 4 << 20 | |
| 186 | 186 | ||
| 187 | 187 | // buildDetachedAgent constructs an Agent from a meta.json with no | |
| @@ -227,43 +227,34 @@ func (f *Swarm) buildDetachedAgent(m agentMeta) *Agent { | |||
| 227 | 227 | // effort: a missing or unreadable log just leaves the agent | |
| 228 | 228 | // detached with an empty transcript. | |
| 229 | 229 | if a.EventLogPath != "" { | |
| 230 | - | replayDetachedEventLog(a) | |
| 230 | + | if evs, err := ReadEventLogTail(a.EventLogPath, detachedReplayBytes); err == nil { | |
| 231 | + | replayEventsIntoAgent(a, evs) | |
| 232 | + | } | |
| 233 | + | if fi, err := os.Stat(a.EventLogPath); err == nil && fi.Size() > detachedReplayBytes { | |
| 234 | + | a.needsFullReplay = true | |
| 235 | + | } | |
| 231 | 236 | } | |
| 232 | 237 | return a | |
| 233 | 238 | } | |
| 234 | 239 | ||
| 235 | - | // replayDetachedEventLog starts at the end of the file, expanding the | |
| 236 | - | // window only when the bounded transcript or latest assistant reply may | |
| 237 | - | // still depend on earlier events. In sparse logs, preserving those fields | |
| 238 | - | // can require reading the entire file. | |
| 239 | - | func replayDetachedEventLog(a *Agent) { | |
| 240 | - | fi, err := os.Stat(a.EventLogPath) | |
| 241 | - | if err != nil { | |
| 240 | + | // loadFullTranscript backfills a detached agent on demand. Keep the | |
| 241 | + | // status inferred from the newest lifecycle event in the startup tail. | |
| 242 | + | // The agent's mutex serializes concurrent dashboard refreshes and resume. | |
| 243 | + | func (a *Agent) loadFullTranscript() { | |
| 244 | + | a.mu.Lock() | |
| 245 | + | defer a.mu.Unlock() | |
| 246 | + | if !a.needsFullReplay { | |
| 242 | 247 | return | |
| 243 | 248 | } | |
| 244 | - | size := fi.Size() | |
| 245 | - | limit := int64(detachedReplayBytes) | |
| 246 | - | for { | |
| 247 | - | evs, err := ReadEventLogTail(a.EventLogPath, limit) | |
| 248 | - | if err != nil { | |
| 249 | - | return | |
| 250 | - | } | |
| 251 | - | if limit >= size { | |
| 252 | - | replayEventsIntoAgent(a, evs) | |
| 253 | - | return | |
| 254 | - | } | |
| 255 | - | trial := &Agent{status: StatusDetached, activity: "detached"} | |
| 256 | - | replayEventsIntoAgent(trial, evs) | |
| 257 | - | if len(trial.transcript) == 2000 && trial.lastAssistant != "" { | |
| 258 | - | replayEventsIntoAgent(a, evs) | |
| 259 | - | return | |
| 260 | - | } | |
| 261 | - | if limit >= size/2 { | |
| 262 | - | limit = size | |
| 263 | - | } else { | |
| 264 | - | limit *= 2 | |
| 265 | - | } | |
| 249 | + | evs, err := ReadEventLog(a.EventLogPath) | |
| 250 | + | if err != nil { | |
| 251 | + | return | |
| 266 | 252 | } | |
| 253 | + | full := &Agent{status: StatusDetached, activity: "detached"} | |
| 254 | + | replayEventsIntoAgent(full, evs) | |
| 255 | + | a.transcript = full.transcript | |
| 256 | + | a.lastAssistant = full.lastAssistant | |
| 257 | + | a.needsFullReplay = false | |
| 267 | 258 | } | |
| 268 | 259 | ||
| 269 | 260 | // replayEventsIntoAgent re-derives an agent's transcript and last | |
| @@ -369,6 +360,7 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) { | |||
| 369 | 360 | if st == StatusRunning || st == StatusPending { | |
| 370 | 361 | return nil, fmt.Errorf("swarm: agent %s is still %s; stop it first", existing.ID, st) | |
| 371 | 362 | } | |
| 363 | + | existing.loadFullTranscript() | |
| 372 | 364 | ||
| 373 | 365 | // Rebuild from the meta record so we don't carry stale runner | |
| 374 | 366 | // state from a previous incarnation. The inbox is transient and | |
| @@ -180,8 +180,8 @@ func (f *Swarm) Reload() (loaded int, errs []error) { | |||||
| 180 | return loaded, errs | 180 | return loaded, errs | ||
| 181 | } | 181 | } | ||
| 182 | 182 | ||||
| 183 | - | // detachedReplayBytes is the initial replay window. Expand it when a | 183 | + | // detachedReplayBytes bounds startup replay; full history is loaded |
| 184 | - | // sparse transcript or a large event leaves relevant history outside it. | 184 | + | // when the dashboard is opened or the agent is resumed. |
| 185 | const detachedReplayBytes = 4 << 20 | 185 | const detachedReplayBytes = 4 << 20 | ||
| 186 | 186 | ||||
| 187 | // buildDetachedAgent constructs an Agent from a meta.json with no | 187 | // buildDetachedAgent constructs an Agent from a meta.json with no | ||
| @@ -227,43 +227,34 @@ func (f *Swarm) buildDetachedAgent(m agentMeta) *Agent { | |||||
| 227 | // effort: a missing or unreadable log just leaves the agent | 227 | // effort: a missing or unreadable log just leaves the agent | ||
| 228 | // detached with an empty transcript. | 228 | // detached with an empty transcript. | ||
| 229 | if a.EventLogPath != "" { | 229 | if a.EventLogPath != "" { | ||
| 230 | - | replayDetachedEventLog(a) | 230 | + | if evs, err := ReadEventLogTail(a.EventLogPath, detachedReplayBytes); err == nil { |
| 231 | + | replayEventsIntoAgent(a, evs) | |||
| 232 | + | } | |||
| 233 | + | if fi, err := os.Stat(a.EventLogPath); err == nil && fi.Size() > detachedReplayBytes { | |||
| 234 | + | a.needsFullReplay = true | |||
| 235 | + | } | |||
| 231 | } | 236 | } | ||
| 232 | return a | 237 | return a | ||
| 233 | } | 238 | } | ||
| 234 | 239 | ||||
| 235 | - | // replayDetachedEventLog starts at the end of the file, expanding the | 240 | + | // loadFullTranscript backfills a detached agent on demand. Keep the |
| 236 | - | // window only when the bounded transcript or latest assistant reply may | 241 | + | // status inferred from the newest lifecycle event in the startup tail. |
| 237 | - | // still depend on earlier events. In sparse logs, preserving those fields | 242 | + | // The agent's mutex serializes concurrent dashboard refreshes and resume. |
| 238 | - | // can require reading the entire file. | 243 | + | func (a *Agent) loadFullTranscript() { |
| 239 | - | func replayDetachedEventLog(a *Agent) { | 244 | + | a.mu.Lock() |
| 240 | - | fi, err := os.Stat(a.EventLogPath) | 245 | + | defer a.mu.Unlock() |
| 241 | - | if err != nil { | 246 | + | if !a.needsFullReplay { |
| 242 | return | 247 | return | ||
| 243 | } | 248 | } | ||
| 244 | - | size := fi.Size() | 249 | + | evs, err := ReadEventLog(a.EventLogPath) |
| 245 | - | limit := int64(detachedReplayBytes) | 250 | + | if err != nil { |
| 246 | - | for { | 251 | + | return |
| 247 | - | evs, err := ReadEventLogTail(a.EventLogPath, limit) | |||
| 248 | - | if err != nil { | |||
| 249 | - | return | |||
| 250 | - | } | |||
| 251 | - | if limit >= size { | |||
| 252 | - | replayEventsIntoAgent(a, evs) | |||
| 253 | - | return | |||
| 254 | - | } | |||
| 255 | - | trial := &Agent{status: StatusDetached, activity: "detached"} | |||
| 256 | - | replayEventsIntoAgent(trial, evs) | |||
| 257 | - | if len(trial.transcript) == 2000 && trial.lastAssistant != "" { | |||
| 258 | - | replayEventsIntoAgent(a, evs) | |||
| 259 | - | return | |||
| 260 | - | } | |||
| 261 | - | if limit >= size/2 { | |||
| 262 | - | limit = size | |||
| 263 | - | } else { | |||
| 264 | - | limit *= 2 | |||
| 265 | - | } | |||
| 266 | } | 252 | } | ||
| 253 | + | full := &Agent{status: StatusDetached, activity: "detached"} | |||
| 254 | + | replayEventsIntoAgent(full, evs) | |||
| 255 | + | a.transcript = full.transcript | |||
| 256 | + | a.lastAssistant = full.lastAssistant | |||
| 257 | + | a.needsFullReplay = false | |||
| 267 | } | 258 | } | ||
| 268 | 259 | ||||
| 269 | // replayEventsIntoAgent re-derives an agent's transcript and last | 260 | // replayEventsIntoAgent re-derives an agent's transcript and last | ||
| @@ -369,6 +360,7 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) { | |||||
| 369 | if st == StatusRunning || st == StatusPending { | 360 | if st == StatusRunning || st == StatusPending { | ||
| 370 | return nil, fmt.Errorf("swarm: agent %s is still %s; stop it first", existing.ID, st) | 361 | return nil, fmt.Errorf("swarm: agent %s is still %s; stop it first", existing.ID, st) | ||
| 371 | } | 362 | } | ||
| 363 | + | existing.loadFullTranscript() | |||
| 372 | 364 | ||||
| 373 | // Rebuild from the meta record so we don't carry stale runner | 365 | // Rebuild from the meta record so we don't carry stale runner | ||
| 374 | // state from a previous incarnation. The inbox is transient and | 366 | // state from a previous incarnation. The inbox is transient and | ||
M packages/agent/swarm/persist_test.go +30 -6
| @@ -243,12 +243,36 @@ func TestDetachedReplayKeepsLastAnswerAcrossLargeEvents(t *testing.T) { | |||
| 243 | 243 | t.Fatal(err) | |
| 244 | 244 | } | |
| 245 | 245 | ||
| 246 | - | a := New(Config{Root: root, RepoRoot: root}).buildDetachedAgent(agentMeta{ | |
| 247 | - | ID: "large", EventLogPath: path, | |
| 248 | - | }) | |
| 249 | - | if got := a.Snapshot(); got.LastAssistant != "final answer" || | |
| 250 | - | len(got.Lines) != 1 || got.Lines[0] != "final answer" || got.Status != StatusDone { | |
| 251 | - | t.Fatalf("replayed snapshot: assistant=%q, lines=%v, status=%s", got.LastAssistant, got.Lines, got.Status) | |
| 246 | + | f := New(Config{Root: root, RepoRoot: root}) | |
| 247 | + | a := f.buildDetachedAgent(agentMeta{ID: "large", EventLogPath: path}) | |
| 248 | + | if got := a.Snapshot(); got.LastAssistant != "" || len(got.Lines) != 0 || got.Status != StatusDone { | |
| 249 | + | t.Fatalf("startup should use a bounded tail: assistant=%q, lines=%v, status=%s", got.LastAssistant, got.Lines, got.Status) | |
| 250 | + | } | |
| 251 | + | f.agents[a.ID] = a | |
| 252 | + | f.order = append(f.order, a.ID) | |
| 253 | + | got := f.SnapshotAll() | |
| 254 | + | if len(got) != 1 || got[0].LastAssistant != "final answer" || | |
| 255 | + | len(got[0].Lines) != 1 || got[0].Lines[0] != "final answer" || got[0].Status != StatusDone { | |
| 256 | + | t.Fatalf("dashboard snapshot: %v", got) | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | // Resume must backfill even when the dashboard was never opened. | |
| 260 | + | f2 := New(Config{Root: root, RepoRoot: root, NewRunner: func(*Agent) Runner { | |
| 261 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |
| 262 | + | <-ctx.Done() | |
| 263 | + | return ctx.Err() | |
| 264 | + | }) | |
| 265 | + | }}) | |
| 266 | + | defer f2.StopAll() | |
| 267 | + | b := f2.buildDetachedAgent(agentMeta{ID: "large", EventLogPath: path}) | |
| 268 | + | f2.agents[b.ID] = b | |
| 269 | + | f2.order = append(f2.order, b.ID) | |
| 270 | + | resumed, err := f2.Resume(context.Background(), b.ID) | |
| 271 | + | if err != nil { | |
| 272 | + | t.Fatal(err) | |
| 273 | + | } | |
| 274 | + | if lines := resumed.Transcript(); len(lines) != 1 || lines[0] != "final answer" { | |
| 275 | + | t.Fatalf("resumed transcript: %v", lines) | |
| 252 | 276 | } | |
| 253 | 277 | } | |
| 254 | 278 | ||
| @@ -243,12 +243,36 @@ func TestDetachedReplayKeepsLastAnswerAcrossLargeEvents(t *testing.T) { | |||||
| 243 | t.Fatal(err) | 243 | t.Fatal(err) | ||
| 244 | } | 244 | } | ||
| 245 | 245 | ||||
| 246 | - | a := New(Config{Root: root, RepoRoot: root}).buildDetachedAgent(agentMeta{ | 246 | + | f := New(Config{Root: root, RepoRoot: root}) |
| 247 | - | ID: "large", EventLogPath: path, | 247 | + | a := f.buildDetachedAgent(agentMeta{ID: "large", EventLogPath: path}) |
| 248 | - | }) | 248 | + | if got := a.Snapshot(); got.LastAssistant != "" || len(got.Lines) != 0 || got.Status != StatusDone { |
| 249 | - | if got := a.Snapshot(); got.LastAssistant != "final answer" || | 249 | + | t.Fatalf("startup should use a bounded tail: assistant=%q, lines=%v, status=%s", got.LastAssistant, got.Lines, got.Status) |
| 250 | - | len(got.Lines) != 1 || got.Lines[0] != "final answer" || got.Status != StatusDone { | 250 | + | } |
| 251 | - | t.Fatalf("replayed snapshot: assistant=%q, lines=%v, status=%s", got.LastAssistant, got.Lines, got.Status) | 251 | + | f.agents[a.ID] = a |
| 252 | + | f.order = append(f.order, a.ID) | |||
| 253 | + | got := f.SnapshotAll() | |||
| 254 | + | if len(got) != 1 || got[0].LastAssistant != "final answer" || | |||
| 255 | + | len(got[0].Lines) != 1 || got[0].Lines[0] != "final answer" || got[0].Status != StatusDone { | |||
| 256 | + | t.Fatalf("dashboard snapshot: %v", got) | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | // Resume must backfill even when the dashboard was never opened. | |||
| 260 | + | f2 := New(Config{Root: root, RepoRoot: root, NewRunner: func(*Agent) Runner { | |||
| 261 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |||
| 262 | + | <-ctx.Done() | |||
| 263 | + | return ctx.Err() | |||
| 264 | + | }) | |||
| 265 | + | }}) | |||
| 266 | + | defer f2.StopAll() | |||
| 267 | + | b := f2.buildDetachedAgent(agentMeta{ID: "large", EventLogPath: path}) | |||
| 268 | + | f2.agents[b.ID] = b | |||
| 269 | + | f2.order = append(f2.order, b.ID) | |||
| 270 | + | resumed, err := f2.Resume(context.Background(), b.ID) | |||
| 271 | + | if err != nil { | |||
| 272 | + | t.Fatal(err) | |||
| 273 | + | } | |||
| 274 | + | if lines := resumed.Transcript(); len(lines) != 1 || lines[0] != "final answer" { | |||
| 275 | + | t.Fatalf("resumed transcript: %v", lines) | |||
| 252 | } | 276 | } | ||
| 253 | } | 277 | } | ||
| 254 | 278 | ||||
M packages/agent/swarm/swarm.go +3 -1
| @@ -540,7 +540,8 @@ func (a *Agent) Snapshot() AgentSnapshot { | |||
| 540 | 540 | } | |
| 541 | 541 | ||
| 542 | 542 | // SnapshotAll returns snapshots of every agent in creation order, | |
| 543 | - | // scoped to the active session when one is set. | |
| 543 | + | // scoped to the active session when one is set. Opening the dashboard | |
| 544 | + | // backfills large detached logs once, rather than delaying startup. | |
| 544 | 545 | // | |
| 545 | 546 | // Scoping rules: | |
| 546 | 547 | // - activeSession == "": no filter, every agent is returned | |
| @@ -561,6 +562,7 @@ func (f *Swarm) SnapshotAll() []AgentSnapshot { | |||
| 561 | 562 | if active != "" && a.SessionID != "" && a.SessionID != active { | |
| 562 | 563 | continue | |
| 563 | 564 | } | |
| 565 | + | a.loadFullTranscript() | |
| 564 | 566 | out = append(out, a.Snapshot()) | |
| 565 | 567 | } | |
| 566 | 568 | // Sort by start time for a stable, deterministic listing. | |
| @@ -540,7 +540,8 @@ func (a *Agent) Snapshot() AgentSnapshot { | |||||
| 540 | } | 540 | } | ||
| 541 | 541 | ||||
| 542 | // SnapshotAll returns snapshots of every agent in creation order, | 542 | // SnapshotAll returns snapshots of every agent in creation order, | ||
| 543 | - | // scoped to the active session when one is set. | 543 | + | // scoped to the active session when one is set. Opening the dashboard |
| 544 | + | // backfills large detached logs once, rather than delaying startup. | |||
| 544 | // | 545 | // | ||
| 545 | // Scoping rules: | 546 | // Scoping rules: | ||
| 546 | // - activeSession == "": no filter, every agent is returned | 547 | // - activeSession == "": no filter, every agent is returned | ||
| @@ -561,6 +562,7 @@ func (f *Swarm) SnapshotAll() []AgentSnapshot { | |||||
| 561 | if active != "" && a.SessionID != "" && a.SessionID != active { | 562 | if active != "" && a.SessionID != "" && a.SessionID != active { | ||
| 562 | continue | 563 | continue | ||
| 563 | } | 564 | } | ||
| 565 | + | a.loadFullTranscript() | |||
| 564 | out = append(out, a.Snapshot()) | 566 | out = append(out, a.Snapshot()) | ||
| 565 | } | 567 | } | ||
| 566 | // Sort by start time for a stable, deterministic listing. | 568 | // Sort by start time for a stable, deterministic listing. | ||