Setting the file. One moment.
Assess Health · Exploring Scouts · PostHog/skills · Skills Docs
ContentsBack to the top of the page — line 141
This file
Number 66.3
Position 3 of 5
Type Python
Size 17 KB
Lines 350 scripts/ assess_health.py
Python · 350 lines · 17 KB
15 concatenate the JSON arrays into one file.
16 --config scout-config-list --json (optional) supplies each scout's expected
17 `run_interval_minutes` so cadence adherence can be scored.
18 --scratchpad scout-scratchpad-search --json (optional) memory-growth signal;
19 entries are attributed to a scout via `created_by_run_id`. Without it, the
20 memory column shows `n/a` and no memory flags are raised.
21 --now ISO-8601 current time (optional) — enables "time since last run" staleness.
22 --skill restrict the report to one scout (e.g. signals-scout-general).
23
24 Output is plain text (terminal-friendly). Pipe to a .txt file with --out.
25
26 Usage:
27 python assess_health.py --runs runs.json --config cfg.json [--scratchpad mem.json] \
28 [--now 2026-06-08T09:00:00Z] [--skill signals-scout-general] [--out health.txt]
29
30 Stdlib only. Python 3.11+."""
31
32 from __future__ import annotations
33
34 import re
35 import sys
36 import json
37 import argparse
38 import statistics
39 from datetime import datetime
40 from typing import Any
41
42 # The per-run budget is 15 minutes (scout_harness/limits.py); a failed run past ~14 minutes ran to the wall.
43 TIMEOUT_MINUTES = 14.0
44 # a gap larger than this multiple of the expected interval counts as a stall
45 STALL_FACTOR = 2.0
46
47 # the obligatory hedgehog
48 HEDGEHOG = r """
49 /////////,
50 ///////////// . PostHog · Signals
51 /////////////// ` . health & performance
52 //////////////// o ` .
53 ````````````````` `- . >
54 ' ' ' ' '
55 """
56
57
58 def load (path: str ) -> Any:
59 with open (path, encoding = "utf-8" ) as fh:
60 return json.load(fh)
61
62
63 def rows (payload: Any) -> list[ dict ]:
64 if isinstance (payload, dict ):
65 inner = payload.get( "results" )
66 return inner if isinstance (inner, list ) else []
67 return payload if isinstance (payload, list ) else []
68
69
70 def parse_ts (ts: str | None ) -> datetime | None :
71 if not ts:
72 return None
73 try :
74 return datetime.fromisoformat(ts.replace( "Z" , "+00:00" ))
75 except ValueError :
76 return None
77
78
79 def minutes_between (a: str | None , b: str | None ) -> float | None :
80 x, y = parse_ts(a), parse_ts(b)
81 if not x or not y:
82 return None
83 return (y - x).total_seconds() / 60.0
84
85
86 def run_wrote (run: dict ) -> bool :
87 """Whether the run produced output, read off the run row's structured fields.
88
89 `emitted_report_ids` / `edited_report_ids` are the report output; `emitted_count`
90 only tallies legacy signal-channel findings (always 0 on current scouts).
91 """
92 return bool (run.get( "emitted_report_ids" ) or run.get( "edited_report_ids" ) or run.get( "emitted_count" ))
93
94
95 def pct (num: int , den: int ) -> str :
96 return f " { round ( 100 * num / den) } %" if den else "-"
97
98
99 def fmt_age (minutes: float | None ) -> str :
100 if minutes is None :
101 return "-"
102 if minutes < 90 :
103 return f " { int (minutes) } m"
104 if minutes < 60 * 36 :
105 return f " { round (minutes / 60 ) } h"
106 return f " { round (minutes / 1440 ) } d"
107
108
109 def table (headers: list[ str ], body: list[list[ str ]]) -> list[ str ]:
110 """Left-aligned fixed-width text table with a dashed header rule."""
111 widths = [ len (h) for h in headers]
112 for r in body:
113 for i, cell in enumerate (r):
114 widths[i] = max (widths[i], len (cell))
115
116 def fmt (r: list[ str ]) -> str :
117 return " " .join(cell.ljust(widths[i]) for i, cell in enumerate (r)).rstrip()
118
119 out = [fmt(headers), " " .join( "-" * w for w in widths)]
120 out += [fmt(r) for r in body]
121 return out
122
123
124 _CANCELLED_ERROR = re.compile( r " ^( asyncio \. ) ? cancell ? ed ( error ) ? \b " )
125
126
127 def _is_cancelled_as_failed (run: dict ) -> bool :
128 # A cancellation caught while the run was still starting is stored as `failed` with the
129 # cancellation's own text as its error; one with an empty error is indistinguishable and stays in.
130 # Match only an error that IS a cancellation, not one that merely mentions cancelling a statement.
131 text = (run.get( "failure_reason" ) or run.get( "error" ) or "" ).strip().lower()
132 return bool ( _CANCELLED_ERROR .match(text)) and "timeout" not in text and "timed out" not in text
133
134
135 def _is_timeout_reason (reason: str | None ) -> bool :
136 # The harness says "timed out after 900s"; other writers say "timeout".
137 text = (reason or "" ).lower()
138 return "timed out" in text or "timeout" in text
139
140
141 def assess_scout (name: str , runs: list[ dict ], interval: float | None , mem_count: int | None ,
142 now: datetime | None , config_last_run: str | None ) -> dict :
143 runs = sorted (runs, key =lambda r: r.get( "started_at" ) or "" )
144 n = len (runs)
145 # A cancelled (worker shutdown, deploy) or in-flight row has no scout outcome to score.
146 settled = [
147 r
148 for r in runs
149 if r.get( "status" ) == "completed" or (r.get( "status" ) == "failed" and not _is_cancelled_as_failed(r))
150 ]
151 completed = sum ( 1 for r in settled if r.get( "status" ) == "completed" )
152 failed = sum ( 1 for r in settled if r.get( "status" ) == "failed" )
153 durations = [
154 m for r in settled if (m := minutes_between(r.get( "started_at" ), r.get( "completed_at" ))) is not None
155 ]
156 median_dur = round (statistics.median(durations), 1 ) if durations else None
157 # A named credential or tool failure is not a timeout however long it ran.
158 timeouts = sum (
159 1
160 for r in settled
161 if r.get( "status" ) == "failed"
162 and (m := minutes_between(r.get( "started_at" ), r.get( "completed_at" ))) is not None
163 and m >= TIMEOUT_MINUTES
164 and ( not r.get( "failure_reason" ) or _is_timeout_reason(r.get( "failure_reason" )))
165 )
166
167 # cadence: consecutive gaps between run starts
168 starts = [s for r in runs if (s := parse_ts(r.get( "started_at" )))]
169 gaps = [(starts[i] - starts[i - 1 ]).total_seconds() / 60.0 for i in range ( 1 , len (starts))]
170 median_gap = round (statistics.median(gaps), 1 ) if gaps else None
171 stalls = sum ( 1 for g in gaps if interval and g > STALL_FACTOR * interval)
172
173 span_min = (starts[ - 1 ] - starts[ 0 ]).total_seconds() / 60.0 if len (starts) >= 2 else 0.0
174 expected = ( int (span_min / interval) + 1 ) if interval and span_min > 0 else None
175 adherence = pct(n, expected) if expected else "-"
176
177 # Report ids land on the row before the run settles, so only settled writers count.
178 wrote = sum ( 1 for r in settled if run_wrote(r))
179 # Two different stalenesses — keep them apart. `last_run_at` is the coordinator's DISPATCH
180 # stamp (advanced the moment a child is enqueued, before any worker runs it); the newest
181 # observed run row's `started_at` is when a run actually EXECUTED. A fresh `last_run_at`
182 # with a much older newest run = "dispatching but not running" (workers backed up / down,
183 # or runs stranded), which a single staleness number that trusts `last_run_at` would hide.
184 dispatch_at = parse_ts(config_last_run)
185 last_start = starts[ - 1 ] if starts else None
186 dispatch_stale_min = (now - dispatch_at).total_seconds() / 60.0 if now and dispatch_at else None
187 run_stale_min = (now - last_start).total_seconds() / 60.0 if now and last_start else None
188 # How far the dispatch stamp has marched ahead of the newest run that materialized. Robust to
189 # the 100-row cap: `last_run_at` is authoritative, and runs-list is newest-first so a scout's
190 # true newest run is in the window whenever it ran recently.
191 dispatch_run_gap_min = (
192 (dispatch_at - last_start).total_seconds() / 60.0 if dispatch_at and last_start else None
193 )
194 # Back-compat single value (dispatch-preferred, as before) for any caller reading stale_min.
195 stale_min = dispatch_stale_min if dispatch_stale_min is not None else run_stale_min
196
197 return {
198 "name" : name, "runs" : n, "completed" : completed, "failed" : failed, "timeouts" : timeouts,
199 "settled" : len (settled), "success_pct" : pct(completed, len (settled)), "median_dur" : median_dur,
200 "median_gap" : median_gap, "interval" : interval, "adherence" : adherence, "stalls" : stalls,
201 "wrote" : wrote, "wrote_pct" : pct(wrote, len (settled)), "mem_count" : mem_count,
202 "stale_min" : stale_min, "dispatch_stale_min" : dispatch_stale_min,
203 "run_stale_min" : run_stale_min, "dispatch_run_gap_min" : dispatch_run_gap_min,
204 }
205
206
207 def render (scouts: list[ dict ], window_note: str , has_mem: bool , * , art: bool = True ) -> str :
208 banner: list[ str ] = []
209 if art:
210 banner = [ HEDGEHOG .strip( " \n " ), "" ]
211
212 if not scouts:
213 return " \n " .join([ * banner, "SCOUT HEALTH" , "" ,
214 "No runs in the supplied window — nothing to assess." ])
215
216 L: list[ str ] = [ * banner, "=" * 78 , " SIGNALS SCOUT HEALTH & PERFORMANCE" , "=" * 78 , " " + window_note, "" ]
217
218 body: list[list[ str ]] = []
219 for s in sorted (scouts, key =lambda x: x[ "name" ]):
220 gap = f " { s[ 'median_gap' ] } m" if s[ "median_gap" ] is not None else "-"
221 interval = "cron" if s.get( "cron" ) else ( f " { int (s[ 'interval' ]) } m" if s[ "interval" ] else "?" )
222 dur = f " { s[ 'median_dur' ] } m" if s[ "median_dur" ] is not None else "-"
223 runs_cell = f " { s[ 'runs' ] } " + ( f " ( { s[ 'failed' ] } F)" if s[ "failed" ] else "" )
224 mem = "n/a" if s[ "mem_count" ] is None else ( str (s[ "mem_count" ]) if s[ "mem_count" ] else "0" )
225 body.append([s[ "name" ], runs_cell, s[ "success_pct" ], s[ "wrote_pct" ],
226 f " { gap } / { interval } " , s[ "adherence" ], dur, mem])
227
228 L += table([ "scout" , "runs" , "ok" , "wrote" , "gap/ival" , "adher" , "med" , "mem" ], body)
229 L += [ "" ]
230
231 flags: list[ str ] = []
232 for s in scouts:
233 if s[ "runs" ] and not s[ "settled" ]:
234 flags.append( f " * { s[ 'name' ] } : { s[ 'runs' ] } run(s) in the window but none settled (queued, in flight, or cancelled): NOT assessed, wait for an outcome." )
235 if s[ "failed" ] and s[ "completed" ] == 0 :
236 flags.append( f " * { s[ 'name' ] } : EVERY settled run failed ( { s[ 'failed' ] } / { s[ 'settled' ] } ): broken, not quiet." )
237 elif s[ "timeouts" ]:
238 flags.append( f " * { s[ 'name' ] } : { s[ 'timeouts' ] } timeout-shaped failure(s) (>= { int ( TIMEOUT_MINUTES ) } m) — likely over-investigation; read the session log." )
239 if s[ "stalls" ]:
240 flags.append( f " * { s[ 'name' ] } : { s[ 'stalls' ] } cadence stall(s) (gap > { int ( STALL_FACTOR ) } x interval) — coordinator skipped it (paused / drained / capped)." )
241 if has_mem and s[ "settled" ] >= 5 and s[ "mem_count" ] == 0 :
242 flags.append( f " * { s[ 'name' ] } : { s[ 'settled' ] } settled runs but an EMPTY scratchpad: not learning." )
243 # Dispatching but not running: the coordinator's last_run_at has marched a full interval+
244 # past the newest run that actually materialized — children are queuing without executing
245 # (workers backed up / down, or runs stranded). Distinct from a cadence stall (gap between
246 # observed runs) because the runs simply aren't there to leave a gap.
247 if s[ "dispatch_run_gap_min" ] is not None and s[ "interval" ] and s[ "dispatch_run_gap_min" ] > s[ "interval" ]:
248 disp = fmt_age(s[ "dispatch_stale_min" ]) if s[ "dispatch_stale_min" ] is not None else "recently"
249 flags.append( f " * { s[ 'name' ] } : dispatched { disp } ago but newest run row { fmt_age(s[ 'run_stale_min' ]) } ago — DISPATCHING BUT NOT RUNNING (workers backed up / down, or runs stranded); last_run_at hides this." )
250 elif s[ "stale_min" ] is not None and s[ "interval" ] and s[ "stale_min" ] > STALL_FACTOR * s[ "interval" ]:
251 flags.append( f " * { s[ 'name' ] } : last run { fmt_age(s[ 'stale_min' ]) } ago vs a { int (s[ 'interval' ]) } m cadence — may be drained from the flag." )
252
253 for s in scouts:
254 if s.get( "cron" ):
255 flags.append(
256 f " * { s[ 'name' ] } : runs on a cron schedule, so cadence, stalls and staleness are NOT assessed here;"
257 " compare last_run_at and the newest run against its slots by hand."
258 )
259 L += [ "-" * 78 , " worth a look" , "-" * 78 ]
260 L += sorted ( set (flags)) if flags else [ " (none — cadence, success, and memory all look nominal)" ]
261
262 L += [ "" , "-" * 78 , " column key" , "-" * 78 ,
263 " runs runs in the window; (NF) = N of them failed" ,
264 " ok success rate: % o f settled runs (completed or failed; cancelled and" ,
265 " in-flight rows are excluded, as is a failed row whose error names a" ,
266 " cancellation) that reached a clean 'completed' status" ,
267 " wrote report rate: % o f settled runs that wrote or edited an inbox report (from" ,
268 " emitted_report_ids / edited_report_ids on the run row; legacy" ,
269 " signal-channel emits count too). Most healthy scouts write rarely —" ,
270 " judge signal-to-noise against the report statuses in inbox-reports-list." ,
271 " gap/ival median gap between consecutive run starts / the configured" ,
272 " run_interval_minutes. gap well above ival = the scout is being skipped." ,
273 " 'cron' = the scout runs on run_cron_schedule; its gaps are irregular by" ,
274 " design, so adherence, stall and staleness flags are skipped for it." ,
275 " adher cadence adherence — runs observed / runs expected across the window" ,
276 " span at that interval. 100% = fired on (nearly) every scheduled tick." ,
277 " med median run duration (start -> finish). healthy runs finish in a couple" ,
278 " of minutes; a ~15m median is timeout-shaped over-investigation." ,
279 " mem durable scratchpad entries attributed to this scout (via the run that" ,
280 " wrote them) in --scratchpad. 'n/a' = no --scratchpad passed; '0' = passed" ,
281 " but none matched (often the writing run falls outside the runs window)." ]
282 return " \n " .join(L)
283
284
285 def main () -> int :
286 ap = argparse.ArgumentParser( description = __doc__ , formatter_class = argparse.RawDescriptionHelpFormatter)
287 ap.add_argument( "--runs" , required = True , help = "scout-runs-list --json over a window" )
288 ap.add_argument( "--config" , help = "scout-config-list --json (for expected interval)" )
289 ap.add_argument( "--scratchpad" , help = "scout-scratchpad-search --json (for memory growth)" )
290 ap.add_argument( "--now" , help = "ISO-8601 current time, for staleness" )
291 ap.add_argument( "--skill" , help = "restrict to one scout skill_name" )
292 ap.add_argument( "--no-art" , dest = "art" , action = "store_false" , help = "skip the hedgehog banner" )
293 ap.add_argument( "--out" , help = "write here instead of stdout (use a .txt path)" )
294 args = ap.parse_args()
295
296 run_rows = rows(load(args.runs))
297 if args.skill:
298 run_rows = [r for r in run_rows if r.get( "skill_name" ) == args.skill]
299
300 cfg_rows = rows(load(args.config)) if args.config else []
301 # A cron scout's gaps are irregular by design, so interval-based scoring would misflag it.
302 intervals = {
303 r.get( "skill_name" ): ( None if r.get( "run_cron_schedule" ) else r.get( "run_interval_minutes" ))
304 for r in cfg_rows
305 }
306 cron_skills = {r.get( "skill_name" ) for r in cfg_rows if r.get( "run_cron_schedule" )}
307 last_run_by_skill = {r.get( "skill_name" ): r.get( "last_run_at" ) for r in cfg_rows}
308 now = parse_ts(args.now) if args.now else None
309
310 # attribute scratchpad entries to a scout via the run that wrote them
311 has_mem = bool (args.scratchpad)
312 mem_by_skill: dict[ str , int ] = {}
313 if has_mem:
314 run_to_skill = {r.get( "run_id" ): r.get( "skill_name" ) for r in run_rows}
315 for entry in rows(load(args.scratchpad)):
316 skill = run_to_skill.get(entry.get( "created_by_run_id" ))
317 if skill:
318 mem_by_skill[skill] = mem_by_skill.get(skill, 0 ) + 1
319
320 by_skill: dict[ str , list[ dict ]] = {}
321 for r in run_rows:
322 by_skill.setdefault(r.get( "skill_name" , "?" ), []).append(r)
323
324 assessed = [
325 assess_scout(name, runs, intervals.get(name), (mem_by_skill.get(name, 0 ) if has_mem else None ),
326 now, last_run_by_skill.get(name))
327 for name, runs in by_skill.items()
328 ]
329 for s in assessed:
330 s[ "cron" ] = s[ "name" ] in cron_skills
331
332 starts = [s for r in run_rows if (s := parse_ts(r.get( "started_at" )))]
333 if starts:
334 lo, hi = min (starts), max (starts)
335 window_note = f "window: { lo : % Y- % m- % d % H :% M} -> { hi : % Y- % m- % d % H :% M} UTC · { len (run_rows) } runs across { len (assessed) } scout(s)."
336 else :
337 window_note = f " { len (run_rows) } runs across { len (assessed) } scout(s)."
338
339 report = render(assessed, window_note, has_mem, art = args.art)
340 if args.out:
341 with open (args.out, "w" , encoding = "utf-8" ) as fh:
342 fh.write(report + " \n " )
343 print ( f "wrote { args.out } " , file = sys.stderr)
344 else :
345 print (report)
346 return 0
347
348
349 if __name__ == "__main__" :
350 sys.exit(main())