oc_chat
Loading...
Searching...
No Matches
session_postmortem.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/scripts/session_postmortem.py
4
5"""
6The session miner — what actually happened in a plan's dogfood sessions.
7
8Purpose
9-------
10Worker sessions produce friction evidence — errors, denials, retry loops,
11truncations, workarounds — that evaporates when the session ends. This module
12mines it mechanically, per the ratified design
13``docs/architecture/AGENT_DISPATCH_IDENTITY_DESIGN_2026-08-26.md`` §4.7: it
14reads the plan's oc_chat dogfood sessions (session-store content — turns, tool
15calls and their outcomes — plus the event streams) and emits a per-plan
16findings pair at ``docs/reports/postmortems/<NNN>-postmortem.md`` and
17``<NNN>-postmortem.json``.
18
19The miner never concludes and never files anything. Findings become backlog
20items only through operator decision at the gate (§4.7, design decision D4);
21nothing here edits roadmap source, a plan, or any document beyond its own two
22output files.
23
24Responsibilities
25----------------
26- :func:`discover_sessions` — resolve which sessions belong to the plan. An
27 explicit ``--session`` list takes precedence; otherwise the store is searched
28 for sessions whose directory name, ``session_name`` or ``active_plan_id``
29 references the plan id. What was searched and what matched is recorded in the
30 output, so a miss is legible rather than silent.
31- :func:`mine_session` — extract findings in the eight pinned categories
32 (:data:`CATEGORIES`) from the chat log and the event stream.
33- :func:`build_report` / :func:`render_json` / :func:`render_markdown` — the
34 two emitters. They carry identical findings and their totals agree by
35 construction: the markdown counts are read off the same ``by_category`` map
36 the JSON publishes.
37- :func:`main` — the ``python -m oc_chat.scripts.session_postmortem <NNN>`` CLI.
38
39Read-only contract (the plan-212 miner's rule)
40----------------------------------------------
41Every input is opened for reading only. The module deliberately does NOT use
42:mod:`oc_chat.core.persistence`'s directory helpers (``_sessions_root``,
43``_session_dir``, ``_archive_dir``) because those call ``_ensure_dir`` and so
44bring a directory into being as a side effect of asking about it; the local
45:func:`_latest_kind` mirrors ``_glob_latest`` without that. The ONLY writes are
46the two postmortem files, each written via a temp file plus ``os.replace`` so a
47crash mid-emit cannot leave a torn report — and cannot touch an input either
48way.
49
50Diagnostics
51-----------
52Domain: OC_CHAT.REPLAY
53 Shared with the offline replay reader deliberately: this is the same
54 read-only-analysis-over-the-event-substrate surface, and the domain table in
55 ``.option_c/debug_config.json`` gates on exact literals — an unlisted domain
56 is silently threshold 0, and that file is outside this plan's scope.
57Levels:
58 L2 — CLI invocation, discovery outcome, emit.
59 L3 — per-session substrate detail.
60 L4 — module-load coverage marker.
61
62Contracts
63---------
64- Exit 0 on success including the empty case (no sessions found still emits an
65 honest postmortem naming what was searched); 2 on user error; 1 unexpected.
66- Every category in :data:`CATEGORIES` appears in both outputs, zero included —
67 an empty section is information, not omission.
68- Output carries no wall-clock stamp, so re-running over unchanged inputs
69 produces byte-identical files (a mined report is a function of its inputs).
70- Findings are lenses, not a partition: one message can raise findings in more
71 than one category, but each (locus, signal) raises at most one finding, under
72 the precedence documented on each detector.
73
74Dependencies
75------------
76- oc_diagnostics (structured debug logging via _dbg)
77- oc_chat.core.event_bootstrap (_slug / _contained_alias_dir — reused rather
78 than copied because alias validation and root containment are security-
79 relevant path handling that must not drift)
80- oc_chat.core.replay (read_events_from_dir — the tolerant NDJSON slot reader)
81- oc_chat.core.persistence (DEFAULT_SESSIONS_DIR and the reserved dir names)
82"""
83from __future__ import annotations
84
85import argparse
86import hashlib
87import json
88import os
89import re
90import sys
91from dataclasses import dataclass, field
92from datetime import datetime
93from pathlib import Path
94from typing import Any, Optional, Sequence
95
96from oc_diagnostics import _dbg
97
98from oc_chat.core.event_bootstrap import _contained_alias_dir, _slug
99from oc_chat.core.persistence import (
100 ARCHIVE_DIR_NAME,
101 DEFAULT_SESSIONS_DIR,
102 LEGACY_DIR_NAME,
103)
104from oc_chat.core.replay import read_events_from_dir
105
106_dbg("OC_CHAT.REPLAY", "session_postmortem", "module", 4)
107
108#: The output contract version carried in the JSON sidecar.
109SCHEMA_VERSION = "postmortem/v1"
110
111#: The pinned category list, exactly design §4.7's, in the order both emitters
112#: render. Order is part of the contract: a reader comparing two postmortems
113#: should not have to re-sort them.
114CATEGORIES: tuple[str, ...] = (
115 "errors",
116 "warnings",
117 "tool_call_failures_and_denials",
118 "truncations",
119 "retry_loops",
120 "missing_tool_workarounds",
121 "cwd_anchor_drift",
122 "blocked_time_gaps",
123)
124
125#: Human headings for the markdown sections.
126CATEGORY_TITLES: dict[str, str] = {
127 "errors": "Errors",
128 "warnings": "Warnings",
129 "tool_call_failures_and_denials": "Tool-call failures and denials",
130 "truncations": "Truncations",
131 "retry_loops": "Retry loops",
132 "missing_tool_workarounds": "Missing-tool workarounds",
133 "cwd_anchor_drift": "cwd / anchor drift",
134 "blocked_time_gaps": "Blocked-time gaps",
135}
136
137#: A plan id that is safe as a filename component by construction. The id
138#: reaches the output path, so validating it here is the path-traversal guard,
139#: not merely input hygiene. Lettered plan numbers (``148g``) are real history.
140PLAN_ID_RE = re.compile(r"^[0-9]{1,4}[a-z]?$")
141
142#: One-line excerpts are bounded so a finding stays readable and so a runaway
143#: payload cannot turn the report into a transcript dump.
144EXCERPT_MAX_CHARS = 200
145
146#: Default wall-clock silence, in seconds, that counts as blocked time. Ten
147#: minutes between two consecutive events in one session is not thinking.
148DEFAULT_GAP_SECONDS = 600.0
149
150#: Two consecutive identical calls already ARE a retry; the run length is the
151#: number of calls, so a finding names a run of >= 2.
152_RETRY_RUN_MIN = 2
153
154#: Completion outcomes that mean the tool call worked.
155_OK_OUTCOMES = frozenset({"ok", "success"})
156
157#: Denial spellings across the permission surfaces.
158_DENY_DECISIONS = frozenset({"deny", "denied", "reject", "rejected"})
159
160#: Substrings that mark a truncation, drawn from the strings the runtime
161#: actually emits (``core/api_context.py`` footers, ``core/react_loop.py``
162#: notices) rather than from a guess at what truncation looks like.
163_TRUNCATION_MARKERS: tuple[str, ...] = (
164 "[output truncated at",
165 "[assistant reply truncated for API context",
166 "[Answer truncated",
167 "[Tool call(s) truncated",
168 "[File truncated at limit]",
169 "[File was truncated at read limit]",
170 "[truncated for summariser]",
171)
172
173#: Substrings that mark the anchor class of failure — a command run against the
174#: wrong directory, a path that is not where the caller assumed, a git command
175#: outside a repository.
176_DRIFT_MARKERS: tuple[str, ...] = (
177 "No such file or directory",
178 "The system cannot find the file specified",
179 "The system cannot find the path specified",
180 "cannot find the path",
181 "fatal: not a git repository",
182 "is not recognized as an internal or external command",
183 "No such directory",
184 "cwd does not exist",
185 "outside the sandbox root",
186)
187
188#: Substrings that mark the model reaching for a tool that is not there.
189_MISSING_TOOL_MARKERS: tuple[str, ...] = (
190 "Unknown tool",
191 "unknown tool",
192 "is not a known tool",
193 "No such tool",
194 "tool not found",
195)
196
197#: Shell programs whose use through ``run_command`` duplicates a first-class
198#: tool. Reaching for these is the classic missing-tool workaround: the model
199#: shells out because it did not find (or could not use) the native tool.
200_SHELL_WORKAROUND_COMMANDS: dict[str, str] = {
201 "cat": "read_file",
202 "type": "read_file",
203 "head": "read_file",
204 "tail": "read_file",
205 "more": "read_file",
206 "grep": "search_files",
207 "rg": "search_files",
208 "findstr": "search_files",
209 "ls": "list_dir",
210 "dir": "list_dir",
211 "find": "search_files",
212 "tree": "list_dir",
213}
214
215
216class _UserError(Exception):
217 """Raised for user-facing errors that map to exit code 2."""
218
219
220@dataclass(frozen=True)
222 """One mined observation: its category, where it came from, what it said."""
223
224 category: str
225 session: str
226 locus: str
227 excerpt: str
228
229 def to_json(self) -> dict[str, str]:
230 """Return the JSON projection of this finding."""
231 return {
232 "category": self.category,
233 "session": self.session,
234 "locus": self.locus,
235 "excerpt": self.excerpt,
236 }
237
238
239@dataclass
241 """One discovered session and the substrates found for it."""
242
243 name: str
244 directory: Path
245 match_reason: str
246 session_id: str = ""
247 meta_path: Optional[Path] = None
248 chat_log_path: Optional[Path] = None
249 event_dirs: tuple[Path, ...] = ()
250
251 def input_paths(self) -> list[Path]:
252 """Return every file this record will be read from, in a stable order."""
253 paths: list[Path] = []
254 if self.meta_path is not None:
255 paths.append(self.meta_path)
256 if self.chat_log_path is not None:
257 paths.append(self.chat_log_path)
258 for event_dir in self.event_dirs:
259 try:
260 paths.extend(sorted(p for p in event_dir.iterdir() if p.is_file()))
261 except OSError:
262 continue # a dir that vanished between discovery and read is not an input
263 return paths
264
265
266@dataclass
268 """What the miner searched, what it examined, and what it matched."""
269
270 mode: str
271 sessions_dir: str
272 event_root: str
273 examined: list[str] = field(default_factory=list)
274 records: list[SessionRecord] = field(default_factory=list)
275
276
277# ─── Small read-only helpers ─────────────────────────────────────────────────
278
279def _excerpt(value: Any) -> str:
280 """Collapse *value* to one bounded line, verbatim apart from whitespace."""
281 text = value if isinstance(value, str) else json.dumps(value, default=str, sort_keys=True)
282 flat = " ".join(text.split())
283 if len(flat) > EXCERPT_MAX_CHARS:
284 return flat[: EXCERPT_MAX_CHARS - 1] + "…"
285 return flat
286
287
288def _read_json(path: Path) -> Optional[Any]:
289 """Read *path* as JSON, tolerating a BOM. Returns ``None`` when unreadable."""
290 func = "_read_json"
291 try:
292 raw = path.read_bytes()
293 except OSError as exc:
294 _dbg("OC_CHAT.REPLAY", func, f"EXTERNAL_ERROR: unreadable input {path}: {exc}", 2)
295 return None
296 if raw.startswith(b"\xef\xbb\xbf"):
297 raw = raw[3:]
298 try:
299 return json.loads(raw.decode("utf-8", errors="replace"))
300 except json.JSONDecodeError as exc:
301 _dbg("OC_CHAT.REPLAY", func, f"VALIDATION_ERROR: malformed JSON in {path}: {exc}", 2)
302 return None
303
304
305def _sha256(path: Path) -> str:
306 """Return the hex SHA-256 of *path*, or ``"unreadable"`` when it cannot be read."""
307 func = "_sha256"
308 digest = hashlib.sha256()
309 try:
310 with open(path, "rb") as handle:
311 for chunk in iter(lambda: handle.read(65536), b""):
312 digest.update(chunk)
313 except OSError as exc:
314 _dbg("OC_CHAT.REPLAY", func, f"EXTERNAL_ERROR: cannot hash {path}: {exc}", 2)
315 return "unreadable"
316 return digest.hexdigest()
317
318
319def _latest_kind(session_dir: Path, kind: str) -> Optional[Path]:
320 """Return the current ``*_{kind}.json`` in *session_dir*, or ``None``.
321
322 Mirrors ``persistence._glob_latest`` (most-recently-modified wins) WITHOUT
323 its raise-on-miss and, crucially, without the ``_ensure_dir`` side effect
324 the surrounding persistence path helpers carry.
325 """
326 try:
327 matches = sorted(session_dir.glob(f"*_{kind}.json"), key=lambda p: p.stat().st_mtime)
328 except OSError:
329 return None
330 return matches[-1] if matches else None
331
332
333def _canonical_args(value: Any) -> str:
334 """Return a stable string form of tool arguments for identity comparison."""
335 if isinstance(value, str):
336 try:
337 value = json.loads(value)
338 except json.JSONDecodeError:
339 return " ".join(value.split())
340 try:
341 return json.dumps(value, sort_keys=True, default=str)
342 except (TypeError, ValueError):
343 return repr(value)
344
345
346def _parse_ts(value: Any) -> Optional[datetime]:
347 """Parse an RFC3339 timestamp; return ``None`` for anything unparseable."""
348 if not isinstance(value, str) or not value:
349 return None
350 text = value[:-1] + "+00:00" if value.endswith("Z") else value
351 try:
352 return datetime.fromisoformat(text)
353 except ValueError:
354 return None
355
356
357def _first_marker(text: str, markers: Sequence[str]) -> Optional[str]:
358 """Return the first marker in *markers* present in *text*, else ``None``."""
359 for marker in markers:
360 if marker in text:
361 return marker
362 return None
363
364
365# ─── Discovery ───────────────────────────────────────────────────────────────
366
367def _plan_tokens(plan_id: str) -> tuple[str, ...]:
368 """Return the spellings of *plan_id* a session name or metadata may carry."""
369 return (plan_id, f"plan-{plan_id}", f"plan_{plan_id}", f"plan {plan_id}")
370
371
372def _name_references_plan(name: str, plan_id: str) -> bool:
373 """True when *name* references *plan_id* as a token, not as a substring.
374
375 ``2240`` and ``1224`` must not answer for plan ``224``, so the id has to sit
376 on a token boundary — the separators a session name actually uses are
377 hyphen, underscore, space and dot.
378 """
379 pattern = re.compile(rf"(?:^|[^0-9a-z]){re.escape(plan_id)}(?:$|[^0-9a-z])", re.IGNORECASE)
380 return bool(pattern.search(name))
381
382
383def _match_reason(plan_id: str, dir_name: str, meta: Optional[dict]) -> Optional[str]:
384 """Return why this session matches *plan_id*, or ``None`` when it does not."""
385 session_name = ""
386 active_plan = ""
387 if isinstance(meta, dict):
388 session_name = str(meta.get("session_name") or "")
389 active_plan = str(meta.get("active_plan_id") or "")
390 if active_plan and (active_plan == plan_id or _name_references_plan(active_plan, plan_id)):
391 return f"metadata active_plan_id={active_plan!r}"
392 if _name_references_plan(dir_name, plan_id):
393 return f"directory name {dir_name!r} references the plan id"
394 if session_name and _name_references_plan(session_name, plan_id):
395 return f"metadata session_name={session_name!r}"
396 for token in _plan_tokens(plan_id)[1:]:
397 if token.lower() in dir_name.lower():
398 return f"directory name {dir_name!r} contains {token!r}"
399 return None
400
401
402def _event_dirs_for(name: str, meta: Optional[dict], event_root: Path) -> tuple[Path, ...]:
403 """Return the existing event directories for a session, in merge order.
404
405 Mirrors ``event_bootstrap.event_read_dirs`` from the persisted metadata
406 rather than from a live ``ChatSession``, because constructing one to ask a
407 question about a stored session would be a write-capable object in a
408 read-only tool.
409 """
410 func = "_event_dirs_for"
411 out: list[Path] = []
412 seen: set[str] = set()
413
414 def _add(path: Optional[Path]) -> None:
415 if path is None:
416 return
417 try:
418 if not path.is_dir():
419 return
420 key = str(path.resolve(strict=False)).casefold()
421 except OSError:
422 return
423 if key in seen:
424 return
425 seen.add(key)
426 out.append(path)
427
428 session_id = ""
429 aliases: list[str] = []
430 if isinstance(meta, dict):
431 session_id = str(meta.get("session_id") or "")
432 raw_aliases = meta.get("legacy_event_slugs")
433 if isinstance(raw_aliases, (list, tuple)):
434 aliases = [a for a in raw_aliases if isinstance(a, str)]
435
436 name_slug = _slug(name)
437 id_key = session_id or name_slug
438 if name_slug != id_key:
439 _add(event_root / name_slug)
440 for alias in aliases:
441 _add(_contained_alias_dir(event_root, alias))
442 _add(event_root / id_key)
443 _dbg("OC_CHAT.REPLAY", func, f"{name}: {len(out)} event dir(s)", 3)
444 return tuple(out)
445
446
448 session_dir: Path,
449 reason: str,
450 event_root: Path,
451 meta: Any = None,
452 meta_path: Optional[Path] = None,
453) -> SessionRecord:
454 """Build a :class:`SessionRecord` for an existing session directory.
455
456 *meta* / *meta_path* let a caller that has already read the metadata (the
457 store search does, to decide the match) hand it over rather than have the
458 file opened a second time.
459 """
460 if meta_path is None:
461 meta_path = _latest_kind(session_dir, "meta")
462 meta = _read_json(meta_path) if meta_path is not None else None
463 meta_dict = meta if isinstance(meta, dict) else None
464 name = session_dir.name
465 if meta_dict and isinstance(meta_dict.get("session_name"), str) and meta_dict["session_name"]:
466 name = meta_dict["session_name"]
467 return SessionRecord(
468 name=name,
469 directory=session_dir,
470 match_reason=reason,
471 session_id=str((meta_dict or {}).get("session_id") or ""),
472 meta_path=meta_path,
473 chat_log_path=_latest_kind(session_dir, "chat_log"),
474 event_dirs=_event_dirs_for(name, meta_dict, event_root),
475 )
476
477
478def _resolve_explicit(raw: str) -> Path:
479 """Resolve one ``--session`` argument to a session directory.
480
481 Accepts the directory itself or either of its two sidecar files, because a
482 caller who has just been reading a ``*_meta.json`` should be able to paste
483 that path.
484 """
485 path = Path(raw)
486 if path.is_file():
487 path = path.parent
488 if not path.is_dir():
489 raise _UserError(f"--session path is not a session directory: {raw}")
490 return path
491
492
494 plan_id: str,
495 sessions_dir: Path,
496 event_root: Path,
497 explicit: Sequence[str] = (),
498) -> Discovery:
499 """Resolve the sessions for *plan_id*, recording what was searched.
500
501 An explicit list wins outright: the operator naming sessions is a stronger
502 signal than any name heuristic, and honouring it silently alongside a store
503 search would make the report's provenance ambiguous.
504 """
505 func = "discover_sessions"
506 if explicit:
507 records = [
508 _record_for_dir(_resolve_explicit(raw), "named explicitly with --session", event_root)
509 for raw in explicit
510 ]
511 _dbg("OC_CHAT.REPLAY", func, f"explicit mode: {len(records)} session(s)", 2)
512 return Discovery(
513 mode="explicit",
514 sessions_dir=str(sessions_dir),
515 event_root=str(event_root),
516 examined=[str(r.directory) for r in records],
517 records=records,
518 )
519
520 examined: list[str] = []
521 records: list[SessionRecord] = []
522 if sessions_dir.is_dir():
523 for child in sorted(sessions_dir.iterdir()):
524 if not child.is_dir() or child.name in (ARCHIVE_DIR_NAME, LEGACY_DIR_NAME):
525 continue
526 examined.append(child.name)
527 meta_path = _latest_kind(child, "meta")
528 meta = _read_json(meta_path) if meta_path is not None else None
529 reason = _match_reason(plan_id, child.name, meta if isinstance(meta, dict) else None)
530 if reason is not None:
531 records.append(_record_for_dir(child, reason, event_root, meta, meta_path))
532 _dbg(
533 "OC_CHAT.REPLAY", func,
534 f"store search: examined {len(examined)}, matched {len(records)}", 2,
535 )
536 return Discovery(
537 mode="store-search",
538 sessions_dir=str(sessions_dir),
539 event_root=str(event_root),
540 examined=examined,
541 records=records,
542 )
543
544
545# ─── Category extraction — the chat-log substrate ────────────────────────────
546
547def _tool_payload(message: dict) -> Optional[dict]:
548 """Return the parsed dict a ``role:"tool"`` message carries, if it is one."""
549 content = message.get("content")
550 if not isinstance(content, str) or not content.strip():
551 return None
552 try:
553 parsed = json.loads(content)
554 except json.JSONDecodeError:
555 return None
556 return parsed if isinstance(parsed, dict) else None
557
558
560 session: str, locus: str, tool_name: str, payload: dict, raw: str,
561) -> Optional[Finding]:
562 """Return the ONE outcome-family finding for a tool result, if any.
563
564 Precedence, most specific first: a missing dependency is a missing-tool
565 workaround; an error or a non-zero return code is drift when its text names
566 the anchor class of failure, otherwise a tool-call failure; a pending
567 confirmation is a denial in the same sense the design means it (the call
568 did not run).
569 """
570 if payload.get("missing_dependency"):
571 return Finding(
572 "missing_tool_workarounds", session, locus,
573 _excerpt(f"{tool_name}: missing_dependency={payload['missing_dependency']!r} "
574 f"hint={payload.get('install_hint', '')!r}"),
575 )
576 error = payload.get("error")
577 if isinstance(error, str) and error.strip():
578 category = "cwd_anchor_drift" if _first_marker(error, _DRIFT_MARKERS) else "tool_call_failures_and_denials"
579 return Finding(category, session, locus, _excerpt(f"{tool_name}: {error}"))
580 if payload.get("requires_confirmation"):
581 return Finding(
582 "tool_call_failures_and_denials", session, locus,
583 _excerpt(f"{tool_name}: awaiting confirmation for "
584 f"{payload.get('command', payload.get('message', ''))}"),
585 )
586 returncode = payload.get("returncode")
587 if isinstance(returncode, int) and returncode != 0:
588 stderr = payload.get("stderr") if isinstance(payload.get("stderr"), str) else ""
589 detail = stderr or raw
590 category = "cwd_anchor_drift" if _first_marker(detail, _DRIFT_MARKERS) else "errors"
591 return Finding(category, session, locus, _excerpt(f"{tool_name}: rc={returncode} {stderr}"))
592 return None
593
594
595def _warning_findings(session: str, locus: str, tool_name: str, payload: dict) -> list[Finding]:
596 """Return the warning-family findings for a tool result.
597
598 A declared ``warnings`` entry is one finding each; stderr text alongside a
599 SUCCESSFUL command is the undeclared form of the same thing (a failing
600 command's stderr is already reported by the outcome family, so counting it
601 here too would double-report one event).
602 """
603 out: list[Finding] = []
604 warnings = payload.get("warnings")
605 if isinstance(warnings, (list, tuple)):
606 for warning in warnings:
607 if warning:
608 out.append(Finding("warnings", session, locus, _excerpt(f"{tool_name}: {warning}")))
609 stderr = payload.get("stderr")
610 if payload.get("returncode") == 0 and isinstance(stderr, str) and stderr.strip():
611 out.append(Finding("warnings", session, locus, _excerpt(f"{tool_name}: stderr {stderr}")))
612 return out
613
614
615def _shell_workaround(tool_name: str, arguments: Any) -> Optional[str]:
616 """Return the native tool a shelled-out command duplicates, else ``None``."""
617 if tool_name != "run_command":
618 return None
619 args = arguments
620 if isinstance(args, str):
621 try:
622 args = json.loads(args)
623 except json.JSONDecodeError:
624 return None
625 if not isinstance(args, dict):
626 return None
627 command = args.get("command")
628 if not isinstance(command, str) or not command.strip():
629 return None
630 base = command.split()[0].rsplit("/", 1)[-1].rsplit("\\", 1)[-1].lower()
631 base = base[:-4] if base.endswith(".exe") else base
632 return _SHELL_WORKAROUND_COMMANDS.get(base)
633
634
635def _mine_chat_log(record: SessionRecord, messages: list) -> list[Finding]:
636 """Extract findings from one session's chat-log message list."""
637 func = "_mine_chat_log"
638 findings: list[Finding] = []
639 call_sequence: list[tuple[str, str, int]] = []
640
641 for index, message in enumerate(messages):
642 if not isinstance(message, dict):
643 continue
644 role = message.get("role")
645 locus = f"chat_log[message {index}]"
646 raw_content = message.get("content") if isinstance(message.get("content"), str) else ""
647
648 if role == "tool":
649 tool_name = str(message.get("name") or "?")
650 locus = f"chat_log[message {index}] tool={tool_name}"
651 payload = _tool_payload(message)
652 if payload is not None:
653 outcome = _outcome_finding(record.name, locus, tool_name, payload, raw_content)
654 if outcome is not None:
655 findings.append(outcome)
656 findings.extend(_warning_findings(record.name, locus, tool_name, payload))
657 if payload.get("truncated") is True:
658 findings.append(Finding(
659 "truncations", record.name, locus,
660 _excerpt(f"{tool_name}: truncated=true total_size={payload.get('total_size')}"),
661 ))
662 raw_content = "" # already reported; do not double-count via a marker
663 marker = _first_marker(raw_content, _TRUNCATION_MARKERS)
664 if marker is not None:
665 findings.append(Finding("truncations", record.name, locus, _excerpt(f"{tool_name}: {marker}")))
666 missing = _first_marker(raw_content, _MISSING_TOOL_MARKERS)
667 if missing is not None:
668 findings.append(Finding(
669 "missing_tool_workarounds", record.name, locus, _excerpt(raw_content),
670 ))
671
672 elif role == "assistant":
673 marker = _first_marker(raw_content, _TRUNCATION_MARKERS)
674 if marker is not None:
675 findings.append(Finding("truncations", record.name, locus, _excerpt(marker)))
676 for call in message.get("tool_calls") or []:
677 if not isinstance(call, dict):
678 continue
679 function = call.get("function") if isinstance(call.get("function"), dict) else {}
680 name = str(function.get("name") or call.get("name") or "?")
681 arguments = function.get("arguments", call.get("arguments"))
682 call_sequence.append((name, _canonical_args(arguments), index))
683 native = _shell_workaround(name, arguments)
684 if native is not None:
685 findings.append(Finding(
686 "missing_tool_workarounds", record.name,
687 f"chat_log[message {index}] tool={name}",
688 _excerpt(f"shelled out where {native} exists: {_canonical_args(arguments)}"),
689 ))
690
691 findings.extend(_retry_findings(record.name, call_sequence, "chat_log"))
692 _dbg("OC_CHAT.REPLAY", func, f"{record.name}: {len(findings)} chat-log finding(s)", 3)
693 return findings
694
695
696def _retry_findings(session: str, sequence: list[tuple[str, str, int]], substrate: str) -> list[Finding]:
697 """Return one finding per maximal run of >= 2 identical consecutive calls."""
698 findings: list[Finding] = []
699 start = 0
700 while start < len(sequence):
701 end = start + 1
702 while end < len(sequence) and sequence[end][:2] == sequence[start][:2]:
703 end += 1
704 run = end - start
705 if run >= _RETRY_RUN_MIN:
706 name, args, first_locus = sequence[start]
707 findings.append(Finding(
708 "retry_loops", session,
709 f"{substrate}[{first_locus}..{sequence[end - 1][2]}]",
710 _excerpt(f"{run}x identical consecutive call to {name}({args})"),
711 ))
712 start = end
713 return findings
714
715
716# ─── Category extraction — the event substrate ───────────────────────────────
717
718def _event_locus(event: Any) -> str:
719 """Return a stable, readable locus string for one event."""
720 turn = (getattr(event, "turn_id", None) or "")[:8]
721 return f"events[{getattr(event, 'event_id', '?')}]" + (f" turn={turn}" if turn else "")
722
723
724def _mine_events(record: SessionRecord, events: list, gap_seconds: float) -> list[Finding]:
725 """Extract findings from one session's ordered event stream."""
726 func = "_mine_events"
727 findings: list[Finding] = []
728 proposals: list[tuple[str, str, int]] = []
729
730 for index, event in enumerate(events):
731 payload = getattr(event, "payload", None)
732 kind = getattr(event, "kind", "")
733 locus = _event_locus(event)
734
735 if kind == "error":
736 findings.append(Finding(
737 "errors", record.name, locus, _excerpt(getattr(payload, "error_summary", "") or "error"),
738 ))
739 elif kind == "hook_executed" and getattr(payload, "exit_code", 0):
740 findings.append(Finding(
741 "errors", record.name, locus,
742 _excerpt(f"hook {getattr(payload, 'hook_name', '?')} exited "
743 f"{getattr(payload, 'exit_code', '?')}"),
744 ))
745 elif kind == "session_ended":
746 outcome = str(getattr(payload, "outcome", "") or "")
747 if outcome and outcome not in _OK_OUTCOMES:
748 findings.append(Finding(
749 "errors", record.name, locus, _excerpt(f"session ended with outcome={outcome}"),
750 ))
751 elif kind == "tool_call_completed":
752 outcome = str(getattr(payload, "outcome", "") or "")
753 if outcome and outcome not in _OK_OUTCOMES:
754 summary = getattr(payload, "error_summary", None) or ""
755 findings.append(Finding(
756 "tool_call_failures_and_denials", record.name, locus,
757 _excerpt(f"outcome={outcome} {summary}".strip()),
758 ))
759 elif kind == "permission_decision":
760 decision = str(getattr(payload, "decision", "") or "").lower()
761 final = str(getattr(payload, "final_outcome", "") or "").lower()
762 if decision in _DENY_DECISIONS or final in _DENY_DECISIONS:
763 reason = getattr(payload, "denial_reason_code", None) or ""
764 findings.append(Finding(
765 "tool_call_failures_and_denials", record.name, locus,
766 _excerpt(f"decision={decision or final} by "
767 f"{getattr(payload, 'decided_by', '?')} {reason}".strip()),
768 ))
769 elif kind == "provider_retried":
770 findings.append(Finding(
771 "retry_loops", record.name, locus,
772 _excerpt(f"provider {getattr(payload, 'provider_name', '?')} retry attempt "
773 f"{getattr(payload, 'attempt', '?')}"),
774 ))
775 elif kind == "provider_rate_limited":
776 findings.append(Finding(
777 "retry_loops", record.name, locus,
778 _excerpt(f"provider {getattr(payload, 'provider_name', '?')} rate limited; "
779 f"retry_after={getattr(payload, 'retry_after_seconds', None)}"),
780 ))
781 elif kind == "tool_call_proposed":
782 proposals.append((
783 str(getattr(payload, "tool_name", "") or "?"),
784 _canonical_args(getattr(payload, "requested_args", {}) or {}),
785 index,
786 ))
787 elif kind == "assistant_message":
788 marker = _first_marker(str(getattr(payload, "text", "") or ""), _TRUNCATION_MARKERS)
789 if marker is not None:
790 findings.append(Finding("truncations", record.name, locus, _excerpt(marker)))
791
792 findings.extend(_retry_findings(record.name, proposals, "events"))
793 findings.extend(_gap_findings(record.name, events, gap_seconds))
794 _dbg("OC_CHAT.REPLAY", func, f"{record.name}: {len(findings)} event finding(s)", 3)
795 return findings
796
797
798def _gap_findings(session: str, events: list, gap_seconds: float) -> list[Finding]:
799 """Return one finding per pair of consecutive events separated by blocked time."""
800 findings: list[Finding] = []
801 previous: Optional[tuple[datetime, Any]] = None
802 for event in events:
803 stamp = _parse_ts(getattr(event, "ts", None))
804 if stamp is None:
805 continue
806 if previous is not None:
807 delta = (stamp - previous[0]).total_seconds()
808 if delta >= gap_seconds:
809 findings.append(Finding(
810 "blocked_time_gaps", session,
811 f"{_event_locus(previous[1])} -> {_event_locus(event)}",
812 _excerpt(f"{delta:.0f}s of silence between "
813 f"{getattr(previous[1], 'kind', '?')} and {getattr(event, 'kind', '?')}"),
814 ))
815 previous = (stamp, event)
816 return findings
817
818
819def mine_session(record: SessionRecord, gap_seconds: float = DEFAULT_GAP_SECONDS) -> list[Finding]:
820 """Mine one session across both substrates, in category-stable order."""
821 func = "mine_session"
822 findings: list[Finding] = []
823
824 if record.chat_log_path is not None:
825 chat_log = _read_json(record.chat_log_path)
826 messages = chat_log.get("messages") if isinstance(chat_log, dict) else None
827 if isinstance(messages, list):
828 findings.extend(_mine_chat_log(record, messages))
829
830 events: list = []
831 for event_dir in record.event_dirs:
832 read, _skipped = read_events_from_dir(event_dir)
833 events.extend(read)
834 if record.session_id:
835 events = [e for e in events if getattr(e, "session_id", "") == record.session_id]
836 events.sort(key=lambda e: (str(getattr(e, "ts", "")),))
837 if events:
838 findings.extend(_mine_events(record, events, gap_seconds))
839
840 _dbg("OC_CHAT.REPLAY", func, f"{record.name}: {len(findings)} finding(s) total", 2)
841 return findings
842
843
844# ─── Report assembly + emitters ──────────────────────────────────────────────
845
846def build_report(plan_id: str, discovery: Discovery, gap_seconds: float = DEFAULT_GAP_SECONDS) -> dict:
847 """Mine every discovered session and assemble the report structure."""
848 func = "build_report"
849 findings: list[Finding] = []
850 sessions: list[dict] = []
851 inputs: list[dict] = []
852
853 for record in discovery.records:
854 record_findings = mine_session(record, gap_seconds)
855 findings.extend(record_findings)
856 sessions.append({
857 "session": record.name,
858 "directory": str(record.directory),
859 "match_reason": record.match_reason,
860 "session_id": record.session_id,
861 "substrates": {
862 "metadata": str(record.meta_path) if record.meta_path else None,
863 "chat_log": str(record.chat_log_path) if record.chat_log_path else None,
864 "event_dirs": [str(d) for d in record.event_dirs],
865 },
866 "finding_count": len(record_findings),
867 })
868 for path in record.input_paths():
869 inputs.append({
870 "path": str(path),
871 "sha256": _sha256(path),
872 "bytes": path.stat().st_size if path.exists() else 0,
873 })
874
875 by_category = {category: 0 for category in CATEGORIES}
876 for finding in findings:
877 by_category[finding.category] = by_category.get(finding.category, 0) + 1
878
879 ordered = sorted(findings, key=lambda f: (CATEGORIES.index(f.category), f.session, f.locus))
880 _dbg("OC_CHAT.REPLAY", func, f"plan {plan_id}: {len(ordered)} finding(s)", 2)
881 return {
882 "schema": SCHEMA_VERSION,
883 "plan_id": plan_id,
884 "discovery": {
885 "mode": discovery.mode,
886 "searched": {
887 "sessions_dir": discovery.sessions_dir,
888 "event_root": discovery.event_root,
889 "session_dirs_examined": discovery.examined,
890 },
891 "matched": sessions,
892 },
893 "inputs": inputs,
894 "totals": {"findings": len(ordered), "by_category": by_category},
895 "findings": [f.to_json() for f in ordered],
896 "gap_seconds": gap_seconds,
897 }
898
899
900def render_json(report: dict) -> str:
901 """Render *report* as the JSON sidecar (stable key order, trailing newline)."""
902 return json.dumps(report, indent=2, sort_keys=False, ensure_ascii=False) + "\n"
903
904
905def render_markdown(report: dict) -> str:
906 """Render *report* as the markdown view of the SAME findings.
907
908 Counts come from ``totals.by_category`` rather than from a second pass over
909 the findings, so the two outputs cannot disagree by construction.
910 """
911 plan_id = report["plan_id"]
912 discovery = report["discovery"]
913 searched = discovery["searched"]
914 totals = report["totals"]
915 lines: list[str] = [
916 f"# Plan {plan_id} — session postmortem",
917 "",
918 f"Mined by `python -m oc_chat.scripts.session_postmortem {plan_id}` "
919 f"({report['schema']}). Read-only over every input; findings are candidates for "
920 "operator judgement at the gate, never filed automatically.",
921 "",
922 "## What was searched",
923 "",
924 f"- Discovery mode: `{discovery['mode']}`",
925 f"- Session store: `{searched['sessions_dir']}`",
926 f"- Event root: `{searched['event_root']}`",
927 f"- Session directories examined: {len(searched['session_dirs_examined'])}"
928 + (f" ({', '.join(searched['session_dirs_examined'])})" if searched["session_dirs_examined"] else ""),
929 f"- Blocked-time threshold: {report['gap_seconds']:.0f}s",
930 "",
931 "## What matched",
932 "",
933 ]
934 if not discovery["matched"]:
935 lines += [
936 f"No session matched plan {plan_id}. Nothing was mined; this postmortem records the "
937 "search, not a finding of health.",
938 "",
939 ]
940 else:
941 lines.append("| Session | Match reason | Chat log | Event dirs | Findings |")
942 lines.append("|---|---|---|---|---|")
943 for entry in discovery["matched"]:
944 substrates = entry["substrates"]
945 lines.append(
946 f"| `{entry['session']}` | {entry['match_reason']} | "
947 f"{'yes' if substrates['chat_log'] else 'no'} | "
948 f"{len(substrates['event_dirs'])} | {entry['finding_count']} |"
949 )
950 lines.append("")
951
952 lines += [
953 "## Findings",
954 "",
955 f"Total: {totals['findings']} across {len(CATEGORIES)} categories. "
956 "A category with zero findings is listed as zero — an empty section is information, "
957 "not omission.",
958 "",
959 ]
960 by_category = totals["by_category"]
961 findings = report["findings"]
962 for category in CATEGORIES:
963 count = by_category.get(category, 0)
964 lines.append(f"### {CATEGORY_TITLES[category]} — {count} finding(s)")
965 lines.append("")
966 if count == 0:
967 lines.append("_None._")
968 lines.append("")
969 continue
970 for finding in findings:
971 if finding["category"] != category:
972 continue
973 lines.append(f"- `{finding['session']}` @ `{finding['locus']}` — {finding['excerpt']}")
974 lines.append("")
975
976 lines += [
977 "## Inputs (read-only)",
978 "",
979 f"{len(report['inputs'])} file(s) were opened for reading. Their digests are recorded so a "
980 "reader can prove the miner left the store byte-identical.",
981 "",
982 ]
983 for entry in report["inputs"]:
984 lines.append(f"- `{entry['path']}` — sha256 `{entry['sha256']}` ({entry['bytes']} bytes)")
985 lines.append("")
986 return "\n".join(lines)
987
988
989def _write_atomic(path: Path, text: str) -> None:
990 """Write *text* to *path* via a temp file + ``os.replace``.
991
992 A crash mid-emit must not leave a torn report. Inputs are untouched either
993 way — the miner only ever writes here.
994 """
995 func = "_write_atomic"
996 path.parent.mkdir(parents=True, exist_ok=True)
997 tmp = path.with_name(path.name + ".tmp")
998 tmp.write_text(text, encoding="utf-8", newline="\n")
999 os.replace(tmp, path)
1000 _dbg("OC_CHAT.REPLAY", func, f"wrote {path}", 2)
1001
1002
1003def emit(report: dict, out_dir: Path) -> tuple[Path, Path]:
1004 """Write the markdown + JSON pair for *report*; return both paths."""
1005 plan_id = report["plan_id"]
1006 md_path = out_dir / f"{plan_id}-postmortem.md"
1007 json_path = out_dir / f"{plan_id}-postmortem.json"
1008 _write_atomic(md_path, render_markdown(report))
1009 _write_atomic(json_path, render_json(report))
1010 return md_path, json_path
1011
1012
1013# ─── CLI ─────────────────────────────────────────────────────────────────────
1014
1015def default_out_dir() -> Path:
1016 """Return the committed postmortem home, resolved from this file's location."""
1017 return Path(__file__).resolve().parents[1] / "docs" / "reports" / "postmortems"
1018
1019
1020def _build_parser() -> argparse.ArgumentParser:
1021 """Build the ``session_postmortem`` argument parser."""
1022 parser = argparse.ArgumentParser(
1023 prog="python -m oc_chat.scripts.session_postmortem",
1024 description="Mine a plan's oc_chat dogfood sessions into a postmortem (read-only).",
1025 )
1026 parser.add_argument("plan_id", help="the plan number, e.g. 224 (lettered ids such as 148g are accepted)")
1027 parser.add_argument("--session", action="append", default=[], metavar="PATH",
1028 help="explicit session directory (repeatable); takes precedence over the store search")
1029 parser.add_argument("--sessions-dir", default=None, help="session store root to search")
1030 # OCT-LINT: disable=no_hardcoded_secrets reason="argparse --help prose mis-flagged as a high-entropy string; not a secret"
1031 parser.add_argument("--event-root", default=None, help="event-log root (default: OC_CHAT_EVENT_DIR or <cwd>/.oc_chat/events)")
1032 parser.add_argument("--out-dir", default=None, help="where to write the two postmortem files")
1033 parser.add_argument("--gap-seconds", type=float, default=DEFAULT_GAP_SECONDS,
1034 help=f"blocked-time threshold in seconds (default {DEFAULT_GAP_SECONDS:.0f})")
1035 parser.add_argument("--quiet", action="store_true", help="suppress the stdout summary")
1036 return parser
1037
1038
1039def _resolve_event_root(raw: Optional[str]) -> Path:
1040 """Resolve the event root: the flag, else the env override, else the default."""
1041 if raw:
1042 return Path(raw)
1043 env_dir = os.environ.get("OC_CHAT_EVENT_DIR")
1044 return Path(env_dir) if env_dir else Path.cwd() / ".oc_chat" / "events"
1045
1046
1047def main(argv: Optional[Sequence[str]] = None) -> int:
1048 """Entry point. Returns the process exit code (0 ok, 1 error, 2 user error)."""
1049 func = "main"
1050 for stream in (sys.stdout, sys.stderr):
1051 try:
1052 stream.reconfigure(errors="replace")
1053 except (AttributeError, ValueError):
1054 pass # non-reconfigurable stream (tests, pipes wrapped by harnesses)
1055 args = _build_parser().parse_args(argv)
1056 try:
1057 plan_id = str(args.plan_id).strip()
1058 if not PLAN_ID_RE.match(plan_id):
1059 raise _UserError(f"plan id must match {PLAN_ID_RE.pattern}; got {args.plan_id!r}")
1060 if args.gap_seconds <= 0:
1061 raise _UserError("--gap-seconds must be positive")
1062 sessions_dir = Path(args.sessions_dir) if args.sessions_dir else Path(DEFAULT_SESSIONS_DIR)
1063 event_root = _resolve_event_root(args.event_root)
1064 out_dir = Path(args.out_dir) if args.out_dir else default_out_dir()
1065 _dbg("OC_CHAT.REPLAY", func, f"plan={plan_id} store={sessions_dir} events={event_root}", 2)
1066
1067 discovery = discover_sessions(plan_id, sessions_dir, event_root, args.session)
1068 report = build_report(plan_id, discovery, args.gap_seconds)
1069 md_path, json_path = emit(report, out_dir)
1070 if not args.quiet:
1071 print(f"plan {plan_id}: {len(discovery.records)} session(s) matched, "
1072 f"{report['totals']['findings']} finding(s)")
1073 print(f" {md_path}")
1074 print(f" {json_path}")
1075 return 0
1076 except _UserError as exc:
1077 print(f"error: {exc}", file=sys.stderr)
1078 return 2
1079 except Exception as exc: # noqa: BLE001 — CLI boundary: no stack trace to the user
1080 _dbg("OC_CHAT.REPLAY", func, f"SYSTEM_ERROR: {type(exc).__name__}: {exc}", 1, override=True)
1081 print(f"error: {type(exc).__name__}: {exc}", file=sys.stderr)
1082 return 1
1083
1084
1085if __name__ == "__main__": # pragma: no cover
1086 sys.exit(main())
tuple[Path,...] _event_dirs_for(str name, Optional[dict] meta, Path event_root)
tuple[str,...] _plan_tokens(str plan_id)
list[Finding] _retry_findings(str session, list[tuple[str, str, int]] sequence, str substrate)
Optional[Finding] _outcome_finding(str session, str locus, str tool_name, dict payload, str raw)
SessionRecord _record_for_dir(Path session_dir, str reason, Path event_root, Any meta=None, Optional[Path] meta_path=None)
list[Finding] _warning_findings(str session, str locus, str tool_name, dict payload)
Optional[Path] _latest_kind(Path session_dir, str kind)
list[Finding] mine_session(SessionRecord record, float gap_seconds=DEFAULT_GAP_SECONDS)
list[Finding] _gap_findings(str session, list events, float gap_seconds)
list[Finding] _mine_events(SessionRecord record, list events, float gap_seconds)
bool _name_references_plan(str name, str plan_id)
Path _resolve_event_root(Optional[str] raw)
dict build_report(str plan_id, Discovery discovery, float gap_seconds=DEFAULT_GAP_SECONDS)
Discovery discover_sessions(str plan_id, Path sessions_dir, Path event_root, Sequence[str] explicit=())
Optional[dict] _tool_payload(dict message)
Optional[datetime] _parse_ts(Any value)
list[Finding] _mine_chat_log(SessionRecord record, list messages)
Optional[str] _first_marker(str text, Sequence[str] markers)
Optional[str] _match_reason(str plan_id, str dir_name, Optional[dict] meta)
argparse.ArgumentParser _build_parser()
Optional[str] _shell_workaround(str tool_name, Any arguments)
tuple[Path, Path] emit(dict report, Path out_dir)
None _write_atomic(Path path, str text)