mishkan-harness 0.2.5 → 0.2.6
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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/payload/mishkan/hooks/post-tool-observe.sh +55 -7
- package/payload/mishkan/hooks/pre-tool-trace.sh +40 -6
- package/payload/mishkan/observability/README.md +15 -12
- package/payload/mishkan/observability/schema.json +18 -1
- package/payload/mishkan/observability/watch/pyproject.toml +1 -1
- package/payload/mishkan/observability/watch/src/mishkan_watch/__init__.py +1 -1
- package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/agents.py +29 -7
- package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/knowledge.py +7 -3
- package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/live.py +42 -13
- package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/usage.py +10 -3
- package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/workflows.py +25 -6
- package/payload/mishkan/observability/watchd/pyproject.toml +1 -1
- package/payload/mishkan/observability/watchd/src/mishkan_watchd/__init__.py +1 -1
- package/payload/mishkan/observability/watchd/src/mishkan_watchd/__main__.py +52 -4
- package/payload/mishkan/observability/watchd/src/mishkan_watchd/server.py +24 -1
- package/payload/mishkan/observability/watchd/src/mishkan_watchd/sources/graphify_tail.py +38 -64
- package/payload/mishkan/observability/watchd/src/mishkan_watchd/state.py +222 -34
- package/payload/mishkan/observability/watchd/tests/test_state.py +502 -4
- package/payload/mishkan/scripts/mishkan-ingest.sh +4 -0
|
@@ -36,9 +36,32 @@ class WatchdServer:
|
|
|
36
36
|
self.lock = asyncio.Lock()
|
|
37
37
|
self._heartbeat_task: asyncio.Task | None = None
|
|
38
38
|
|
|
39
|
+
@staticmethod
|
|
40
|
+
def _socket_is_live(socket_path: Path) -> bool:
|
|
41
|
+
"""Return True if a daemon is already listening on socket_path.
|
|
42
|
+
|
|
43
|
+
Attempts a non-blocking connect and an immediate close. If it
|
|
44
|
+
succeeds the socket has a live owner; if it raises (connection
|
|
45
|
+
refused, file not found, timeout) the socket is stale or absent.
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
|
|
49
|
+
s.settimeout(0.5)
|
|
50
|
+
s.connect(str(socket_path))
|
|
51
|
+
return True
|
|
52
|
+
except (OSError, socket.timeout):
|
|
53
|
+
return False
|
|
54
|
+
|
|
39
55
|
async def start(self) -> asyncio.AbstractServer:
|
|
40
|
-
# Ensure parent dir
|
|
56
|
+
# Ensure parent dir exists.
|
|
41
57
|
self.socket_path.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
# Check liveness BEFORE unlinking: if a daemon is already serving
|
|
59
|
+
# this socket, do not steal it — return a no-op guard server instead.
|
|
60
|
+
# Only unlink a socket whose owner is gone (stale file).
|
|
61
|
+
if self.socket_path.exists() and self._socket_is_live(self.socket_path):
|
|
62
|
+
raise RuntimeError(
|
|
63
|
+
f"mishkan-watchd: daemon already running on {self.socket_path}"
|
|
64
|
+
)
|
|
42
65
|
try:
|
|
43
66
|
self.socket_path.unlink()
|
|
44
67
|
except FileNotFoundError:
|
|
@@ -1,18 +1,22 @@
|
|
|
1
|
-
"""Graphify daemon source —
|
|
1
|
+
"""Graphify daemon source — stats-only watcher for graphify-out/.
|
|
2
2
|
|
|
3
3
|
Graphify produces deterministic AST graphs under `<project>/graphify-out/`.
|
|
4
|
-
This source watches every active session's project for changes
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
4
|
+
This source watches every active session's project for graph.json changes
|
|
5
|
+
and surfaces current node/edge/community counts as state refreshes.
|
|
6
|
+
|
|
7
|
+
STATS-ONLY (as of Fix ③): this source no longer owns scan or query COUNTS.
|
|
8
|
+
Real graphify CLI invocations are detected by post-tool-observe.sh (Bash
|
|
9
|
+
hook, PostToolUse) which emits the authoritative graphify_scan and
|
|
10
|
+
graphify_query events that carry session_id and increment the counters in
|
|
11
|
+
state.py. This source only emits graphify_scan events with stats_only=True
|
|
12
|
+
so state.py updates the size display (nodes/edges/communities) WITHOUT
|
|
13
|
+
incrementing the scan counter. This eliminates:
|
|
14
|
+
- double-counting (hook + tail both firing for one real scan), and
|
|
15
|
+
- the phantom scan on every daemon restart (old last_graph_mtime=0.0 init).
|
|
16
|
+
|
|
17
|
+
The memory/ watcher is also removed: graphify query --save-result is not
|
|
18
|
+
the documented workflow; the hook detects the real `graphify query` CLI
|
|
19
|
+
invocation instead.
|
|
16
20
|
|
|
17
21
|
Fail-open per the daemon contract. Missing graphify-out/ is silent —
|
|
18
22
|
many projects don't use Graphify and that's fine.
|
|
@@ -34,36 +38,34 @@ def _iso(ts: float | None = None) -> str:
|
|
|
34
38
|
|
|
35
39
|
|
|
36
40
|
class _ProjectWatcher:
|
|
37
|
-
"""One watcher per project root that has a graphify-out/ dir.
|
|
41
|
+
"""One watcher per project root that has a graphify-out/ dir.
|
|
42
|
+
|
|
43
|
+
Stats-only: emits graphify_scan with stats_only=True when graph.json
|
|
44
|
+
mtime advances. This refreshes the node/edge/community display in the
|
|
45
|
+
Knowledge tab WITHOUT incrementing the scan counter in state.py.
|
|
46
|
+
|
|
47
|
+
The mtime baseline is initialised to the CURRENT mtime of graph.json
|
|
48
|
+
at construction time so an already-built graph does NOT fire a phantom
|
|
49
|
+
stats event on daemon restart. It fires only when the graph is actually
|
|
50
|
+
rebuilt after the daemon started.
|
|
51
|
+
"""
|
|
38
52
|
|
|
39
53
|
def __init__(self, project_path: Path, queue: asyncio.Queue[dict[str, Any]]) -> None:
|
|
40
54
|
self.project_path = project_path
|
|
41
55
|
self.queue = queue
|
|
42
56
|
self.graph_dir = project_path / "graphify-out"
|
|
43
57
|
self.graph_json = self.graph_dir / "graph.json"
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
self.last_graph_mtime = 0.0
|
|
51
|
-
# Memory queries DO use seen-state to avoid replaying old queries
|
|
52
|
-
# on every restart (queries are append-only, replays would spam).
|
|
53
|
-
self.seen_memory: set[str] = set()
|
|
54
|
-
if self.memory_dir.is_dir():
|
|
55
|
-
try:
|
|
56
|
-
for f in self.memory_dir.iterdir():
|
|
57
|
-
if f.is_file():
|
|
58
|
-
self.seen_memory.add(f.name)
|
|
59
|
-
except OSError:
|
|
60
|
-
pass
|
|
58
|
+
# Initialise to the current mtime so pre-existing graphs do NOT
|
|
59
|
+
# produce a phantom stats event on the first poll.
|
|
60
|
+
try:
|
|
61
|
+
self.last_graph_mtime: float = self.graph_json.stat().st_mtime
|
|
62
|
+
except OSError:
|
|
63
|
+
self.last_graph_mtime = 0.0
|
|
61
64
|
|
|
62
65
|
async def step(self) -> None:
|
|
63
66
|
if not self.graph_dir.is_dir():
|
|
64
67
|
return
|
|
65
68
|
await self._check_graph()
|
|
66
|
-
await self._check_memory()
|
|
67
69
|
|
|
68
70
|
async def _check_graph(self) -> None:
|
|
69
71
|
try:
|
|
@@ -84,12 +86,15 @@ class _ProjectWatcher:
|
|
|
84
86
|
"tool": None,
|
|
85
87
|
"outcome": "completed",
|
|
86
88
|
"payload": {
|
|
87
|
-
"op": "
|
|
89
|
+
"op": "stats",
|
|
88
90
|
"project": str(self.project_path),
|
|
89
91
|
"nodes": stats.get("nodes"),
|
|
90
92
|
"edges": stats.get("edges"),
|
|
91
93
|
"communities": stats.get("communities"),
|
|
92
94
|
"scanned_at": _iso(mtime),
|
|
95
|
+
# stats_only=True signals state.py to update size fields
|
|
96
|
+
# but NOT increment the scan counter.
|
|
97
|
+
"stats_only": True,
|
|
93
98
|
},
|
|
94
99
|
})
|
|
95
100
|
|
|
@@ -120,37 +125,6 @@ class _ProjectWatcher:
|
|
|
120
125
|
"communities": len(communities) if isinstance(communities, list) else None,
|
|
121
126
|
}
|
|
122
127
|
|
|
123
|
-
async def _check_memory(self) -> None:
|
|
124
|
-
if not self.memory_dir.is_dir():
|
|
125
|
-
return
|
|
126
|
-
try:
|
|
127
|
-
entries = [f for f in self.memory_dir.iterdir() if f.is_file()]
|
|
128
|
-
except OSError:
|
|
129
|
-
return
|
|
130
|
-
for f in entries:
|
|
131
|
-
if f.name in self.seen_memory:
|
|
132
|
-
continue
|
|
133
|
-
self.seen_memory.add(f.name)
|
|
134
|
-
payload = {"op": "query", "project": str(self.project_path), "file": f.name}
|
|
135
|
-
try:
|
|
136
|
-
data = json.loads(f.read_text())
|
|
137
|
-
if isinstance(data, dict):
|
|
138
|
-
payload["question"] = (data.get("question") or "")[:200]
|
|
139
|
-
answer = data.get("answer") or ""
|
|
140
|
-
payload["answer_excerpt"] = answer[:200] if isinstance(answer, str) else ""
|
|
141
|
-
payload["query_type"] = data.get("type") or data.get("query_type")
|
|
142
|
-
except Exception:
|
|
143
|
-
pass
|
|
144
|
-
await self.queue.put({
|
|
145
|
-
"ts": _iso(),
|
|
146
|
-
"session": None,
|
|
147
|
-
"project": str(self.project_path),
|
|
148
|
-
"type": "graphify_query",
|
|
149
|
-
"tool": None,
|
|
150
|
-
"outcome": "completed",
|
|
151
|
-
"payload": payload,
|
|
152
|
-
})
|
|
153
|
-
|
|
154
128
|
|
|
155
129
|
def _decode_project(p: str) -> str:
|
|
156
130
|
"""Decode Claude Code's encoded project dir name into an absolute path.
|
|
@@ -32,6 +32,20 @@ def _now() -> str:
|
|
|
32
32
|
# a hook fires for a brand-new session before its first JSONL flush.
|
|
33
33
|
_PENDING_TTL_S = 15.0
|
|
34
34
|
|
|
35
|
+
# A workflow run that has received no workflow_* event for this many seconds
|
|
36
|
+
# is transitioned to "stale" in the snapshot. The Workflow tool returns
|
|
37
|
+
# immediately and reports completion via task-notification (not a tool call),
|
|
38
|
+
# so workflow_complete is never emitted by any hook — a TTL is the only
|
|
39
|
+
# durable way to clear lingering "running" entries.
|
|
40
|
+
WORKFLOW_STALE_TTL_S = 900
|
|
41
|
+
|
|
42
|
+
# A session_stop from session_discover is ignored when the session is "busy":
|
|
43
|
+
# either it still has active agents, or a real bus event was applied within
|
|
44
|
+
# this window. Chosen comfortably above session_discover's 60 s active_window
|
|
45
|
+
# so a session whose transcript goes quiet while a subagent is writing to its
|
|
46
|
+
# own nested JSONL is not torn down mid-run.
|
|
47
|
+
SESSION_KEEPALIVE_S = 90.0
|
|
48
|
+
|
|
35
49
|
|
|
36
50
|
@dataclass
|
|
37
51
|
class AgentState:
|
|
@@ -62,6 +76,10 @@ class WorkflowState:
|
|
|
62
76
|
spend_usd: float = 0.0
|
|
63
77
|
tokens_total: int = 0
|
|
64
78
|
started: str = ""
|
|
79
|
+
# Monotonic timestamp of the last workflow_* event for this run. Used by
|
|
80
|
+
# the stale-sweep in to_snapshot() to transition runs that have received
|
|
81
|
+
# no activity for WORKFLOW_STALE_TTL_S to status "stale".
|
|
82
|
+
last_activity_mono: float = field(default_factory=monotonic)
|
|
65
83
|
|
|
66
84
|
|
|
67
85
|
@dataclass
|
|
@@ -77,6 +95,18 @@ class SessionState:
|
|
|
77
95
|
cache_read: int = 0
|
|
78
96
|
cache_write: int = 0
|
|
79
97
|
cost_estimate_usd: float = 0.0
|
|
98
|
+
# Most-recent turn's input footprint (SET, not accumulated). Equals
|
|
99
|
+
# cache_read + cache_write + tokens_in from the last token_usage event.
|
|
100
|
+
# This is what the TUI Usage gauge should divide by the context window to
|
|
101
|
+
# get a meaningful fill fraction; the cumulative tokens_in alone reads ~0%
|
|
102
|
+
# under prompt caching because cache_read tokens are not billed as input.
|
|
103
|
+
last_context_tokens: int = 0
|
|
104
|
+
# Monotonic timestamp of the last bus event applied to this session.
|
|
105
|
+
# Used by the session_stop guard to keep a busy session (active agents or
|
|
106
|
+
# recent activity) alive when session_discover declares it stale because
|
|
107
|
+
# its parent transcript has not been written to (subagent writes go to
|
|
108
|
+
# nested subagents/agent-*.jsonl, leaving the parent file quiet).
|
|
109
|
+
last_event_mono: float = 0.0
|
|
80
110
|
|
|
81
111
|
|
|
82
112
|
@dataclass
|
|
@@ -163,15 +193,42 @@ class HarnessState:
|
|
|
163
193
|
def apply(self, event: dict[str, Any]) -> None:
|
|
164
194
|
"""Apply a bus-format event to the state.
|
|
165
195
|
|
|
166
|
-
Authority gate
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
196
|
+
Authority gate — two tiers:
|
|
197
|
+
|
|
198
|
+
Tier 1 (session_discover path): session_discover emits session_start
|
|
199
|
+
when it observes an active JSONL file (transcript mtime < 60 s). That
|
|
200
|
+
adds the session to _confirmed_alive and flushes any buffered events.
|
|
201
|
+
session_stop is the stop authority; it removes from _confirmed_alive and
|
|
202
|
+
tombstones the session in _stopped_recently.
|
|
203
|
+
|
|
204
|
+
Tier 2 (live-event path): bus_tail seeks to EOF on startup — there is no
|
|
205
|
+
historical replay. Any event that arrives for a session_id that is not
|
|
206
|
+
tombstoned (_stopped_recently) is therefore a genuine live signal. On the
|
|
207
|
+
first such event for an unconfirmed session we confirm it immediately,
|
|
208
|
+
create the SessionState, and flush any buffered events, then fall through
|
|
209
|
+
to apply the triggering event. This fixes the agent-only session blindspot:
|
|
210
|
+
during an agent run the parent transcript is quiet (subagent writes go to
|
|
211
|
+
nested subagents/agent-*.jsonl), so session_discover never confirms the
|
|
212
|
+
session via mtime; but bus events (agent_spawn, tool_call, token_usage)
|
|
213
|
+
stream in live, and tier 2 picks them up on the first one.
|
|
214
|
+
|
|
215
|
+
Tombstone resurrection: because bus_tail seeks to EOF there is no
|
|
216
|
+
historical replay, so a fresh event for a tombstoned session is genuine
|
|
217
|
+
new activity — the session was spuriously stopped by session_discover
|
|
218
|
+
when its parent transcript went quiet during an agent run, not actually
|
|
219
|
+
dead. The tombstone branch un-tombstones, re-confirms, and falls through
|
|
220
|
+
so the triggering event is applied. A truly-ended session produces no new
|
|
221
|
+
events, so it stays tombstoned naturally. Lagging hook events (the
|
|
222
|
+
original phantom-resurrection concern) are distinguished by the busy-guard
|
|
223
|
+
on session_stop: a session that was genuinely busy was never tombstoned in
|
|
224
|
+
the first place. This branch therefore only fires for sessions that were
|
|
225
|
+
spuriously stopped while quiet-but-active (the quiet-then-active pattern).
|
|
226
|
+
|
|
227
|
+
Liveness is refreshed by bus activity (last_event_mono). A session_stop
|
|
228
|
+
from session_discover is ignored when the session is busy (agents_active
|
|
229
|
+
non-empty OR last_event_mono within SESSION_KEEPALIVE_S); session_discover
|
|
230
|
+
re-polls and re-emits session_stop, so idle sessions are still cleaned up
|
|
231
|
+
normally.
|
|
175
232
|
"""
|
|
176
233
|
try:
|
|
177
234
|
self._sweep_pending()
|
|
@@ -185,19 +242,39 @@ class HarnessState:
|
|
|
185
242
|
self._ensure_session(session_id, event.get("project") or "unknown")
|
|
186
243
|
self._flush_pending(session_id)
|
|
187
244
|
elif etype == "session_stop":
|
|
188
|
-
#
|
|
245
|
+
# Busy-guard handled below; fall through.
|
|
189
246
|
pass
|
|
190
247
|
elif session_id in self._stopped_recently:
|
|
191
|
-
#
|
|
192
|
-
#
|
|
193
|
-
|
|
248
|
+
# A live event for a tombstoned session. bus_tail seeks to
|
|
249
|
+
# EOF (no historical replay), so this is genuine new activity
|
|
250
|
+
# — the session was spuriously stopped by session_discover
|
|
251
|
+
# when its parent transcript went quiet during an agent run,
|
|
252
|
+
# not actually dead. Un-tombstone and re-confirm so its
|
|
253
|
+
# agents/tokens reappear. (A truly-ended session produces no
|
|
254
|
+
# new events, so it stays tombstoned.)
|
|
255
|
+
try:
|
|
256
|
+
self._stopped_recently.remove(session_id)
|
|
257
|
+
except ValueError:
|
|
258
|
+
pass
|
|
259
|
+
self._confirmed_alive.add(session_id)
|
|
260
|
+
self._ensure_session(session_id, event.get("project") or "unknown")
|
|
261
|
+
self._flush_pending(session_id)
|
|
262
|
+
# fall through (NO return) so this event is applied below
|
|
194
263
|
elif session_id not in self._confirmed_alive:
|
|
195
|
-
#
|
|
196
|
-
#
|
|
197
|
-
#
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
264
|
+
# bus_tail seeks to EOF (no historical replay), so a live
|
|
265
|
+
# event for a non-tombstoned, unconfirmed session is a
|
|
266
|
+
# genuine alive signal. Confirm on this first event rather
|
|
267
|
+
# than waiting for session_discover's transcript-mtime poll
|
|
268
|
+
# — which never fires for an agent-only session whose parent
|
|
269
|
+
# transcript stays quiet while subagents write to their own
|
|
270
|
+
# nested JSONL files. The _stopped_recently tombstone check
|
|
271
|
+
# above still blocks resurrection of recently-stopped sessions.
|
|
272
|
+
self._confirmed_alive.add(session_id)
|
|
273
|
+
self._ensure_session(session_id, event.get("project") or "unknown")
|
|
274
|
+
self._flush_pending(session_id)
|
|
275
|
+
# Fall through (NO return) so this event reaches the
|
|
276
|
+
# dispatch below and agents_active / tokens / etc. populate
|
|
277
|
+
# immediately.
|
|
201
278
|
|
|
202
279
|
if etype == "agent_spawn":
|
|
203
280
|
self._on_agent_spawn(session_id, event)
|
|
@@ -217,10 +294,29 @@ class HarnessState:
|
|
|
217
294
|
self._on_cognee_op(event)
|
|
218
295
|
elif etype in ("graphify_scan", "graphify_query"):
|
|
219
296
|
self._on_graphify(event)
|
|
297
|
+
elif etype in ("workflow_start", "workflow_update"):
|
|
298
|
+
self._on_workflow_event(session_id, event)
|
|
220
299
|
elif etype == "session_start":
|
|
221
300
|
pass # ensured above
|
|
222
301
|
elif etype == "session_stop":
|
|
223
302
|
if session_id:
|
|
303
|
+
sess = self.sessions.get(session_id)
|
|
304
|
+
busy = (
|
|
305
|
+
sess is not None
|
|
306
|
+
and (
|
|
307
|
+
bool(sess.agents_active)
|
|
308
|
+
or (monotonic() - sess.last_event_mono) < SESSION_KEEPALIVE_S
|
|
309
|
+
)
|
|
310
|
+
)
|
|
311
|
+
if busy:
|
|
312
|
+
# Session still has running agents or received a real
|
|
313
|
+
# bus event recently — transcript staleness from
|
|
314
|
+
# session_discover does not reflect true liveness here
|
|
315
|
+
# (subagent writes go to nested JSONL files). Ignore
|
|
316
|
+
# this stop; session_discover will re-emit on the next
|
|
317
|
+
# poll cycle and the guard re-evaluates then.
|
|
318
|
+
return
|
|
319
|
+
# Genuinely idle: no active agents and no recent bus events.
|
|
224
320
|
self.sessions.pop(session_id, None)
|
|
225
321
|
self._confirmed_alive.discard(session_id)
|
|
226
322
|
self._pending.pop(session_id, None)
|
|
@@ -230,6 +326,10 @@ class HarnessState:
|
|
|
230
326
|
|
|
231
327
|
if session_id and session_id in self.sessions:
|
|
232
328
|
self.sessions[session_id].recent_events.append(event)
|
|
329
|
+
# Stamp liveness so the session_stop busy-guard has a fresh
|
|
330
|
+
# monotonic reference. Any real bus event refreshes the window,
|
|
331
|
+
# keeping a session alive through a transcript-quiet subagent run.
|
|
332
|
+
self.sessions[session_id].last_event_mono = monotonic()
|
|
233
333
|
except Exception:
|
|
234
334
|
return # fail-open on any state error
|
|
235
335
|
|
|
@@ -299,11 +399,15 @@ class HarnessState:
|
|
|
299
399
|
if not sid:
|
|
300
400
|
return
|
|
301
401
|
sess = self.sessions[sid]
|
|
302
|
-
agent_name = event.get("agent") or (event.get("payload") or {}).get("subagent_type")
|
|
303
|
-
if not agent_name:
|
|
304
|
-
return
|
|
305
402
|
payload = event.get("payload") or {}
|
|
306
|
-
|
|
403
|
+
# Key by tool_use_id so two concurrent agents of the same subagent_type
|
|
404
|
+
# do not collide. Fall back to agent_name when tool_use_id is absent
|
|
405
|
+
# (e.g. replayed legacy events that predate the new schema).
|
|
406
|
+
key = payload.get("tool_use_id") or event.get("agent") or payload.get("subagent_type")
|
|
407
|
+
if not key:
|
|
408
|
+
return
|
|
409
|
+
agent_name = (event.get("agent") or payload.get("subagent_type") or key)
|
|
410
|
+
sess.agents_active[key] = AgentState(
|
|
307
411
|
name=agent_name,
|
|
308
412
|
started=event.get("ts") or _now(),
|
|
309
413
|
last_tool=event.get("tool"),
|
|
@@ -317,10 +421,13 @@ class HarnessState:
|
|
|
317
421
|
sess = self.sessions.get(sid)
|
|
318
422
|
if not sess:
|
|
319
423
|
return
|
|
320
|
-
|
|
321
|
-
|
|
424
|
+
payload = event.get("payload") or {}
|
|
425
|
+
# Mirror _on_agent_spawn: pop by tool_use_id first, fall back to
|
|
426
|
+
# agent field for legacy events.
|
|
427
|
+
key = payload.get("tool_use_id") or event.get("agent")
|
|
428
|
+
if not key:
|
|
322
429
|
return
|
|
323
|
-
sess.agents_active.pop(
|
|
430
|
+
sess.agents_active.pop(key, None)
|
|
324
431
|
|
|
325
432
|
def _on_tool_call(self, sid: Optional[str], event: dict[str, Any]) -> None:
|
|
326
433
|
if not sid:
|
|
@@ -341,6 +448,52 @@ class HarnessState:
|
|
|
341
448
|
sess.cache_read += int(p.get("cache_read") or 0)
|
|
342
449
|
sess.cache_write += int(p.get("cache_write") or 0)
|
|
343
450
|
sess.cost_estimate_usd += float(p.get("cost_estimate_usd") or 0.0)
|
|
451
|
+
# SET (do not accumulate): the per-turn context footprint that the
|
|
452
|
+
# Usage gauge should display. Under prompt caching the vast majority
|
|
453
|
+
# of the context lives in cache_read, not tokens_in, so cumulative
|
|
454
|
+
# tokens_in alone would read ~0% of the context window.
|
|
455
|
+
sess.last_context_tokens = (
|
|
456
|
+
int(p.get("cache_read") or 0)
|
|
457
|
+
+ int(p.get("cache_write") or 0)
|
|
458
|
+
+ int(p.get("tokens_in") or 0)
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
def _on_workflow_event(self, sid: Optional[str], event: dict[str, Any]) -> None:
|
|
462
|
+
"""Record a workflow_start or workflow_update into the owning session.
|
|
463
|
+
|
|
464
|
+
Stamps last_activity_mono on every call so the stale-sweep in
|
|
465
|
+
to_snapshot() has a fresh monotonic reference to work from.
|
|
466
|
+
workflow_complete is never emitted (the Workflow tool is fire-and-forget
|
|
467
|
+
from the hook's perspective); TTL-based staling in to_snapshot() is the
|
|
468
|
+
durable path to clearing "running" entries.
|
|
469
|
+
"""
|
|
470
|
+
if not sid:
|
|
471
|
+
return
|
|
472
|
+
sess = self.sessions.get(sid)
|
|
473
|
+
if not sess:
|
|
474
|
+
return
|
|
475
|
+
p = event.get("payload") or {}
|
|
476
|
+
run_id = p.get("run_id") or p.get("workflow_id")
|
|
477
|
+
if not run_id:
|
|
478
|
+
return
|
|
479
|
+
etype = event.get("type")
|
|
480
|
+
if etype == "workflow_start":
|
|
481
|
+
sess.workflows_active[run_id] = WorkflowState(
|
|
482
|
+
name=p.get("name") or p.get("scriptPath") or run_id,
|
|
483
|
+
run_id=run_id,
|
|
484
|
+
phase="running",
|
|
485
|
+
started=event.get("ts") or _now(),
|
|
486
|
+
last_activity_mono=monotonic(),
|
|
487
|
+
)
|
|
488
|
+
elif etype == "workflow_update":
|
|
489
|
+
wf = sess.workflows_active.get(run_id)
|
|
490
|
+
if wf:
|
|
491
|
+
wf.phase = p.get("phase") or wf.phase
|
|
492
|
+
wf.phases_total = int(p.get("phases_total") or wf.phases_total)
|
|
493
|
+
wf.phases_done = int(p.get("phases_done") or wf.phases_done)
|
|
494
|
+
wf.spend_usd = float(p.get("spend_usd") or wf.spend_usd)
|
|
495
|
+
wf.tokens_total = int(p.get("tokens_total") or wf.tokens_total)
|
|
496
|
+
wf.last_activity_mono = monotonic()
|
|
344
497
|
|
|
345
498
|
def _on_worktree(self, event: dict[str, Any]) -> None:
|
|
346
499
|
p = event.get("payload") or {}
|
|
@@ -390,24 +543,39 @@ class HarnessState:
|
|
|
390
543
|
def _on_graphify(self, event: dict[str, Any]) -> None:
|
|
391
544
|
"""Aggregate Graphify scan/query events into a single rollup.
|
|
392
545
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
546
|
+
Event source split (as of Fix ③):
|
|
547
|
+
- graphify_scan / graphify_query COUNTS come from the Bash hook
|
|
548
|
+
(post-tool-observe.sh), which detects real CLI invocations.
|
|
549
|
+
These events carry session_id so they also appear in session
|
|
550
|
+
recent_events correctly.
|
|
551
|
+
- graphify_tail is STATS-ONLY: it reads graph.json node/edge counts
|
|
552
|
+
and emits graphify_scan events that carry nodes/edges/communities
|
|
553
|
+
but whose 'stats_only' flag is set. Those update the size stats
|
|
554
|
+
WITHOUT incrementing the scan counter. This prevents double-counting
|
|
555
|
+
and eliminates the phantom-scan-on-restart from the old mtime=0 init.
|
|
556
|
+
|
|
557
|
+
The snapshot serializer flattens this to a dict for the TUI
|
|
558
|
+
Knowledge tab to read directly.
|
|
397
559
|
"""
|
|
398
560
|
p = event.get("payload") or {}
|
|
399
561
|
etype = event.get("type")
|
|
400
562
|
g = self.graphify
|
|
401
563
|
if etype == "graphify_scan":
|
|
564
|
+
# Always update node/edge/community stats when present —
|
|
565
|
+
# both hook events and graphify_tail stats events carry these.
|
|
402
566
|
if p.get("nodes") is not None:
|
|
403
567
|
g.nodes = int(p["nodes"])
|
|
404
568
|
if p.get("edges") is not None:
|
|
405
569
|
g.edges = int(p["edges"])
|
|
406
570
|
if p.get("communities") is not None:
|
|
407
571
|
g.communities = int(p["communities"])
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
572
|
+
# Only increment the scan counter for real invocations (hook events).
|
|
573
|
+
# graphify_tail stats events set stats_only=True to signal they are
|
|
574
|
+
# size refreshes, not new scan invocations.
|
|
575
|
+
if not p.get("stats_only"):
|
|
576
|
+
g.scans += 1
|
|
577
|
+
g.last_scan_project = p.get("project")
|
|
578
|
+
g.last_scan_at = p.get("scanned_at") or event.get("ts")
|
|
411
579
|
elif etype == "graphify_query":
|
|
412
580
|
g.queries += 1
|
|
413
581
|
g.last_query_project = p.get("project")
|
|
@@ -419,6 +587,25 @@ def _dc_to_dict(dc) -> dict[str, Any]:
|
|
|
419
587
|
return {k: v for k, v in dc.__dict__.items()}
|
|
420
588
|
|
|
421
589
|
|
|
590
|
+
def _workflow_dict(wf: WorkflowState) -> dict[str, Any]:
|
|
591
|
+
"""Serialise a WorkflowState, substituting "stale" for any non-terminal
|
|
592
|
+
status whose last_activity_mono is older than WORKFLOW_STALE_TTL_S.
|
|
593
|
+
|
|
594
|
+
The live WorkflowState object is never mutated here — the substitution is
|
|
595
|
+
snapshot-only so the in-memory record stays authoritative if a late event
|
|
596
|
+
arrives for the same run_id.
|
|
597
|
+
"""
|
|
598
|
+
d = _dc_to_dict(wf)
|
|
599
|
+
# last_activity_mono is an internal float; strip it from the wire shape.
|
|
600
|
+
d.pop("last_activity_mono", None)
|
|
601
|
+
terminal = {"stale", "completed", "failed", "cancelled"}
|
|
602
|
+
if d.get("phase") not in terminal:
|
|
603
|
+
age = monotonic() - wf.last_activity_mono
|
|
604
|
+
if age > WORKFLOW_STALE_TTL_S:
|
|
605
|
+
d["phase"] = "stale"
|
|
606
|
+
return d
|
|
607
|
+
|
|
608
|
+
|
|
422
609
|
def _session_dict(s: SessionState) -> dict[str, Any]:
|
|
423
610
|
# Truncate recent_events for the snapshot frame so the daemon doesn't
|
|
424
611
|
# send 200 events × N sessions on connect — that easily exceeds the
|
|
@@ -432,11 +619,12 @@ def _session_dict(s: SessionState) -> dict[str, Any]:
|
|
|
432
619
|
"project": s.project,
|
|
433
620
|
"started": s.started,
|
|
434
621
|
"agents_active": {k: _dc_to_dict(v) for k, v in s.agents_active.items()},
|
|
435
|
-
"workflows_active": {k:
|
|
622
|
+
"workflows_active": {k: _workflow_dict(v) for k, v in s.workflows_active.items()},
|
|
436
623
|
"recent_events": recent,
|
|
437
624
|
"tokens_in": s.tokens_in,
|
|
438
625
|
"tokens_out": s.tokens_out,
|
|
439
626
|
"cache_read": s.cache_read,
|
|
440
627
|
"cache_write": s.cache_write,
|
|
441
628
|
"cost_estimate_usd": s.cost_estimate_usd,
|
|
629
|
+
"last_context_tokens": s.last_context_tokens,
|
|
442
630
|
}
|