cursorfleet 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. cursorfleet/__init__.py +9 -0
  2. cursorfleet/_templates/cursor/agents/architect.md +18 -0
  3. cursorfleet/_templates/cursor/agents/implementer.md +20 -0
  4. cursorfleet/_templates/cursor/agents/patcher.md +17 -0
  5. cursorfleet/_templates/cursor/agents/qa.md +21 -0
  6. cursorfleet/_templates/cursor/agents/reviewer.md +20 -0
  7. cursorfleet/_templates/cursor/agents/scout.md +17 -0
  8. cursorfleet/_templates/cursor/agents/tester.md +20 -0
  9. cursorfleet/_templates/cursor/agents-md/root.md +7 -0
  10. cursorfleet/_templates/cursor/agents-md/work.md +7 -0
  11. cursorfleet/_templates/cursor/config/config.toml +47 -0
  12. cursorfleet/_templates/cursor/hooks/entry.json +4 -0
  13. cursorfleet/_templates/cursor/rules/cursorfleet-core.mdc +10 -0
  14. cursorfleet/_templates/cursor/rules/cursorfleet-handoff.mdc +15 -0
  15. cursorfleet/_templates/cursor/skills/cursorfleet-artifacts/SKILL.md +57 -0
  16. cursorfleet/_templates/cursor/skills/cursorfleet-coordinator/SKILL.md +35 -0
  17. cursorfleet/adapters/__init__.py +0 -0
  18. cursorfleet/adapters/cursor/__init__.py +0 -0
  19. cursorfleet/adapters/cursor/blocks.py +104 -0
  20. cursorfleet/adapters/cursor/diagnostics.py +547 -0
  21. cursorfleet/adapters/cursor/fsutil.py +156 -0
  22. cursorfleet/adapters/cursor/hook_main.py +428 -0
  23. cursorfleet/adapters/cursor/hook_normalize.py +527 -0
  24. cursorfleet/adapters/cursor/hook_policy.py +76 -0
  25. cursorfleet/adapters/cursor/hook_sanitize.py +474 -0
  26. cursorfleet/adapters/cursor/hooksjson.py +305 -0
  27. cursorfleet/adapters/cursor/installer.py +626 -0
  28. cursorfleet/adapters/cursor/kit.py +210 -0
  29. cursorfleet/adapters/cursor/kit_probe.py +54 -0
  30. cursorfleet/adapters/cursor/lock.py +145 -0
  31. cursorfleet/adapters/cursor/templates.py +60 -0
  32. cursorfleet/adapters/cursor/validation.py +365 -0
  33. cursorfleet/adapters/cursor/workspace.py +80 -0
  34. cursorfleet/cli/__init__.py +0 -0
  35. cursorfleet/cli/commands/__init__.py +1 -0
  36. cursorfleet/cli/commands/_common.py +82 -0
  37. cursorfleet/cli/commands/_resolution.py +56 -0
  38. cursorfleet/cli/commands/doctor.py +81 -0
  39. cursorfleet/cli/commands/events.py +197 -0
  40. cursorfleet/cli/commands/index.py +55 -0
  41. cursorfleet/cli/commands/init.py +71 -0
  42. cursorfleet/cli/commands/migrate.py +43 -0
  43. cursorfleet/cli/commands/replay.py +76 -0
  44. cursorfleet/cli/commands/run.py +97 -0
  45. cursorfleet/cli/commands/status.py +210 -0
  46. cursorfleet/cli/commands/tui.py +47 -0
  47. cursorfleet/cli/commands/uninstall.py +73 -0
  48. cursorfleet/cli/commands/validate.py +69 -0
  49. cursorfleet/cli/main.py +81 -0
  50. cursorfleet/config/__init__.py +44 -0
  51. cursorfleet/config/io.py +27 -0
  52. cursorfleet/config/models.py +154 -0
  53. cursorfleet/config/roster.py +245 -0
  54. cursorfleet/events/__init__.py +39 -0
  55. cursorfleet/events/forbidden.py +45 -0
  56. cursorfleet/events/ids.py +109 -0
  57. cursorfleet/events/kinds.py +132 -0
  58. cursorfleet/events/models.py +213 -0
  59. cursorfleet/events/pathcheck.py +45 -0
  60. cursorfleet/git/__init__.py +0 -0
  61. cursorfleet/git/collector.py +226 -0
  62. cursorfleet/git/runner.py +135 -0
  63. cursorfleet/git/worktrees.py +71 -0
  64. cursorfleet/policy/__init__.py +0 -0
  65. cursorfleet/py.typed +0 -0
  66. cursorfleet/safeexe.py +70 -0
  67. cursorfleet/schema_gen.py +73 -0
  68. cursorfleet/state/__init__.py +0 -0
  69. cursorfleet/state/artifact_scan.py +309 -0
  70. cursorfleet/state/context.py +38 -0
  71. cursorfleet/state/event_store.py +227 -0
  72. cursorfleet/state/indexer.py +443 -0
  73. cursorfleet/state/lock.py +74 -0
  74. cursorfleet/state/models.py +268 -0
  75. cursorfleet/state/reducer.py +588 -0
  76. cursorfleet/state/retention.py +218 -0
  77. cursorfleet/state/run_builder.py +136 -0
  78. cursorfleet/state/run_model.py +43 -0
  79. cursorfleet/state/run_store.py +95 -0
  80. cursorfleet/state/runtime.py +645 -0
  81. cursorfleet/state/spool.py +186 -0
  82. cursorfleet/state/spool_read.py +242 -0
  83. cursorfleet/state/status_doc.py +293 -0
  84. cursorfleet/tui/__init__.py +0 -0
  85. cursorfleet/tui/app.py +581 -0
  86. cursorfleet/tui/data.py +545 -0
  87. cursorfleet/tui/filters.py +82 -0
  88. cursorfleet/tui/gates.py +255 -0
  89. cursorfleet/tui/help.py +101 -0
  90. cursorfleet/tui/layout.py +26 -0
  91. cursorfleet/tui/theme.py +176 -0
  92. cursorfleet/tui/views.py +1077 -0
  93. cursorfleet/tui/watch.py +33 -0
  94. cursorfleet/workflow/__init__.py +0 -0
  95. cursorfleet/workflow/artifacts.py +148 -0
  96. cursorfleet/workflow/frontmatter.py +232 -0
  97. cursorfleet-0.1.0.dist-info/METADATA +161 -0
  98. cursorfleet-0.1.0.dist-info/RECORD +101 -0
  99. cursorfleet-0.1.0.dist-info/WHEEL +4 -0
  100. cursorfleet-0.1.0.dist-info/entry_points.txt +3 -0
  101. cursorfleet-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,588 @@
1
+ """Pure, deterministic reducer: events -> session/agent/task state and fleet views.
2
+
3
+ Guarantees (tested, including with Hypothesis):
4
+
5
+ - ``reduce_events`` is a function of the SET of events: input order and exact duplicates
6
+ (same ``event_id``) do not change the result. Events are folded in the total order
7
+ ``(ts UTC, event_id)``.
8
+ - ``apply_event`` is the incremental form; it requires events in that order (the indexer
9
+ checks the watermark and rebuilds a session from the spool when an event arrives late).
10
+ - No clock reads: stale detection takes an injected ``now``. Staleness never turns into
11
+ "idle": a silent agent becomes ``stale_offline`` (with the previous lane kept in
12
+ ``last_lane``), and an agent with no telemetry at all is ``unknown``.
13
+
14
+ Lane semantics are heuristics (PROVISIONAL, ADR 0001 Q1: tool hooks may not identify the
15
+ agent). We do NOT attribute unattributed events to a running subagent by timing; they
16
+ accumulate on the ``main`` entry, labelled ``attribution: unknown``.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import json
23
+ from collections.abc import Callable, Iterable, Sequence
24
+ from datetime import datetime, timedelta
25
+
26
+ from cursorfleet.adapters.cursor.hook_sanitize import is_verify_command
27
+ from cursorfleet.events.kinds import (
28
+ AgentStatus,
29
+ Attribution,
30
+ EventKind,
31
+ Outcome,
32
+ Source,
33
+ )
34
+ from cursorfleet.events.models import Event
35
+ from cursorfleet.state.models import (
36
+ BUDGET_UNKNOWN,
37
+ MAX_FILES_PER_AGENT,
38
+ AgentAcc,
39
+ AgentView,
40
+ FleetView,
41
+ GateAcc,
42
+ GateView,
43
+ Lane,
44
+ LastTest,
45
+ LastTestView,
46
+ SessionAcc,
47
+ SessionView,
48
+ StopReport,
49
+ StopReportView,
50
+ TaskView,
51
+ utc,
52
+ )
53
+
54
+ DEFAULT_STALE_AFTER_S = 900
55
+ MAIN = "main"
56
+
57
+ _ATTRIBUTION_RANK = {Attribution.UNKNOWN: 0, Attribution.INFERRED: 1, Attribution.EXACT: 2}
58
+ _PLANNING_TOOLS = frozenset({"todowrite", "todo_write", "createplan", "create_plan", "switchmode"})
59
+ _READ_TOOLS = frozenset(
60
+ {
61
+ "read", "readfile", "grep", "glob", "semanticsearch", "codebase_search", "readlints",
62
+ "listdir", "ls", "websearch", "webfetch", "fetchmcpresource", "getdynamictools",
63
+ }
64
+ ) # fmt: skip
65
+ _WRITE_TOOLS = frozenset(
66
+ {"write", "strreplace", "edit", "delete", "applypatch", "editnotebook", "multiedit"}
67
+ )
68
+ _EARLY_LANES = frozenset({Lane.UNKNOWN, Lane.QUEUED, Lane.LOADING_CONTEXT})
69
+ _ACTIVE_LANES = frozenset({Lane.PLANNING, Lane.WORKING, Lane.VERIFYING, Lane.PATCHING})
70
+
71
+
72
+ def sort_key(event: Event) -> tuple[datetime, str]:
73
+ return utc(event.ts), event.event_id
74
+
75
+
76
+ def watermark_of(session: SessionAcc) -> tuple[datetime, str]:
77
+ return session.watermark_ts, session.watermark_id
78
+
79
+
80
+ # ---------------------------------------------------------------- folding
81
+
82
+
83
+ def _new_session(event: Event) -> SessionAcc:
84
+ ts = utc(event.ts)
85
+ return SessionAcc(
86
+ session_id=event.session_id,
87
+ first_ts=ts,
88
+ last_ts=ts,
89
+ watermark_ts=ts,
90
+ watermark_id=event.event_id,
91
+ )
92
+
93
+
94
+ def _set_lane(agent: AgentAcc, lane: Lane, event: Event) -> None:
95
+ if agent.lane is not lane:
96
+ agent.lane_since = utc(event.ts)
97
+ agent.lane = lane
98
+ agent.lane_source = event.source
99
+ if event.source is not Source.SELF_REPORTED:
100
+ agent.lifecycle_only = False # real activity replaces lifecycle-only evidence
101
+
102
+
103
+ def _touch_agent(agent: AgentAcc, event: Event) -> None:
104
+ ts = utc(event.ts)
105
+ agent.last_ts = max(agent.last_ts, ts)
106
+ agent.events += 1
107
+ if event.source is not Source.SELF_REPORTED:
108
+ agent.last_live_ts = ts if agent.last_live_ts is None else max(agent.last_live_ts, ts)
109
+ rank = _ATTRIBUTION_RANK
110
+ if agent.events == 1 or rank[event.attribution] < rank[agent.attribution]:
111
+ agent.attribution = event.attribution
112
+ if event.agent_role is not None:
113
+ agent.role = event.agent_role
114
+ if event.agent_instance_id is not None:
115
+ agent.instance_id = event.agent_instance_id
116
+ if event.worktree_id is not None:
117
+ agent.worktree_id = event.worktree_id
118
+ if event.branch is not None:
119
+ agent.branch = event.branch
120
+ if event.commit is not None:
121
+ agent.commit = event.commit
122
+ if event.issue_ref is not None and event.issue_ref not in agent.issue_refs:
123
+ agent.issue_refs = sorted([*agent.issue_refs, event.issue_ref])
124
+
125
+
126
+ def _get_agent(session: SessionAcc, key: str, event: Event) -> AgentAcc:
127
+ agent = session.agents.get(key)
128
+ if agent is None:
129
+ ts = utc(event.ts)
130
+ agent = AgentAcc(key=key, first_ts=ts, last_ts=ts)
131
+ session.agents[key] = agent
132
+ return agent
133
+
134
+
135
+ def _is_verify(event: Event) -> bool:
136
+ command = event.command
137
+ if command is None:
138
+ return False
139
+ return is_verify_command(command.argv0, command.subcommand, command.display)
140
+
141
+
142
+ def _tool_lane(agent: AgentAcc, event: Event) -> Lane:
143
+ name = (event.tool_name or "").lower()
144
+ if _is_verify(event):
145
+ return Lane.VERIFYING
146
+ if name in _PLANNING_TOOLS:
147
+ return Lane.PLANNING if agent.lane in (_EARLY_LANES | {Lane.PLANNING}) else Lane.WORKING
148
+ if name in _READ_TOOLS:
149
+ if agent.lane in _EARLY_LANES:
150
+ return Lane.LOADING_CONTEXT
151
+ return agent.lane if agent.lane in (_ACTIVE_LANES) else Lane.WORKING
152
+ if agent.reviewed and name in _WRITE_TOOLS:
153
+ return Lane.PATCHING
154
+ return Lane.WORKING
155
+
156
+
157
+ def _add_file(agent: AgentAcc, path: str) -> None:
158
+ if path in agent.files:
159
+ return
160
+ if len(agent.files) >= MAX_FILES_PER_AGENT:
161
+ agent.files_overflow = True
162
+ return
163
+ agent.files = sorted([*agent.files, path])
164
+
165
+
166
+ def _on_session_started(s: SessionAcc, a: AgentAcc, e: Event) -> None:
167
+ s.started_ts = utc(e.ts) if s.started_ts is None else min(s.started_ts, utc(e.ts))
168
+ s.ended = False
169
+ s.ended_ts = None
170
+ s.end_outcome = None
171
+ if a.lane is Lane.UNKNOWN:
172
+ _set_lane(a, Lane.QUEUED, e)
173
+
174
+
175
+ def _on_session_stopped(s: SessionAcc, a: AgentAcc, e: Event) -> None:
176
+ s.ended = True
177
+ s.ended_ts = utc(e.ts)
178
+ s.end_outcome = e.outcome
179
+ _set_lane(a, Lane.DONE, e)
180
+
181
+
182
+ def _on_status(s: SessionAcc, a: AgentAcc, e: Event) -> None:
183
+ status = e.status
184
+ if status is AgentStatus.WAITING:
185
+ a.reviewed = True
186
+ _set_lane(a, Lane.AWAITING_REVIEW, e)
187
+ elif status in (AgentStatus.ERROR, AgentStatus.BLOCKED):
188
+ _set_lane(a, Lane.BLOCKED, e)
189
+ elif status is AgentStatus.WORKING:
190
+ _set_lane(a, Lane.WORKING, e)
191
+ elif status is AgentStatus.DONE:
192
+ _set_lane(a, Lane.DONE, e)
193
+ # idle/unknown: keep the lane; a silent agent turns stale at snapshot time
194
+
195
+
196
+ def _on_tool_started(s: SessionAcc, a: AgentAcc, e: Event) -> None:
197
+ shell = (e.tool_name or "").lower() in {"shell", "bash", "terminal"}
198
+ if e.hook == "beforeShellExecution":
199
+ a.before_shell_calls += 1
200
+ else:
201
+ a.pre_calls += 1
202
+ if shell:
203
+ a.pre_shell_calls += 1
204
+ a.last_tool = e.tool_name or a.last_tool
205
+ _set_lane(a, _tool_lane(a, e), e)
206
+
207
+
208
+ def _on_tool_completed(s: SessionAcc, a: AgentAcc, e: Event) -> None:
209
+ a.last_tool = e.tool_name or a.last_tool
210
+
211
+
212
+ def _on_tool_failed(s: SessionAcc, a: AgentAcc, e: Event) -> None:
213
+ a.tool_failures += 1
214
+ a.last_tool = e.tool_name or a.last_tool
215
+
216
+
217
+ def _on_test(s: SessionAcc, a: AgentAcc, e: Event) -> None:
218
+ a.last_test = LastTest(outcome=e.outcome or Outcome.UNKNOWN, ts=utc(e.ts))
219
+ _set_lane(a, Lane.VERIFYING, e)
220
+
221
+
222
+ def _on_file_changed(s: SessionAcc, a: AgentAcc, e: Event) -> None:
223
+ for path in e.paths:
224
+ _add_file(a, path.path)
225
+ _set_lane(a, Lane.PATCHING if a.reviewed else Lane.WORKING, e)
226
+
227
+
228
+ def _on_compacted(s: SessionAcc, a: AgentAcc, e: Event) -> None:
229
+ a.compactions += 1
230
+ if e.metrics is not None:
231
+ a.last_context_usage_percent = e.metrics.context_usage_percent
232
+ a.last_context_tokens = e.metrics.context_tokens
233
+ a.last_context_window = e.metrics.context_window_size
234
+
235
+
236
+ def _on_plan(s: SessionAcc, a: AgentAcc, e: Event) -> None:
237
+ a.plans += 1
238
+ if a.lane in (_EARLY_LANES | {Lane.PLANNING}):
239
+ _set_lane(a, Lane.PLANNING, e)
240
+
241
+
242
+ def _on_context_loaded(s: SessionAcc, a: AgentAcc, e: Event) -> None:
243
+ a.contexts_loaded += 1
244
+ if a.lane in _EARLY_LANES:
245
+ _set_lane(a, Lane.LOADING_CONTEXT, e)
246
+
247
+
248
+ def _on_handoff(s: SessionAcc, a: AgentAcc, e: Event) -> None:
249
+ a.handoffs += 1
250
+ a.reviewed = True
251
+ _set_lane(a, Lane.AWAITING_REVIEW, e)
252
+
253
+
254
+ def _on_blocker(s: SessionAcc, a: AgentAcc, e: Event) -> None:
255
+ a.blockers += 1
256
+ _set_lane(a, Lane.BLOCKED, e)
257
+
258
+
259
+ def _on_gate(s: SessionAcc, a: AgentAcc, e: Event) -> None:
260
+ if e.gate is not None:
261
+ s.gates[e.gate.name] = GateAcc(state=e.gate.state.value, ts=utc(e.ts), commit=e.commit)
262
+
263
+
264
+ def _on_subagent_started(s: SessionAcc, a: AgentAcc, e: Event) -> None:
265
+ a.lifecycle = "running"
266
+ a.started_ts = utc(e.ts)
267
+ a.stopped_ts = None
268
+ a.spawn_link = e.tool_use_id or a.spawn_link
269
+ _set_lane(a, Lane.WORKING, e)
270
+ # Running, but nothing attributed to it yet: shown as lifecycle-only evidence.
271
+ a.lifecycle_only = a.events <= 1
272
+
273
+
274
+ _HANDLERS: dict[EventKind, Callable[[SessionAcc, AgentAcc, Event], None]] = {
275
+ EventKind.SESSION_STARTED: _on_session_started,
276
+ EventKind.SESSION_STOPPED: _on_session_stopped,
277
+ EventKind.STATUS_CHANGED: _on_status,
278
+ EventKind.TOOL_STARTED: _on_tool_started,
279
+ EventKind.TOOL_COMPLETED: _on_tool_completed,
280
+ EventKind.TOOL_FAILED: _on_tool_failed,
281
+ EventKind.TEST_COMPLETED: _on_test,
282
+ EventKind.VERIFICATION_OBSERVED: _on_test,
283
+ EventKind.FILE_CHANGED: _on_file_changed,
284
+ EventKind.CONTEXT_COMPACTED: _on_compacted,
285
+ EventKind.PLAN_CREATED: _on_plan,
286
+ EventKind.CONTEXT_LOADED: _on_context_loaded,
287
+ EventKind.HANDOFF_CREATED: _on_handoff,
288
+ EventKind.BLOCKER_RAISED: _on_blocker,
289
+ EventKind.GATE_CHANGED: _on_gate,
290
+ EventKind.SUBAGENT_STARTED: _on_subagent_started,
291
+ }
292
+
293
+ # Events after which a previously blocked/awaiting agent is demonstrably active again are
294
+ # handled by _set_lane in the handlers above (any tool/file lane overrides blocked).
295
+
296
+
297
+ def _pair_stop(session: SessionAcc, event: Event) -> AgentAcc | None:
298
+ """Find the running subagent a ``subagent.stopped`` belongs to (PROVISIONAL pairing).
299
+
300
+ Exact: the event carries an instance id. Otherwise the open subagents of the same
301
+ role are candidates; choose the one whose start time best matches
302
+ ``stop_ts - duration_ms`` (earliest start when no duration is known).
303
+ """
304
+ if event.agent_id is not None and event.agent_instance_id is not None:
305
+ found = session.agents.get(event.agent_id)
306
+ if found is not None and found.lifecycle == "running":
307
+ return found
308
+ candidates = [
309
+ agent
310
+ for key, agent in sorted(session.agents.items())
311
+ if agent.lifecycle == "running" and agent.role == event.agent_role and key != MAIN
312
+ ]
313
+ if not candidates:
314
+ return None
315
+ duration = event.metrics.duration_ms if event.metrics is not None else None
316
+ if duration is None:
317
+ return min(candidates, key=lambda a: (a.started_ts or a.first_ts, a.key))
318
+ expected_start = utc(event.ts) - timedelta(milliseconds=duration)
319
+ return min(
320
+ candidates,
321
+ key=lambda a: (abs((a.started_ts or a.first_ts) - expected_start), a.key),
322
+ )
323
+
324
+
325
+ def _on_subagent_stopped(session: SessionAcc, event: Event) -> AgentAcc:
326
+ agent = _pair_stop(session, event)
327
+ if agent is None:
328
+ session.unpaired_stops += 1
329
+ key = event.agent_id or event.agent_role or "unknown-subagent"
330
+ agent = _get_agent(session, key, event)
331
+ agent.role = agent.role or event.agent_role
332
+ _touch_agent(agent, event)
333
+ agent.lifecycle = "stopped"
334
+ agent.stopped_ts = utc(event.ts)
335
+ agent.lifecycle_only = False
336
+ metrics = event.metrics
337
+ agent.stop_report = StopReport(
338
+ outcome=event.outcome,
339
+ duration_ms=metrics.duration_ms if metrics else None,
340
+ tool_call_count=metrics.tool_call_count if metrics else None,
341
+ message_count=metrics.message_count if metrics else None,
342
+ loop_count=metrics.loop_count if metrics else None,
343
+ )
344
+ _set_lane(agent, Lane.BLOCKED if event.outcome is Outcome.FAILED else Lane.DONE, event)
345
+ return agent
346
+
347
+
348
+ def apply_event(session: SessionAcc | None, event: Event) -> SessionAcc:
349
+ """Fold one event (in ``sort_key`` order) into ``session``; returns the session."""
350
+ key = sort_key(event)
351
+ if session is None:
352
+ session = _new_session(event)
353
+ elif key <= watermark_of(session) and session.events > 0:
354
+ msg = "apply_event requires events in (ts, event_id) order; rebuild the session"
355
+ raise ValueError(msg)
356
+ ts = utc(event.ts)
357
+ session.events += 1
358
+ session.last_ts = max(session.last_ts, ts)
359
+ session.first_ts = min(session.first_ts, ts)
360
+ if event.source is not Source.SELF_REPORTED:
361
+ live = session.last_live_ts
362
+ session.last_live_ts = ts if live is None else max(live, ts)
363
+ session.by_source[event.source.value] = session.by_source.get(event.source.value, 0) + 1
364
+ session.watermark_ts, session.watermark_id = key
365
+ if event.cursor_version is not None:
366
+ session.cursor_version = event.cursor_version
367
+ if event.branch is not None:
368
+ session.branch = event.branch
369
+ if event.commit is not None:
370
+ session.commit = event.commit
371
+ if event.worktree_id is not None and event.worktree_id not in session.worktree_ids:
372
+ session.worktree_ids = sorted([*session.worktree_ids, event.worktree_id])
373
+
374
+ if event.kind is EventKind.SUBAGENT_STOPPED:
375
+ _on_subagent_stopped(session, event)
376
+ return session
377
+ agent = _get_agent(session, event.agent_id or MAIN, event)
378
+ _touch_agent(agent, event)
379
+ handler = _HANDLERS.get(event.kind)
380
+ if handler is not None:
381
+ handler(session, agent, event)
382
+ return session
383
+
384
+
385
+ def dedupe_and_sort(events: Iterable[Event]) -> list[Event]:
386
+ """Total order ``(ts, event_id, content)`` with one event per ``event_id``."""
387
+ ordered = sorted(events, key=lambda e: (*sort_key(e), e.model_dump_json()))
388
+ seen: set[str] = set()
389
+ unique: list[Event] = []
390
+ for event in ordered:
391
+ if event.event_id in seen:
392
+ continue
393
+ seen.add(event.event_id)
394
+ unique.append(event)
395
+ return unique
396
+
397
+
398
+ def reduce_events(events: Iterable[Event]) -> dict[str, SessionAcc]:
399
+ """Fold any collection of events into per-session accumulators (order-independent)."""
400
+ sessions: dict[str, SessionAcc] = {}
401
+ for event in dedupe_and_sort(events):
402
+ sessions[event.session_id] = apply_event(sessions.get(event.session_id), event)
403
+ return dict(sorted(sessions.items()))
404
+
405
+
406
+ # ---------------------------------------------------------------- views
407
+
408
+
409
+ def _live_ts(agent: AgentAcc, session: SessionAcc) -> datetime | None:
410
+ live = agent.last_live_ts
411
+ if agent.lifecycle == "running" and agent.lifecycle_only and session.last_live_ts is not None:
412
+ # A running subagent that emits no attributed telemetry is as alive as its session.
413
+ return session.last_live_ts if live is None else max(live, session.last_live_ts)
414
+ return live
415
+
416
+
417
+ def _basis(agent: AgentAcc) -> str:
418
+ if agent.lane is Lane.UNKNOWN or agent.lane_source is None:
419
+ return "none"
420
+ if agent.lifecycle_only:
421
+ return "lifecycle_only"
422
+ return {
423
+ Source.OBSERVED: "observed_activity",
424
+ Source.SELF_REPORTED: "self_reported",
425
+ Source.DERIVED: "derived",
426
+ }[agent.lane_source]
427
+
428
+
429
+ def agent_view(
430
+ agent: AgentAcc, session: SessionAcc, now: datetime, stale_after_s: int
431
+ ) -> AgentView:
432
+ lane = agent.lane
433
+ stale = False
434
+ last_lane: str | None = None
435
+ live = _live_ts(agent, session)
436
+ if lane not in (Lane.DONE, Lane.UNKNOWN):
437
+ age = (now - live).total_seconds() if live is not None else None
438
+ if age is None or age > stale_after_s:
439
+ stale = True
440
+ last_lane = lane.value
441
+ lane = Lane.STALE_OFFLINE
442
+ end = agent.stopped_ts or agent.last_ts
443
+ return AgentView(
444
+ key=agent.key,
445
+ role=agent.role,
446
+ instance_id=agent.instance_id,
447
+ attribution=agent.attribution.value,
448
+ lane=lane.value,
449
+ lane_source=agent.lane_source.value if agent.lane_source else None,
450
+ lane_basis=_basis(agent),
451
+ stale=stale,
452
+ last_lane=last_lane,
453
+ lifecycle=agent.lifecycle,
454
+ lifecycle_only=agent.lifecycle_only,
455
+ first_ts=agent.first_ts,
456
+ last_ts=agent.last_ts,
457
+ elapsed_s=round(max((end - agent.first_ts).total_seconds(), 0.0), 3),
458
+ tool_call_count=agent.tool_call_count,
459
+ tool_failures=agent.tool_failures,
460
+ compactions=agent.compactions,
461
+ last_context_usage_percent=agent.last_context_usage_percent,
462
+ files_changed=len(agent.files),
463
+ reviewed=agent.reviewed,
464
+ last_test=(
465
+ LastTestView(outcome=agent.last_test.outcome.value, ts=agent.last_test.ts)
466
+ if agent.last_test
467
+ else None
468
+ ),
469
+ stop_report=(
470
+ StopReportView(
471
+ outcome=agent.stop_report.outcome.value if agent.stop_report.outcome else None,
472
+ duration_ms=agent.stop_report.duration_ms,
473
+ tool_call_count=agent.stop_report.tool_call_count,
474
+ message_count=agent.stop_report.message_count,
475
+ )
476
+ if agent.stop_report
477
+ else None
478
+ ),
479
+ issue_refs=list(agent.issue_refs),
480
+ worktree_id=agent.worktree_id,
481
+ branch=agent.branch,
482
+ token_budget=BUDGET_UNKNOWN,
483
+ )
484
+
485
+
486
+ def _session_lane(session: SessionAcc, agents: Sequence[AgentView]) -> str:
487
+ if session.ended:
488
+ return Lane.DONE.value
489
+ if not agents:
490
+ return Lane.UNKNOWN.value
491
+ for view in agents:
492
+ if view.key == MAIN:
493
+ return view.lane
494
+ latest = max(agents, key=lambda v: (v.last_ts, v.key))
495
+ return latest.lane
496
+
497
+
498
+ def session_view(session: SessionAcc, now: datetime, stale_after_s: int) -> SessionView:
499
+ agents = [
500
+ agent_view(agent, session, now, stale_after_s)
501
+ for _key, agent in sorted(session.agents.items())
502
+ ]
503
+ end = session.ended_ts or session.last_ts
504
+ return SessionView(
505
+ session_id=session.session_id,
506
+ lane=_session_lane(session, agents),
507
+ ended=session.ended,
508
+ end_outcome=session.end_outcome.value if session.end_outcome else None,
509
+ first_ts=session.first_ts,
510
+ last_ts=session.last_ts,
511
+ started_ts=session.started_ts,
512
+ ended_ts=session.ended_ts,
513
+ elapsed_s=round(max((end - session.first_ts).total_seconds(), 0.0), 3),
514
+ tool_call_count=sum(a.tool_call_count for a in session.agents.values()),
515
+ compactions=sum(a.compactions for a in session.agents.values()),
516
+ event_count=session.events,
517
+ by_source=dict(sorted(session.by_source.items())),
518
+ cursor_version=session.cursor_version,
519
+ branch=session.branch,
520
+ commit=session.commit,
521
+ worktree_ids=list(session.worktree_ids),
522
+ unpaired_stops=session.unpaired_stops,
523
+ token_budget=BUDGET_UNKNOWN,
524
+ agents=agents,
525
+ gates=[
526
+ GateView(name=name, state=gate.state, ts=gate.ts, commit=gate.commit)
527
+ for name, gate in sorted(session.gates.items())
528
+ ],
529
+ )
530
+
531
+
532
+ def _task_views(sessions: Sequence[SessionAcc], views: dict[str, SessionView]) -> list[TaskView]:
533
+ grouped: dict[str, list[tuple[SessionAcc, AgentAcc]]] = {}
534
+ for session in sessions:
535
+ for _key, agent in sorted(session.agents.items()):
536
+ for ref in agent.issue_refs:
537
+ grouped.setdefault(ref, []).append((session, agent))
538
+ tasks: list[TaskView] = []
539
+ for ref in sorted(grouped):
540
+ members = grouped[ref]
541
+ lead_session, lead_agent = max(
542
+ members, key=lambda m: (m[1].last_ts, m[0].session_id, m[1].key)
543
+ )
544
+ lane = next(
545
+ v.lane for v in views[lead_session.session_id].agents if v.key == lead_agent.key
546
+ )
547
+ tasks.append(
548
+ TaskView(
549
+ issue_ref=ref,
550
+ lane=lane,
551
+ session_ids=sorted({s.session_id for s, _ in members}),
552
+ agent_keys=sorted({a.key for _, a in members}),
553
+ first_ts=min(a.first_ts for _, a in members),
554
+ last_ts=max(a.last_ts for _, a in members),
555
+ plans=sum(a.plans for _, a in members),
556
+ handoffs=sum(a.handoffs for _, a in members),
557
+ blockers=sum(a.blockers for _, a in members),
558
+ )
559
+ )
560
+ return tasks
561
+
562
+
563
+ def snapshot(
564
+ sessions: dict[str, SessionAcc], *, now: datetime, stale_after_s: int = DEFAULT_STALE_AFTER_S
565
+ ) -> FleetView:
566
+ """Derive the fleet view at ``now`` (injected). Pure and deterministic."""
567
+ now = utc(now)
568
+ ordered = [sessions[key] for key in sorted(sessions)]
569
+ views = {s.session_id: session_view(s, now, stale_after_s) for s in ordered}
570
+ return FleetView(
571
+ generated_at=now,
572
+ stale_after_s=stale_after_s,
573
+ sessions=[views[s.session_id] for s in ordered],
574
+ tasks=_task_views(ordered, views),
575
+ )
576
+
577
+
578
+ def canonical_json(model: FleetView | SessionAcc | dict[str, SessionAcc]) -> str:
579
+ """Stable JSON text for determinism checks and digests (sorted keys, no whitespace)."""
580
+ if isinstance(model, dict):
581
+ data: object = {k: json.loads(v.model_dump_json()) for k, v in sorted(model.items())}
582
+ else:
583
+ data = json.loads(model.model_dump_json())
584
+ return json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
585
+
586
+
587
+ def digest(model: FleetView | SessionAcc | dict[str, SessionAcc]) -> str:
588
+ return hashlib.sha256(canonical_json(model).encode("ascii")).hexdigest()