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.
- cursorfleet/__init__.py +9 -0
- cursorfleet/_templates/cursor/agents/architect.md +18 -0
- cursorfleet/_templates/cursor/agents/implementer.md +20 -0
- cursorfleet/_templates/cursor/agents/patcher.md +17 -0
- cursorfleet/_templates/cursor/agents/qa.md +21 -0
- cursorfleet/_templates/cursor/agents/reviewer.md +20 -0
- cursorfleet/_templates/cursor/agents/scout.md +17 -0
- cursorfleet/_templates/cursor/agents/tester.md +20 -0
- cursorfleet/_templates/cursor/agents-md/root.md +7 -0
- cursorfleet/_templates/cursor/agents-md/work.md +7 -0
- cursorfleet/_templates/cursor/config/config.toml +47 -0
- cursorfleet/_templates/cursor/hooks/entry.json +4 -0
- cursorfleet/_templates/cursor/rules/cursorfleet-core.mdc +10 -0
- cursorfleet/_templates/cursor/rules/cursorfleet-handoff.mdc +15 -0
- cursorfleet/_templates/cursor/skills/cursorfleet-artifacts/SKILL.md +57 -0
- cursorfleet/_templates/cursor/skills/cursorfleet-coordinator/SKILL.md +35 -0
- cursorfleet/adapters/__init__.py +0 -0
- cursorfleet/adapters/cursor/__init__.py +0 -0
- cursorfleet/adapters/cursor/blocks.py +104 -0
- cursorfleet/adapters/cursor/diagnostics.py +547 -0
- cursorfleet/adapters/cursor/fsutil.py +156 -0
- cursorfleet/adapters/cursor/hook_main.py +428 -0
- cursorfleet/adapters/cursor/hook_normalize.py +527 -0
- cursorfleet/adapters/cursor/hook_policy.py +76 -0
- cursorfleet/adapters/cursor/hook_sanitize.py +474 -0
- cursorfleet/adapters/cursor/hooksjson.py +305 -0
- cursorfleet/adapters/cursor/installer.py +626 -0
- cursorfleet/adapters/cursor/kit.py +210 -0
- cursorfleet/adapters/cursor/kit_probe.py +54 -0
- cursorfleet/adapters/cursor/lock.py +145 -0
- cursorfleet/adapters/cursor/templates.py +60 -0
- cursorfleet/adapters/cursor/validation.py +365 -0
- cursorfleet/adapters/cursor/workspace.py +80 -0
- cursorfleet/cli/__init__.py +0 -0
- cursorfleet/cli/commands/__init__.py +1 -0
- cursorfleet/cli/commands/_common.py +82 -0
- cursorfleet/cli/commands/_resolution.py +56 -0
- cursorfleet/cli/commands/doctor.py +81 -0
- cursorfleet/cli/commands/events.py +197 -0
- cursorfleet/cli/commands/index.py +55 -0
- cursorfleet/cli/commands/init.py +71 -0
- cursorfleet/cli/commands/migrate.py +43 -0
- cursorfleet/cli/commands/replay.py +76 -0
- cursorfleet/cli/commands/run.py +97 -0
- cursorfleet/cli/commands/status.py +210 -0
- cursorfleet/cli/commands/tui.py +47 -0
- cursorfleet/cli/commands/uninstall.py +73 -0
- cursorfleet/cli/commands/validate.py +69 -0
- cursorfleet/cli/main.py +81 -0
- cursorfleet/config/__init__.py +44 -0
- cursorfleet/config/io.py +27 -0
- cursorfleet/config/models.py +154 -0
- cursorfleet/config/roster.py +245 -0
- cursorfleet/events/__init__.py +39 -0
- cursorfleet/events/forbidden.py +45 -0
- cursorfleet/events/ids.py +109 -0
- cursorfleet/events/kinds.py +132 -0
- cursorfleet/events/models.py +213 -0
- cursorfleet/events/pathcheck.py +45 -0
- cursorfleet/git/__init__.py +0 -0
- cursorfleet/git/collector.py +226 -0
- cursorfleet/git/runner.py +135 -0
- cursorfleet/git/worktrees.py +71 -0
- cursorfleet/policy/__init__.py +0 -0
- cursorfleet/py.typed +0 -0
- cursorfleet/safeexe.py +70 -0
- cursorfleet/schema_gen.py +73 -0
- cursorfleet/state/__init__.py +0 -0
- cursorfleet/state/artifact_scan.py +309 -0
- cursorfleet/state/context.py +38 -0
- cursorfleet/state/event_store.py +227 -0
- cursorfleet/state/indexer.py +443 -0
- cursorfleet/state/lock.py +74 -0
- cursorfleet/state/models.py +268 -0
- cursorfleet/state/reducer.py +588 -0
- cursorfleet/state/retention.py +218 -0
- cursorfleet/state/run_builder.py +136 -0
- cursorfleet/state/run_model.py +43 -0
- cursorfleet/state/run_store.py +95 -0
- cursorfleet/state/runtime.py +645 -0
- cursorfleet/state/spool.py +186 -0
- cursorfleet/state/spool_read.py +242 -0
- cursorfleet/state/status_doc.py +293 -0
- cursorfleet/tui/__init__.py +0 -0
- cursorfleet/tui/app.py +581 -0
- cursorfleet/tui/data.py +545 -0
- cursorfleet/tui/filters.py +82 -0
- cursorfleet/tui/gates.py +255 -0
- cursorfleet/tui/help.py +101 -0
- cursorfleet/tui/layout.py +26 -0
- cursorfleet/tui/theme.py +176 -0
- cursorfleet/tui/views.py +1077 -0
- cursorfleet/tui/watch.py +33 -0
- cursorfleet/workflow/__init__.py +0 -0
- cursorfleet/workflow/artifacts.py +148 -0
- cursorfleet/workflow/frontmatter.py +232 -0
- cursorfleet-0.1.0.dist-info/METADATA +161 -0
- cursorfleet-0.1.0.dist-info/RECORD +101 -0
- cursorfleet-0.1.0.dist-info/WHEEL +4 -0
- cursorfleet-0.1.0.dist-info/entry_points.txt +3 -0
- 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()
|