agent-postmortem 0.3.4__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.
- agent_postmortem/__init__.py +9 -0
- agent_postmortem/argerrors.py +160 -0
- agent_postmortem/claims.py +43 -0
- agent_postmortem/cli.py +2278 -0
- agent_postmortem/commands.py +1543 -0
- agent_postmortem/config.py +435 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000001.jsonl +5 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000001.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000002.jsonl +2 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000002.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000003.jsonl +5 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000003.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000004.jsonl +2 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000004.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/journal.jsonl +7 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000.jsonl +4 -0
- agent_postmortem/detectors/__init__.py +98 -0
- agent_postmortem/detectors/attempt_failed.py +261 -0
- agent_postmortem/detectors/base.py +319 -0
- agent_postmortem/detectors/bash_backgrounded.py +72 -0
- agent_postmortem/detectors/bash_timeout.py +94 -0
- agent_postmortem/detectors/claim_contradiction.py +251 -0
- agent_postmortem/detectors/classifier_unavailable.py +73 -0
- agent_postmortem/detectors/codex_unavailable.py +102 -0
- agent_postmortem/detectors/codex_write_denied.py +100 -0
- agent_postmortem/detectors/git_cwd_mismatch.py +74 -0
- agent_postmortem/detectors/mislabeled_kill.py +164 -0
- agent_postmortem/detectors/missing_artifact.py +159 -0
- agent_postmortem/detectors/permission_denied.py +99 -0
- agent_postmortem/detectors/silent_retry.py +163 -0
- agent_postmortem/detectors/structured_output_failure.py +91 -0
- agent_postmortem/detectors/tool_json_error.py +77 -0
- agent_postmortem/detectors/wasted_time.py +125 -0
- agent_postmortem/detectors/workflow_failed.py +62 -0
- agent_postmortem/disclosure.py +931 -0
- agent_postmortem/hooks/model_allocation_guard.mjs +248 -0
- agent_postmortem/hooks_cli.py +1269 -0
- agent_postmortem/ledger.py +624 -0
- agent_postmortem/markers.py +505 -0
- agent_postmortem/models.py +815 -0
- agent_postmortem/observe.py +792 -0
- agent_postmortem/reader.py +4611 -0
- agent_postmortem/redact.py +1981 -0
- agent_postmortem/report.py +3010 -0
- agent_postmortem/rescue.py +2096 -0
- agent_postmortem/returns.py +499 -0
- agent_postmortem/rules.py +627 -0
- agent_postmortem/severity.py +275 -0
- agent_postmortem/state.py +1261 -0
- agent_postmortem/trend.py +391 -0
- agent_postmortem/watch.py +973 -0
- agent_postmortem-0.3.4.dist-info/METADATA +264 -0
- agent_postmortem-0.3.4.dist-info/RECORD +56 -0
- agent_postmortem-0.3.4.dist-info/WHEEL +4 -0
- agent_postmortem-0.3.4.dist-info/entry_points.txt +3 -0
- agent_postmortem-0.3.4.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,4611 @@
|
|
|
1
|
+
"""Discover Claude Code run directories/transcripts and parse them into a Run.
|
|
2
|
+
|
|
3
|
+
Tolerant by design: a malformed line becomes an entry in Run.diagnostics, not
|
|
4
|
+
an exception that aborts the scan. Transcripts are read line by line and
|
|
5
|
+
never held in memory as a whole file or a list of raw lines; only the parsed
|
|
6
|
+
Event fields (each text field truncated to _EXCERPT_LIMIT chars) survive. A
|
|
7
|
+
field's full decoded text is still scanned for exit codes and known runtime
|
|
8
|
+
markers before it is truncated (see _scan_markers): the excerpt bounds what
|
|
9
|
+
survives for display and evidence, not what a detector can see happened.
|
|
10
|
+
|
|
11
|
+
Two run kinds (docs/DESIGN.md section 1): 'workflow' (a wf_* directory under
|
|
12
|
+
.../subagents/workflows/) and 'session' (a main-session transcript plus its
|
|
13
|
+
Agent-tool subagent transcripts under .../subagents/agent-*.jsonl). Both
|
|
14
|
+
share the transcript-parsing helpers below (_read_transcript and friends);
|
|
15
|
+
load_run dispatches on layout and load_session owns the session-specific
|
|
16
|
+
assembly (docs/DESIGN.md section 4.2).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import contextlib
|
|
22
|
+
import contextvars
|
|
23
|
+
import copy
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import time
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from datetime import datetime, timezone
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
from . import commands, disclosure, ledger, markers, returns
|
|
34
|
+
from .claims import self_reported_problems
|
|
35
|
+
from .detectors.base import attribute_interrupt, meaningful_event_before
|
|
36
|
+
from .markers import (
|
|
37
|
+
API_ERROR,
|
|
38
|
+
BASH_BACKGROUNDED,
|
|
39
|
+
BASH_TIMEOUT,
|
|
40
|
+
CLASSIFIER_UNAVAILABLE,
|
|
41
|
+
GIT_NOT_REPO,
|
|
42
|
+
KILL_SENTINEL_PREFIX,
|
|
43
|
+
MACHINE_SLEEP,
|
|
44
|
+
SANDBOX_WRITE_DENIED,
|
|
45
|
+
SHELL_GUARD_BLOCK_REGEX,
|
|
46
|
+
STRUCTURED_OUTPUT_MISMATCH,
|
|
47
|
+
TOOL_INPUT_INVALID,
|
|
48
|
+
USAGE_LIMIT_SESSION,
|
|
49
|
+
USAGE_LIMIT_WEEKLY,
|
|
50
|
+
)
|
|
51
|
+
from .models import (
|
|
52
|
+
FACTS_UNKNOWN,
|
|
53
|
+
LAUNCH_DIAGNOSTIC_PREFIXES,
|
|
54
|
+
Attempt,
|
|
55
|
+
AttemptFacts,
|
|
56
|
+
Event,
|
|
57
|
+
Generation,
|
|
58
|
+
Interrupt,
|
|
59
|
+
KeyedDiagnostic,
|
|
60
|
+
Lifecycle,
|
|
61
|
+
LogicalAgent,
|
|
62
|
+
MarkerHit,
|
|
63
|
+
QualityNote,
|
|
64
|
+
Run,
|
|
65
|
+
ToolCall,
|
|
66
|
+
discovery_part,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
_EXCERPT_LIMIT = 400
|
|
70
|
+
_EXIT_CODE_RE = re.compile(r"[Ee]xit code[: ]+(-?\d+)")
|
|
71
|
+
|
|
72
|
+
# ToolCall bounds (docs/DESIGN.md 4.1's v0.2 reader changes): a path or
|
|
73
|
+
# command past these lengths is not truncated (which would silently make it
|
|
74
|
+
# look like a different, shorter one); a path is dropped to None, a command
|
|
75
|
+
# is dropped to None with its sha256 kept instead.
|
|
76
|
+
_TOOL_CALL_PATH_LIMIT = 1024
|
|
77
|
+
_TOOL_CALL_COMMAND_LIMIT = 2048
|
|
78
|
+
# Which tool_use input field ToolCall.path is read from, per tool name
|
|
79
|
+
# (docs/DESIGN.md 4.1's v0.2 reader changes). Every other tool leaves path
|
|
80
|
+
# None.
|
|
81
|
+
_PATH_TOOL_INPUT_FIELD = {
|
|
82
|
+
"Edit": "file_path",
|
|
83
|
+
"Write": "file_path",
|
|
84
|
+
"NotebookEdit": "notebook_path",
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# docs/DESIGN.md 4.1 (N4b-1): MultiEdit's `file_path` lives in its own private
|
|
88
|
+
# ToolCall field, never in `path`, so schema-2 paths_written, in_flight, and
|
|
89
|
+
# rescue (all of which read `path`) cannot change.
|
|
90
|
+
_MULTIEDIT_TOOL = "MultiEdit"
|
|
91
|
+
# Tools whose tool_use `command` is kept on a ToolCall (docs/DESIGN.md 4.1).
|
|
92
|
+
_COMMAND_TOOLS = ("Bash", "Monitor")
|
|
93
|
+
|
|
94
|
+
# Harness/tooling scaffolding a field's text can carry that is not something
|
|
95
|
+
# the agent said or did (docs/DESIGN.md 4.5): stripped before the excerpt is
|
|
96
|
+
# taken so it never dominates meaningful_event_before's "last real event"
|
|
97
|
+
# search or an evidence line, but markers.py still scans the text before
|
|
98
|
+
# this strip runs, so a marker fragment hidden inside one of these spans is
|
|
99
|
+
# never missed.
|
|
100
|
+
_SYSTEM_REMINDER_RE = re.compile(r"<system-reminder>.*?</system-reminder>", re.DOTALL)
|
|
101
|
+
_TOTAL_TOKENS_RE = re.compile(r"<total_tokens>.*?</total_tokens>", re.DOTALL)
|
|
102
|
+
_HARNESS_NOTE_RE = re.compile(r"\[harness:.*?\]", re.DOTALL)
|
|
103
|
+
|
|
104
|
+
# docs/DESIGN.md 5.3: the opaque id shapes a workflow agent id / session
|
|
105
|
+
# subagent id and a workflow run directory name are documented to have.
|
|
106
|
+
# Ingestion validates every id against these before it is carried into a
|
|
107
|
+
# Run, Attempt, or Finding; see _valid_agent_id and _valid_workflow_run_id.
|
|
108
|
+
_AGENT_ID_RE = re.compile(r"a[0-9a-f]{16}")
|
|
109
|
+
_WORKFLOW_RUN_ID_RE = re.compile(r"wf_[0-9a-f]{8}-[0-9a-f]{3}")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _valid_agent_id(agent_id: str | None, diagnostics: list[str], context: str) -> str:
|
|
113
|
+
"""A conforming agent id (docs/DESIGN.md 5.1: 17 hex chars starting with
|
|
114
|
+
'a') passes through unchanged -- it carries no user text, so it is not
|
|
115
|
+
redacted anywhere else in the tool. A non-conforming one (a corrupted
|
|
116
|
+
journal entry, a hand-edited or foreign transcript filename) is opaque
|
|
117
|
+
for a different reason: it might be anything, including a real name, so
|
|
118
|
+
it is replaced by 'agent-' + sha256(id)[:8] and never carried into a
|
|
119
|
+
Run/Attempt/Finding verbatim. The diagnostic names only the fixed
|
|
120
|
+
`context` and the replacement, never the original value."""
|
|
121
|
+
if agent_id and _AGENT_ID_RE.fullmatch(agent_id):
|
|
122
|
+
return agent_id
|
|
123
|
+
replacement = "agent-" + hashlib.sha256((agent_id or "").encode("utf-8")).hexdigest()[:8]
|
|
124
|
+
diagnostics.append(
|
|
125
|
+
f"{context}: agent id did not match the expected format, replaced with {replacement}"
|
|
126
|
+
)
|
|
127
|
+
return replacement
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def valid_workflow_run_id(name: str, diagnostics: list[str] | None = None) -> str:
|
|
131
|
+
"""A conforming workflow run id (docs/DESIGN.md 5.1:
|
|
132
|
+
'wf_' + 8 hex + '-' + 3 hex) passes through unchanged. A non-conforming
|
|
133
|
+
run directory name is replaced by 'wf_' + sha256(name)[:8] + '-x'; the
|
|
134
|
+
diagnostic never echoes the original directory name."""
|
|
135
|
+
if _WORKFLOW_RUN_ID_RE.fullmatch(name or ""):
|
|
136
|
+
return name
|
|
137
|
+
replacement = "wf_" + hashlib.sha256((name or "").encode("utf-8")).hexdigest()[:8] + "-x"
|
|
138
|
+
if diagnostics is not None:
|
|
139
|
+
diagnostics.append(
|
|
140
|
+
f"workflow run directory: run id did not match the expected format, "
|
|
141
|
+
f"replaced with {replacement}"
|
|
142
|
+
)
|
|
143
|
+
return replacement
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# Kept under its old name for the in-module callers; every other module
|
|
147
|
+
# (watch, hooks_cli) uses the public valid_workflow_run_id (N3b C2).
|
|
148
|
+
_valid_workflow_run_id = valid_workflow_run_id
|
|
149
|
+
|
|
150
|
+
# Every literal marker fragment a detector matches with a plain substring
|
|
151
|
+
# test, in one place, so _scan_markers can check all of them against a
|
|
152
|
+
# field's full text in a single pass.
|
|
153
|
+
_MARKER_FRAGMENTS: tuple[str, ...] = (
|
|
154
|
+
*BASH_BACKGROUNDED,
|
|
155
|
+
*BASH_TIMEOUT,
|
|
156
|
+
*TOOL_INPUT_INVALID,
|
|
157
|
+
*STRUCTURED_OUTPUT_MISMATCH,
|
|
158
|
+
*SANDBOX_WRITE_DENIED,
|
|
159
|
+
*CLASSIFIER_UNAVAILABLE,
|
|
160
|
+
*GIT_NOT_REPO,
|
|
161
|
+
*USAGE_LIMIT_SESSION,
|
|
162
|
+
*USAGE_LIMIT_WEEKLY,
|
|
163
|
+
*MACHINE_SLEEP,
|
|
164
|
+
*API_ERROR,
|
|
165
|
+
)
|
|
166
|
+
_SHELL_GUARD_RE = re.compile(SHELL_GUARD_BLOCK_REGEX)
|
|
167
|
+
_SHELL_GUARD_MARKER = "SHELL_GUARD_BLOCK_REGEX"
|
|
168
|
+
|
|
169
|
+
# A tool_result whose resolved tool_name is one of these wraps a child's own
|
|
170
|
+
# output inside the parent's transcript (docs/DESIGN.md 4.2): it is marked
|
|
171
|
+
# envelope=True with markers scrubbed so a child's error text quoted here is
|
|
172
|
+
# never double-counted.
|
|
173
|
+
_ENVELOPE_TOOL_NAMES = frozenset({"Agent", "Task", "Workflow", "TaskOutput"})
|
|
174
|
+
|
|
175
|
+
# docs/DESIGN.md 4.3's self-reference rule: a tool_result whose resolved
|
|
176
|
+
# tool_name is one of these is read_only_output=True unconditionally -- the
|
|
177
|
+
# tool can only ever report what is already on disk, never a runtime
|
|
178
|
+
# failure that happened to this agent.
|
|
179
|
+
_READ_ONLY_TOOL_NAMES = frozenset({"Read", "Grep", "Glob", "NotebookRead"})
|
|
180
|
+
|
|
181
|
+
# docs/DESIGN.md 4.3's self-reference rule (D-B): a Bash command counts as
|
|
182
|
+
# read-only when its text contains one of these read commands as a whole
|
|
183
|
+
# word ANYWHERE in it -- not only as the first word -- so a loop like `for f
|
|
184
|
+
# in ...; do echo "=== $f ==="; cat "$f"; done` is caught too, and none of
|
|
185
|
+
# _NOT_READ_ONLY_BASH_RE's write-indicating tokens (a real write, an
|
|
186
|
+
# interpreter/test runner invocation, or output redirection) appear anywhere
|
|
187
|
+
# in it either.
|
|
188
|
+
_READ_ONLY_BASH_RE = re.compile(
|
|
189
|
+
r"\b(?:cat|sed|head|tail|grep|rg|nl|less|more|bat|wc|diff)\b"
|
|
190
|
+
r"|\bgit\s+(?:show|diff|log|grep|blame)\b"
|
|
191
|
+
)
|
|
192
|
+
_NOT_READ_ONLY_BASH_RE = re.compile(
|
|
193
|
+
r"\b(?:node|codex|python|pytest|npm|npx|curl|make|tee)\b|\buv\s+run\b"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _is_read_only_bash_command(command: str | None) -> bool:
|
|
198
|
+
if not isinstance(command, str) or not command.strip():
|
|
199
|
+
return False
|
|
200
|
+
if ">" in command:
|
|
201
|
+
return False
|
|
202
|
+
if _NOT_READ_ONLY_BASH_RE.search(command):
|
|
203
|
+
return False
|
|
204
|
+
return bool(_READ_ONLY_BASH_RE.search(command))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _tool_result_read_only(
|
|
208
|
+
tool_name: str | None, tool_use_id: object, tool_commands: dict | None
|
|
209
|
+
) -> bool:
|
|
210
|
+
"""docs/DESIGN.md 4.3's self-reference rule (D-A): whether a tool_result
|
|
211
|
+
resolved to tool_name is read_only_output -- unconditionally for Read/
|
|
212
|
+
Grep/Glob/NotebookRead, or for Bash when the paired tool_use's command
|
|
213
|
+
(tracked in tool_commands by tool_use_id) is one of the read-only
|
|
214
|
+
forms."""
|
|
215
|
+
if tool_name in _READ_ONLY_TOOL_NAMES:
|
|
216
|
+
return True
|
|
217
|
+
if tool_name != "Bash" or tool_commands is None or not isinstance(tool_use_id, str):
|
|
218
|
+
return False
|
|
219
|
+
return _is_read_only_bash_command(tool_commands.get(tool_use_id))
|
|
220
|
+
|
|
221
|
+
_UUID_RE = re.compile(
|
|
222
|
+
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _exists(path: Path) -> bool:
|
|
227
|
+
"""Whether `path` may exist. Only an absent path (or a file under a non-directory) is False:
|
|
228
|
+
an unreadable one (a directory without the execute bit, an overlong name) is not proof of
|
|
229
|
+
absence, so the caller's own open reports it as an unreadable file, a diagnostic and never a
|
|
230
|
+
crash (F fix 8, W8-5). `Path.exists` raises `PermissionError` there."""
|
|
231
|
+
try:
|
|
232
|
+
path.stat()
|
|
233
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
234
|
+
return False
|
|
235
|
+
except OSError:
|
|
236
|
+
return True
|
|
237
|
+
except ValueError:
|
|
238
|
+
return False
|
|
239
|
+
return True
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _is_file(path: Path) -> bool:
|
|
243
|
+
"""`Path.is_file` that treats any `OSError` as "not a file" (older Pythons raise for an
|
|
244
|
+
unreadable parent directory)."""
|
|
245
|
+
try:
|
|
246
|
+
return path.is_file()
|
|
247
|
+
except (OSError, ValueError):
|
|
248
|
+
return False
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _is_dir(path: Path) -> bool:
|
|
252
|
+
"""`Path.is_dir` that treats any `OSError` as "not a directory"."""
|
|
253
|
+
try:
|
|
254
|
+
return path.is_dir()
|
|
255
|
+
except (OSError, ValueError):
|
|
256
|
+
return False
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class NotARun(Exception):
|
|
260
|
+
"""path is neither a workflow run directory (has journal.jsonl) nor a
|
|
261
|
+
session transcript (a .jsonl file whose first parsed line carries
|
|
262
|
+
sessionId). docs/DESIGN.md 4.2; the CLI turns this into exit 2."""
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class RunUnreadable(Exception):
|
|
266
|
+
"""docs/DESIGN.md 4.8 (0.3.4): load_run could not read the run at all, because a
|
|
267
|
+
RecursionError got past every parse site. run_id is the id the run would print (a workflow
|
|
268
|
+
id passed through valid_workflow_run_id, or a session's public s_ id), never a path, so a
|
|
269
|
+
caller can name the run at any disclosure level. The CLI turns this into exit 4."""
|
|
270
|
+
|
|
271
|
+
def __init__(self, run_id: str) -> None:
|
|
272
|
+
super().__init__(run_id)
|
|
273
|
+
self.run_id = run_id
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _scan_markers(text: str | None) -> frozenset[str]:
|
|
277
|
+
"""Which markers.py fragments (or, for the one regex marker, its name)
|
|
278
|
+
appear anywhere in text. Called on a field's full decoded text before
|
|
279
|
+
_excerpt bounds it, so a marker past the excerpt limit is still seen."""
|
|
280
|
+
if not text:
|
|
281
|
+
return frozenset()
|
|
282
|
+
hits = {fragment for fragment in _MARKER_FRAGMENTS if fragment in text}
|
|
283
|
+
if _SHELL_GUARD_RE.search(text):
|
|
284
|
+
hits.add(_SHELL_GUARD_MARKER)
|
|
285
|
+
return frozenset(hits)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# --- run discovery and dispatch --------------------------------------------
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@dataclass(frozen=True)
|
|
292
|
+
class RunRef:
|
|
293
|
+
kind: str # 'workflow' | 'session'
|
|
294
|
+
path: Path
|
|
295
|
+
run_id: str
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# Process-level registry backing _session_public_id's collision detection,
|
|
299
|
+
# mirroring ParentIndex's process-level cache below: every (slug, uuid) pair
|
|
300
|
+
# seen so far, keyed by the 10-hex id its payload hashes to. A second,
|
|
301
|
+
# distinct pair landing on the same key is a public-id collision.
|
|
302
|
+
_SESSION_ID_REGISTRY: dict[str, set[tuple[str, str]]] = {}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _session_id_digest(payload: str) -> str:
|
|
306
|
+
"""The full sha256 hex digest behind a session's public id. A thin,
|
|
307
|
+
monkeypatchable seam: a test forces a public-id collision between two
|
|
308
|
+
distinct (slug, uuid) pairs by replacing this function, since sha256
|
|
309
|
+
makes a real 10-hex collision practically unreachable."""
|
|
310
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _session_public_id(slug: str, uuid: str, diagnostics: list[str] | None = None) -> str:
|
|
314
|
+
"""'s_' + the first 10 hex chars of sha256(f"{slug}/{uuid}") (docs/
|
|
315
|
+
DESIGN.md 4.2). A second, distinct (slug, uuid) pair landing on the same
|
|
316
|
+
10-hex id switches both to a 12-hex id instead and, when diagnostics is
|
|
317
|
+
given, records the collision there."""
|
|
318
|
+
digest = _session_id_digest(f"{slug}/{uuid}")
|
|
319
|
+
short = digest[:10]
|
|
320
|
+
seen = _SESSION_ID_REGISTRY.setdefault(short, set())
|
|
321
|
+
seen.add((slug, uuid))
|
|
322
|
+
if len(seen) > 1:
|
|
323
|
+
if diagnostics is not None:
|
|
324
|
+
others = sorted(f"{s}/{u}" for s, u in seen if (s, u) != (slug, uuid))
|
|
325
|
+
diagnostics.append(
|
|
326
|
+
f"session public id collision on s_{short}: {slug}/{uuid} collides with "
|
|
327
|
+
f"{', '.join(others)}; using a 12-hex id instead"
|
|
328
|
+
)
|
|
329
|
+
return f"s_{digest[:12]}"
|
|
330
|
+
return f"s_{short}"
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def discover_runs(
|
|
334
|
+
root: Path, kinds: frozenset[str] = frozenset({"workflow", "session"}),
|
|
335
|
+
skipped: list[Path] | None = None,
|
|
336
|
+
) -> list[RunRef]:
|
|
337
|
+
"""Find every run of the given kinds under root, sorted by path.
|
|
338
|
+
|
|
339
|
+
Workflow refs: <root>/<project>/<session>/subagents/workflows/wf_*
|
|
340
|
+
(docs/DESIGN.md section 1). Session refs: <root>/<project>/<uuid>.jsonl.
|
|
341
|
+
"""
|
|
342
|
+
root = Path(root)
|
|
343
|
+
if not _exists(root):
|
|
344
|
+
return []
|
|
345
|
+
|
|
346
|
+
refs: list[RunRef] = []
|
|
347
|
+
if "workflow" in kinds:
|
|
348
|
+
for path in root.glob("*/*/subagents/workflows/wf_*"):
|
|
349
|
+
if _is_dir(path):
|
|
350
|
+
refs.append(RunRef(kind="workflow", path=path, run_id=path.name))
|
|
351
|
+
if "session" in kinds:
|
|
352
|
+
session_paths = []
|
|
353
|
+
for path in root.glob("*/*.jsonl"):
|
|
354
|
+
if not (_is_file(path) and _UUID_RE.fullmatch(path.stem)):
|
|
355
|
+
continue
|
|
356
|
+
is_run, unreadable = _conversation_probe(path)
|
|
357
|
+
if is_run:
|
|
358
|
+
session_paths.append(path)
|
|
359
|
+
elif unreadable and skipped is not None:
|
|
360
|
+
# docs/DESIGN.md 4.8 (0.3.4): a transcript with no readable line is not a run,
|
|
361
|
+
# but scan says it left one out
|
|
362
|
+
skipped.append(path)
|
|
363
|
+
# Register every (slug, uuid) pair in this batch before reading any
|
|
364
|
+
# of them back: a collision between two paths discovered together
|
|
365
|
+
# must give both the same 12-hex id, not just whichever is asked for
|
|
366
|
+
# second (docs/DESIGN.md 4.2).
|
|
367
|
+
for path in session_paths:
|
|
368
|
+
_session_public_id(path.parent.name, path.stem)
|
|
369
|
+
for path in session_paths:
|
|
370
|
+
run_id = _session_public_id(path.parent.name, path.stem)
|
|
371
|
+
refs.append(RunRef(kind="session", path=path, run_id=run_id))
|
|
372
|
+
|
|
373
|
+
refs.sort(key=lambda ref: ref.path)
|
|
374
|
+
return refs
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def has_run_candidates(root: Path) -> bool:
|
|
378
|
+
"""Whether root holds anything discover_runs could return: a workflow run directory or a
|
|
379
|
+
UUID-named session transcript (docs/DESIGN.md 4.8's empty state). Globs only and never
|
|
380
|
+
opens a file, so an error path that asks cannot fail on a transcript's content; a root
|
|
381
|
+
that cannot be listed answers True, so the caller keeps its original message."""
|
|
382
|
+
root = Path(root)
|
|
383
|
+
try:
|
|
384
|
+
if not _is_dir(root):
|
|
385
|
+
return False
|
|
386
|
+
# the same type checks discover_runs makes, without reading any content
|
|
387
|
+
if any(_is_dir(path) for path in root.glob("*/*/subagents/workflows/wf_*")):
|
|
388
|
+
return True
|
|
389
|
+
return any(
|
|
390
|
+
_is_file(path) and _UUID_RE.fullmatch(path.stem) for path in root.glob("*/*.jsonl")
|
|
391
|
+
)
|
|
392
|
+
except OSError:
|
|
393
|
+
return True
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
# docs/DESIGN.md 4.1 (G-C, GC-1): a final physical line with no trailing newline whose bytes
|
|
397
|
+
# are not valid UTF-8 is a half-written line (a live write cut inside a character); an
|
|
398
|
+
# undecodable byte anywhere else is the N4 C2 rule. A transcript is read strictly, the cost it
|
|
399
|
+
# always had; only a strict read that fails to decode, on a file whose last line is torn, goes
|
|
400
|
+
# on with errors="surrogateescape", where an undecodable byte shows up as one U+DC80..U+DCFF
|
|
401
|
+
# character in its line (GC-1b: scanning every line cost 4.3 s of CPU on a 248-run scan).
|
|
402
|
+
_UNDECODABLE_BYTE_RE = re.compile("[\udc80-\udcff]")
|
|
403
|
+
_TAIL_STEP = 65536
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _ends_in_torn_line(path: Path) -> bool:
|
|
407
|
+
"""Whether the bytes after the file's last line break are not valid UTF-8: a final line
|
|
408
|
+
with no newline, cut inside a character. Reads backwards from the end of the file."""
|
|
409
|
+
chunks: list[bytes] = []
|
|
410
|
+
try:
|
|
411
|
+
with path.open("rb") as handle:
|
|
412
|
+
position = handle.seek(0, os.SEEK_END)
|
|
413
|
+
while position > 0:
|
|
414
|
+
step = min(_TAIL_STEP, position)
|
|
415
|
+
position -= step
|
|
416
|
+
handle.seek(position)
|
|
417
|
+
chunk = handle.read(step)
|
|
418
|
+
cut = max(chunk.rfind(b"\n"), chunk.rfind(b"\r"))
|
|
419
|
+
if cut >= 0:
|
|
420
|
+
chunks.append(chunk[cut + 1 :])
|
|
421
|
+
break
|
|
422
|
+
chunks.append(chunk)
|
|
423
|
+
except OSError:
|
|
424
|
+
return False
|
|
425
|
+
tail = b"".join(reversed(chunks))
|
|
426
|
+
try:
|
|
427
|
+
tail.decode("utf-8")
|
|
428
|
+
except UnicodeDecodeError:
|
|
429
|
+
return True
|
|
430
|
+
return False
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _transcript_lines(path: Path):
|
|
434
|
+
"""(line_no, line, torn) for every physical line of a transcript, read strictly. When the
|
|
435
|
+
strict read fails to decode and the file's last line is torn, the read goes on from the
|
|
436
|
+
first line it had not given yet, with errors="surrogateescape": the torn last line comes
|
|
437
|
+
back once, with an empty `line` and torn True; an undecodable byte on any other line raises
|
|
438
|
+
UnicodeDecodeError, as the strict read did. OSError propagates."""
|
|
439
|
+
given = 0
|
|
440
|
+
try:
|
|
441
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
442
|
+
for line_no, line in enumerate(handle, start=1):
|
|
443
|
+
given = line_no
|
|
444
|
+
yield line_no, line, False
|
|
445
|
+
return
|
|
446
|
+
except UnicodeDecodeError:
|
|
447
|
+
if not _ends_in_torn_line(path):
|
|
448
|
+
raise
|
|
449
|
+
with path.open("r", encoding="utf-8", errors="surrogateescape") as handle:
|
|
450
|
+
for line_no, line in enumerate(handle, start=1):
|
|
451
|
+
if line_no <= given:
|
|
452
|
+
continue
|
|
453
|
+
if _UNDECODABLE_BYTE_RE.search(line):
|
|
454
|
+
if line.endswith("\n"):
|
|
455
|
+
raise UnicodeDecodeError(
|
|
456
|
+
"utf-8", b"", 0, 1, "undecodable byte before the end of a transcript line"
|
|
457
|
+
)
|
|
458
|
+
yield line_no, "", True
|
|
459
|
+
else:
|
|
460
|
+
yield line_no, line, False
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
@contextlib.contextmanager
|
|
464
|
+
def _transcript_reader(path: Path):
|
|
465
|
+
"""_transcript_lines(path), closed on exit, so a caller that stops early closes the file."""
|
|
466
|
+
lines = _transcript_lines(path)
|
|
467
|
+
try:
|
|
468
|
+
yield lines
|
|
469
|
+
finally:
|
|
470
|
+
lines.close()
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
# docs/DESIGN.md 4.1 (0.3.4): a line nested deeper than the parser can follow raises
|
|
474
|
+
# RecursionError, not a JSONDecodeError. Every read of run data treats it as a line that is not
|
|
475
|
+
# JSON, and load_run counts what its read skipped (Run.skipped_lines, Run.skipped_files).
|
|
476
|
+
_UNPARSEABLE = (json.JSONDecodeError, ValueError, RecursionError)
|
|
477
|
+
_TOO_DEEP = "nested too deeply to parse"
|
|
478
|
+
_PARENT_BAD_LINE = "undecodable parent transcript line"
|
|
479
|
+
_PARENT_NOT_OBJECT = "parent transcript line that is not a JSON object"
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
@dataclass
|
|
483
|
+
class _ReadSkips:
|
|
484
|
+
lines: int = 0
|
|
485
|
+
files: int = 0
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
_READ_SKIPS: contextvars.ContextVar[_ReadSkips | None] = contextvars.ContextVar(
|
|
489
|
+
"read_skips", default=None,
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _skipped_line(count: int = 1) -> None:
|
|
494
|
+
skips = _READ_SKIPS.get()
|
|
495
|
+
if skips is not None:
|
|
496
|
+
skips.lines += count
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _skipped_file() -> None:
|
|
500
|
+
skips = _READ_SKIPS.get()
|
|
501
|
+
if skips is not None:
|
|
502
|
+
skips.files += 1
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _unparseable_text(exc: BaseException) -> str:
|
|
506
|
+
return _TOO_DEEP if isinstance(exc, RecursionError) else str(exc)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _has_timestamped_conversation(path: Path) -> bool:
|
|
510
|
+
return _conversation_probe(path)[0]
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _conversation_probe(path: Path) -> tuple[bool, bool]:
|
|
514
|
+
"""(is a session run, holds no readable line at all). A session run is a file that carries
|
|
515
|
+
at least one 'user' or 'assistant' entry with a parseable timestamp (docs/DESIGN.md 4.2).
|
|
516
|
+
|
|
517
|
+
Real Claude Code project directories can hold one-line 'bridge-session'
|
|
518
|
+
files (type 'bridge-session', no timestamp, no user/assistant entry at
|
|
519
|
+
all) that are plumbing, not a run anyone had a conversation in; without
|
|
520
|
+
this check they would still match the '<uuid>.jsonl' discovery glob and
|
|
521
|
+
show up in scan as an empty session. Stops at the first qualifying
|
|
522
|
+
entry rather than reading the whole file: a real session's first
|
|
523
|
+
timestamped entry is typically near the top, and a file with none (the
|
|
524
|
+
only case that reads to EOF) is small by construction."""
|
|
525
|
+
if not _is_file(path) or path.suffix != ".jsonl":
|
|
526
|
+
return False, False
|
|
527
|
+
unparseable = False
|
|
528
|
+
parsed = False
|
|
529
|
+
try:
|
|
530
|
+
with _transcript_reader(path) as lines:
|
|
531
|
+
for _line_no, line, _torn in lines:
|
|
532
|
+
line = line.strip()
|
|
533
|
+
if not line:
|
|
534
|
+
continue
|
|
535
|
+
try:
|
|
536
|
+
entry = json.loads(line)
|
|
537
|
+
except _UNPARSEABLE:
|
|
538
|
+
unparseable = True
|
|
539
|
+
continue
|
|
540
|
+
parsed = True
|
|
541
|
+
if not isinstance(entry, dict):
|
|
542
|
+
continue
|
|
543
|
+
if entry.get("type") not in ("user", "assistant"):
|
|
544
|
+
continue
|
|
545
|
+
if _parse_ts(entry.get("timestamp")) is not None:
|
|
546
|
+
return True, False
|
|
547
|
+
except UnicodeError:
|
|
548
|
+
# bytes that are not UTF-8 before any qualifying entry: the file
|
|
549
|
+
# cannot be ruled out as a session, and its own read reports it
|
|
550
|
+
# unreadable (docs/DESIGN.md 4.1, N4 fix C2)
|
|
551
|
+
return True, False
|
|
552
|
+
except OSError:
|
|
553
|
+
return False, False
|
|
554
|
+
# (is a run, has no readable line at all): a file whose every line failed to parse is
|
|
555
|
+
# left out like any non-run, but discover_runs reports it (docs/DESIGN.md 4.8, 0.3.4)
|
|
556
|
+
return False, unparseable and not parsed
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _is_workflow_dir(path: Path) -> bool:
|
|
560
|
+
"""A run is a directory with its own journal.jsonl (docs/DESIGN.md
|
|
561
|
+
section 1)."""
|
|
562
|
+
return _is_dir(path) and _exists(path / "journal.jsonl")
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _sniff_session_file(path: Path) -> bool:
|
|
566
|
+
"""Whether the first *decodable* line of a .jsonl file carries sessionId
|
|
567
|
+
(docs/DESIGN.md 4.2's load_run dispatch: "a .jsonl file whose first
|
|
568
|
+
parsed line carries sessionId"), without parsing the rest of it. A
|
|
569
|
+
malformed physical line ahead of it is skipped, not decisive: it is
|
|
570
|
+
still recorded as a diagnostic once load_session actually reads the
|
|
571
|
+
file, same as any other malformed line."""
|
|
572
|
+
if not _is_file(path) or path.suffix != ".jsonl":
|
|
573
|
+
return False
|
|
574
|
+
try:
|
|
575
|
+
with _transcript_reader(path) as lines:
|
|
576
|
+
for _line_no, line, _torn in lines:
|
|
577
|
+
line = line.strip()
|
|
578
|
+
if not line:
|
|
579
|
+
continue
|
|
580
|
+
try:
|
|
581
|
+
entry = json.loads(line)
|
|
582
|
+
except _UNPARSEABLE:
|
|
583
|
+
continue
|
|
584
|
+
return isinstance(entry, dict) and "sessionId" in entry
|
|
585
|
+
except UnicodeError:
|
|
586
|
+
return True
|
|
587
|
+
except OSError:
|
|
588
|
+
return False
|
|
589
|
+
return False
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def load_run(ref_or_path: "RunRef | Path | str") -> Run:
|
|
594
|
+
"""Parse one run (a workflow directory or a session transcript) into a
|
|
595
|
+
Run. Dispatches on layout, per docs/DESIGN.md 4.2; raises NotARun for
|
|
596
|
+
anything else.
|
|
597
|
+
|
|
598
|
+
A RunRef already carries the kind discover_runs found it under, so it
|
|
599
|
+
is honored directly rather than re-sniffed: discover_runs's workflow
|
|
600
|
+
refs come from */*/subagents/workflows/wf_* directories regardless of
|
|
601
|
+
whether journal.jsonl exists yet (docs/DESIGN.md 4.2), and _load_workflow
|
|
602
|
+
already tolerates a missing journal.jsonl with a diagnostic. Re-checking
|
|
603
|
+
_is_workflow_dir here would raise NotARun for exactly the directories
|
|
604
|
+
discover_runs promised callers a RunRef for, aborting a fleet scan.
|
|
605
|
+
|
|
606
|
+
docs/DESIGN.md 4.1 (0.3.4): the lines and files this read skipped are counted on the Run.
|
|
607
|
+
"""
|
|
608
|
+
skips = _ReadSkips()
|
|
609
|
+
token = _READ_SKIPS.set(skips)
|
|
610
|
+
try:
|
|
611
|
+
run = _load_run(ref_or_path)
|
|
612
|
+
except RecursionError:
|
|
613
|
+
# docs/DESIGN.md 4.8 (0.3.4): the last guard, for a recursion no parse site caught
|
|
614
|
+
raise RunUnreadable(_public_run_id(ref_or_path)) from None
|
|
615
|
+
finally:
|
|
616
|
+
_READ_SKIPS.reset(token)
|
|
617
|
+
run.skipped_lines = skips.lines
|
|
618
|
+
run.skipped_files = skips.files
|
|
619
|
+
return run
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _public_run_id(ref_or_path: "RunRef | Path | str") -> str:
|
|
623
|
+
"""The id a run at ref_or_path prints, worked out without reading it (RunUnreadable)."""
|
|
624
|
+
if isinstance(ref_or_path, RunRef):
|
|
625
|
+
if ref_or_path.kind == "workflow":
|
|
626
|
+
return valid_workflow_run_id(ref_or_path.path.name)
|
|
627
|
+
return ref_or_path.run_id
|
|
628
|
+
path = Path(ref_or_path)
|
|
629
|
+
if path.suffix == ".jsonl" and not _is_dir(path):
|
|
630
|
+
return _session_public_id(path.parent.name, path.stem)
|
|
631
|
+
return valid_workflow_run_id(path.name)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _load_run(ref_or_path: "RunRef | Path | str") -> Run:
|
|
635
|
+
if isinstance(ref_or_path, RunRef):
|
|
636
|
+
if ref_or_path.kind == "workflow":
|
|
637
|
+
return _load_workflow(ref_or_path.path)
|
|
638
|
+
if ref_or_path.kind == "session":
|
|
639
|
+
return load_session(ref_or_path.path)
|
|
640
|
+
|
|
641
|
+
path = ref_or_path.path if isinstance(ref_or_path, RunRef) else Path(ref_or_path)
|
|
642
|
+
if _is_workflow_dir(path):
|
|
643
|
+
return _load_workflow(path)
|
|
644
|
+
if _sniff_session_file(path):
|
|
645
|
+
return load_session(path)
|
|
646
|
+
raise NotARun(str(path))
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
# --- workflow runs -----------------------------------------------------
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _load_workflow(path: Path) -> Run:
|
|
653
|
+
"""Parse one workflow run directory (journal.jsonl, meta files,
|
|
654
|
+
transcripts) into a Run."""
|
|
655
|
+
path = Path(path)
|
|
656
|
+
diagnostics: list[str] = []
|
|
657
|
+
run_id = _valid_workflow_run_id(path.name, diagnostics)
|
|
658
|
+
project_slug = _project_slug(path)
|
|
659
|
+
|
|
660
|
+
# docs/DESIGN.md 4.1/4.13.1 (N4b-1): Run.discovery keeps each source's
|
|
661
|
+
# own diagnostics, in the order reported, beside the flat `diagnostics`
|
|
662
|
+
# list (whose content and order are unchanged).
|
|
663
|
+
discovery: dict[str, list[str]] = {
|
|
664
|
+
"journal": [], "parent": [], "transcripts": [], "meta": [],
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
def note(source: str, text: str) -> None:
|
|
668
|
+
diagnostics.append(text)
|
|
669
|
+
if not isinstance(text, QualityNote):
|
|
670
|
+
discovery[source].append(text)
|
|
671
|
+
|
|
672
|
+
journal_start = len(diagnostics)
|
|
673
|
+
journal, started_events, journal_ok, journal_line_numbers = _read_journal(path, diagnostics)
|
|
674
|
+
discovery["journal"].extend(discovery_part(diagnostics[journal_start:]))
|
|
675
|
+
incomplete = not journal_ok
|
|
676
|
+
|
|
677
|
+
# docs/DESIGN.md 4.1 (unit M1a): the per-key state machine decides how
|
|
678
|
+
# each 'started' occurrence closes (or stays open); _workflow_finished_
|
|
679
|
+
# from_occurrences turns that into Run.finished below, and every
|
|
680
|
+
# diagnostic it raises (a mismatched agentId, an orphan result/failed)
|
|
681
|
+
# forces status INCOMPLETE, same as any other reader diagnostic.
|
|
682
|
+
journal_attempts, journal_sm_diagnostics = _run_journal_state_machine(
|
|
683
|
+
journal, journal_line_numbers
|
|
684
|
+
)
|
|
685
|
+
attempt_lookup = {id(occ.started_entry): occ for occ in journal_attempts}
|
|
686
|
+
diagnostics.extend(journal_sm_diagnostics)
|
|
687
|
+
discovery["journal"].extend(journal_sm_diagnostics)
|
|
688
|
+
incomplete = incomplete or bool(journal_sm_diagnostics)
|
|
689
|
+
# docs/DESIGN.md 4.6: line numbers for every journal entry, by identity,
|
|
690
|
+
# so a malformed 'started' entry's diagnostic can still cite
|
|
691
|
+
# 'journal.jsonl:<line>' below -- entries in `started_events` are the
|
|
692
|
+
# same dict objects `_read_journal` appended from `journal`, never copies.
|
|
693
|
+
entry_line_no = {id(entry): line_no for entry, line_no in zip(journal, journal_line_numbers)}
|
|
694
|
+
|
|
695
|
+
claude_version: str | None = None
|
|
696
|
+
session_id: str | None = None
|
|
697
|
+
all_event_ts: list[datetime] = []
|
|
698
|
+
attempts: list[Attempt] = []
|
|
699
|
+
agents_by_key: dict[str, LogicalAgent] = {}
|
|
700
|
+
ordinal_counts: dict[str | None, int] = {}
|
|
701
|
+
|
|
702
|
+
for entry in started_events:
|
|
703
|
+
raw_key = entry.get("key")
|
|
704
|
+
raw_agent_id_field = entry.get("agentId", "")
|
|
705
|
+
raw_label_field = entry.get("label", "")
|
|
706
|
+
raw_phase_field = entry.get("phase", "")
|
|
707
|
+
line_no = entry_line_no.get(id(entry))
|
|
708
|
+
|
|
709
|
+
# docs/DESIGN.md 4.6: a non-string key or agentId names no attempt
|
|
710
|
+
# this reader can safely build -- an int/list/bool key can never
|
|
711
|
+
# index agents_by_key the way a string key does below, and a
|
|
712
|
+
# non-string agentId used to crash _valid_agent_id (M1 review
|
|
713
|
+
# finding 2). Skip the whole entry, with a line-specific diagnostic,
|
|
714
|
+
# and force status INCOMPLETE, rather than build a malformed Attempt.
|
|
715
|
+
# A missing or explicit-null key/agentId is the existing, valid
|
|
716
|
+
# "unkeyed"/"blank id" shape and is untouched here; only a *present
|
|
717
|
+
# but wrongly typed* value is a problem.
|
|
718
|
+
if raw_key is not None and not isinstance(raw_key, str):
|
|
719
|
+
note("journal", f"journal.jsonl:{line_no}: started event has a non-string key")
|
|
720
|
+
incomplete = True
|
|
721
|
+
continue
|
|
722
|
+
if raw_agent_id_field is not None and not isinstance(raw_agent_id_field, str):
|
|
723
|
+
note("journal", f"journal.jsonl:{line_no}: started event has a non-string agentId")
|
|
724
|
+
incomplete = True
|
|
725
|
+
continue
|
|
726
|
+
|
|
727
|
+
key = raw_key
|
|
728
|
+
raw_agent_id = raw_agent_id_field if raw_agent_id_field is not None else ""
|
|
729
|
+
|
|
730
|
+
# docs/DESIGN.md 4.6 (N3b, C-5): a missing, null, or non-canonical
|
|
731
|
+
# string agentId still builds its Attempt below, exactly as before,
|
|
732
|
+
# but the journal entry is not a trustworthy identity, so the run is
|
|
733
|
+
# INCOMPLETE and the diagnostic names the line. (A non-string value
|
|
734
|
+
# was skipped above with its own diagnostic.)
|
|
735
|
+
canonical_agent_id = disclosure.is_canonical_id("agent", raw_agent_id_field)
|
|
736
|
+
if not canonical_agent_id:
|
|
737
|
+
note("journal", f"journal.jsonl:{line_no}: started event has no canonical agentId")
|
|
738
|
+
incomplete = True
|
|
739
|
+
|
|
740
|
+
# A non-string label or phase is cosmetic, never identifying: the
|
|
741
|
+
# entry still becomes an attempt, with the bad value replaced by ""
|
|
742
|
+
# (never None -- report.py's AGENT table formats it directly with
|
|
743
|
+
# f"{label:<22}", which raises on None) and a diagnostic recorded.
|
|
744
|
+
if isinstance(raw_label_field, str):
|
|
745
|
+
label = raw_label_field
|
|
746
|
+
else:
|
|
747
|
+
label = ""
|
|
748
|
+
if raw_label_field is not None:
|
|
749
|
+
note(
|
|
750
|
+
"journal",
|
|
751
|
+
QualityNote(f"journal.jsonl:{line_no}: started event has a non-string label"),
|
|
752
|
+
)
|
|
753
|
+
if isinstance(raw_phase_field, str):
|
|
754
|
+
phase = raw_phase_field
|
|
755
|
+
else:
|
|
756
|
+
phase = ""
|
|
757
|
+
if raw_phase_field is not None:
|
|
758
|
+
note(
|
|
759
|
+
"journal",
|
|
760
|
+
QualityNote(f"journal.jsonl:{line_no}: started event has a non-string phase"),
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
# journal_attempt is None only for an unkeyed entry (key is None --
|
|
764
|
+
# the state machine tracks attempts per key and has nothing to key
|
|
765
|
+
# an unkeyed entry's occurrence by either); a non-string key or
|
|
766
|
+
# agentId already continued past this point above. ordinal falls
|
|
767
|
+
# back to the same per-key counting v0.1 always did.
|
|
768
|
+
journal_attempt = attempt_lookup.get(id(entry))
|
|
769
|
+
if journal_attempt is not None:
|
|
770
|
+
ordinal = journal_attempt.ordinal
|
|
771
|
+
else:
|
|
772
|
+
ordinal_counts[key] = ordinal_counts.get(key, 0) + 1
|
|
773
|
+
ordinal = ordinal_counts[key]
|
|
774
|
+
|
|
775
|
+
# Filesystem lookups use the journal value only when it is a
|
|
776
|
+
# canonical agent id (N3b S14): the id is spliced into a file name,
|
|
777
|
+
# so a value such as `q/../../OUTSIDE` would name a file outside the
|
|
778
|
+
# run directory. A non-canonical id has no transcript and no meta
|
|
779
|
+
# file opened for it; the attempt is already INCOMPLETE (C-5 above).
|
|
780
|
+
meta_start = len(diagnostics)
|
|
781
|
+
meta = _load_meta(path, raw_agent_id, diagnostics) if canonical_agent_id else {}
|
|
782
|
+
discovery["meta"].extend(discovery_part(diagnostics[meta_start:]))
|
|
783
|
+
agent_type = meta.get("agentType")
|
|
784
|
+
id_start = len(diagnostics)
|
|
785
|
+
agent_id = _valid_agent_id(raw_agent_id, diagnostics, "journal.jsonl")
|
|
786
|
+
discovery["journal"].extend(discovery_part(diagnostics[id_start:]))
|
|
787
|
+
|
|
788
|
+
transcript_path = path / f"agent-{raw_agent_id}.jsonl"
|
|
789
|
+
unreadable = False
|
|
790
|
+
transcript_state = None
|
|
791
|
+
if canonical_agent_id and _exists(transcript_path):
|
|
792
|
+
transcript_start = len(diagnostics)
|
|
793
|
+
transcript_state = ledger.TranscriptState()
|
|
794
|
+
transcript_result = _read_transcript_tracked(
|
|
795
|
+
transcript_path, diagnostics, state=transcript_state
|
|
796
|
+
)
|
|
797
|
+
discovery["transcripts"].extend(discovery_part(diagnostics[transcript_start:]))
|
|
798
|
+
(
|
|
799
|
+
events, version, transcript_session_id, unreadable, over_threshold,
|
|
800
|
+
tool_calls, marker_hits,
|
|
801
|
+
) = transcript_result
|
|
802
|
+
incomplete = incomplete or unreadable or over_threshold
|
|
803
|
+
# docs/DESIGN.md 4.2 (F5 hardening): a duplicate tool_use_id
|
|
804
|
+
# anywhere in this attempt's transcript is a reader diagnostic
|
|
805
|
+
# like any other, so it forces Run.status INCOMPLETE too.
|
|
806
|
+
incomplete = incomplete or any(call.ambiguous for call in tool_calls)
|
|
807
|
+
if claude_version is None and version:
|
|
808
|
+
claude_version = version
|
|
809
|
+
if session_id is None and transcript_session_id:
|
|
810
|
+
session_id = transcript_session_id
|
|
811
|
+
else:
|
|
812
|
+
transcript_path = None
|
|
813
|
+
events = []
|
|
814
|
+
tool_calls = []
|
|
815
|
+
marker_hits = []
|
|
816
|
+
|
|
817
|
+
event_ts = [event.ts for event in events if event.ts is not None]
|
|
818
|
+
first_ts = min(event_ts) if event_ts else None
|
|
819
|
+
last_ts = max(event_ts) if event_ts else None
|
|
820
|
+
all_event_ts.extend(event_ts)
|
|
821
|
+
incomplete = incomplete or _edge_event_has_timestamp_diagnostic(events, diagnostics)
|
|
822
|
+
|
|
823
|
+
# docs/DESIGN.md 4.1: the journal state machine decides the final
|
|
824
|
+
# outcome whenever it closed this attempt ('completed' / 'failed'
|
|
825
|
+
# always win, even over a trailing kill sentinel; 'superseded'
|
|
826
|
+
# becomes 'killed' instead when the sentinel is present -- the v0.1
|
|
827
|
+
# silent retry, unchanged). An attempt the state machine never
|
|
828
|
+
# closed (still open at end of journal) falls back to the
|
|
829
|
+
# transcript alone, exactly as v0.1 always did for every attempt.
|
|
830
|
+
base_outcome = _compute_outcome(transcript_path, events)
|
|
831
|
+
closed_by = journal_attempt.closed_by if journal_attempt is not None else None
|
|
832
|
+
if closed_by == "completed":
|
|
833
|
+
outcome = "completed"
|
|
834
|
+
elif closed_by == "failed":
|
|
835
|
+
outcome = "failed"
|
|
836
|
+
elif closed_by == "superseded":
|
|
837
|
+
if base_outcome == "killed":
|
|
838
|
+
outcome = "killed"
|
|
839
|
+
else:
|
|
840
|
+
outcome = "superseded"
|
|
841
|
+
incomplete = True
|
|
842
|
+
note(
|
|
843
|
+
"journal",
|
|
844
|
+
KeyedDiagnostic(
|
|
845
|
+
f"journal.jsonl: key@{line_no} attempt {ordinal} was replaced by a "
|
|
846
|
+
"later attempt with no recorded outcome",
|
|
847
|
+
f"journal.jsonl: key {key!r} attempt {ordinal} was replaced by a "
|
|
848
|
+
"later attempt with no recorded outcome",
|
|
849
|
+
),
|
|
850
|
+
)
|
|
851
|
+
else:
|
|
852
|
+
outcome = base_outcome
|
|
853
|
+
|
|
854
|
+
# docs/DESIGN.md section 3/4.1: reported is read only for an
|
|
855
|
+
# attempt the state machine actually closed with a 'result' event
|
|
856
|
+
# (closed_by == 'completed') -- never for 'failed', 'superseded',
|
|
857
|
+
# or an attempt still open at the end of the journal, even when
|
|
858
|
+
# base_outcome alone would otherwise read 'completed' for one of
|
|
859
|
+
# those (an attempt with no journal-recorded result at all has
|
|
860
|
+
# nothing of its own to report).
|
|
861
|
+
reported = None
|
|
862
|
+
reported_line = None
|
|
863
|
+
self_report = None
|
|
864
|
+
if closed_by == "completed" and journal_attempt is not None:
|
|
865
|
+
reported = _build_reported(journal_attempt.closing_entry)
|
|
866
|
+
reported_line = journal_attempt.closing_line
|
|
867
|
+
# docs/DESIGN.md 4.3 (G3): only the shared predicate's answer survives, never a key
|
|
868
|
+
# name or a value
|
|
869
|
+
self_report = self_reported_problems(
|
|
870
|
+
journal_attempt.closing_entry.get("result")
|
|
871
|
+
if isinstance(journal_attempt.closing_entry, dict) else None
|
|
872
|
+
)
|
|
873
|
+
|
|
874
|
+
attempt = Attempt(
|
|
875
|
+
agent_id=agent_id,
|
|
876
|
+
key=key,
|
|
877
|
+
label=label,
|
|
878
|
+
phase=phase,
|
|
879
|
+
agent_type=agent_type,
|
|
880
|
+
meta=meta,
|
|
881
|
+
transcript_path=transcript_path,
|
|
882
|
+
events=events,
|
|
883
|
+
first_ts=first_ts,
|
|
884
|
+
last_ts=last_ts,
|
|
885
|
+
ordinal=ordinal,
|
|
886
|
+
outcome=outcome,
|
|
887
|
+
usage=_sum_usage(events),
|
|
888
|
+
reported=reported,
|
|
889
|
+
reported_line=reported_line,
|
|
890
|
+
_self_report=self_report,
|
|
891
|
+
tool_calls=tool_calls,
|
|
892
|
+
marker_hits=marker_hits,
|
|
893
|
+
# docs/DESIGN.md 4.1 (N4b-1): the state machine's own closure,
|
|
894
|
+
# None for an unkeyed entry (it has no occurrence).
|
|
895
|
+
journal_state=(
|
|
896
|
+
None if journal_attempt is None else (closed_by if closed_by else "open")
|
|
897
|
+
),
|
|
898
|
+
_transcript_state=transcript_state,
|
|
899
|
+
entry_cwds=tuple(transcript_state.cwds) if transcript_state is not None else (),
|
|
900
|
+
)
|
|
901
|
+
attempt.facts = _build_attempt_facts(
|
|
902
|
+
tool_calls, outcome, last_ts, transcript_path, unreadable
|
|
903
|
+
)
|
|
904
|
+
# Interrupt tracks the transcript's own kill sentinel, independent
|
|
905
|
+
# of the final outcome above (docs/DESIGN.md 4.4): a 'failed'
|
|
906
|
+
# attempt whose transcript also ends in the sentinel still keeps it
|
|
907
|
+
# as evidence rather than losing it to the journal's own verdict.
|
|
908
|
+
if base_outcome == "killed":
|
|
909
|
+
attempt.interrupt = _build_interrupt(attempt)
|
|
910
|
+
attempts.append(attempt)
|
|
911
|
+
|
|
912
|
+
if key is not None:
|
|
913
|
+
agent = agents_by_key.get(key)
|
|
914
|
+
if agent is None:
|
|
915
|
+
agent = LogicalAgent(key=key, label=label, phase=phase, attempts=[])
|
|
916
|
+
agents_by_key[key] = agent
|
|
917
|
+
agent.attempts.append(attempt)
|
|
918
|
+
|
|
919
|
+
# docs/DESIGN.md 4.13.1 (S1, N4 fix): a transcript or meta file of the run
|
|
920
|
+
# directory that no journal started event names.
|
|
921
|
+
discovery["transcripts"].extend(_unnamed_file_diagnostics(path, started_events))
|
|
922
|
+
|
|
923
|
+
run = Run(
|
|
924
|
+
run_id=run_id,
|
|
925
|
+
kind="workflow",
|
|
926
|
+
path=path,
|
|
927
|
+
project_slug=project_slug,
|
|
928
|
+
session_id=session_id,
|
|
929
|
+
claude_version=claude_version,
|
|
930
|
+
journal=journal,
|
|
931
|
+
agents=list(agents_by_key.values()),
|
|
932
|
+
attempts=attempts,
|
|
933
|
+
started_at=min(all_event_ts) if all_event_ts else None,
|
|
934
|
+
ended_at=max(all_event_ts) if all_event_ts else None,
|
|
935
|
+
diagnostics=diagnostics,
|
|
936
|
+
status="INCOMPLETE" if incomplete else "COMPLETE",
|
|
937
|
+
discovery=discovery,
|
|
938
|
+
)
|
|
939
|
+
# One parent-index snapshot serves interrupt attribution, the parent
|
|
940
|
+
# discovery source, and the lifecycle (and so Generation.result_ts):
|
|
941
|
+
# no second parent read (docs/DESIGN.md 4.13.1, N4b-1).
|
|
942
|
+
parent_index = _parent_index_for(run)
|
|
943
|
+
_attribute_interrupts(run, parent_index)
|
|
944
|
+
# docs/DESIGN.md section 3 (N2 root-cause fix): _lifecycle_from_parts is
|
|
945
|
+
# the one table _load_workflow and the public workflow_lifecycle (used
|
|
946
|
+
# by the hook, which has no already-parsed Run to reuse) both build on --
|
|
947
|
+
# hooks_cli's workflow-unit finished check (7.2) ultimately calls the
|
|
948
|
+
# same table (through lifecycle_finished), so this reader and that hook
|
|
949
|
+
# can never again drift into two different answers the way the N2
|
|
950
|
+
# checkpoint did. Unlike workflow_lifecycle, this call passes the
|
|
951
|
+
# journal resolution _load_workflow already computed above (N1N2 review
|
|
952
|
+
# finding 7) instead of letting workflow_lifecycle read and
|
|
953
|
+
# state-machine journal.jsonl a second time.
|
|
954
|
+
journal_resolved = _workflow_finished_from_occurrences(journal_attempts)
|
|
955
|
+
run.lifecycle = _lifecycle_from_parts(run.path, run_id, journal_resolved, parent_index)
|
|
956
|
+
run.finished = lifecycle_finished(run.lifecycle)
|
|
957
|
+
if run.lifecycle.terminal == "unknown":
|
|
958
|
+
# docs/DESIGN.md 4.13.3 (v0.3 N2): a terminal the reader could not
|
|
959
|
+
# pin down (a conflict, an unrecognized status, an ambiguous launch,
|
|
960
|
+
# an unreadable parent, an incomplete generation) is exactly the
|
|
961
|
+
# same "the reader could not fully establish what happened" shape
|
|
962
|
+
# 4.6 already forces INCOMPLETE for everywhere else.
|
|
963
|
+
run.status = "INCOMPLETE"
|
|
964
|
+
# docs/DESIGN.md 4.13.1 (N4b-2): the ledgers and the run's own risk
|
|
965
|
+
# population completeness, from the final discovery and generations.
|
|
966
|
+
ledger.attach_ledgers(run)
|
|
967
|
+
return run
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def _unnamed_file_diagnostics(run_dir: Path, started_events: list[dict]) -> list[str]:
|
|
971
|
+
"""One diagnostic per `agent-<id>.jsonl` or `agent-<id>.meta.json` regular
|
|
972
|
+
file of a workflow run directory whose id no journal `started` event names
|
|
973
|
+
as its `agentId` string, sorted by file name (docs/DESIGN.md 4.13.1, S1)."""
|
|
974
|
+
named = {
|
|
975
|
+
event["agentId"] for event in started_events if isinstance(event.get("agentId"), str)
|
|
976
|
+
}
|
|
977
|
+
unnamed: list[str] = []
|
|
978
|
+
for pattern, suffix in (("agent-*.jsonl", ".jsonl"), ("agent-*.meta.json", ".meta.json")):
|
|
979
|
+
for file_path in run_dir.glob(pattern):
|
|
980
|
+
agent_id = file_path.name.removeprefix("agent-").removesuffix(suffix)
|
|
981
|
+
if _is_file(file_path) and agent_id not in named:
|
|
982
|
+
unnamed.append(file_path.name)
|
|
983
|
+
return [
|
|
984
|
+
f"{name}: transcript named by no journal started event" for name in sorted(unnamed)
|
|
985
|
+
]
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def _project_slug(path: Path) -> str:
|
|
989
|
+
"""The project directory name, two levels above 'subagents' in path.
|
|
990
|
+
|
|
991
|
+
Falls back to the run directory's parent name when path was not reached
|
|
992
|
+
through the standard <root>/<project>/<session>/subagents/workflows/wf_*
|
|
993
|
+
layout (for example a fixture or a bare directory passed on the command
|
|
994
|
+
line), so load_run never raises just because of where it was called from.
|
|
995
|
+
"""
|
|
996
|
+
parts = path.parts
|
|
997
|
+
if "subagents" in parts:
|
|
998
|
+
idx = parts.index("subagents")
|
|
999
|
+
if idx >= 2:
|
|
1000
|
+
return parts[idx - 2]
|
|
1001
|
+
return path.parent.name
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def _read_journal(
|
|
1005
|
+
path: Path, diagnostics: list[str]
|
|
1006
|
+
) -> tuple[list[dict], list[dict], bool, list[int]]:
|
|
1007
|
+
"""journal, started_events, whether the journal could be parsed cleanly
|
|
1008
|
+
(docs/DESIGN.md 4.6: False when the file is missing or any line in it
|
|
1009
|
+
failed to decode as a JSON object, in which case Run.status must be
|
|
1010
|
+
INCOMPLETE rather than trusting a partial read), and line_numbers -- the
|
|
1011
|
+
1-based journal.jsonl line number of each entry in `journal`, in the same
|
|
1012
|
+
order, so a caller can later point evidence at the exact line a 'result'
|
|
1013
|
+
or 'failed' event came from (docs/DESIGN.md section 3,
|
|
1014
|
+
Attempt.reported_line)."""
|
|
1015
|
+
journal: list[dict] = []
|
|
1016
|
+
started_events: list[dict] = []
|
|
1017
|
+
line_numbers: list[int] = []
|
|
1018
|
+
journal_path = path / "journal.jsonl"
|
|
1019
|
+
if not _exists(journal_path):
|
|
1020
|
+
diagnostics.append("journal.jsonl: missing")
|
|
1021
|
+
return journal, started_events, False, line_numbers
|
|
1022
|
+
|
|
1023
|
+
ok = True
|
|
1024
|
+
try:
|
|
1025
|
+
with journal_path.open("r", encoding="utf-8") as handle:
|
|
1026
|
+
for line_no, line in enumerate(handle, start=1):
|
|
1027
|
+
# an unfinished last line (no newline yet) is a live write, not a skipped line
|
|
1028
|
+
skipped = 1 if line.endswith("\n") else 0
|
|
1029
|
+
line = line.strip()
|
|
1030
|
+
if not line:
|
|
1031
|
+
continue
|
|
1032
|
+
try:
|
|
1033
|
+
entry = json.loads(line)
|
|
1034
|
+
except _UNPARSEABLE as exc:
|
|
1035
|
+
diagnostics.append(f"journal.jsonl:{line_no}: {_unparseable_text(exc)}")
|
|
1036
|
+
_skipped_line(skipped)
|
|
1037
|
+
ok = False
|
|
1038
|
+
continue
|
|
1039
|
+
if not isinstance(entry, dict):
|
|
1040
|
+
diagnostics.append(f"journal.jsonl:{line_no}: not a JSON object")
|
|
1041
|
+
_skipped_line(skipped)
|
|
1042
|
+
ok = False
|
|
1043
|
+
continue
|
|
1044
|
+
journal.append(entry)
|
|
1045
|
+
line_numbers.append(line_no)
|
|
1046
|
+
if entry.get("type") == "started":
|
|
1047
|
+
started_events.append(entry)
|
|
1048
|
+
except OSError as exc:
|
|
1049
|
+
diagnostics.append(f"journal.jsonl: unreadable: {exc}")
|
|
1050
|
+
return journal, started_events, False, line_numbers
|
|
1051
|
+
except UnicodeError as exc:
|
|
1052
|
+
diagnostics.append(f"journal.jsonl: undecodable: {exc}")
|
|
1053
|
+
return journal, started_events, False, line_numbers
|
|
1054
|
+
return journal, started_events, ok, line_numbers
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
# --- per-key journal state machine (docs/DESIGN.md 4.1, unit M1a) ------
|
|
1058
|
+
#
|
|
1059
|
+
# Real Claude Code journals carry a `failed` event ("type", "key",
|
|
1060
|
+
# "agentId") that v0.1 never read, and a real `result` event carries
|
|
1061
|
+
# "type", "key", "agentId", "result" -- never the "outcome" field v0.1's
|
|
1062
|
+
# fixtures happened to include but no code ever looked at. Because `failed`
|
|
1063
|
+
# was ignored, a run whose every key actually ended (some by result, some
|
|
1064
|
+
# by failure) was shown as unfinished forever. `_run_journal_state_machine`
|
|
1065
|
+
# is the one place that tracks, per key, at most one open attempt at a
|
|
1066
|
+
# time, consuming events in file order. `workflow_lifecycle` builds on it
|
|
1067
|
+
# (via `_workflow_finished_from_occurrences`) for the journal-resolved row
|
|
1068
|
+
# of the run lifecycle table (docs/DESIGN.md section 3, 4.13.3) that
|
|
1069
|
+
# `_load_workflow`'s Run.finished and hooks_cli's workflow-unit finished
|
|
1070
|
+
# check both ultimately read off Lifecycle -- never this state machine
|
|
1071
|
+
# directly. `workflow_journal_finished` also builds on it, but only for
|
|
1072
|
+
# watch's own, deliberately narrower, journal-only DONE rule (4.12).
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
@dataclass
|
|
1076
|
+
class _JournalAttempt:
|
|
1077
|
+
"""One 'started' occurrence of a key and how the state machine below
|
|
1078
|
+
resolved it, in journal order. `closed_by` is None while the attempt
|
|
1079
|
+
is still open at the end of the journal. reader._load_workflow turns
|
|
1080
|
+
`closed_by` plus whether this attempt's own transcript ends in the kill
|
|
1081
|
+
sentinel into the Attempt's final `outcome`: 'completed' and 'failed'
|
|
1082
|
+
are final as soon as the state machine assigns them (even over a
|
|
1083
|
+
trailing sentinel: the sentinel still becomes Attempt.interrupt, kept
|
|
1084
|
+
as evidence, but the journal's own report of what happened wins); a
|
|
1085
|
+
'superseded' occurrence becomes 'killed' instead when its transcript
|
|
1086
|
+
carries the sentinel (the v0.1 silent retry, unchanged); an occurrence
|
|
1087
|
+
that is still open falls back to the transcript alone, exactly as
|
|
1088
|
+
v0.1 always did for every attempt."""
|
|
1089
|
+
|
|
1090
|
+
key: str
|
|
1091
|
+
agent_id: str
|
|
1092
|
+
ordinal: int
|
|
1093
|
+
started_entry: dict
|
|
1094
|
+
closed_by: str | None = None # None | 'completed' | 'failed' | 'superseded'
|
|
1095
|
+
closing_entry: dict | None = None
|
|
1096
|
+
# The 1-based journal.jsonl line number closing_entry came from, or None
|
|
1097
|
+
# when line numbers were not supplied to the state machine (callers that
|
|
1098
|
+
# only care about 'finished', such as workflow_journal_finished, never
|
|
1099
|
+
# pass them). _load_workflow uses this to set Attempt.reported_line.
|
|
1100
|
+
closing_line: int | None = None
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
def _run_journal_state_machine(
|
|
1104
|
+
journal: list[dict],
|
|
1105
|
+
line_numbers: list[int] | None = None,
|
|
1106
|
+
) -> tuple[list[_JournalAttempt], list[str]]:
|
|
1107
|
+
"""The per-key attempt state machine of docs/DESIGN.md 4.1: at most one
|
|
1108
|
+
attempt is open per key at a time, events consumed in file order.
|
|
1109
|
+
|
|
1110
|
+
- 'started' with no attempt open for its key opens attempt n+1.
|
|
1111
|
+
- 'started' while one is open closes it 'superseded' (reader.py may
|
|
1112
|
+
reclassify this as 'killed' once it has the transcript; see
|
|
1113
|
+
_JournalAttempt above), then opens n+1.
|
|
1114
|
+
- 'result' / 'failed' whose agentId equals the open attempt's, or that
|
|
1115
|
+
carries no agentId at all, closes it 'completed' / 'failed'.
|
|
1116
|
+
- 'result' / 'failed' with a different agentId, or with no attempt open
|
|
1117
|
+
for its key, closes nothing and adds a diagnostic.
|
|
1118
|
+
|
|
1119
|
+
An entry that is not a dict, or whose 'key' is not a string, is skipped
|
|
1120
|
+
silently: _read_journal already turned a line that could not become a
|
|
1121
|
+
JSON object into its own diagnostic, and a keyless entry names no
|
|
1122
|
+
attempt this state machine could ever resolve. Returns (occurrences,
|
|
1123
|
+
diagnostics); occurrences are in the same order as their 'started'
|
|
1124
|
+
events.
|
|
1125
|
+
|
|
1126
|
+
line_numbers, when given, is parallel to `journal` (as _read_journal
|
|
1127
|
+
returns it) and fills in each occurrence's `closing_line`. Omitted by
|
|
1128
|
+
every caller that only needs 'finished' (workflow_journal_finished), so
|
|
1129
|
+
it defaults to None for every occurrence rather than requiring every
|
|
1130
|
+
caller to thread a matching list through."""
|
|
1131
|
+
open_by_key: dict[str, _JournalAttempt] = {}
|
|
1132
|
+
ordinal_counts: dict[str, int] = {}
|
|
1133
|
+
occurrences: list[_JournalAttempt] = []
|
|
1134
|
+
diagnostics: list[str] = []
|
|
1135
|
+
if line_numbers is None:
|
|
1136
|
+
line_numbers = [None] * len(journal)
|
|
1137
|
+
|
|
1138
|
+
for entry, line_no in zip(journal, line_numbers):
|
|
1139
|
+
if not isinstance(entry, dict):
|
|
1140
|
+
continue
|
|
1141
|
+
key = entry.get("key")
|
|
1142
|
+
if not isinstance(key, str):
|
|
1143
|
+
continue
|
|
1144
|
+
etype = entry.get("type")
|
|
1145
|
+
|
|
1146
|
+
if etype == "started":
|
|
1147
|
+
raw_agent_id = entry.get("agentId")
|
|
1148
|
+
agent_id = raw_agent_id if isinstance(raw_agent_id, str) else ""
|
|
1149
|
+
current = open_by_key.get(key)
|
|
1150
|
+
if current is not None:
|
|
1151
|
+
current.closed_by = "superseded"
|
|
1152
|
+
current.closing_entry = entry
|
|
1153
|
+
current.closing_line = line_no
|
|
1154
|
+
ordinal_counts[key] = ordinal_counts.get(key, 0) + 1
|
|
1155
|
+
occurrence = _JournalAttempt(
|
|
1156
|
+
key=key, agent_id=agent_id, ordinal=ordinal_counts[key], started_entry=entry,
|
|
1157
|
+
)
|
|
1158
|
+
occurrences.append(occurrence)
|
|
1159
|
+
open_by_key[key] = occurrence
|
|
1160
|
+
elif etype in ("result", "failed"):
|
|
1161
|
+
current = open_by_key.get(key)
|
|
1162
|
+
event_agent_id = entry.get("agentId")
|
|
1163
|
+
if current is None:
|
|
1164
|
+
diagnostics.append(
|
|
1165
|
+
KeyedDiagnostic(
|
|
1166
|
+
f"journal.jsonl: {etype!r} event for key@{line_no} has no open attempt",
|
|
1167
|
+
f"journal.jsonl: {etype!r} event for key {key!r} has no open attempt",
|
|
1168
|
+
)
|
|
1169
|
+
)
|
|
1170
|
+
continue
|
|
1171
|
+
if event_agent_id is not None and event_agent_id != current.agent_id:
|
|
1172
|
+
diagnostics.append(
|
|
1173
|
+
KeyedDiagnostic(
|
|
1174
|
+
f"journal.jsonl: {etype!r} event for key@{line_no} does not match "
|
|
1175
|
+
"the open attempt's agent id",
|
|
1176
|
+
f"journal.jsonl: {etype!r} event for key {key!r} does not match "
|
|
1177
|
+
"the open attempt's agent id",
|
|
1178
|
+
)
|
|
1179
|
+
)
|
|
1180
|
+
continue
|
|
1181
|
+
current.closed_by = "completed" if etype == "result" else "failed"
|
|
1182
|
+
current.closing_entry = entry
|
|
1183
|
+
current.closing_line = line_no
|
|
1184
|
+
del open_by_key[key]
|
|
1185
|
+
# every other type (launched, unknown types) is ignored here, same
|
|
1186
|
+
# as everywhere else in the reader (docs/DESIGN.md 3).
|
|
1187
|
+
|
|
1188
|
+
return occurrences, diagnostics
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
# --- Attempt.reported: the agent's own self-reported result (docs/ -----
|
|
1192
|
+
# --- DESIGN.md section 3, 4.1; .reviews/proposals/2026-09-v0.2-guided- ---
|
|
1193
|
+
# --- rescue.md section 4.3) ----------------------------------------------
|
|
1194
|
+
#
|
|
1195
|
+
# A real journal 'result' event's own value is a dict in most cases and a
|
|
1196
|
+
# string in the rest (measured: 483/705 dict, 222/705 string on real data).
|
|
1197
|
+
# A dict's values are the agent's self-reported claims about its own work
|
|
1198
|
+
# (tests_passed, ok, report_path, ...); many of those observed keys (note,
|
|
1199
|
+
# summary, bytes, ...) are free text or otherwise unbounded and are never
|
|
1200
|
+
# carried past the key name itself. A string result can run to 25k chars
|
|
1201
|
+
# and is private end to end: only its length ever leaves this function.
|
|
1202
|
+
|
|
1203
|
+
_REPORTED_MAX_KEYS = 40
|
|
1204
|
+
_REPORTED_KEY_NAME_LIMIT = 64
|
|
1205
|
+
_REPORTED_STATUS_LIMIT = 32
|
|
1206
|
+
_REPORTED_REPORT_PATH_LIMIT = 1024
|
|
1207
|
+
_REPORTED_MAX_COMMITS = 20
|
|
1208
|
+
_COMMIT_HEX_RE = re.compile(r"[0-9a-f]{7,40}")
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
def _reported_claims(value: dict) -> dict:
|
|
1212
|
+
"""The allowlisted subset of a dict result's own claims (docs/DESIGN.md
|
|
1213
|
+
section 3): only these exact keys, only when present with the one
|
|
1214
|
+
shape each is allowed to have; every other key or shape is dropped
|
|
1215
|
+
silently (it still shows up in the sibling 'keys' list, never here).
|
|
1216
|
+
`bool` is checked with `type(v) is int` for the integer claims (not
|
|
1217
|
+
`isinstance`), since Python's bool is a subclass of int and 'ok'/
|
|
1218
|
+
'ready' already have their own, separate bool-only claims."""
|
|
1219
|
+
claims: dict = {}
|
|
1220
|
+
|
|
1221
|
+
for key in ("ok", "ready"):
|
|
1222
|
+
if key in value and isinstance(value[key], bool):
|
|
1223
|
+
claims[key] = value[key]
|
|
1224
|
+
|
|
1225
|
+
if "status" in value:
|
|
1226
|
+
status = value["status"]
|
|
1227
|
+
if isinstance(status, str) and len(status) <= _REPORTED_STATUS_LIMIT:
|
|
1228
|
+
claims["status"] = status
|
|
1229
|
+
|
|
1230
|
+
for key in ("tests_passed", "tests_failed", "suite_passed", "suite_failed"):
|
|
1231
|
+
if key in value and type(value[key]) is int:
|
|
1232
|
+
claims[key] = value[key]
|
|
1233
|
+
|
|
1234
|
+
if "blockers" in value and isinstance(value["blockers"], list):
|
|
1235
|
+
claims["blockers_count"] = len(value["blockers"])
|
|
1236
|
+
|
|
1237
|
+
if "commits" in value and isinstance(value["commits"], list):
|
|
1238
|
+
commits = value["commits"]
|
|
1239
|
+
claims["commits_count"] = len(commits)
|
|
1240
|
+
claims["commits"] = [
|
|
1241
|
+
item for item in commits if isinstance(item, str) and _COMMIT_HEX_RE.fullmatch(item)
|
|
1242
|
+
][:_REPORTED_MAX_COMMITS]
|
|
1243
|
+
|
|
1244
|
+
if "report_path" in value:
|
|
1245
|
+
report_path = value["report_path"]
|
|
1246
|
+
if isinstance(report_path, str) and len(report_path) <= _REPORTED_REPORT_PATH_LIMIT:
|
|
1247
|
+
claims["report_path"] = report_path
|
|
1248
|
+
|
|
1249
|
+
return claims
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
def _build_reported(closing_entry: dict) -> dict | None:
|
|
1253
|
+
"""Attempt.reported for an attempt the journal state machine closed with
|
|
1254
|
+
a 'result' event (docs/DESIGN.md section 3): None for a 'failed' event,
|
|
1255
|
+
for one that never closed, or for a malformed/absent `result` value.
|
|
1256
|
+
Never reads anything the journal did not itself carry on this one
|
|
1257
|
+
entry."""
|
|
1258
|
+
value = closing_entry.get("result") if isinstance(closing_entry, dict) else None
|
|
1259
|
+
if isinstance(value, str):
|
|
1260
|
+
return {"type": "text", "text_length": len(value)}
|
|
1261
|
+
if isinstance(value, dict):
|
|
1262
|
+
keys = [key[:_REPORTED_KEY_NAME_LIMIT] for key in sorted(value.keys())]
|
|
1263
|
+
return {
|
|
1264
|
+
"type": "dict",
|
|
1265
|
+
"keys": keys[:_REPORTED_MAX_KEYS],
|
|
1266
|
+
"claims": _reported_claims(value),
|
|
1267
|
+
}
|
|
1268
|
+
return None
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
def _workflow_finished_from_occurrences(occurrences: list[_JournalAttempt]) -> bool:
|
|
1272
|
+
"""docs/DESIGN.md 4.1: a key is resolved once it has no open attempt.
|
|
1273
|
+
True when there is at least one (started) key and every key is
|
|
1274
|
+
resolved -- the last occurrence recorded for each key is the one that
|
|
1275
|
+
matters, since an earlier occurrence of the same key is always closed
|
|
1276
|
+
(superseded) the moment a later one starts."""
|
|
1277
|
+
last_by_key: dict[str, _JournalAttempt] = {}
|
|
1278
|
+
for occurrence in occurrences:
|
|
1279
|
+
last_by_key[occurrence.key] = occurrence
|
|
1280
|
+
if not last_by_key:
|
|
1281
|
+
return False
|
|
1282
|
+
return all(occurrence.closed_by is not None for occurrence in last_by_key.values())
|
|
1283
|
+
|
|
1284
|
+
|
|
1285
|
+
def workflow_journal_finished(journal: list[dict]) -> bool:
|
|
1286
|
+
"""docs/DESIGN.md 4.1 and 4.12: whether every key the journal started has
|
|
1287
|
+
been resolved by the per-key state machine above -- the journal-only
|
|
1288
|
+
reading of 'finished', never the full definition of Run.finished (that
|
|
1289
|
+
is workflow_lifecycle/lifecycle_finished, docs/DESIGN.md section 3). This
|
|
1290
|
+
narrower rule is kept, and called out by name, for exactly one caller:
|
|
1291
|
+
watch's DONE event (4.12), which deliberately tracks the journal alone as
|
|
1292
|
+
a live progress signal while it is still growing, not the parent's own
|
|
1293
|
+
(slower-arriving) terminal evidence. Not used by _load_workflow or by
|
|
1294
|
+
hooks_cli._workflow_finished since the N2 root-cause fix -- both of
|
|
1295
|
+
those now go through workflow_lifecycle so they can never again drift
|
|
1296
|
+
into two different answers for the same run (the N2 checkpoint had
|
|
1297
|
+
hooks_cli still calling this function directly, which is exactly how
|
|
1298
|
+
that drift happened)."""
|
|
1299
|
+
occurrences, _ = _run_journal_state_machine(journal)
|
|
1300
|
+
return _workflow_finished_from_occurrences(occurrences)
|
|
1301
|
+
|
|
1302
|
+
|
|
1303
|
+
def _load_meta_file(meta_path: Path, diagnostics: list[str]) -> dict:
|
|
1304
|
+
if not _exists(meta_path):
|
|
1305
|
+
return {}
|
|
1306
|
+
try:
|
|
1307
|
+
with meta_path.open("r", encoding="utf-8") as handle:
|
|
1308
|
+
data = json.load(handle)
|
|
1309
|
+
except (*_UNPARSEABLE, OSError) as exc:
|
|
1310
|
+
diagnostics.append(f"{meta_path.name}: {_unparseable_text(exc)}")
|
|
1311
|
+
_skipped_file()
|
|
1312
|
+
return {}
|
|
1313
|
+
if not isinstance(data, dict):
|
|
1314
|
+
diagnostics.append(f"{meta_path.name}: not a JSON object")
|
|
1315
|
+
return {}
|
|
1316
|
+
return data
|
|
1317
|
+
|
|
1318
|
+
|
|
1319
|
+
def _load_meta(run_path: Path, agent_id: str, diagnostics: list[str]) -> dict:
|
|
1320
|
+
return _load_meta_file(run_path / f"agent-{agent_id}.meta.json", diagnostics)
|
|
1321
|
+
|
|
1322
|
+
|
|
1323
|
+
# --- session runs -----------------------------------------------------
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
def _session_dir(path: Path) -> Path:
|
|
1327
|
+
"""The <root>/<slug>/<session>/ directory sitting next to a session's
|
|
1328
|
+
own <session>.jsonl transcript, holding its subagents/ subdirectory."""
|
|
1329
|
+
return path.parent / path.stem
|
|
1330
|
+
|
|
1331
|
+
|
|
1332
|
+
def _discover_session_agent_paths(path: Path) -> list[Path]:
|
|
1333
|
+
subagents_dir = _session_dir(path) / "subagents"
|
|
1334
|
+
if not _is_dir(subagents_dir):
|
|
1335
|
+
return []
|
|
1336
|
+
return sorted(p for p in subagents_dir.glob("agent-*.jsonl") if _is_file(p))
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
def _orphan_meta_diagnostics(main_path: Path) -> list[str]:
|
|
1340
|
+
"""One diagnostic per canonical `subagents/agent-<id>.meta.json` of the
|
|
1341
|
+
session directory whose `agent-<id>.jsonl` is not a file, in sorted id
|
|
1342
|
+
order (docs/DESIGN.md 4.13.1, C1). The meta file's content is not read."""
|
|
1343
|
+
subagents_dir = _session_dir(main_path) / "subagents"
|
|
1344
|
+
if not _is_dir(subagents_dir):
|
|
1345
|
+
return []
|
|
1346
|
+
orphans: list[str] = []
|
|
1347
|
+
for meta_path in subagents_dir.glob("agent-*.meta.json"):
|
|
1348
|
+
agent_id = meta_path.name.removeprefix("agent-").removesuffix(".meta.json")
|
|
1349
|
+
if (
|
|
1350
|
+
_is_file(meta_path)
|
|
1351
|
+
and disclosure.is_canonical_id("agent", agent_id)
|
|
1352
|
+
and not _is_file(subagents_dir / f"agent-{agent_id}.jsonl")
|
|
1353
|
+
):
|
|
1354
|
+
orphans.append(agent_id)
|
|
1355
|
+
return [
|
|
1356
|
+
f"agent-{agent_id}.meta.json: child named by a meta file has no transcript"
|
|
1357
|
+
for agent_id in sorted(orphans)
|
|
1358
|
+
]
|
|
1359
|
+
|
|
1360
|
+
|
|
1361
|
+
def _parse_main_attempt(
|
|
1362
|
+
main_path: Path,
|
|
1363
|
+
) -> tuple[Attempt, str | None, str | None, list[str], bool, list[datetime]]:
|
|
1364
|
+
"""(main_attempt, claude_version, session_id, diagnostics, incomplete,
|
|
1365
|
+
event_timestamps) for main_path alone, with no subagent involved. The
|
|
1366
|
+
building block both load_session (one fresh parse) and
|
|
1367
|
+
load_session_view (docs/DESIGN.md 7.2, D6: optionally cached across
|
|
1368
|
+
several calls within one hook invocation) share."""
|
|
1369
|
+
diagnostics: list[str] = []
|
|
1370
|
+
launch_log = _LaunchLog()
|
|
1371
|
+
main_state = ledger.TranscriptState()
|
|
1372
|
+
# docs/DESIGN.md 4.2 (FA4): a fork's inherited prefix is the parent's, not this
|
|
1373
|
+
# session's; the same raw boundary ParentIndex uses.
|
|
1374
|
+
inherited = _fork_boundary(main_path)
|
|
1375
|
+
(
|
|
1376
|
+
main_events, claude_version, session_id, unreadable, over_threshold, main_tool_calls,
|
|
1377
|
+
main_marker_hits,
|
|
1378
|
+
) = _read_transcript_tracked(
|
|
1379
|
+
main_path, diagnostics, launch_log=launch_log, state=main_state,
|
|
1380
|
+
skip_through_line=inherited,
|
|
1381
|
+
)
|
|
1382
|
+
if inherited and not unreadable:
|
|
1383
|
+
diagnostics.append(QualityNote(
|
|
1384
|
+
f"inherited {inherited} leading lines from another session (a fork); "
|
|
1385
|
+
"they are that session's"
|
|
1386
|
+
))
|
|
1387
|
+
incomplete = unreadable or over_threshold
|
|
1388
|
+
incomplete = incomplete or any(call.ambiguous for call in main_tool_calls)
|
|
1389
|
+
main_ts = [event.ts for event in main_events if event.ts is not None]
|
|
1390
|
+
incomplete = incomplete or _edge_event_has_timestamp_diagnostic(main_events, diagnostics)
|
|
1391
|
+
main_outcome = _compute_outcome(main_path, main_events)
|
|
1392
|
+
main_last_ts = max(main_ts) if main_ts else None
|
|
1393
|
+
main_attempt = Attempt(
|
|
1394
|
+
agent_id=main_path.stem,
|
|
1395
|
+
key=None,
|
|
1396
|
+
label="main",
|
|
1397
|
+
phase="",
|
|
1398
|
+
agent_type=None,
|
|
1399
|
+
meta={},
|
|
1400
|
+
transcript_path=main_path,
|
|
1401
|
+
events=main_events,
|
|
1402
|
+
first_ts=min(main_ts) if main_ts else None,
|
|
1403
|
+
last_ts=main_last_ts,
|
|
1404
|
+
ordinal=1,
|
|
1405
|
+
outcome=main_outcome,
|
|
1406
|
+
usage=_sum_usage(main_events),
|
|
1407
|
+
tool_calls=main_tool_calls,
|
|
1408
|
+
marker_hits=main_marker_hits,
|
|
1409
|
+
_launch_log=launch_log,
|
|
1410
|
+
_transcript_state=main_state,
|
|
1411
|
+
entry_cwds=tuple(main_state.cwds),
|
|
1412
|
+
)
|
|
1413
|
+
main_attempt.facts = _build_attempt_facts(
|
|
1414
|
+
main_tool_calls, main_outcome, main_last_ts, main_path, unreadable
|
|
1415
|
+
)
|
|
1416
|
+
if main_outcome == "killed":
|
|
1417
|
+
main_attempt.interrupt = _build_interrupt(main_attempt)
|
|
1418
|
+
return main_attempt, claude_version, session_id, diagnostics, incomplete, main_ts
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
def _load_session_from_paths(
|
|
1422
|
+
main_path: Path, agent_paths: list[Path], *, main_cache: dict | None = None,
|
|
1423
|
+
reconcile: bool = False,
|
|
1424
|
+
) -> Run:
|
|
1425
|
+
"""Parse a session run from an explicit main transcript plus an
|
|
1426
|
+
explicit list of subagent transcript paths (docs/DESIGN.md 4.2, 7.2's
|
|
1427
|
+
D6): the shared body of load_session (every subagent discovered under
|
|
1428
|
+
the session directory) and load_session_view (only the named ones, for
|
|
1429
|
+
an isolated single-subagent analysis).
|
|
1430
|
+
|
|
1431
|
+
`main_cache`, when given, is a plain dict the caller owns and reuses
|
|
1432
|
+
across several calls: the main transcript is then parsed at most once
|
|
1433
|
+
per dict, keyed by path, rather than once per call (docs/DESIGN.md
|
|
1434
|
+
7.2's "the main transcript is parsed once per invocation"). load_session
|
|
1435
|
+
never passes one, so its own behavior (and cost) is unchanged.
|
|
1436
|
+
|
|
1437
|
+
Raises NotARun (docs/DESIGN.md 4.2) for a main_path with no timestamped
|
|
1438
|
+
user/assistant entry -- a bridge-session marker, for example -- the same
|
|
1439
|
+
rule discover_runs applies, so a direct call here is never fooled by one
|
|
1440
|
+
that slipped past discovery some other way."""
|
|
1441
|
+
main_path = Path(main_path)
|
|
1442
|
+
if not _has_timestamped_conversation(main_path):
|
|
1443
|
+
raise NotARun(str(main_path))
|
|
1444
|
+
diagnostics: list[str] = []
|
|
1445
|
+
slug = main_path.parent.name
|
|
1446
|
+
uuid = main_path.stem
|
|
1447
|
+
run_id = _session_public_id(slug, uuid, diagnostics)
|
|
1448
|
+
|
|
1449
|
+
cached = main_cache.get(main_path) if main_cache is not None else None
|
|
1450
|
+
if cached is None:
|
|
1451
|
+
cached = _parse_main_attempt(main_path)
|
|
1452
|
+
if main_cache is not None:
|
|
1453
|
+
main_cache[main_path] = cached
|
|
1454
|
+
main_attempt, claude_version, session_id, main_diagnostics, incomplete, main_ts = cached
|
|
1455
|
+
if main_cache is not None:
|
|
1456
|
+
# The cached parse is shared by every view of this invocation, but a
|
|
1457
|
+
# ledger depends on the run it belongs to (its discovery), so each run
|
|
1458
|
+
# gets its own copy of the main attempt.
|
|
1459
|
+
main_attempt = copy.copy(main_attempt)
|
|
1460
|
+
diagnostics.extend(main_diagnostics)
|
|
1461
|
+
# docs/DESIGN.md 4.1/4.13.1 (N4b-1): per-source diagnostics beside the
|
|
1462
|
+
# flat list. A session has no journal.
|
|
1463
|
+
discovery: dict[str, list[str]] = {
|
|
1464
|
+
"journal": [], "parent": [], "transcripts": discovery_part(main_diagnostics),
|
|
1465
|
+
"meta": [],
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
all_ts = list(main_ts)
|
|
1469
|
+
agents: list[LogicalAgent] = []
|
|
1470
|
+
attempts: list[Attempt] = []
|
|
1471
|
+
|
|
1472
|
+
# docs/DESIGN.md 4.13.1 (G-A): the facts every analyzed child's transcript recorded, for the
|
|
1473
|
+
# return decision (resume requests may come from any transcript of the session).
|
|
1474
|
+
child_facts: dict[Path, returns.ReturnFacts] = {}
|
|
1475
|
+
child_meta: list[tuple[Attempt, str, dict, bool]] = []
|
|
1476
|
+
|
|
1477
|
+
for agent_path in agent_paths:
|
|
1478
|
+
agent_path = Path(agent_path)
|
|
1479
|
+
id_start = len(diagnostics)
|
|
1480
|
+
agent_id = _valid_agent_id(
|
|
1481
|
+
agent_path.stem.removeprefix("agent-"), diagnostics, "session subagent"
|
|
1482
|
+
)
|
|
1483
|
+
discovery["transcripts"].extend(discovery_part(diagnostics[id_start:]))
|
|
1484
|
+
meta_path = agent_path.with_name(f"{agent_path.stem}.meta.json")
|
|
1485
|
+
meta_start = len(diagnostics)
|
|
1486
|
+
meta = _load_meta_file(meta_path, diagnostics)
|
|
1487
|
+
meta_bad = len(diagnostics) > meta_start
|
|
1488
|
+
discovery["meta"].extend(discovery_part(diagnostics[meta_start:]))
|
|
1489
|
+
label = meta.get("description", "")
|
|
1490
|
+
if not isinstance(label, str):
|
|
1491
|
+
label = ""
|
|
1492
|
+
agent_type = meta.get("agentType")
|
|
1493
|
+
phase = meta.get("workflowPhase", "")
|
|
1494
|
+
if not isinstance(phase, str):
|
|
1495
|
+
phase = ""
|
|
1496
|
+
|
|
1497
|
+
transcript_start = len(diagnostics)
|
|
1498
|
+
sub_state = ledger.TranscriptState()
|
|
1499
|
+
sub_log = _LaunchLog()
|
|
1500
|
+
(
|
|
1501
|
+
events, sub_version, _, sub_unreadable, sub_over_threshold, sub_tool_calls,
|
|
1502
|
+
sub_marker_hits,
|
|
1503
|
+
) = _read_transcript_tracked(agent_path, diagnostics, state=sub_state, launch_log=sub_log)
|
|
1504
|
+
sub_log.facts.path = agent_path
|
|
1505
|
+
sub_log.facts.unreadable = sub_unreadable
|
|
1506
|
+
child_facts[agent_path] = sub_log.facts
|
|
1507
|
+
discovery["transcripts"].extend(discovery_part(diagnostics[transcript_start:]))
|
|
1508
|
+
incomplete = incomplete or sub_unreadable or sub_over_threshold
|
|
1509
|
+
incomplete = incomplete or any(call.ambiguous for call in sub_tool_calls)
|
|
1510
|
+
if claude_version is None and sub_version:
|
|
1511
|
+
claude_version = sub_version
|
|
1512
|
+
event_ts = [event.ts for event in events if event.ts is not None]
|
|
1513
|
+
all_ts.extend(event_ts)
|
|
1514
|
+
incomplete = incomplete or _edge_event_has_timestamp_diagnostic(events, diagnostics)
|
|
1515
|
+
outcome = _compute_outcome(agent_path, events)
|
|
1516
|
+
sub_last_ts = max(event_ts) if event_ts else None
|
|
1517
|
+
|
|
1518
|
+
attempt = Attempt(
|
|
1519
|
+
agent_id=agent_id,
|
|
1520
|
+
key=agent_id,
|
|
1521
|
+
label=label,
|
|
1522
|
+
phase=phase,
|
|
1523
|
+
agent_type=agent_type,
|
|
1524
|
+
meta=meta,
|
|
1525
|
+
transcript_path=agent_path,
|
|
1526
|
+
events=events,
|
|
1527
|
+
first_ts=min(event_ts) if event_ts else None,
|
|
1528
|
+
last_ts=sub_last_ts,
|
|
1529
|
+
ordinal=1,
|
|
1530
|
+
outcome=outcome,
|
|
1531
|
+
usage=_sum_usage(events),
|
|
1532
|
+
tool_calls=sub_tool_calls,
|
|
1533
|
+
marker_hits=sub_marker_hits,
|
|
1534
|
+
_transcript_state=sub_state,
|
|
1535
|
+
entry_cwds=tuple(sub_state.cwds),
|
|
1536
|
+
)
|
|
1537
|
+
attempt.facts = _build_attempt_facts(
|
|
1538
|
+
sub_tool_calls, outcome, sub_last_ts, agent_path, sub_unreadable
|
|
1539
|
+
)
|
|
1540
|
+
child_meta.append((attempt, agent_path.stem.removeprefix("agent-"), meta, meta_bad))
|
|
1541
|
+
if outcome == "killed":
|
|
1542
|
+
attempt.interrupt = _build_interrupt(attempt)
|
|
1543
|
+
attempts.append(attempt)
|
|
1544
|
+
agents.append(LogicalAgent(key=agent_id, label=label, phase=phase, attempts=[attempt]))
|
|
1545
|
+
|
|
1546
|
+
run = Run(
|
|
1547
|
+
run_id=run_id,
|
|
1548
|
+
kind="session",
|
|
1549
|
+
path=main_path,
|
|
1550
|
+
project_slug=slug,
|
|
1551
|
+
session_id=session_id or uuid,
|
|
1552
|
+
claude_version=claude_version,
|
|
1553
|
+
journal=[],
|
|
1554
|
+
main=main_attempt,
|
|
1555
|
+
agents=agents,
|
|
1556
|
+
attempts=attempts,
|
|
1557
|
+
started_at=min(all_ts) if all_ts else None,
|
|
1558
|
+
ended_at=max(all_ts) if all_ts else None,
|
|
1559
|
+
diagnostics=diagnostics,
|
|
1560
|
+
status="INCOMPLETE" if incomplete else "COMPLETE",
|
|
1561
|
+
# docs/DESIGN.md 4.13.3 (v0.3 N2, D11): a session carries no Workflow
|
|
1562
|
+
# launch and no generation to pair, so it has no lifecycle model to
|
|
1563
|
+
# report at all -- finished is null, not true, distinct from the
|
|
1564
|
+
# dataclass's own "never set" default of True.
|
|
1565
|
+
finished=None,
|
|
1566
|
+
discovery=discovery,
|
|
1567
|
+
)
|
|
1568
|
+
if child_meta:
|
|
1569
|
+
_assign_return_states(main_path, child_facts, child_meta)
|
|
1570
|
+
parent_index = _parent_index_for(run)
|
|
1571
|
+
_attribute_interrupts(run, parent_index)
|
|
1572
|
+
if reconcile:
|
|
1573
|
+
# docs/DESIGN.md 4.13.1 (C1, N4 fix): a canonical meta file whose
|
|
1574
|
+
# child has no transcript, in sorted id order, before the launches.
|
|
1575
|
+
discovery["transcripts"].extend(_orphan_meta_diagnostics(main_path))
|
|
1576
|
+
if reconcile and main_attempt._launch_log is not None:
|
|
1577
|
+
# docs/DESIGN.md 4.13.1 (C-14, N4b-1): every Agent, Task, and
|
|
1578
|
+
# Workflow launch of the main transcript reconciles with a discovered
|
|
1579
|
+
# child, or adds one `transcripts` diagnostic. Workflow launches use
|
|
1580
|
+
# the generations of the same parent-index snapshot.
|
|
1581
|
+
discovery["transcripts"].extend(
|
|
1582
|
+
_reconcile_session_launches(
|
|
1583
|
+
main_path, main_attempt._launch_log, agent_paths, list(parent_index.generations)
|
|
1584
|
+
)
|
|
1585
|
+
)
|
|
1586
|
+
ledger.attach_ledgers(run)
|
|
1587
|
+
return run
|
|
1588
|
+
|
|
1589
|
+
|
|
1590
|
+
def _read_evidence_facts(path: Path) -> "returns.ReturnFacts":
|
|
1591
|
+
"""The return facts of a transcript read as evidence only (never an analyzed attempt): a
|
|
1592
|
+
nested child's launcher, or any other session transcript that may hold a resume request. A
|
|
1593
|
+
transcript that is not a file, or cannot be read, is `unreadable`."""
|
|
1594
|
+
log = _LaunchLog()
|
|
1595
|
+
if not _is_file(path):
|
|
1596
|
+
facts = returns.ReturnFacts(path, unreadable=True)
|
|
1597
|
+
return facts
|
|
1598
|
+
result = _read_transcript_tracked(path, [], launch_log=log)
|
|
1599
|
+
facts = log.facts
|
|
1600
|
+
facts.path = path
|
|
1601
|
+
facts.unreadable = result[3]
|
|
1602
|
+
return facts
|
|
1603
|
+
|
|
1604
|
+
|
|
1605
|
+
class _SessionReturns:
|
|
1606
|
+
"""The return decision of the session subagents of one main transcript (docs/DESIGN.md
|
|
1607
|
+
4.13.1, T0 to T5): the main transcript's facts and accepted notifications come from its
|
|
1608
|
+
ParentIndex (incremental, shared with the hooks); a nested child's launcher and every other
|
|
1609
|
+
transcript read for a resume request are evidence only."""
|
|
1610
|
+
|
|
1611
|
+
def __init__(self, main_path: Path, loaded: "dict[Path, returns.ReturnFacts]"):
|
|
1612
|
+
self.main_path = Path(main_path)
|
|
1613
|
+
self.index = ParentIndex.for_path(self.main_path)
|
|
1614
|
+
self.main = self.index.facts
|
|
1615
|
+
self.main_unreadable = self.index._unreadable_diagnostic is not None
|
|
1616
|
+
self.loaded = loaded
|
|
1617
|
+
self._evidence: dict[Path, returns.ReturnFacts] = {}
|
|
1618
|
+
self.subagents = _session_dir(self.main_path) / "subagents"
|
|
1619
|
+
ids: set[str] = set()
|
|
1620
|
+
if _is_dir(self.subagents):
|
|
1621
|
+
patterns = (("agent-*.jsonl", ".jsonl"), ("agent-*.meta.json", ".meta.json"))
|
|
1622
|
+
for pattern, suffix in patterns:
|
|
1623
|
+
for found in self.subagents.glob(pattern):
|
|
1624
|
+
ids.add(found.name.removeprefix("agent-").removesuffix(suffix))
|
|
1625
|
+
self.siblings = frozenset(ids)
|
|
1626
|
+
self._notes = [
|
|
1627
|
+
returns.Note(n.line_no, n.task_id, n.tool_use_id, n.status, n.ts)
|
|
1628
|
+
for n in self.index._accepted_notifications()
|
|
1629
|
+
]
|
|
1630
|
+
|
|
1631
|
+
def path_of(self, agent_id: str) -> Path:
|
|
1632
|
+
return self.subagents / f"agent-{agent_id}.jsonl"
|
|
1633
|
+
|
|
1634
|
+
def facts_of(self, agent_id: str) -> "returns.ReturnFacts":
|
|
1635
|
+
path = self.path_of(agent_id)
|
|
1636
|
+
if path in self.loaded:
|
|
1637
|
+
return self.loaded[path]
|
|
1638
|
+
if path not in self._evidence:
|
|
1639
|
+
self._evidence[path] = _read_evidence_facts(path)
|
|
1640
|
+
return self._evidence[path]
|
|
1641
|
+
|
|
1642
|
+
def notes_of(self, agent_id: str) -> "list[returns.Note]":
|
|
1643
|
+
return [n for n in self._notes if n.task_id == agent_id]
|
|
1644
|
+
|
|
1645
|
+
def launcher_facts(self, key: str) -> "returns.ReturnFacts":
|
|
1646
|
+
return self.main if key == "main" else self.facts_of(key)
|
|
1647
|
+
|
|
1648
|
+
def digest_for(self, agent_id: str, meta: dict) -> str:
|
|
1649
|
+
return returns.main_side_digest(
|
|
1650
|
+
agent_id, self.main, self.notes_of(agent_id), meta.get("toolUseId", returns.ABSENT)
|
|
1651
|
+
)
|
|
1652
|
+
|
|
1653
|
+
def decide(self, agent_id: str, meta: dict, meta_bad: bool):
|
|
1654
|
+
"""(Decision, the launcher's facts or None)."""
|
|
1655
|
+
key, reason = returns.launcher_of(meta, self.siblings, agent_id)
|
|
1656
|
+
issuers = [self.main, *self.loaded.values()]
|
|
1657
|
+
launcher = None
|
|
1658
|
+
if key is not None and not meta_bad:
|
|
1659
|
+
launcher = self.launcher_facts(key)
|
|
1660
|
+
if not any(launcher is f for f in issuers):
|
|
1661
|
+
issuers.append(launcher)
|
|
1662
|
+
|
|
1663
|
+
def early(reason_word: str):
|
|
1664
|
+
return returns.Decision(
|
|
1665
|
+
returns.UNKNOWN, reason_word,
|
|
1666
|
+
returns.resume_count(agent_id, issuers, self.siblings),
|
|
1667
|
+
), launcher
|
|
1668
|
+
|
|
1669
|
+
if meta_bad:
|
|
1670
|
+
return early("malformed")
|
|
1671
|
+
if key is None:
|
|
1672
|
+
return early(reason)
|
|
1673
|
+
# GA-1: a transcript that may hold a resume request and cannot be read at all hides
|
|
1674
|
+
# which child it resumed, so every child of the session is unknown (a malformed or
|
|
1675
|
+
# half-written line inside a readable transcript is only a recorded limit, 4.13.1)
|
|
1676
|
+
if self.main_unreadable or any(f.unreadable for f in issuers):
|
|
1677
|
+
return early("parent_unreadable")
|
|
1678
|
+
return returns.decide(
|
|
1679
|
+
agent_id, launcher, self.main, self.notes_of(agent_id), issuers, self.siblings,
|
|
1680
|
+
meta.get("toolUseId", returns.ABSENT),
|
|
1681
|
+
), launcher
|
|
1682
|
+
|
|
1683
|
+
|
|
1684
|
+
def _assign_return_states(
|
|
1685
|
+
main_path: Path, child_facts: "dict[Path, returns.ReturnFacts]", children: list,
|
|
1686
|
+
) -> None:
|
|
1687
|
+
"""Set return_state, return_reason, resumes, returned_to_parent and return_evidence on
|
|
1688
|
+
every session subagent attempt (docs/DESIGN.md 4.1, 4.13.1)."""
|
|
1689
|
+
context = _SessionReturns(main_path, child_facts)
|
|
1690
|
+
for attempt, agent_id, meta, meta_bad in children:
|
|
1691
|
+
decision, launcher = context.decide(agent_id, meta, meta_bad)
|
|
1692
|
+
attempt.return_state = decision.state
|
|
1693
|
+
attempt.return_reason = decision.reason
|
|
1694
|
+
attempt.resumes = decision.resumes
|
|
1695
|
+
attempt.returned_to_parent = decision.state == returns.RETURNED
|
|
1696
|
+
if decision.evidence is not None:
|
|
1697
|
+
source, line, in_launcher = decision.evidence
|
|
1698
|
+
where = launcher.path if in_launcher and launcher is not None else Path(main_path)
|
|
1699
|
+
attempt.return_evidence = (source, where, line)
|
|
1700
|
+
|
|
1701
|
+
|
|
1702
|
+
def session_child_return(main_path: Path, agent_path: Path, cache: dict | None = None):
|
|
1703
|
+
"""(Decision, main-side digest, launcher transcript path or None, unreadable tokens) of one
|
|
1704
|
+
session subagent, from the main transcript and, when it is nested, its launcher read as
|
|
1705
|
+
evidence only (docs/DESIGN.md 7.2's finished rule and fingerprint). The child's own
|
|
1706
|
+
transcript is not read, only its meta file. `cache`, a dict the caller owns for one hook
|
|
1707
|
+
invocation, keeps the main transcript's context so its notifications are listed once."""
|
|
1708
|
+
agent_path = Path(agent_path)
|
|
1709
|
+
agent_id = agent_path.stem.removeprefix("agent-")
|
|
1710
|
+
meta_path = agent_path.with_name(f"{agent_path.stem}.meta.json")
|
|
1711
|
+
diagnostics: list[str] = []
|
|
1712
|
+
meta = _load_meta_file(meta_path, diagnostics)
|
|
1713
|
+
key = ("returns", Path(main_path))
|
|
1714
|
+
context = cache.get(key) if cache is not None else None
|
|
1715
|
+
if context is None:
|
|
1716
|
+
context = _SessionReturns(Path(main_path), {})
|
|
1717
|
+
if cache is not None:
|
|
1718
|
+
cache[key] = context
|
|
1719
|
+
decision, launcher = context.decide(agent_id, meta, bool(diagnostics))
|
|
1720
|
+
path = launcher.path if launcher is not None and launcher is not context.main else None
|
|
1721
|
+
# GA-3: which inputs could not be read, as stable tokens for the hook's fingerprint
|
|
1722
|
+
unreadable = tuple(
|
|
1723
|
+
token for token, bad in (
|
|
1724
|
+
("main", context.main_unreadable),
|
|
1725
|
+
("meta", bool(diagnostics)),
|
|
1726
|
+
("launcher", path is not None and launcher.unreadable),
|
|
1727
|
+
) if bad
|
|
1728
|
+
)
|
|
1729
|
+
return decision, context.digest_for(agent_id, meta), path, unreadable
|
|
1730
|
+
|
|
1731
|
+
|
|
1732
|
+
def load_session(path: Path) -> Run:
|
|
1733
|
+
"""Parse a session run: the main transcript plus its Agent-tool
|
|
1734
|
+
subagent transcripts (docs/DESIGN.md 4.2). Run.main is the main
|
|
1735
|
+
transcript as an Attempt (label='main', key=None, ordinal=1); each
|
|
1736
|
+
subagents/agent-*.jsonl becomes one Attempt and one LogicalAgent.
|
|
1737
|
+
Retries of an Agent-tool subagent are not observable in v0.1: each
|
|
1738
|
+
transcript file is exactly one attempt, ordinal 1."""
|
|
1739
|
+
path = Path(path)
|
|
1740
|
+
return _load_session_from_paths(path, _discover_session_agent_paths(path), reconcile=True)
|
|
1741
|
+
|
|
1742
|
+
|
|
1743
|
+
def load_session_view(
|
|
1744
|
+
main_path: Path, subagent_paths: list[Path], *, main_cache: dict | None = None
|
|
1745
|
+
) -> Run:
|
|
1746
|
+
"""Parse main_path plus only the named subagent_paths -- never the rest
|
|
1747
|
+
of the session's subagents/ directory (docs/DESIGN.md 7.2's D6):
|
|
1748
|
+
analyzing one finished subagent unit in isolation must not be
|
|
1749
|
+
contaminated by an unrelated sibling's parse failure, and must not cost
|
|
1750
|
+
O(every subagent of the session) to do it. An empty subagent_paths list
|
|
1751
|
+
is a main-thread-only view (docs/DESIGN.md 7.2's D5: session-end).
|
|
1752
|
+
|
|
1753
|
+
Attempts are matched back to a unit by `attempt.transcript_path`, never
|
|
1754
|
+
by re-deriving an agent id from the filename: _valid_agent_id may
|
|
1755
|
+
replace a non-conforming id, so a caller must never assume the id it
|
|
1756
|
+
passed in survives unchanged onto the Attempt it gets back."""
|
|
1757
|
+
return _load_session_from_paths(
|
|
1758
|
+
Path(main_path), [Path(p) for p in subagent_paths], main_cache=main_cache
|
|
1759
|
+
)
|
|
1760
|
+
|
|
1761
|
+
|
|
1762
|
+
# --- outcome and interrupt attribution --------------------------------
|
|
1763
|
+
|
|
1764
|
+
|
|
1765
|
+
def _last_user_event_index(events: list[Event]) -> int | None:
|
|
1766
|
+
last = None
|
|
1767
|
+
for index, event in enumerate(events):
|
|
1768
|
+
if event.kind == "user":
|
|
1769
|
+
last = index
|
|
1770
|
+
return last
|
|
1771
|
+
|
|
1772
|
+
|
|
1773
|
+
def _compute_outcome(transcript_path: Path | None, events: list[Event]) -> str:
|
|
1774
|
+
if transcript_path is None:
|
|
1775
|
+
return "unknown"
|
|
1776
|
+
index = _last_user_event_index(events)
|
|
1777
|
+
if index is not None and events[index].text.startswith(KILL_SENTINEL_PREFIX):
|
|
1778
|
+
return "killed"
|
|
1779
|
+
return "completed"
|
|
1780
|
+
|
|
1781
|
+
|
|
1782
|
+
def _build_interrupt(attempt: Attempt) -> Interrupt | None:
|
|
1783
|
+
"""Interrupt for an attempt whose last user event is the kill sentinel
|
|
1784
|
+
(docs/DESIGN.md 4.4). preceding comes from
|
|
1785
|
+
detectors.base.meaningful_event_before (4.5); silence_seconds is None
|
|
1786
|
+
when a timestamp is missing, the delta is negative, or preceding is None
|
|
1787
|
+
(including because a compaction event stopped the walk -- a context
|
|
1788
|
+
reset makes "silence" meaningless). cause stays 'unattributed' and
|
|
1789
|
+
cause_evidence None here; _attribute_interrupts fills them in once every
|
|
1790
|
+
attempt of the run has been loaded (docs/DESIGN.md 4.4 needs to know
|
|
1791
|
+
whether a key has a later attempt)."""
|
|
1792
|
+
events = attempt.events
|
|
1793
|
+
kill_index = _last_user_event_index(events)
|
|
1794
|
+
if kill_index is None:
|
|
1795
|
+
return None
|
|
1796
|
+
kill_event = events[kill_index]
|
|
1797
|
+
if not kill_event.text.startswith(KILL_SENTINEL_PREFIX):
|
|
1798
|
+
return None
|
|
1799
|
+
|
|
1800
|
+
preceding = meaningful_event_before(attempt, kill_index)
|
|
1801
|
+
|
|
1802
|
+
silence_seconds: float | None = None
|
|
1803
|
+
if preceding is not None and preceding.ts is not None and kill_event.ts is not None:
|
|
1804
|
+
delta = (kill_event.ts - preceding.ts).total_seconds()
|
|
1805
|
+
if delta >= 0:
|
|
1806
|
+
silence_seconds = delta
|
|
1807
|
+
|
|
1808
|
+
return Interrupt(
|
|
1809
|
+
kill_ts=kill_event.ts,
|
|
1810
|
+
preceding=preceding,
|
|
1811
|
+
silence_seconds=silence_seconds,
|
|
1812
|
+
cause="unattributed",
|
|
1813
|
+
cause_evidence=None,
|
|
1814
|
+
)
|
|
1815
|
+
|
|
1816
|
+
|
|
1817
|
+
# docs/DESIGN.md 4.1's v0.2 reader changes: the tool names ToolCall.path is
|
|
1818
|
+
# ever set for; used by _build_attempt_facts as a defensive filter (path is
|
|
1819
|
+
# already None for every other tool -- see _make_tool_call -- so this only
|
|
1820
|
+
# guards against a future tool being added to _PATH_TOOL_INPUT_FIELD without
|
|
1821
|
+
# also being a "wrote a file" tool).
|
|
1822
|
+
_PATH_WRITING_TOOL_NAMES = frozenset(_PATH_TOOL_INPUT_FIELD)
|
|
1823
|
+
|
|
1824
|
+
# outcomes for which a not-yet-resolved tool call is genuinely "in flight"
|
|
1825
|
+
# (docs/DESIGN.md 4.1's v0.2 reader changes): a 'completed' attempt reports
|
|
1826
|
+
# [] unconditionally, since the journal's own verdict wins and a stray
|
|
1827
|
+
# no_result call in its transcript proves nothing was actually abandoned.
|
|
1828
|
+
_IN_FLIGHT_OUTCOMES = frozenset({"killed", "failed", "superseded"})
|
|
1829
|
+
|
|
1830
|
+
|
|
1831
|
+
def _build_attempt_facts(
|
|
1832
|
+
tool_calls: list[ToolCall],
|
|
1833
|
+
outcome: str,
|
|
1834
|
+
last_ts: datetime | None,
|
|
1835
|
+
transcript_path: Path | None,
|
|
1836
|
+
unreadable: bool,
|
|
1837
|
+
) -> AttemptFacts | None:
|
|
1838
|
+
"""AttemptFacts derived from one attempt's tool_calls, computed once the
|
|
1839
|
+
transcript is fully read (docs/DESIGN.md 4.1/4.2's v0.2 reader changes).
|
|
1840
|
+
|
|
1841
|
+
None only when transcript_path is None -- no transcript exists at all.
|
|
1842
|
+
A transcript that exists but could not even be opened (`unreadable`,
|
|
1843
|
+
docs/DESIGN.md 4.6) still produces an AttemptFacts, with every field set
|
|
1844
|
+
to FACTS_UNKNOWN rather than a misleadingly empty list or zero count."""
|
|
1845
|
+
if transcript_path is None:
|
|
1846
|
+
return None
|
|
1847
|
+
if unreadable:
|
|
1848
|
+
return AttemptFacts(
|
|
1849
|
+
paths_written=FACTS_UNKNOWN,
|
|
1850
|
+
in_flight=FACTS_UNKNOWN,
|
|
1851
|
+
commits=FACTS_UNKNOWN,
|
|
1852
|
+
cwds=FACTS_UNKNOWN,
|
|
1853
|
+
background_unfinished=FACTS_UNKNOWN,
|
|
1854
|
+
last_tool=FACTS_UNKNOWN,
|
|
1855
|
+
seconds_since_last_tool_at_end=FACTS_UNKNOWN,
|
|
1856
|
+
commit_commands_without_id=FACTS_UNKNOWN,
|
|
1857
|
+
)
|
|
1858
|
+
|
|
1859
|
+
paths_written: list[str] = []
|
|
1860
|
+
seen_paths: set[str] = set()
|
|
1861
|
+
in_flight: list[dict] = []
|
|
1862
|
+
commits: list[str] = []
|
|
1863
|
+
seen_commits: set[str] = set()
|
|
1864
|
+
cwds: list[str] = []
|
|
1865
|
+
seen_cwds: set[str] = set()
|
|
1866
|
+
background_unfinished = 0
|
|
1867
|
+
commit_commands_without_id = 0
|
|
1868
|
+
|
|
1869
|
+
for call in tool_calls:
|
|
1870
|
+
completion = call.completion
|
|
1871
|
+
|
|
1872
|
+
if (
|
|
1873
|
+
call.tool_name in _PATH_WRITING_TOOL_NAMES
|
|
1874
|
+
and call.path is not None
|
|
1875
|
+
and completion == "completed_ok"
|
|
1876
|
+
and call.path not in seen_paths
|
|
1877
|
+
):
|
|
1878
|
+
seen_paths.add(call.path)
|
|
1879
|
+
paths_written.append(call.path)
|
|
1880
|
+
|
|
1881
|
+
if completion == "no_result" and outcome in _IN_FLIGHT_OUTCOMES:
|
|
1882
|
+
in_flight.append(
|
|
1883
|
+
{"tool": call.tool_name, "path": call.path, "line_no": call.line_no}
|
|
1884
|
+
)
|
|
1885
|
+
|
|
1886
|
+
for commit_id in call.commit_ids:
|
|
1887
|
+
if commit_id not in seen_commits:
|
|
1888
|
+
seen_commits.add(commit_id)
|
|
1889
|
+
commits.append(commit_id)
|
|
1890
|
+
|
|
1891
|
+
# FA6 (C1): a CALL (never a segment) that completed_ok, contains a commit command,
|
|
1892
|
+
# and yielded no id (a quiet commit prints no summary line). Whether the shell
|
|
1893
|
+
# reached the command is not provable from one call's exit status.
|
|
1894
|
+
if (
|
|
1895
|
+
call.tool_name == "Bash"
|
|
1896
|
+
and completion == "completed_ok"
|
|
1897
|
+
and call.classification is not None
|
|
1898
|
+
and call.classification.commit_command_present
|
|
1899
|
+
and not call.commit_ids
|
|
1900
|
+
):
|
|
1901
|
+
commit_commands_without_id += 1
|
|
1902
|
+
|
|
1903
|
+
if call.cwd is not None and call.cwd not in seen_cwds:
|
|
1904
|
+
seen_cwds.add(call.cwd)
|
|
1905
|
+
cwds.append(call.cwd)
|
|
1906
|
+
|
|
1907
|
+
if completion == "backgrounded":
|
|
1908
|
+
background_unfinished += 1
|
|
1909
|
+
|
|
1910
|
+
last_tool = tool_calls[-1].tool_name if tool_calls else None
|
|
1911
|
+
|
|
1912
|
+
seconds_since_last_tool_at_end: float | None = None
|
|
1913
|
+
if tool_calls and last_ts is not None:
|
|
1914
|
+
last_call_ts = tool_calls[-1].ts
|
|
1915
|
+
if last_call_ts is not None:
|
|
1916
|
+
delta = (last_ts - last_call_ts).total_seconds()
|
|
1917
|
+
if delta >= 0:
|
|
1918
|
+
seconds_since_last_tool_at_end = delta
|
|
1919
|
+
|
|
1920
|
+
return AttemptFacts(
|
|
1921
|
+
paths_written=paths_written,
|
|
1922
|
+
in_flight=in_flight,
|
|
1923
|
+
commits=commits,
|
|
1924
|
+
cwds=cwds,
|
|
1925
|
+
background_unfinished=background_unfinished,
|
|
1926
|
+
last_tool=last_tool,
|
|
1927
|
+
seconds_since_last_tool_at_end=seconds_since_last_tool_at_end,
|
|
1928
|
+
commit_commands_without_id=commit_commands_without_id,
|
|
1929
|
+
)
|
|
1930
|
+
|
|
1931
|
+
|
|
1932
|
+
def _sum_usage(events: list[Event]) -> dict:
|
|
1933
|
+
totals: dict[str, int | float] = {}
|
|
1934
|
+
for event in events:
|
|
1935
|
+
for key, value in event.usage.items():
|
|
1936
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
1937
|
+
totals[key] = totals.get(key, 0) + value
|
|
1938
|
+
return totals
|
|
1939
|
+
|
|
1940
|
+
|
|
1941
|
+
def _note(text: str, quality: bool) -> str:
|
|
1942
|
+
"""text tagged as a data-quality note (docs/DESIGN.md 4.13.1, A2) when
|
|
1943
|
+
`quality`, else unchanged."""
|
|
1944
|
+
return QualityNote(text) if quality else text
|
|
1945
|
+
|
|
1946
|
+
|
|
1947
|
+
def _parse_ts(
|
|
1948
|
+
value: object,
|
|
1949
|
+
path: Path | None = None,
|
|
1950
|
+
line_no: int | None = None,
|
|
1951
|
+
diagnostics: list[str] | None = None,
|
|
1952
|
+
*,
|
|
1953
|
+
quality: bool = False,
|
|
1954
|
+
) -> datetime | None:
|
|
1955
|
+
"""Parse an ISO 8601 timestamp, or None for an absent one.
|
|
1956
|
+
|
|
1957
|
+
A present but unparseable value is a malformed timestamp, not a merely
|
|
1958
|
+
absent one: it is recorded as a diagnostic (path:line) so a reader never
|
|
1959
|
+
silently loses temporal evidence without a trace, even though the caller
|
|
1960
|
+
still gets None back and must treat duration as unknown.
|
|
1961
|
+
"""
|
|
1962
|
+
if value is None:
|
|
1963
|
+
return None
|
|
1964
|
+
if not isinstance(value, str) or not value:
|
|
1965
|
+
if diagnostics is not None and path is not None and line_no is not None:
|
|
1966
|
+
diagnostics.append(
|
|
1967
|
+
_note(f"{path.name}:{line_no}: timestamp is not a string", quality)
|
|
1968
|
+
)
|
|
1969
|
+
return None
|
|
1970
|
+
text = value[:-1] + "+00:00" if value.endswith("Z") else value
|
|
1971
|
+
try:
|
|
1972
|
+
parsed = datetime.fromisoformat(text)
|
|
1973
|
+
except ValueError:
|
|
1974
|
+
if diagnostics is not None and path is not None and line_no is not None:
|
|
1975
|
+
diagnostics.append(
|
|
1976
|
+
_note(f"{path.name}:{line_no}: timestamp could not be parsed", quality)
|
|
1977
|
+
)
|
|
1978
|
+
return None
|
|
1979
|
+
if parsed.tzinfo is None:
|
|
1980
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
1981
|
+
return parsed
|
|
1982
|
+
|
|
1983
|
+
|
|
1984
|
+
def _strip_harness_noise(text: str) -> str:
|
|
1985
|
+
"""Remove <system-reminder>, <total_tokens>, and [harness: ...] spans
|
|
1986
|
+
(docs/DESIGN.md 4.5) from a field's full text before it becomes an
|
|
1987
|
+
Event's excerpt."""
|
|
1988
|
+
text = _SYSTEM_REMINDER_RE.sub("", text)
|
|
1989
|
+
text = _TOTAL_TOKENS_RE.sub("", text)
|
|
1990
|
+
text = _HARNESS_NOTE_RE.sub("", text)
|
|
1991
|
+
return text
|
|
1992
|
+
|
|
1993
|
+
|
|
1994
|
+
def _excerpt(text: str | None) -> str:
|
|
1995
|
+
if not text:
|
|
1996
|
+
return ""
|
|
1997
|
+
return _strip_harness_noise(text)[:_EXCERPT_LIMIT]
|
|
1998
|
+
|
|
1999
|
+
|
|
2000
|
+
def _parse_exit_code(text: str) -> int | None:
|
|
2001
|
+
match = _EXIT_CODE_RE.search(text)
|
|
2002
|
+
if not match:
|
|
2003
|
+
return None
|
|
2004
|
+
try:
|
|
2005
|
+
return int(match.group(1))
|
|
2006
|
+
except ValueError:
|
|
2007
|
+
return None
|
|
2008
|
+
|
|
2009
|
+
|
|
2010
|
+
def _flatten_block_text(content: object) -> str:
|
|
2011
|
+
"""Flatten a tool_result 'content' field (string or list of blocks) to
|
|
2012
|
+
text. docs/DESIGN.md section 3: a content list item can itself be a
|
|
2013
|
+
plain string, not only a {"text": ...} dict block, so both forms are
|
|
2014
|
+
collected here."""
|
|
2015
|
+
if isinstance(content, str):
|
|
2016
|
+
return content
|
|
2017
|
+
if isinstance(content, list):
|
|
2018
|
+
parts = []
|
|
2019
|
+
for block in content:
|
|
2020
|
+
if isinstance(block, str):
|
|
2021
|
+
parts.append(block)
|
|
2022
|
+
elif isinstance(block, dict) and isinstance(block.get("text"), str):
|
|
2023
|
+
parts.append(block["text"])
|
|
2024
|
+
return "\n".join(parts)
|
|
2025
|
+
if content is None:
|
|
2026
|
+
return ""
|
|
2027
|
+
return str(content)
|
|
2028
|
+
|
|
2029
|
+
|
|
2030
|
+
# --- marker provenance (docs/DESIGN.md 4.13.2, v0.3 N1) --------------------
|
|
2031
|
+
|
|
2032
|
+
|
|
2033
|
+
def _flatten_for_markers(content: object) -> str:
|
|
2034
|
+
"""docs/DESIGN.md 4.13.2's text flattening, used only for MarkerHit
|
|
2035
|
+
evaluation -- never Event.text, which keeps using _flatten_block_text's
|
|
2036
|
+
older "join every text/string element" rule so existing display/export
|
|
2037
|
+
behavior is untouched. A string content as-is; a list's first element
|
|
2038
|
+
whose type is 'text' (a bare string list item does not count here,
|
|
2039
|
+
unlike _flatten_block_text); anything else, ''. Leading whitespace
|
|
2040
|
+
(spaces, tabs, carriage returns, newlines) is then stripped; no other
|
|
2041
|
+
normalization."""
|
|
2042
|
+
if isinstance(content, str):
|
|
2043
|
+
flat = content
|
|
2044
|
+
elif isinstance(content, list):
|
|
2045
|
+
flat = ""
|
|
2046
|
+
for block in content:
|
|
2047
|
+
if (
|
|
2048
|
+
isinstance(block, dict)
|
|
2049
|
+
and block.get("type") == "text"
|
|
2050
|
+
and isinstance(block.get("text"), str)
|
|
2051
|
+
):
|
|
2052
|
+
flat = block["text"]
|
|
2053
|
+
break
|
|
2054
|
+
else:
|
|
2055
|
+
flat = ""
|
|
2056
|
+
return flat.lstrip(" \t\r\n")
|
|
2057
|
+
|
|
2058
|
+
|
|
2059
|
+
def _tool_result_shapes(
|
|
2060
|
+
tool_name: str | None, is_codex_bash: bool, is_error: bool = False,
|
|
2061
|
+
) -> set[str]:
|
|
2062
|
+
"""Which marker-registry shape tags a tool_result block paired to
|
|
2063
|
+
tool_name (and, for a Bash call, whether its own command runs codex as a
|
|
2064
|
+
program; whether the block has is_error true) is a candidate block for
|
|
2065
|
+
(docs/DESIGN.md 4.13.2)."""
|
|
2066
|
+
shapes: set[str] = set()
|
|
2067
|
+
if is_error:
|
|
2068
|
+
shapes.add(markers.SHAPE_TOOL_RESULT_ERROR)
|
|
2069
|
+
if tool_name == "Bash":
|
|
2070
|
+
shapes.add(markers.SHAPE_TOOL_RESULT_BASH)
|
|
2071
|
+
if is_codex_bash:
|
|
2072
|
+
shapes.add(markers.SHAPE_TOOL_RESULT_BASH_CODEX)
|
|
2073
|
+
if tool_name == "StructuredOutput":
|
|
2074
|
+
shapes.add(markers.SHAPE_TOOL_RESULT_STRUCTURED_OUTPUT)
|
|
2075
|
+
else:
|
|
2076
|
+
shapes.add(markers.SHAPE_TOOL_RESULT_ANY_EXCEPT_STRUCTURED_OUTPUT)
|
|
2077
|
+
return shapes
|
|
2078
|
+
|
|
2079
|
+
|
|
2080
|
+
def _marker_hits_for_text(
|
|
2081
|
+
text: str,
|
|
2082
|
+
applicable_shapes: set[str],
|
|
2083
|
+
*,
|
|
2084
|
+
event_line: int,
|
|
2085
|
+
tool_use_id: str | None,
|
|
2086
|
+
result_line: int | None,
|
|
2087
|
+
) -> list[MarkerHit]:
|
|
2088
|
+
"""Every MarkerHit the registry yields for one flattened block of text
|
|
2089
|
+
(docs/DESIGN.md 4.13.2's candidate rule): for each canonical id whose
|
|
2090
|
+
permitted shape intersects applicable_shapes, a candidate (one of its
|
|
2091
|
+
fragment strings occurs anywhere in text) yields exactly one MarkerHit,
|
|
2092
|
+
anchored when its predicate holds. An id whose shape never matches this
|
|
2093
|
+
block yields nothing at all, not even a negative hit."""
|
|
2094
|
+
hits: list[MarkerHit] = []
|
|
2095
|
+
for spec in markers.REGISTRY.values():
|
|
2096
|
+
matched_shape = next((shape for shape in spec.shapes if shape in applicable_shapes), None)
|
|
2097
|
+
if matched_shape is None:
|
|
2098
|
+
continue
|
|
2099
|
+
if not spec.candidate(text):
|
|
2100
|
+
continue
|
|
2101
|
+
anchored = spec.predicate(text, matched_shape)
|
|
2102
|
+
hits.append(
|
|
2103
|
+
MarkerHit(
|
|
2104
|
+
marker=spec.id,
|
|
2105
|
+
producer=spec.producer,
|
|
2106
|
+
event_line=event_line,
|
|
2107
|
+
tool_use_id=tool_use_id,
|
|
2108
|
+
result_line=result_line,
|
|
2109
|
+
anchored=anchored,
|
|
2110
|
+
in_payload=not anchored,
|
|
2111
|
+
)
|
|
2112
|
+
)
|
|
2113
|
+
return hits
|
|
2114
|
+
|
|
2115
|
+
|
|
2116
|
+
def _user_entry_marker_hits(
|
|
2117
|
+
entry: dict, line_no: int, tool_names: dict, tool_commands: dict,
|
|
2118
|
+
) -> list[MarkerHit]:
|
|
2119
|
+
"""Every MarkerHit from one user entry (docs/DESIGN.md 4.13.2): the
|
|
2120
|
+
whole entry's own flattened text (kill_sentinel's permitted shape --
|
|
2121
|
+
"a user entry, not a tool_result block"), plus each tool_result block it
|
|
2122
|
+
carries, evaluated separately by its own tool_use_id."""
|
|
2123
|
+
hits: list[MarkerHit] = []
|
|
2124
|
+
message = entry.get("message")
|
|
2125
|
+
message = message if isinstance(message, dict) else {}
|
|
2126
|
+
content = message.get("content")
|
|
2127
|
+
|
|
2128
|
+
whole_text = _flatten_for_markers(content)
|
|
2129
|
+
hits.extend(
|
|
2130
|
+
_marker_hits_for_text(
|
|
2131
|
+
whole_text, {markers.SHAPE_USER_ENTRY},
|
|
2132
|
+
event_line=line_no, tool_use_id=None, result_line=None,
|
|
2133
|
+
)
|
|
2134
|
+
)
|
|
2135
|
+
|
|
2136
|
+
if isinstance(content, list):
|
|
2137
|
+
for block in content:
|
|
2138
|
+
if not isinstance(block, dict) or block.get("type") != "tool_result":
|
|
2139
|
+
continue
|
|
2140
|
+
raw_tool_use_id = block.get("tool_use_id")
|
|
2141
|
+
tool_use_id = raw_tool_use_id if isinstance(raw_tool_use_id, str) else None
|
|
2142
|
+
tool_name = tool_names.get(tool_use_id) if tool_use_id is not None else None
|
|
2143
|
+
command = tool_commands.get(tool_use_id) if tool_use_id is not None else None
|
|
2144
|
+
is_codex_bash = tool_name == "Bash" and markers.runs_codex_program(command)
|
|
2145
|
+
block_text = _flatten_for_markers(block.get("content"))
|
|
2146
|
+
shapes = _tool_result_shapes(tool_name, is_codex_bash, block.get("is_error") is True)
|
|
2147
|
+
hits.extend(
|
|
2148
|
+
_marker_hits_for_text(
|
|
2149
|
+
block_text, shapes,
|
|
2150
|
+
event_line=line_no, tool_use_id=tool_use_id, result_line=line_no,
|
|
2151
|
+
)
|
|
2152
|
+
)
|
|
2153
|
+
return hits
|
|
2154
|
+
|
|
2155
|
+
|
|
2156
|
+
def _assistant_entry_flatten(entry: dict) -> str:
|
|
2157
|
+
message = entry.get("message")
|
|
2158
|
+
message = message if isinstance(message, dict) else {}
|
|
2159
|
+
return _flatten_for_markers(message.get("content"))
|
|
2160
|
+
|
|
2161
|
+
|
|
2162
|
+
def _finalize_assistant_marker_hits(
|
|
2163
|
+
assistant_entries: list[tuple[int, str]],
|
|
2164
|
+
final_entry_type: str | None,
|
|
2165
|
+
final_entry_line_no: int | None,
|
|
2166
|
+
attempt_has_codex_bash: bool,
|
|
2167
|
+
) -> list[MarkerHit]:
|
|
2168
|
+
"""The two whole-assistant-entry marker shapes that need attempt-wide
|
|
2169
|
+
context the streaming pass cannot have yet (docs/DESIGN.md 4.13.2):
|
|
2170
|
+
sandbox_write_denied's "assistant text block of an attempt that has
|
|
2171
|
+
such a call" shape (every assistant entry, once attempt_has_codex_bash
|
|
2172
|
+
is known), and usage_limit_session/weekly, machine_sleep, api_error's
|
|
2173
|
+
"the attempt's own final transcript entry, an assistant entry" shape
|
|
2174
|
+
(only the one entry that really is last, once the whole transcript has
|
|
2175
|
+
been read)."""
|
|
2176
|
+
hits: list[MarkerHit] = []
|
|
2177
|
+
for line_no, text in assistant_entries:
|
|
2178
|
+
shapes: set[str] = set()
|
|
2179
|
+
if attempt_has_codex_bash:
|
|
2180
|
+
shapes.add(markers.SHAPE_ASSISTANT_CODEX_ATTEMPT)
|
|
2181
|
+
if final_entry_type == "assistant" and line_no == final_entry_line_no:
|
|
2182
|
+
shapes.add(markers.SHAPE_FINAL_ASSISTANT_ENTRY)
|
|
2183
|
+
if not shapes:
|
|
2184
|
+
continue
|
|
2185
|
+
hits.extend(
|
|
2186
|
+
_marker_hits_for_text(
|
|
2187
|
+
text, shapes, event_line=line_no, tool_use_id=None, result_line=None,
|
|
2188
|
+
)
|
|
2189
|
+
)
|
|
2190
|
+
return hits
|
|
2191
|
+
|
|
2192
|
+
|
|
2193
|
+
def _make_tool_call(
|
|
2194
|
+
tool_id: str,
|
|
2195
|
+
tool_name: str | None,
|
|
2196
|
+
input_obj: dict,
|
|
2197
|
+
line_no: int,
|
|
2198
|
+
ts: datetime | None,
|
|
2199
|
+
cwd_raw: object,
|
|
2200
|
+
) -> ToolCall:
|
|
2201
|
+
"""One ToolCall, filled from the tool_use block's own FULL parsed input
|
|
2202
|
+
(never Event's 400-char excerpt) -- docs/DESIGN.md 4.1/4.2's v0.2 reader
|
|
2203
|
+
changes, plus the N4b-1 private fields (run_in_background, a Monitor
|
|
2204
|
+
command, MultiEdit's path, and the 4.13.1 classification computed from
|
|
2205
|
+
the FULL command)."""
|
|
2206
|
+
path = None
|
|
2207
|
+
path_state = None # why a file tool's target is None (docs/DESIGN.md 4.13.1)
|
|
2208
|
+
field_name = _PATH_TOOL_INPUT_FIELD.get(tool_name or "")
|
|
2209
|
+
if field_name is not None:
|
|
2210
|
+
raw_path = input_obj.get(field_name)
|
|
2211
|
+
if isinstance(raw_path, str) and len(raw_path) <= _TOOL_CALL_PATH_LIMIT:
|
|
2212
|
+
path = raw_path
|
|
2213
|
+
else:
|
|
2214
|
+
path_state = "truncated" if isinstance(raw_path, str) else "missing"
|
|
2215
|
+
|
|
2216
|
+
multiedit_path = None
|
|
2217
|
+
if tool_name == _MULTIEDIT_TOOL:
|
|
2218
|
+
raw_path = input_obj.get("file_path")
|
|
2219
|
+
if isinstance(raw_path, str) and len(raw_path) <= _TOOL_CALL_PATH_LIMIT:
|
|
2220
|
+
multiedit_path = raw_path
|
|
2221
|
+
else:
|
|
2222
|
+
path_state = "truncated" if isinstance(raw_path, str) else "missing"
|
|
2223
|
+
|
|
2224
|
+
command = None
|
|
2225
|
+
command_sha256 = None
|
|
2226
|
+
run_in_background = None
|
|
2227
|
+
raw_command = None
|
|
2228
|
+
if tool_name in _COMMAND_TOOLS:
|
|
2229
|
+
raw_command = input_obj.get("command")
|
|
2230
|
+
run_in_background = input_obj.get("run_in_background")
|
|
2231
|
+
if isinstance(raw_command, str):
|
|
2232
|
+
if len(raw_command) <= _TOOL_CALL_COMMAND_LIMIT:
|
|
2233
|
+
command = raw_command
|
|
2234
|
+
else:
|
|
2235
|
+
command_sha256 = hashlib.sha256(raw_command.encode("utf-8")).hexdigest()
|
|
2236
|
+
|
|
2237
|
+
cwd = os.path.normpath(cwd_raw) if isinstance(cwd_raw, str) and cwd_raw else None
|
|
2238
|
+
|
|
2239
|
+
return ToolCall(
|
|
2240
|
+
tool_use_id=tool_id,
|
|
2241
|
+
tool_name=tool_name,
|
|
2242
|
+
line_no=line_no,
|
|
2243
|
+
ts=ts,
|
|
2244
|
+
path=path,
|
|
2245
|
+
command=command,
|
|
2246
|
+
command_sha256=command_sha256,
|
|
2247
|
+
runs_codex_program=tool_name == "Bash" and markers.runs_codex_program(
|
|
2248
|
+
raw_command if isinstance(raw_command, str) else None
|
|
2249
|
+
),
|
|
2250
|
+
cwd=cwd,
|
|
2251
|
+
run_in_background=run_in_background,
|
|
2252
|
+
multiedit_path=multiedit_path,
|
|
2253
|
+
classification=commands.classify_call(
|
|
2254
|
+
tool_name, raw_command, run_in_background=run_in_background, cwd=cwd
|
|
2255
|
+
),
|
|
2256
|
+
_path_state=path_state,
|
|
2257
|
+
)
|
|
2258
|
+
|
|
2259
|
+
|
|
2260
|
+
def _apply_background_hits(
|
|
2261
|
+
hits: list[MarkerHit], tool_call_index: dict[str, ToolCall], tool_commands: dict
|
|
2262
|
+
) -> None:
|
|
2263
|
+
"""An anchored bash_backgrounded hit on a Bash result makes that call
|
|
2264
|
+
row 2 of the truth table (docs/DESIGN.md 4.13.1): the classification is
|
|
2265
|
+
recomputed from the FULL command with the hit. A payload-only hit never
|
|
2266
|
+
does."""
|
|
2267
|
+
for hit in hits:
|
|
2268
|
+
if hit.marker != "bash_backgrounded" or not hit.anchored or hit.tool_use_id is None:
|
|
2269
|
+
continue
|
|
2270
|
+
call = tool_call_index.get(hit.tool_use_id)
|
|
2271
|
+
if call is None or call.tool_name != "Bash":
|
|
2272
|
+
continue
|
|
2273
|
+
call.classification = commands.classify_call(
|
|
2274
|
+
"Bash", tool_commands.get(hit.tool_use_id),
|
|
2275
|
+
run_in_background=call.run_in_background, backgrounded_hit=True, cwd=call.cwd,
|
|
2276
|
+
)
|
|
2277
|
+
|
|
2278
|
+
|
|
2279
|
+
# --- the session launch log (docs/DESIGN.md 4.13.1, C-14, N4b-1) ----------------
|
|
2280
|
+
|
|
2281
|
+
# The child-launching session tools; SendMessage continues an existing child
|
|
2282
|
+
# and launches none.
|
|
2283
|
+
_LAUNCH_TOOLS = ("Agent", "Task", "Workflow")
|
|
2284
|
+
_ABSENT = object()
|
|
2285
|
+
|
|
2286
|
+
|
|
2287
|
+
def _result_digest(block: dict, tool_use_result: object) -> str | None:
|
|
2288
|
+
"""docs/DESIGN.md 4.13.1 and 4.13.3 (N4 fix, A4): identifies one launch
|
|
2289
|
+
result for the duplicate rule. Two results with the same digest (the
|
|
2290
|
+
`tool_result` block and the entry-level `toolUseResult` identical as
|
|
2291
|
+
parsed JSON) are one result. None when the pair cannot be serialized, so
|
|
2292
|
+
such a result never equals another."""
|
|
2293
|
+
try:
|
|
2294
|
+
text = json.dumps([block, tool_use_result], sort_keys=True, ensure_ascii=True)
|
|
2295
|
+
except (TypeError, ValueError):
|
|
2296
|
+
return None
|
|
2297
|
+
return hashlib.sha256(text.encode("ascii")).hexdigest()
|
|
2298
|
+
|
|
2299
|
+
|
|
2300
|
+
def _is_rejected_launch(block: dict, tool_use_result: object) -> bool:
|
|
2301
|
+
"""docs/DESIGN.md 4.13.1 (N4 fix, A4 and A5): a launch result whose
|
|
2302
|
+
`tool_result` block has `is_error` true and whose entry-level
|
|
2303
|
+
`toolUseResult` is a string. This is the rejected shape only; it is not
|
|
2304
|
+
proof that no run was created (Codex round 2, R2-3): a rejected resume
|
|
2305
|
+
launch stays involved with the run it names, and an initial rejected
|
|
2306
|
+
launch stays `undiscovered` in the session launch table."""
|
|
2307
|
+
return block.get("is_error") is True and isinstance(tool_use_result, str)
|
|
2308
|
+
|
|
2309
|
+
|
|
2310
|
+
def _distinct_results(results: list, digest_of) -> list:
|
|
2311
|
+
"""The results of one launch with byte-identical repeats (same digest, a
|
|
2312
|
+
digest of None never repeats) collapsed into the first of each."""
|
|
2313
|
+
seen: set[str] = set()
|
|
2314
|
+
out = []
|
|
2315
|
+
for result in results:
|
|
2316
|
+
digest = digest_of(result)
|
|
2317
|
+
if digest is not None:
|
|
2318
|
+
if digest in seen:
|
|
2319
|
+
continue
|
|
2320
|
+
seen.add(digest)
|
|
2321
|
+
out.append(result)
|
|
2322
|
+
return out
|
|
2323
|
+
|
|
2324
|
+
|
|
2325
|
+
def _launch_status_kind(tool_use_result: object) -> str:
|
|
2326
|
+
"""The status class of an Agent launch result (docs/DESIGN.md 4.1, 4.13.1; N5 fix 1, S2):
|
|
2327
|
+
'async' for `status` `async_launched` or `isAsync` true, 'plain' for a result with no
|
|
2328
|
+
`status` key, 'completed' for the string `completed`, else 'other' (any other string, or a
|
|
2329
|
+
value that is not a string)."""
|
|
2330
|
+
if not isinstance(tool_use_result, dict):
|
|
2331
|
+
return "plain"
|
|
2332
|
+
status = tool_use_result.get("status", _ABSENT)
|
|
2333
|
+
if tool_use_result.get("isAsync") is True or status == "async_launched":
|
|
2334
|
+
return "async"
|
|
2335
|
+
if status is _ABSENT:
|
|
2336
|
+
return "plain"
|
|
2337
|
+
return "completed" if status == "completed" else "other"
|
|
2338
|
+
|
|
2339
|
+
|
|
2340
|
+
def _carried_ids(result: tuple) -> list[str]:
|
|
2341
|
+
"""The string ids one recorded launch result carries, in either location."""
|
|
2342
|
+
return [value for value in (result[0], result[1]) if isinstance(value, str)]
|
|
2343
|
+
|
|
2344
|
+
|
|
2345
|
+
class _LaunchLog:
|
|
2346
|
+
"""What a session main transcript recorded while it streamed, for the
|
|
2347
|
+
launch reconciliation below: every launch (an assistant `tool_use` block
|
|
2348
|
+
named Agent, Task, or Workflow, with its raw id and physical line), how
|
|
2349
|
+
many assistant `tool_use` blocks carry each string id (4.2's duplicate
|
|
2350
|
+
rule), and, per tool_use_id, each `tool_result` block's own `agentId` and
|
|
2351
|
+
its entry's `toolUseResult.agentId` (_ABSENT when the key is missing)."""
|
|
2352
|
+
|
|
2353
|
+
def __init__(self) -> None:
|
|
2354
|
+
# (tool, raw id, physical line, the string resumeFromRunId of a Workflow's input)
|
|
2355
|
+
self.launches: list[tuple[str, object, int, str | None]] = []
|
|
2356
|
+
self.use_counts: dict[str, int] = {}
|
|
2357
|
+
# per tool_use_id: (block agentId, entry agentId, rejected, digest, is_error, kind)
|
|
2358
|
+
# where kind is the launch result's status class: 'plain' (no status),
|
|
2359
|
+
# 'completed', 'async' (async_launched or isAsync true), or 'other'
|
|
2360
|
+
self.results: dict[str, list[tuple[object, object, bool, str | None, bool, str]]] = {}
|
|
2361
|
+
# docs/DESIGN.md 4.13.1 (G-A): the facts the return decision reads (returns.ReturnFacts)
|
|
2362
|
+
self.facts = returns.ReturnFacts()
|
|
2363
|
+
|
|
2364
|
+
def clear(self) -> None:
|
|
2365
|
+
self.launches.clear()
|
|
2366
|
+
self.use_counts.clear()
|
|
2367
|
+
self.results.clear()
|
|
2368
|
+
self.facts = returns.ReturnFacts()
|
|
2369
|
+
|
|
2370
|
+
def record_use(
|
|
2371
|
+
self, tool_name: str | None, tool_id: object, line_no: int,
|
|
2372
|
+
resume_from: str | None = None, ts: datetime | None = None, tool_input: object = None,
|
|
2373
|
+
) -> None:
|
|
2374
|
+
self.facts.note_use(tool_name, tool_id, line_no, ts, tool_input)
|
|
2375
|
+
if isinstance(tool_id, str):
|
|
2376
|
+
self.use_counts[tool_id] = self.use_counts.get(tool_id, 0) + 1
|
|
2377
|
+
if tool_name in _LAUNCH_TOOLS:
|
|
2378
|
+
self.launches.append((tool_name, tool_id, line_no, resume_from))
|
|
2379
|
+
|
|
2380
|
+
def record_result(
|
|
2381
|
+
self, tool_use_id: str, block: dict, tool_use_result: object,
|
|
2382
|
+
tur_present: bool = True, line_no: int = 0,
|
|
2383
|
+
) -> None:
|
|
2384
|
+
self.facts.note_result(tool_use_id, block, tool_use_result, tur_present, line_no)
|
|
2385
|
+
block_agent = block["agentId"] if "agentId" in block else _ABSENT
|
|
2386
|
+
entry_agent = (
|
|
2387
|
+
tool_use_result["agentId"]
|
|
2388
|
+
if isinstance(tool_use_result, dict) and "agentId" in tool_use_result
|
|
2389
|
+
else _ABSENT
|
|
2390
|
+
)
|
|
2391
|
+
# docs/DESIGN.md 4.13.3 (A5): matching is by id only, so the digest of
|
|
2392
|
+
# every result is computed, whether or not its launch came first.
|
|
2393
|
+
digest = _result_digest(block, tool_use_result)
|
|
2394
|
+
self.results.setdefault(tool_use_id, []).append((
|
|
2395
|
+
block_agent, entry_agent, _is_rejected_launch(block, tool_use_result), digest,
|
|
2396
|
+
block.get("is_error") is True, _launch_status_kind(tool_use_result),
|
|
2397
|
+
))
|
|
2398
|
+
|
|
2399
|
+
def child_returned(self, child_id: str, notifications=None) -> bool:
|
|
2400
|
+
"""docs/DESIGN.md 4.1, 4.13.1 (S11, N5c-1): whether the child with this
|
|
2401
|
+
id returned a result to its parent. True only when exactly one Agent or
|
|
2402
|
+
Task launch block names the child through a result, no other `tool_use`
|
|
2403
|
+
block shares that launch's id, the launch has exactly one result (results
|
|
2404
|
+
identical as parsed JSON count once), and that result is not an error,
|
|
2405
|
+
not the rejected shape, carries only string ids, and its ids equal the
|
|
2406
|
+
child when both locations are present. Anything else is False: a missing,
|
|
2407
|
+
error, rejected, malformed, duplicate, or conflicting result, a
|
|
2408
|
+
duplicated launch id, and a child with no matching launch. A result whose
|
|
2409
|
+
status is `async_launched` (or whose `isAsync` is true) is not a return by
|
|
2410
|
+
itself (N5 fix 1, S2): `notifications`, a callable giving the transcript's
|
|
2411
|
+
accepted task notifications (4.13.3), must hold one for this child and this
|
|
2412
|
+
launch after the launch's own `tool_use` line (FA3: not after its result line,
|
|
2413
|
+
which the lifecycle uses), and every one of that pair must say `completed`. Any other status
|
|
2414
|
+
string is not a return."""
|
|
2415
|
+
naming = []
|
|
2416
|
+
launch_lines: dict[str, int] = {}
|
|
2417
|
+
for tool, raw_id, line, _resume in self.launches:
|
|
2418
|
+
if tool not in ("Agent", "Task") or not isinstance(raw_id, str):
|
|
2419
|
+
continue
|
|
2420
|
+
if any(child_id in _carried_ids(result) for result in self.results.get(raw_id, [])):
|
|
2421
|
+
naming.append(raw_id)
|
|
2422
|
+
launch_lines[raw_id] = line
|
|
2423
|
+
if len(naming) != 1:
|
|
2424
|
+
return False
|
|
2425
|
+
launch_id = naming[0]
|
|
2426
|
+
if self.use_counts.get(launch_id, 0) != 1:
|
|
2427
|
+
return False
|
|
2428
|
+
results = _distinct_results(self.results[launch_id], lambda result: result[3])
|
|
2429
|
+
if len(results) != 1:
|
|
2430
|
+
return False
|
|
2431
|
+
block_agent, entry_agent, rejected, _digest, is_error, kind = results[0]
|
|
2432
|
+
if rejected or is_error:
|
|
2433
|
+
return False
|
|
2434
|
+
present = [value for value in (block_agent, entry_agent) if value is not _ABSENT]
|
|
2435
|
+
if not (present and all(value == child_id for value in present)):
|
|
2436
|
+
return False
|
|
2437
|
+
if kind in ("plain", "completed"):
|
|
2438
|
+
return True
|
|
2439
|
+
if kind != "async" or notifications is None:
|
|
2440
|
+
return False
|
|
2441
|
+
# FA3: the return-specific boundary is the launch's own tool_use line (a
|
|
2442
|
+
# fast child can notify between the call and its result line); a
|
|
2443
|
+
# notification before it cannot be about this launch.
|
|
2444
|
+
launch_line = launch_lines[launch_id]
|
|
2445
|
+
pair = [
|
|
2446
|
+
notification for notification in notifications()
|
|
2447
|
+
if notification.task_id == child_id and notification.tool_use_id == launch_id
|
|
2448
|
+
and notification.line_no > launch_line
|
|
2449
|
+
]
|
|
2450
|
+
return bool(pair) and all(
|
|
2451
|
+
_TERMINAL_STATUS_VOCAB.get(notification.status) == "completed"
|
|
2452
|
+
for notification in pair
|
|
2453
|
+
)
|
|
2454
|
+
|
|
2455
|
+
|
|
2456
|
+
def _discovered_run_dir(workflows_dir: Path, run_id: str) -> bool:
|
|
2457
|
+
"""A canonical workflow run id that names a directory of the session's
|
|
2458
|
+
`workflows` directory."""
|
|
2459
|
+
return bool(_WORKFLOW_RUN_ID_RE.fullmatch(run_id)) and _is_dir(workflows_dir / run_id)
|
|
2460
|
+
|
|
2461
|
+
|
|
2462
|
+
def _launch_code(
|
|
2463
|
+
tool: str,
|
|
2464
|
+
raw_id: object,
|
|
2465
|
+
line_no: int,
|
|
2466
|
+
resume_from: str | None,
|
|
2467
|
+
log: _LaunchLog,
|
|
2468
|
+
child_ids: frozenset[str],
|
|
2469
|
+
generations: list[Generation],
|
|
2470
|
+
workflows_dir: Path,
|
|
2471
|
+
) -> str | None:
|
|
2472
|
+
"""The first applicable code of the decision table for one launch (4.13.1),
|
|
2473
|
+
or None when it reconciles."""
|
|
2474
|
+
if not isinstance(raw_id, str):
|
|
2475
|
+
return "malformed_launch"
|
|
2476
|
+
if log.use_counts.get(raw_id, 0) > 1:
|
|
2477
|
+
return "ambiguous_tool_id"
|
|
2478
|
+
results = log.results.get(raw_id, [])
|
|
2479
|
+
if not results:
|
|
2480
|
+
return "no_result"
|
|
2481
|
+
results = _distinct_results(results, lambda result: result[3])
|
|
2482
|
+
if len(results) > 1:
|
|
2483
|
+
return "ambiguous_result"
|
|
2484
|
+
if tool == "Workflow":
|
|
2485
|
+
if results[0][2]:
|
|
2486
|
+
# A5: an error result is not proof that no run exists.
|
|
2487
|
+
if resume_from is not None:
|
|
2488
|
+
return None if _discovered_run_dir(workflows_dir, resume_from) else "undiscovered"
|
|
2489
|
+
return "undiscovered"
|
|
2490
|
+
for generation in generations:
|
|
2491
|
+
if (
|
|
2492
|
+
generation.tool_use_id == raw_id
|
|
2493
|
+
and generation.launch_line == line_no
|
|
2494
|
+
and _WORKFLOW_RUN_ID_RE.fullmatch(generation.run_id or "")
|
|
2495
|
+
and _is_dir(workflows_dir / generation.run_id)
|
|
2496
|
+
):
|
|
2497
|
+
return None
|
|
2498
|
+
return "undiscovered"
|
|
2499
|
+
block_agent, entry_agent, _rejected, _digest, _is_error, _kind = results[0]
|
|
2500
|
+
present = [value for value in (block_agent, entry_agent) if value is not _ABSENT]
|
|
2501
|
+
if any(not isinstance(value, str) for value in present):
|
|
2502
|
+
return "non_string_id"
|
|
2503
|
+
if len(present) == 2 and present[0] != present[1]:
|
|
2504
|
+
return "conflicting_ids"
|
|
2505
|
+
if not present:
|
|
2506
|
+
return "no_id"
|
|
2507
|
+
return None if present[0] in child_ids else "undiscovered"
|
|
2508
|
+
|
|
2509
|
+
|
|
2510
|
+
def _reconcile_session_launches(
|
|
2511
|
+
main_path: Path,
|
|
2512
|
+
log: _LaunchLog,
|
|
2513
|
+
agent_paths: list[Path],
|
|
2514
|
+
generations: list[Generation],
|
|
2515
|
+
) -> list[str]:
|
|
2516
|
+
"""One diagnostic per Agent, Task, or Workflow launch of the main
|
|
2517
|
+
transcript that does not reconcile with a discovered child, in tool-use
|
|
2518
|
+
order (docs/DESIGN.md 4.13.1, C-14)."""
|
|
2519
|
+
child_ids = frozenset(Path(p).stem.removeprefix("agent-") for p in agent_paths)
|
|
2520
|
+
workflows_dir = _session_dir(main_path) / "subagents" / "workflows"
|
|
2521
|
+
out: list[str] = []
|
|
2522
|
+
for tool, raw_id, line_no, resume_from in log.launches:
|
|
2523
|
+
code = _launch_code(
|
|
2524
|
+
tool, raw_id, line_no, resume_from, log, child_ids, generations, workflows_dir
|
|
2525
|
+
)
|
|
2526
|
+
if code is not None:
|
|
2527
|
+
out.append(
|
|
2528
|
+
f"{main_path.name}:{line_no}: {tool} launch has no discovered child ({code})"
|
|
2529
|
+
)
|
|
2530
|
+
return out
|
|
2531
|
+
|
|
2532
|
+
|
|
2533
|
+
def _note_entry_shape(entry: dict, raw_type: object, state: "ledger.TranscriptState") -> None:
|
|
2534
|
+
"""The entry-level shape rules of docs/DESIGN.md 4.13.1 (unexpected shape and
|
|
2535
|
+
unknown entry type) for the ledger's reasons."""
|
|
2536
|
+
if not isinstance(raw_type, str):
|
|
2537
|
+
state.unexpected_shape = True
|
|
2538
|
+
return
|
|
2539
|
+
if raw_type == "user" or raw_type == "assistant":
|
|
2540
|
+
message = entry.get("message")
|
|
2541
|
+
if not isinstance(message, dict) or not isinstance(message.get("content"), (str, list)):
|
|
2542
|
+
state.unexpected_shape = True
|
|
2543
|
+
elif not ledger.entry_type_known(raw_type):
|
|
2544
|
+
state.unknown_type = True
|
|
2545
|
+
|
|
2546
|
+
|
|
2547
|
+
def _fork_scan(path: Path) -> tuple[int, bool]:
|
|
2548
|
+
"""(boundary, settled) for a session main transcript (docs/DESIGN.md 4.2, FA4).
|
|
2549
|
+
|
|
2550
|
+
boundary is the physical line number of the last line of the inherited prefix: the
|
|
2551
|
+
last entry whose `sessionId` is a string differing from the file's own stem, before
|
|
2552
|
+
the first entry carrying the stem itself (0 when there is no such entry, or no own
|
|
2553
|
+
entry at all). settled is True once an own entry was seen, so the boundary can never
|
|
2554
|
+
change as the file grows; False means the file has no own entry yet (nothing is
|
|
2555
|
+
inherited for now). An unreadable file inherits nothing. Undecodable, blank, and
|
|
2556
|
+
sessionId-less lines never decide the boundary themselves."""
|
|
2557
|
+
own = path.stem
|
|
2558
|
+
last_foreign = 0
|
|
2559
|
+
try:
|
|
2560
|
+
with _transcript_reader(path) as lines:
|
|
2561
|
+
for line_no, line, _torn in lines:
|
|
2562
|
+
# cheap prefilter: an entry with a top-level sessionId spells the key
|
|
2563
|
+
# literally, so a line without it is never decoded here
|
|
2564
|
+
if '"sessionId"' not in line:
|
|
2565
|
+
continue
|
|
2566
|
+
try:
|
|
2567
|
+
entry = json.loads(line)
|
|
2568
|
+
except _UNPARSEABLE:
|
|
2569
|
+
continue
|
|
2570
|
+
if not isinstance(entry, dict):
|
|
2571
|
+
continue
|
|
2572
|
+
session = entry.get("sessionId")
|
|
2573
|
+
if not isinstance(session, str):
|
|
2574
|
+
continue
|
|
2575
|
+
if session == own:
|
|
2576
|
+
return last_foreign, True
|
|
2577
|
+
last_foreign = line_no
|
|
2578
|
+
except (OSError, UnicodeError):
|
|
2579
|
+
return 0, False
|
|
2580
|
+
return 0, False
|
|
2581
|
+
|
|
2582
|
+
|
|
2583
|
+
def _fork_boundary(path: Path) -> int:
|
|
2584
|
+
"""The number of the last inherited physical line of a session main transcript, 0
|
|
2585
|
+
when nothing is inherited (see _fork_scan)."""
|
|
2586
|
+
return _fork_scan(path)[0]
|
|
2587
|
+
|
|
2588
|
+
|
|
2589
|
+
def _read_transcript(
|
|
2590
|
+
transcript_path: Path, diagnostics: list[str], *, launch_log: "_LaunchLog | None" = None,
|
|
2591
|
+
state: "ledger.TranscriptState | None" = None, skip_through_line: int = 0,
|
|
2592
|
+
) -> tuple[list[Event], str | None, str | None, int, int, list[ToolCall], list[MarkerHit]]:
|
|
2593
|
+
"""events, version, session_id, (total_lines, bad_lines), tool_calls, and
|
|
2594
|
+
marker_hits: total_lines/bad_lines are the non-blank physical line count
|
|
2595
|
+
and how many of them failed to decode as JSON at all (docs/DESIGN.md
|
|
2596
|
+
4.6's "5 percent of a transcript's lines were undecodable"; a line that
|
|
2597
|
+
decodes but is not a JSON object is a different, already-diagnosed
|
|
2598
|
+
problem, not an undecodable one). tool_calls is every ToolCall in
|
|
2599
|
+
transcript order (docs/DESIGN.md 4.1/4.2's v0.2 reader changes), filled
|
|
2600
|
+
from each tool_use block's own full parsed input and updated in place
|
|
2601
|
+
when its matching tool_result is seen. marker_hits is every MarkerHit
|
|
2602
|
+
built from the marker provenance registry (docs/DESIGN.md 4.13.2, v0.3
|
|
2603
|
+
N1), in the order its block was encountered, except the two whole-
|
|
2604
|
+
assistant-entry shapes (sandbox_write_denied's attempt-has-a-codex-call
|
|
2605
|
+
shape, and the four final-transcript-entry markers), which are resolved
|
|
2606
|
+
once the whole transcript -- and therefore tool_calls and the true last
|
|
2607
|
+
entry -- are known, and so are appended last.
|
|
2608
|
+
|
|
2609
|
+
skip_through_line (FA4): the physical lines 1..skip_through_line are a fork's
|
|
2610
|
+
inherited prefix (a session main transcript only, _fork_boundary) and are skipped
|
|
2611
|
+
entirely, before any accumulator, count, or diagnostic is touched; line numbers
|
|
2612
|
+
keep counting physically."""
|
|
2613
|
+
events: list[Event] = []
|
|
2614
|
+
version: str | None = None
|
|
2615
|
+
session_id: str | None = None
|
|
2616
|
+
tool_names: dict[str, str | None] = {}
|
|
2617
|
+
# docs/DESIGN.md 4.3 (D-A): the Bash tool_use command text paired to
|
|
2618
|
+
# each tool_use_id, so a later tool_result can be judged read-only
|
|
2619
|
+
# without re-reading the tool_use event.
|
|
2620
|
+
tool_commands: dict[str, str | None] = {}
|
|
2621
|
+
# docs/DESIGN.md 4.1/4.2's v0.2 reader changes: every ToolCall built so
|
|
2622
|
+
# far, in transcript order, plus an index by tool_use_id so the matching
|
|
2623
|
+
# tool_result (which can arrive in any order relative to a sibling call
|
|
2624
|
+
# started in the same assistant message) updates the right one.
|
|
2625
|
+
tool_calls: list[ToolCall] = []
|
|
2626
|
+
tool_call_index: dict[str, ToolCall] = {}
|
|
2627
|
+
# docs/DESIGN.md 4.2's v0.2 reader changes (F5 hardening): every
|
|
2628
|
+
# ToolCall built so far for a given tool_use_id, in the order seen --
|
|
2629
|
+
# unlike tool_call_index (which only ever points at the latest one),
|
|
2630
|
+
# this is how a second tool_use block with the same id is recognized as
|
|
2631
|
+
# a duplicate at all.
|
|
2632
|
+
tool_calls_by_id: dict[str, list[ToolCall]] = {}
|
|
2633
|
+
total_lines = 0
|
|
2634
|
+
bad_lines = 0
|
|
2635
|
+
# Usage dedup is scoped to one transcript (docs/DESIGN.md 4.2): the
|
|
2636
|
+
# reader keeps a set of (message.id, requestId) pairs seen while reading
|
|
2637
|
+
# this file only, resetting for the next attempt's transcript.
|
|
2638
|
+
dedup: dict[tuple[str, str], dict] = {}
|
|
2639
|
+
# docs/DESIGN.md 4.13.2 (v0.3 N1): marker hits decided immediately
|
|
2640
|
+
# (every tool_result block, plus kill_sentinel on each user entry's own
|
|
2641
|
+
# text), and the raw material for the two whole-assistant-entry shapes
|
|
2642
|
+
# that need attempt-wide context, resolved after the loop.
|
|
2643
|
+
marker_hits: list[MarkerHit] = []
|
|
2644
|
+
assistant_marker_candidates: list[tuple[int, str]] = []
|
|
2645
|
+
final_entry_type: str | None = None
|
|
2646
|
+
final_entry_line_no: int | None = None
|
|
2647
|
+
|
|
2648
|
+
with _transcript_reader(transcript_path) as lines:
|
|
2649
|
+
for line_no, line, torn in lines:
|
|
2650
|
+
if line_no <= skip_through_line:
|
|
2651
|
+
continue
|
|
2652
|
+
if torn:
|
|
2653
|
+
# docs/DESIGN.md 4.1 (G-C, GC-1): a final line cut inside a character is a
|
|
2654
|
+
# half-written line, like an ASCII one; the diagnostic quotes none of it
|
|
2655
|
+
total_lines += 1
|
|
2656
|
+
bad_lines += 1
|
|
2657
|
+
# not counted as skipped: a live write in progress, not a bad line (0.3.4)
|
|
2658
|
+
diagnostics.append(
|
|
2659
|
+
f"{transcript_path.name}:{line_no}: half-written last line (cut inside a "
|
|
2660
|
+
"character)"
|
|
2661
|
+
)
|
|
2662
|
+
if state is not None:
|
|
2663
|
+
state.undecodable = True
|
|
2664
|
+
continue
|
|
2665
|
+
# an unfinished last line (no newline yet) is a live write, not a skipped line
|
|
2666
|
+
skipped = 1 if line.endswith("\n") else 0
|
|
2667
|
+
line = line.strip()
|
|
2668
|
+
if not line:
|
|
2669
|
+
continue
|
|
2670
|
+
total_lines += 1
|
|
2671
|
+
try:
|
|
2672
|
+
entry = json.loads(line)
|
|
2673
|
+
except _UNPARSEABLE as exc:
|
|
2674
|
+
diagnostics.append(f"{transcript_path.name}:{line_no}: {_unparseable_text(exc)}")
|
|
2675
|
+
bad_lines += 1
|
|
2676
|
+
_skipped_line(skipped)
|
|
2677
|
+
if state is not None:
|
|
2678
|
+
state.undecodable = True
|
|
2679
|
+
continue
|
|
2680
|
+
if not isinstance(entry, dict):
|
|
2681
|
+
_skipped_line(skipped)
|
|
2682
|
+
diagnostics.append(f"{transcript_path.name}:{line_no}: not a JSON object")
|
|
2683
|
+
if state is not None:
|
|
2684
|
+
state.unexpected_shape = True
|
|
2685
|
+
continue
|
|
2686
|
+
|
|
2687
|
+
if state is not None:
|
|
2688
|
+
# docs/DESIGN.md 4.13.6 (N6): the run cwd set, from the one read this
|
|
2689
|
+
# transcript already gets; a top-level non-empty string only.
|
|
2690
|
+
cwd_value = entry.get("cwd")
|
|
2691
|
+
if isinstance(cwd_value, str) and cwd_value:
|
|
2692
|
+
state.cwds.setdefault(cwd_value, None)
|
|
2693
|
+
|
|
2694
|
+
if version is None and isinstance(entry.get("version"), str):
|
|
2695
|
+
version = entry["version"]
|
|
2696
|
+
if session_id is None and isinstance(entry.get("sessionId"), str):
|
|
2697
|
+
session_id = entry["sessionId"]
|
|
2698
|
+
|
|
2699
|
+
raw_type = entry.get("type", "")
|
|
2700
|
+
ts = _parse_ts(
|
|
2701
|
+
entry.get("timestamp"), transcript_path, line_no, diagnostics, quality=True
|
|
2702
|
+
)
|
|
2703
|
+
# docs/DESIGN.md 4.13.2: trailing "attachment" entries (Claude Code 2.1.305+
|
|
2704
|
+
# writes a hook_success one after the last assistant entry) do not count.
|
|
2705
|
+
if raw_type != "attachment":
|
|
2706
|
+
final_entry_type = raw_type
|
|
2707
|
+
final_entry_line_no = line_no
|
|
2708
|
+
if state is not None:
|
|
2709
|
+
_note_entry_shape(entry, entry.get("type"), state)
|
|
2710
|
+
|
|
2711
|
+
if raw_type == "user":
|
|
2712
|
+
events.extend(
|
|
2713
|
+
_user_events(
|
|
2714
|
+
entry, transcript_path, line_no, ts, raw_type,
|
|
2715
|
+
tool_names, tool_commands, diagnostics,
|
|
2716
|
+
tool_call_index=tool_call_index, launch_log=launch_log, state=state,
|
|
2717
|
+
)
|
|
2718
|
+
)
|
|
2719
|
+
entry_hits = _user_entry_marker_hits(entry, line_no, tool_names, tool_commands)
|
|
2720
|
+
marker_hits.extend(entry_hits)
|
|
2721
|
+
_apply_background_hits(entry_hits, tool_call_index, tool_commands)
|
|
2722
|
+
elif raw_type == "assistant":
|
|
2723
|
+
events.extend(
|
|
2724
|
+
_assistant_events(
|
|
2725
|
+
entry, transcript_path, line_no, ts, raw_type,
|
|
2726
|
+
tool_names, tool_commands, dedup, diagnostics,
|
|
2727
|
+
tool_calls=tool_calls, tool_call_index=tool_call_index,
|
|
2728
|
+
tool_calls_by_id=tool_calls_by_id, launch_log=launch_log, state=state,
|
|
2729
|
+
)
|
|
2730
|
+
)
|
|
2731
|
+
assistant_marker_candidates.append((line_no, _assistant_entry_flatten(entry)))
|
|
2732
|
+
elif raw_type == "attachment":
|
|
2733
|
+
events.append(
|
|
2734
|
+
_attachment_event(entry, transcript_path, line_no, ts, raw_type, diagnostics)
|
|
2735
|
+
)
|
|
2736
|
+
elif raw_type == "system" and entry.get("subtype") == "compact_boundary":
|
|
2737
|
+
events.append(_compaction_event(transcript_path, line_no, ts, raw_type))
|
|
2738
|
+
else:
|
|
2739
|
+
events.append(_unknown_event(entry, transcript_path, line_no, ts, raw_type))
|
|
2740
|
+
|
|
2741
|
+
attempt_has_codex_bash = any(
|
|
2742
|
+
call.tool_name == "Bash" and call.runs_codex_program
|
|
2743
|
+
for call in tool_calls
|
|
2744
|
+
)
|
|
2745
|
+
marker_hits.extend(
|
|
2746
|
+
_finalize_assistant_marker_hits(
|
|
2747
|
+
assistant_marker_candidates, final_entry_type, final_entry_line_no,
|
|
2748
|
+
attempt_has_codex_bash,
|
|
2749
|
+
)
|
|
2750
|
+
)
|
|
2751
|
+
|
|
2752
|
+
return events, version, session_id, total_lines, bad_lines, tool_calls, marker_hits
|
|
2753
|
+
|
|
2754
|
+
|
|
2755
|
+
# docs/DESIGN.md 4.6: a transcript whose undecodable-line rate exceeds this
|
|
2756
|
+
# is not merely lossy in a few spots, it is unreliable enough that the run's
|
|
2757
|
+
# analysis status must say so rather than silently reporting COMPLETE over
|
|
2758
|
+
# whatever survived.
|
|
2759
|
+
_UNDECODABLE_LINE_THRESHOLD = 0.05
|
|
2760
|
+
|
|
2761
|
+
|
|
2762
|
+
def _read_transcript_tracked(
|
|
2763
|
+
transcript_path: Path, diagnostics: list[str], *, launch_log: "_LaunchLog | None" = None,
|
|
2764
|
+
state: "ledger.TranscriptState | None" = None, skip_through_line: int = 0,
|
|
2765
|
+
) -> tuple[list[Event], str | None, str | None, bool, bool, list[ToolCall], list[MarkerHit]]:
|
|
2766
|
+
"""_read_transcript, plus the two docs/DESIGN.md 4.6 conditions it alone
|
|
2767
|
+
can observe: `unreadable` when the file could not even be opened (a
|
|
2768
|
+
permission or IO error, never mere absence -- a caller only reaches this
|
|
2769
|
+
for a transcript path it already confirmed exists), and
|
|
2770
|
+
`over_threshold` when more than _UNDECODABLE_LINE_THRESHOLD of its lines
|
|
2771
|
+
failed to decode. tool_calls and marker_hits are [] when unreadable
|
|
2772
|
+
(docs/DESIGN.md 4.1's v0.2 reader changes: a caller that gets
|
|
2773
|
+
unreadable=True builds an AttemptFacts with every field set to
|
|
2774
|
+
FACTS_UNKNOWN instead of trusting an empty tool_calls list; marker_hits
|
|
2775
|
+
has no such sibling and is simply [])."""
|
|
2776
|
+
try:
|
|
2777
|
+
handle = transcript_path.open("r", encoding="utf-8")
|
|
2778
|
+
handle.close()
|
|
2779
|
+
except OSError as exc:
|
|
2780
|
+
diagnostics.append(f"{transcript_path.name}: unreadable: {exc}")
|
|
2781
|
+
if state is not None:
|
|
2782
|
+
state.unreadable = True
|
|
2783
|
+
return [], None, None, True, False, [], []
|
|
2784
|
+
|
|
2785
|
+
diagnostics_before = len(diagnostics)
|
|
2786
|
+
try:
|
|
2787
|
+
events, version, session_id, total_lines, bad_lines, tool_calls, marker_hits = (
|
|
2788
|
+
_read_transcript(
|
|
2789
|
+
transcript_path, diagnostics, launch_log=launch_log, state=state,
|
|
2790
|
+
skip_through_line=skip_through_line,
|
|
2791
|
+
)
|
|
2792
|
+
)
|
|
2793
|
+
except (OSError, UnicodeError) as exc:
|
|
2794
|
+
# docs/DESIGN.md 4.1 (N4 fix, C2): a failure anywhere in the read (bytes
|
|
2795
|
+
# that are not UTF-8, or an I/O error, after any number of good lines)
|
|
2796
|
+
# is the same unreadable shape as an open failure: whatever the read
|
|
2797
|
+
# accumulated before it failed is discarded, so the ledger is exactly
|
|
2798
|
+
# Ledger([], 'partial', ['transcript_unreadable', ...]).
|
|
2799
|
+
del diagnostics[diagnostics_before:]
|
|
2800
|
+
diagnostics.append(f"{transcript_path.name}: unreadable: {exc}")
|
|
2801
|
+
if state is not None:
|
|
2802
|
+
state.undecodable = False
|
|
2803
|
+
state.unexpected_shape = False
|
|
2804
|
+
state.unknown_type = False
|
|
2805
|
+
state.unreadable = True
|
|
2806
|
+
state.cwds.clear()
|
|
2807
|
+
if launch_log is not None:
|
|
2808
|
+
launch_log.clear()
|
|
2809
|
+
return [], None, None, True, False, [], []
|
|
2810
|
+
over_threshold = total_lines > 0 and (bad_lines / total_lines) > _UNDECODABLE_LINE_THRESHOLD
|
|
2811
|
+
return events, version, session_id, False, over_threshold, tool_calls, marker_hits
|
|
2812
|
+
|
|
2813
|
+
|
|
2814
|
+
def _edge_event_has_timestamp_diagnostic(events: list[Event], diagnostics: list[str]) -> bool:
|
|
2815
|
+
"""Whether the first or last event in events came from a line _parse_ts
|
|
2816
|
+
already flagged with a timestamp diagnostic (docs/DESIGN.md 4.6): such
|
|
2817
|
+
an event's ts is None not because the field was absent (harmless) but
|
|
2818
|
+
because it was present and malformed, which means first_ts/last_ts for
|
|
2819
|
+
this attempt may be silently wrong, not just imprecise."""
|
|
2820
|
+
if not events:
|
|
2821
|
+
return False
|
|
2822
|
+
for event in (events[0], events[-1]):
|
|
2823
|
+
prefix = f"{event.path.name}:{event.line_no}: timestamp"
|
|
2824
|
+
if any(diag.startswith(prefix) for diag in diagnostics):
|
|
2825
|
+
return True
|
|
2826
|
+
return False
|
|
2827
|
+
|
|
2828
|
+
|
|
2829
|
+
def _as_object(
|
|
2830
|
+
value: object, path: Path, line_no: int, diagnostics: list[str] | None, field: str,
|
|
2831
|
+
*, quality: bool = False,
|
|
2832
|
+
) -> dict:
|
|
2833
|
+
"""value if it is already a dict, else {} with a path:line diagnostic
|
|
2834
|
+
(docs/DESIGN.md section 3: the reader tolerates unknown shapes). A
|
|
2835
|
+
missing field (None) is the common, harmless case and never diagnosed;
|
|
2836
|
+
only a present-but-wrong-shaped field is."""
|
|
2837
|
+
if isinstance(value, dict):
|
|
2838
|
+
return value
|
|
2839
|
+
if value is not None and diagnostics is not None:
|
|
2840
|
+
diagnostics.append(_note(
|
|
2841
|
+
f"{path.name}:{line_no}: {field} is not an object (got {type(value).__name__})",
|
|
2842
|
+
quality,
|
|
2843
|
+
))
|
|
2844
|
+
return {}
|
|
2845
|
+
|
|
2846
|
+
|
|
2847
|
+
def _as_text(
|
|
2848
|
+
value: object, path: Path, line_no: int, diagnostics: list[str] | None, field: str
|
|
2849
|
+
) -> str:
|
|
2850
|
+
"""value if it is already a string, else '' with a path:line
|
|
2851
|
+
diagnostic. A missing field ('' from .get(field, "")) is never
|
|
2852
|
+
diagnosed; only a present-but-wrong-shaped field is."""
|
|
2853
|
+
if isinstance(value, str):
|
|
2854
|
+
return value
|
|
2855
|
+
if value and diagnostics is not None:
|
|
2856
|
+
diagnostics.append(_note(
|
|
2857
|
+
f"{path.name}:{line_no}: {field} is not a string (got {type(value).__name__})",
|
|
2858
|
+
True,
|
|
2859
|
+
))
|
|
2860
|
+
return ""
|
|
2861
|
+
|
|
2862
|
+
|
|
2863
|
+
def _user_events(
|
|
2864
|
+
entry, path, line_no, ts, raw_type, tool_names, tool_commands=None, diagnostics=None,
|
|
2865
|
+
tool_call_index=None, launch_log=None, state=None,
|
|
2866
|
+
) -> list[Event]:
|
|
2867
|
+
message = _as_object(entry.get("message"), path, line_no, diagnostics, "message")
|
|
2868
|
+
content = message.get("content")
|
|
2869
|
+
events: list[Event] = []
|
|
2870
|
+
|
|
2871
|
+
if isinstance(content, str):
|
|
2872
|
+
events.append(Event(
|
|
2873
|
+
path=path, line_no=line_no, ts=ts, kind="user", raw_type=raw_type,
|
|
2874
|
+
tool_name=None, text=_excerpt(content), exit_code=None,
|
|
2875
|
+
duration_ms=None, is_error=False, usage={}, markers=_scan_markers(content),
|
|
2876
|
+
))
|
|
2877
|
+
return events
|
|
2878
|
+
|
|
2879
|
+
if not isinstance(content, list):
|
|
2880
|
+
return events
|
|
2881
|
+
|
|
2882
|
+
for block in content:
|
|
2883
|
+
if isinstance(block, str):
|
|
2884
|
+
# docs/DESIGN.md section 3: "User blocks: tool_result (...) or a
|
|
2885
|
+
# plain string." A bare string list item is user text, the same
|
|
2886
|
+
# as a {"type":"text","text":...} block, and it is where the
|
|
2887
|
+
# kill sentinel itself sometimes arrives (docs/DESIGN.md 4.4).
|
|
2888
|
+
events.append(Event(
|
|
2889
|
+
path=path, line_no=line_no, ts=ts, kind="user", raw_type=raw_type,
|
|
2890
|
+
tool_name=None, text=_excerpt(block), exit_code=None,
|
|
2891
|
+
duration_ms=None, is_error=False, usage={}, markers=_scan_markers(block),
|
|
2892
|
+
))
|
|
2893
|
+
continue
|
|
2894
|
+
if not isinstance(block, dict):
|
|
2895
|
+
continue
|
|
2896
|
+
block_type = block.get("type")
|
|
2897
|
+
if block_type == "tool_result":
|
|
2898
|
+
tool_use_id = block.get("tool_use_id")
|
|
2899
|
+
if state is not None and not isinstance(tool_use_id, str):
|
|
2900
|
+
state.unexpected_shape = True
|
|
2901
|
+
tool_name = tool_names.get(tool_use_id) if isinstance(tool_use_id, str) else None
|
|
2902
|
+
full_text = _flatten_block_text(block.get("content"))
|
|
2903
|
+
is_envelope = tool_name in _ENVELOPE_TOOL_NAMES
|
|
2904
|
+
is_error = bool(block.get("is_error", False))
|
|
2905
|
+
events.append(Event(
|
|
2906
|
+
path=path, line_no=line_no, ts=ts, kind="tool_result", raw_type=raw_type,
|
|
2907
|
+
tool_name=tool_name, text=_excerpt(full_text),
|
|
2908
|
+
exit_code=_parse_exit_code(full_text),
|
|
2909
|
+
duration_ms=None, is_error=is_error,
|
|
2910
|
+
usage={},
|
|
2911
|
+
markers=frozenset() if is_envelope else _scan_markers(full_text),
|
|
2912
|
+
envelope=is_envelope,
|
|
2913
|
+
read_only_output=_tool_result_read_only(tool_name, tool_use_id, tool_commands),
|
|
2914
|
+
tool_use_id=tool_use_id if isinstance(tool_use_id, str) else None,
|
|
2915
|
+
))
|
|
2916
|
+
# docs/DESIGN.md 4.1/4.2's v0.2 reader changes: fill in the
|
|
2917
|
+
# matching ToolCall (by tool_use_id, never by position -- two
|
|
2918
|
+
# tool_use blocks in one assistant message can have their
|
|
2919
|
+
# results arrive in either order) from this tool_result's own
|
|
2920
|
+
# FULL text, before _excerpt bounds it.
|
|
2921
|
+
if launch_log is not None and isinstance(tool_use_id, str):
|
|
2922
|
+
launch_log.record_result(
|
|
2923
|
+
tool_use_id, block, entry.get("toolUseResult"),
|
|
2924
|
+
"toolUseResult" in entry, line_no,
|
|
2925
|
+
)
|
|
2926
|
+
elif launch_log is not None:
|
|
2927
|
+
launch_log.facts.note_bad_result(tool_use_id, block, entry.get("toolUseResult"))
|
|
2928
|
+
if tool_call_index is not None and isinstance(tool_use_id, str):
|
|
2929
|
+
call = tool_call_index.get(tool_use_id)
|
|
2930
|
+
if call is not None:
|
|
2931
|
+
call.result_line = line_no
|
|
2932
|
+
call.is_error = is_error
|
|
2933
|
+
call.backgrounded = all(
|
|
2934
|
+
fragment in full_text for fragment in BASH_BACKGROUNDED
|
|
2935
|
+
)
|
|
2936
|
+
call.exit_code = _parse_exit_code(full_text)
|
|
2937
|
+
# docs/DESIGN.md 4.13.1's commit grammar (C-9), from the
|
|
2938
|
+
# commit segments of the call's classification (computed
|
|
2939
|
+
# from the FULL command text, C-11), for a completed_ok or
|
|
2940
|
+
# completed_error call only (A1).
|
|
2941
|
+
if (
|
|
2942
|
+
call.tool_name == "Bash"
|
|
2943
|
+
and call.classification is not None
|
|
2944
|
+
and call.completion in commands.COMMIT_COMPLETIONS
|
|
2945
|
+
):
|
|
2946
|
+
call.commit_ids = commands.commit_ids(
|
|
2947
|
+
call.classification.commit, full_text
|
|
2948
|
+
)
|
|
2949
|
+
elif block_type == "text":
|
|
2950
|
+
block_text = _as_text(block.get("text", ""), path, line_no, diagnostics, "block text")
|
|
2951
|
+
events.append(Event(
|
|
2952
|
+
path=path, line_no=line_no, ts=ts, kind="user", raw_type=raw_type,
|
|
2953
|
+
tool_name=None, text=_excerpt(block_text), exit_code=None,
|
|
2954
|
+
duration_ms=None, is_error=False, usage={}, markers=_scan_markers(block_text),
|
|
2955
|
+
))
|
|
2956
|
+
else:
|
|
2957
|
+
events.append(_unknown_event(block, path, line_no, ts, raw_type))
|
|
2958
|
+
return events
|
|
2959
|
+
|
|
2960
|
+
|
|
2961
|
+
def _assistant_events(
|
|
2962
|
+
entry, path, line_no, ts, raw_type, tool_names, tool_commands=None, dedup=None,
|
|
2963
|
+
diagnostics=None, tool_calls=None, tool_call_index=None, tool_calls_by_id=None,
|
|
2964
|
+
launch_log=None, state=None,
|
|
2965
|
+
) -> list[Event]:
|
|
2966
|
+
"""Every block in one assistant transcript entry, with usage attached to
|
|
2967
|
+
exactly one of them (the first), regardless of how many blocks the
|
|
2968
|
+
message carries or whether any of them is a text block. message.usage
|
|
2969
|
+
describes the whole entry, not any single block, so summing per-Event
|
|
2970
|
+
usage across an attempt must not double it (two text blocks) or lose it
|
|
2971
|
+
(a tool-use-only message).
|
|
2972
|
+
|
|
2973
|
+
dedup and diagnostics are optional so existing direct callers (tests
|
|
2974
|
+
exercising a single entry in isolation) keep working unchanged; a real
|
|
2975
|
+
_read_transcript call always supplies both. When given, a (message.id,
|
|
2976
|
+
requestId) pair already in dedup zeroes this entry's usage (docs/
|
|
2977
|
+
DESIGN.md 4.2's dedup rule); an entry missing either field is always
|
|
2978
|
+
counted (the ccusage convention).
|
|
2979
|
+
|
|
2980
|
+
Usage never silently disappears: it is attached to the first event this
|
|
2981
|
+
entry emits, whatever kind that event is (tool_use, text, thinking, or
|
|
2982
|
+
even an 'unknown' block this milestone's markers.py does not recognize
|
|
2983
|
+
yet). An entry that emits no event at all (empty content, or content
|
|
2984
|
+
made only of unrecognized blocks) still gets a synthetic 'unknown' event
|
|
2985
|
+
so its usage is not lost."""
|
|
2986
|
+
message = _as_object(entry.get("message"), path, line_no, diagnostics, "message")
|
|
2987
|
+
content = message.get("content")
|
|
2988
|
+
remaining_usage = _as_object(
|
|
2989
|
+
message.get("usage"), path, line_no, diagnostics, "usage", quality=True
|
|
2990
|
+
)
|
|
2991
|
+
|
|
2992
|
+
message_id = message.get("id")
|
|
2993
|
+
request_id = entry.get("requestId")
|
|
2994
|
+
pair = (message_id, request_id) if message_id and request_id else None
|
|
2995
|
+
if pair is not None and dedup is not None:
|
|
2996
|
+
if pair in dedup:
|
|
2997
|
+
if diagnostics is not None and dedup[pair] != remaining_usage:
|
|
2998
|
+
diagnostics.append(QualityNote(
|
|
2999
|
+
f"{path.name}:{line_no}: duplicate usage for a (message id, requestId) pair "
|
|
3000
|
+
"differs from its first occurrence"
|
|
3001
|
+
))
|
|
3002
|
+
remaining_usage = {}
|
|
3003
|
+
else:
|
|
3004
|
+
dedup[pair] = remaining_usage
|
|
3005
|
+
|
|
3006
|
+
events: list[Event] = []
|
|
3007
|
+
|
|
3008
|
+
def _take_usage() -> dict:
|
|
3009
|
+
nonlocal remaining_usage
|
|
3010
|
+
usage, remaining_usage = remaining_usage, {}
|
|
3011
|
+
return usage
|
|
3012
|
+
|
|
3013
|
+
def _finish() -> list[Event]:
|
|
3014
|
+
# message.usage describes the whole entry; if nothing above claimed
|
|
3015
|
+
# it (empty content, or content made only of blocks no branch below
|
|
3016
|
+
# recognizes), it must not simply vanish (docs/DESIGN.md 4.2).
|
|
3017
|
+
if not events and remaining_usage:
|
|
3018
|
+
unknown_event = _unknown_event(entry, path, line_no, ts, raw_type)
|
|
3019
|
+
unknown_event.usage = _take_usage()
|
|
3020
|
+
events.append(unknown_event)
|
|
3021
|
+
return events
|
|
3022
|
+
|
|
3023
|
+
if isinstance(content, str):
|
|
3024
|
+
events.append(Event(
|
|
3025
|
+
path=path, line_no=line_no, ts=ts, kind="assistant", raw_type=raw_type,
|
|
3026
|
+
tool_name=None, text=_excerpt(content), exit_code=None,
|
|
3027
|
+
duration_ms=None, is_error=False, usage=_take_usage(), markers=_scan_markers(content),
|
|
3028
|
+
))
|
|
3029
|
+
return _finish()
|
|
3030
|
+
|
|
3031
|
+
if not isinstance(content, list):
|
|
3032
|
+
return _finish()
|
|
3033
|
+
|
|
3034
|
+
for block in content:
|
|
3035
|
+
if not isinstance(block, dict):
|
|
3036
|
+
continue
|
|
3037
|
+
block_type = block.get("type")
|
|
3038
|
+
if block_type == "tool_use":
|
|
3039
|
+
tool_name = block.get("name")
|
|
3040
|
+
if not isinstance(tool_name, str):
|
|
3041
|
+
tool_name = None
|
|
3042
|
+
tool_id = block.get("id")
|
|
3043
|
+
if isinstance(tool_id, str):
|
|
3044
|
+
tool_names[tool_id] = tool_name
|
|
3045
|
+
if state is not None and (tool_name is None or not isinstance(tool_id, str)):
|
|
3046
|
+
state.unexpected_shape = True
|
|
3047
|
+
if launch_log is not None:
|
|
3048
|
+
launch_input = block.get("input")
|
|
3049
|
+
resume = (
|
|
3050
|
+
launch_input.get("resumeFromRunId") if isinstance(launch_input, dict) else None
|
|
3051
|
+
)
|
|
3052
|
+
launch_log.record_use(
|
|
3053
|
+
tool_name, tool_id, line_no, resume if isinstance(resume, str) else None,
|
|
3054
|
+
ts=ts, tool_input=launch_input,
|
|
3055
|
+
)
|
|
3056
|
+
raw_input = block.get("input", {})
|
|
3057
|
+
input_obj = _as_object(raw_input, path, line_no, diagnostics, "tool_use input")
|
|
3058
|
+
input_text = json.dumps(input_obj, ensure_ascii=False)
|
|
3059
|
+
if isinstance(tool_id, str) and tool_commands is not None and tool_name == "Bash":
|
|
3060
|
+
command = input_obj.get("command")
|
|
3061
|
+
tool_commands[tool_id] = command if isinstance(command, str) else None
|
|
3062
|
+
if isinstance(tool_id, str) and tool_calls is not None and tool_call_index is not None:
|
|
3063
|
+
call = _make_tool_call(tool_id, tool_name, input_obj, line_no, ts, entry.get("cwd"))
|
|
3064
|
+
tool_calls.append(call)
|
|
3065
|
+
tool_call_index[tool_id] = call
|
|
3066
|
+
# docs/DESIGN.md 4.2's v0.2 reader changes (F5 hardening): a
|
|
3067
|
+
# tool_use_id seen before in this same transcript means the
|
|
3068
|
+
# pairing below can no longer tell which tool_result belongs
|
|
3069
|
+
# to which call. Diagnose it (so Run.status becomes
|
|
3070
|
+
# INCOMPLETE) and mark EVERY ToolCall sharing this id --
|
|
3071
|
+
# including ones already built -- ambiguous, never only the
|
|
3072
|
+
# newest one.
|
|
3073
|
+
if tool_calls_by_id is not None:
|
|
3074
|
+
siblings = tool_calls_by_id.setdefault(tool_id, [])
|
|
3075
|
+
if siblings:
|
|
3076
|
+
first_line = siblings[0].line_no
|
|
3077
|
+
if diagnostics is not None:
|
|
3078
|
+
diagnostics.append(
|
|
3079
|
+
f"{path.name}:{line_no}: duplicate tool_use_id {tool_id} "
|
|
3080
|
+
f"at lines {first_line} and {line_no}"
|
|
3081
|
+
)
|
|
3082
|
+
for sibling in siblings:
|
|
3083
|
+
sibling.ambiguous = True
|
|
3084
|
+
sibling.commit_ids = ()
|
|
3085
|
+
call.ambiguous = True
|
|
3086
|
+
siblings.append(call)
|
|
3087
|
+
events.append(Event(
|
|
3088
|
+
path=path, line_no=line_no, ts=ts, kind="tool_use", raw_type=raw_type,
|
|
3089
|
+
tool_name=tool_name, text=_excerpt(input_text),
|
|
3090
|
+
exit_code=None, duration_ms=None, is_error=False, usage=_take_usage(),
|
|
3091
|
+
markers=_scan_markers(input_text),
|
|
3092
|
+
))
|
|
3093
|
+
elif block_type == "text":
|
|
3094
|
+
block_text = _as_text(block.get("text", ""), path, line_no, diagnostics, "block text")
|
|
3095
|
+
events.append(Event(
|
|
3096
|
+
path=path, line_no=line_no, ts=ts, kind="assistant", raw_type=raw_type,
|
|
3097
|
+
tool_name=None, text=_excerpt(block_text), exit_code=None,
|
|
3098
|
+
duration_ms=None, is_error=False, usage=_take_usage(),
|
|
3099
|
+
markers=_scan_markers(block_text),
|
|
3100
|
+
))
|
|
3101
|
+
elif block_type == "thinking":
|
|
3102
|
+
events.append(Event(
|
|
3103
|
+
path=path, line_no=line_no, ts=ts, kind="thinking", raw_type=raw_type,
|
|
3104
|
+
tool_name=None, text="", exit_code=None,
|
|
3105
|
+
duration_ms=None, is_error=False, usage=_take_usage(), markers=frozenset(),
|
|
3106
|
+
))
|
|
3107
|
+
else:
|
|
3108
|
+
unknown_event = _unknown_event(block, path, line_no, ts, raw_type)
|
|
3109
|
+
unknown_event.usage = _take_usage()
|
|
3110
|
+
events.append(unknown_event)
|
|
3111
|
+
return _finish()
|
|
3112
|
+
|
|
3113
|
+
|
|
3114
|
+
def _attachment_event(entry, path, line_no, ts, raw_type, diagnostics=None) -> Event:
|
|
3115
|
+
attachment = _as_object(
|
|
3116
|
+
entry.get("attachment"), path, line_no, diagnostics, "attachment", quality=True
|
|
3117
|
+
)
|
|
3118
|
+
text = attachment.get("text")
|
|
3119
|
+
if not isinstance(text, str):
|
|
3120
|
+
pieces = [str(attachment[key]) for key in ("stdout", "stderr") if attachment.get(key)]
|
|
3121
|
+
if pieces:
|
|
3122
|
+
text = "\n".join(pieces)
|
|
3123
|
+
else:
|
|
3124
|
+
content = attachment.get("content")
|
|
3125
|
+
text = (
|
|
3126
|
+
content if isinstance(content, str) else json.dumps(attachment, ensure_ascii=False)
|
|
3127
|
+
)
|
|
3128
|
+
|
|
3129
|
+
exit_code = attachment.get("exitCode")
|
|
3130
|
+
if not isinstance(exit_code, int):
|
|
3131
|
+
exit_code = None
|
|
3132
|
+
duration_ms = attachment.get("durationMs")
|
|
3133
|
+
if not isinstance(duration_ms, int):
|
|
3134
|
+
duration_ms = None
|
|
3135
|
+
|
|
3136
|
+
return Event(
|
|
3137
|
+
path=path, line_no=line_no, ts=ts, kind="attachment", raw_type=raw_type,
|
|
3138
|
+
tool_name=None, text=_excerpt(text), exit_code=exit_code,
|
|
3139
|
+
duration_ms=duration_ms, is_error=False, usage={}, markers=_scan_markers(text),
|
|
3140
|
+
)
|
|
3141
|
+
|
|
3142
|
+
|
|
3143
|
+
def _compaction_event(path, line_no, ts, raw_type) -> Event:
|
|
3144
|
+
"""A system entry with subtype == 'compact_boundary' (docs/DESIGN.md
|
|
3145
|
+
section 3): a context compaction, carrying no excerpt text or markers of
|
|
3146
|
+
its own."""
|
|
3147
|
+
return Event(
|
|
3148
|
+
path=path, line_no=line_no, ts=ts, kind="compaction", raw_type=raw_type,
|
|
3149
|
+
tool_name=None, text="", exit_code=None, duration_ms=None, is_error=False,
|
|
3150
|
+
usage={}, markers=frozenset(),
|
|
3151
|
+
)
|
|
3152
|
+
|
|
3153
|
+
|
|
3154
|
+
def _unknown_event(entry, path, line_no, ts, raw_type) -> Event:
|
|
3155
|
+
entry_text = json.dumps(entry, ensure_ascii=False)
|
|
3156
|
+
return Event(
|
|
3157
|
+
path=path, line_no=line_no, ts=ts, kind="unknown", raw_type=raw_type,
|
|
3158
|
+
tool_name=None, text=_excerpt(entry_text),
|
|
3159
|
+
exit_code=None, duration_ms=None, is_error=False, usage={},
|
|
3160
|
+
markers=_scan_markers(entry_text),
|
|
3161
|
+
)
|
|
3162
|
+
|
|
3163
|
+
|
|
3164
|
+
# --- parent transcript index --------------------------------------------
|
|
3165
|
+
|
|
3166
|
+
|
|
3167
|
+
@dataclass(frozen=True)
|
|
3168
|
+
class _TaskStop:
|
|
3169
|
+
ts: datetime | None
|
|
3170
|
+
ids: frozenset[str] # task_id, agent_id, or any other string value in input
|
|
3171
|
+
line_no: int
|
|
3172
|
+
text: str # excerpt of the tool_use input, for Interrupt.cause_evidence
|
|
3173
|
+
# docs/DESIGN.md 4.13.3 (v0.3 N1): the input's own 'task_id'/'shell_id'
|
|
3174
|
+
# fields specifically, None when absent or not a string -- used to match
|
|
3175
|
+
# a generation's own task_id exactly, rather than the looser `ids`
|
|
3176
|
+
# membership test 4.4's attribute_interrupt still uses.
|
|
3177
|
+
task_id: str | None = None
|
|
3178
|
+
shell_id: str | None = None
|
|
3179
|
+
|
|
3180
|
+
|
|
3181
|
+
@dataclass(frozen=True)
|
|
3182
|
+
class _KillSentinel:
|
|
3183
|
+
ts: datetime
|
|
3184
|
+
line_no: int
|
|
3185
|
+
text: str
|
|
3186
|
+
|
|
3187
|
+
|
|
3188
|
+
def _string_values(data: object) -> frozenset[str]:
|
|
3189
|
+
if not isinstance(data, dict):
|
|
3190
|
+
return frozenset()
|
|
3191
|
+
return frozenset(value for value in data.values() if isinstance(value, str))
|
|
3192
|
+
|
|
3193
|
+
|
|
3194
|
+
@dataclass(frozen=True)
|
|
3195
|
+
class _Launch:
|
|
3196
|
+
"""docs/DESIGN.md 4.13.3 (v0.3 N1): one Workflow tool_use block."""
|
|
3197
|
+
|
|
3198
|
+
line_no: int
|
|
3199
|
+
tool_use_id: str
|
|
3200
|
+
resume_from: str | None
|
|
3201
|
+
# FA2: the parsed timestamp of the assistant entry holding this tool_use
|
|
3202
|
+
# block (None when absent or unparseable): the generation's launch_ts.
|
|
3203
|
+
ts: datetime | None = None
|
|
3204
|
+
|
|
3205
|
+
|
|
3206
|
+
# docs/DESIGN.md 4.13.3's status vocabulary: for notifications and
|
|
3207
|
+
# synchronous launch results alike, 'completed' -> 'completed', 'failed' ->
|
|
3208
|
+
# 'failed', 'killed'/'stopped'/'cancelled' -> 'stopped'. Any string not in
|
|
3209
|
+
# this table is unrecognized.
|
|
3210
|
+
_TERMINAL_STATUS_VOCAB = {
|
|
3211
|
+
"completed": "completed",
|
|
3212
|
+
"failed": "failed",
|
|
3213
|
+
"killed": "stopped",
|
|
3214
|
+
"stopped": "stopped",
|
|
3215
|
+
"cancelled": "stopped",
|
|
3216
|
+
}
|
|
3217
|
+
|
|
3218
|
+
_ENVELOPE_TAG_RE = {
|
|
3219
|
+
"task-id": re.compile(r"<task-id>(.*?)</task-id>", re.DOTALL),
|
|
3220
|
+
"tool-use-id": re.compile(r"<tool-use-id>(.*?)</tool-use-id>", re.DOTALL),
|
|
3221
|
+
"status": re.compile(r"<status>(.*?)</status>", re.DOTALL),
|
|
3222
|
+
}
|
|
3223
|
+
|
|
3224
|
+
|
|
3225
|
+
def _parse_envelope(text: object) -> tuple[str, str, str] | None:
|
|
3226
|
+
"""docs/DESIGN.md 4.13.3's envelope: after stripping surrounding
|
|
3227
|
+
whitespace, the content starts with <task-notification>, ends with
|
|
3228
|
+
</task-notification>, and contains exactly one each of <task-id>,
|
|
3229
|
+
<tool-use-id>, and <status>. Returns (task_id, tool_use_id, status), or
|
|
3230
|
+
None when the shape does not hold -- a quoted copy inside prose or a
|
|
3231
|
+
compaction summary never has the WHOLE text be just the envelope, so it
|
|
3232
|
+
is never mistaken for a real one."""
|
|
3233
|
+
if not isinstance(text, str):
|
|
3234
|
+
return None
|
|
3235
|
+
stripped = text.strip()
|
|
3236
|
+
if not (
|
|
3237
|
+
stripped.startswith("<task-notification>") and stripped.endswith("</task-notification>")
|
|
3238
|
+
):
|
|
3239
|
+
return None
|
|
3240
|
+
values: dict[str, str] = {}
|
|
3241
|
+
for tag, pattern in _ENVELOPE_TAG_RE.items():
|
|
3242
|
+
matches = pattern.findall(stripped)
|
|
3243
|
+
if len(matches) != 1:
|
|
3244
|
+
return None
|
|
3245
|
+
values[tag] = matches[0].strip()
|
|
3246
|
+
return values["task-id"], values["tool-use-id"], values["status"]
|
|
3247
|
+
|
|
3248
|
+
|
|
3249
|
+
@dataclass(frozen=True)
|
|
3250
|
+
class _NotificationCandidate:
|
|
3251
|
+
line_no: int
|
|
3252
|
+
form: str # 'queue' | 'user_with_origin' | 'user_no_origin'
|
|
3253
|
+
content: object # the raw (unparsed) envelope candidate text
|
|
3254
|
+
ts: datetime | None = field(default=None, compare=False)
|
|
3255
|
+
|
|
3256
|
+
|
|
3257
|
+
@dataclass(frozen=True)
|
|
3258
|
+
class _Notification:
|
|
3259
|
+
line_no: int
|
|
3260
|
+
task_id: str
|
|
3261
|
+
tool_use_id: str
|
|
3262
|
+
status: str
|
|
3263
|
+
ts: datetime | None = field(default=None, compare=False)
|
|
3264
|
+
|
|
3265
|
+
|
|
3266
|
+
@dataclass(frozen=True)
|
|
3267
|
+
class _AmbiguousLaunch:
|
|
3268
|
+
"""docs/DESIGN.md 4.13.3's association rule 1 (v0.3 N2): one group of
|
|
3269
|
+
duplicate tool_use/tool_result blocks sharing a Workflow tool_use id.
|
|
3270
|
+
`candidate_run_ids` is every run id this ambiguous launch could be
|
|
3271
|
+
evidence for -- every launch's own `resumeFromRunId` plus every
|
|
3272
|
+
well-formed result's own `runId` -- so a caller can tell whether it
|
|
3273
|
+
"involves" a particular inspected run (rule 1: unknown/ambiguous_launch
|
|
3274
|
+
for exactly those runs, ignored for every other one)."""
|
|
3275
|
+
|
|
3276
|
+
tool_use_id: str
|
|
3277
|
+
diagnostic: str # the exact "ambiguous launch '<id>': lines ..." string
|
|
3278
|
+
candidate_run_ids: frozenset[str]
|
|
3279
|
+
|
|
3280
|
+
|
|
3281
|
+
_HASH_CHUNK_SIZE = 65536
|
|
3282
|
+
|
|
3283
|
+
# docs/DESIGN.md 4.13.3's run lifecycle activity rule: 10 minutes, the same
|
|
3284
|
+
# threshold hooks_cli._UNKNOWN_QUIET_SECONDS already uses for the "possibly
|
|
3285
|
+
# live" hook heuristic (7.2/12). Restated here (not imported) since
|
|
3286
|
+
# hooks_cli.py already imports reader.py -- reader.py importing hooks_cli
|
|
3287
|
+
# back would be a cycle.
|
|
3288
|
+
ACTIVITY_RECENT_SECONDS = 600.0
|
|
3289
|
+
|
|
3290
|
+
|
|
3291
|
+
def activity_word(newest_mtime: float | None, now: float | None = None) -> str:
|
|
3292
|
+
"""docs/DESIGN.md 4.13.3: 'recent' when the newest mtime among a run's
|
|
3293
|
+
own files is under 10 minutes old, 'stale' when older, 'unknown' when
|
|
3294
|
+
every file's stat failed (newest_mtime is None). `now` defaults to the
|
|
3295
|
+
real wall clock -- this is a file-freshness check, never part of
|
|
3296
|
+
report._now()'s own injectable-clock convention, which only ever governs
|
|
3297
|
+
human-facing relative ages computed from parsed transcript timestamps."""
|
|
3298
|
+
if newest_mtime is None:
|
|
3299
|
+
return "unknown"
|
|
3300
|
+
if now is None:
|
|
3301
|
+
now = time.time()
|
|
3302
|
+
return "recent" if (now - newest_mtime) < ACTIVITY_RECENT_SECONDS else "stale"
|
|
3303
|
+
|
|
3304
|
+
|
|
3305
|
+
def _workflow_run_files(path: Path) -> list[Path]:
|
|
3306
|
+
"""Every file directly inside a workflow run's own wf_* directory (docs/
|
|
3307
|
+
DESIGN.md 4.13.3's activity rule population; the same population
|
|
3308
|
+
cli._run_files_for_liveness uses for kind == 'workflow', duplicated here
|
|
3309
|
+
since reader.py cannot import cli.py)."""
|
|
3310
|
+
try:
|
|
3311
|
+
return [p for p in path.iterdir() if _is_file(p)]
|
|
3312
|
+
except OSError:
|
|
3313
|
+
return []
|
|
3314
|
+
|
|
3315
|
+
|
|
3316
|
+
def _workflow_activity(path: Path) -> str:
|
|
3317
|
+
newest: float | None = None
|
|
3318
|
+
for file_path in _workflow_run_files(path):
|
|
3319
|
+
try:
|
|
3320
|
+
mtime = file_path.stat().st_mtime
|
|
3321
|
+
except OSError:
|
|
3322
|
+
continue
|
|
3323
|
+
if newest is None or mtime > newest:
|
|
3324
|
+
newest = mtime
|
|
3325
|
+
return activity_word(newest)
|
|
3326
|
+
|
|
3327
|
+
|
|
3328
|
+
# --- parent inspection completeness (docs/DESIGN.md 4.13.3, v0.3 N2) -------
|
|
3329
|
+
|
|
3330
|
+
|
|
3331
|
+
_BENIGN_ENQUEUE_KEYS = frozenset({"type", "operation", "sessionId", "timestamp"})
|
|
3332
|
+
|
|
3333
|
+
|
|
3334
|
+
def _entry_shape_ok(entry: dict) -> bool:
|
|
3335
|
+
"""docs/DESIGN.md 4.13.3's Run lifecycle paragraph: the shape every
|
|
3336
|
+
'user'/'assistant'/'queue-operation' entry, and every tool_use/
|
|
3337
|
+
tool_result block inside one, must have for parent inspection to stay
|
|
3338
|
+
complete. Any entry type not named here (system, thinking, ...) is
|
|
3339
|
+
unconstrained."""
|
|
3340
|
+
etype = entry.get("type")
|
|
3341
|
+
if etype == "user":
|
|
3342
|
+
message = entry.get("message")
|
|
3343
|
+
if not isinstance(message, dict):
|
|
3344
|
+
return False
|
|
3345
|
+
content = message.get("content")
|
|
3346
|
+
if not isinstance(content, (str, list)):
|
|
3347
|
+
return False
|
|
3348
|
+
if isinstance(content, list):
|
|
3349
|
+
for block in content:
|
|
3350
|
+
if isinstance(block, dict) and block.get("type") == "tool_result":
|
|
3351
|
+
if not isinstance(block.get("tool_use_id"), str):
|
|
3352
|
+
return False
|
|
3353
|
+
return True
|
|
3354
|
+
if etype == "assistant":
|
|
3355
|
+
message = entry.get("message")
|
|
3356
|
+
if not isinstance(message, dict):
|
|
3357
|
+
return False
|
|
3358
|
+
content = message.get("content")
|
|
3359
|
+
if not isinstance(content, list):
|
|
3360
|
+
return False
|
|
3361
|
+
for block in content:
|
|
3362
|
+
if isinstance(block, dict) and block.get("type") == "tool_use":
|
|
3363
|
+
if not isinstance(block.get("id"), str) or not isinstance(block.get("name"), str):
|
|
3364
|
+
return False
|
|
3365
|
+
return True
|
|
3366
|
+
if etype == "queue-operation":
|
|
3367
|
+
operation = entry.get("operation")
|
|
3368
|
+
if not isinstance(operation, str):
|
|
3369
|
+
return False
|
|
3370
|
+
if operation == "enqueue" and not isinstance(entry.get("content"), str):
|
|
3371
|
+
# docs/DESIGN.md 4.13.3 (N4 fix, M-N4-1 b): a bare enqueue marker
|
|
3372
|
+
# whose key set is exactly these four carries nothing at all
|
|
3373
|
+
return set(entry) == _BENIGN_ENQUEUE_KEYS
|
|
3374
|
+
return True
|
|
3375
|
+
return True
|
|
3376
|
+
|
|
3377
|
+
|
|
3378
|
+
class ParentIndex:
|
|
3379
|
+
"""Incremental, fingerprinted cache of a main session transcript (docs/
|
|
3380
|
+
DESIGN.md 4.2, 4.13.3): TaskStop calls, kill sentinels, compaction
|
|
3381
|
+
timestamps, and -- v0.3 N1 -- Workflow launch/result pairs turned into
|
|
3382
|
+
Generation records. Consulted by detectors.base.attribute_interrupt
|
|
3383
|
+
(4.4) via reader._attribute_interrupts, and by ParentIndex.generations
|
|
3384
|
+
for a run's own lifecycle work (N2).
|
|
3385
|
+
|
|
3386
|
+
`for_path` re-reads only the bytes appended since the last access,
|
|
3387
|
+
unless the file's inode changed, shrank, or its already-parsed prefix no
|
|
3388
|
+
longer hashes the same (a same-size rewrite) -- any of which rebuilds
|
|
3389
|
+
the whole index from zero. `lines_parsed` and `rebuilds` are exposed for
|
|
3390
|
+
tests; both start at 0 and the initial build is never counted as a
|
|
3391
|
+
rebuild.
|
|
3392
|
+
"""
|
|
3393
|
+
|
|
3394
|
+
_cache: dict[Path, "ParentIndex"] = {}
|
|
3395
|
+
|
|
3396
|
+
def __init__(
|
|
3397
|
+
self,
|
|
3398
|
+
path: Path | None,
|
|
3399
|
+
task_stops: list[_TaskStop],
|
|
3400
|
+
kill_sentinels: list[_KillSentinel],
|
|
3401
|
+
compaction_timestamps: list[datetime],
|
|
3402
|
+
diagnostics: list[str] | None = None,
|
|
3403
|
+
tool_result_agent_ids: frozenset[str] = frozenset(),
|
|
3404
|
+
generations: list[Generation] | None = None,
|
|
3405
|
+
):
|
|
3406
|
+
self.path = path
|
|
3407
|
+
self.task_stops = task_stops
|
|
3408
|
+
self.kill_sentinels = kill_sentinels
|
|
3409
|
+
# Backward-compatible view: every earlier caller of this index only
|
|
3410
|
+
# ever wanted the timestamps.
|
|
3411
|
+
self.kill_timestamps = [sentinel.ts for sentinel in kill_sentinels]
|
|
3412
|
+
self.compaction_timestamps = compaction_timestamps
|
|
3413
|
+
# docs/DESIGN.md 7.2 (D2): agentId values seen on a tool_result
|
|
3414
|
+
# block anywhere in this transcript -- a subagent named here has
|
|
3415
|
+
# already returned control to its parent. A plain, growing set (not
|
|
3416
|
+
# frozen): == frozenset() still holds for an empty one, and nothing
|
|
3417
|
+
# else needs it immutable.
|
|
3418
|
+
self.tool_result_agent_ids: set[str] = set(tool_result_agent_ids)
|
|
3419
|
+
# docs/DESIGN.md 4.13.1 (G-A): launches, resume requests and their results, for the
|
|
3420
|
+
# return decision of a session subagent; incremental like everything else here
|
|
3421
|
+
self.facts = returns.ReturnFacts(path)
|
|
3422
|
+
# (docs/DESIGN.md 4.13.3, v0.3 N1) every Generation this index could
|
|
3423
|
+
# build from the transcript seen so far, across every run any launch
|
|
3424
|
+
# here is associated with (association rules 2-5 collapse into a
|
|
3425
|
+
# single run_id per launch -- the result's own runId when well
|
|
3426
|
+
# formed, otherwise the launch's own resumeFromRunId -- so no
|
|
3427
|
+
# external "inspected run X" is needed to compute the full set; a
|
|
3428
|
+
# caller that cares about one run filters by Generation.run_id).
|
|
3429
|
+
# Ordered by launch_line.
|
|
3430
|
+
self.generations: list[Generation] = list(generations) if generations is not None else []
|
|
3431
|
+
# (docs/DESIGN.md 4.13.3's association rule 1, v0.3 N2) every
|
|
3432
|
+
# ambiguous launch group seen so far, for Lifecycle's own
|
|
3433
|
+
# unknown/ambiguous_launch row -- an ambiguous launch produces no
|
|
3434
|
+
# Generation (N1), so this is the only place that information
|
|
3435
|
+
# survives past _rebuild_generations.
|
|
3436
|
+
self.ambiguous_launches: list[_AmbiguousLaunch] = []
|
|
3437
|
+
# path:line diagnostics from a malformed parent-transcript entry, a
|
|
3438
|
+
# single "unreadable"/"undecodable" entry when the file itself could
|
|
3439
|
+
# not be read, or a generation-building diagnostic (an ambiguous
|
|
3440
|
+
# launch, a result-less or malformed launch with no resume, an
|
|
3441
|
+
# unrecognized launch status) -- docs/DESIGN.md section 2's tolerant
|
|
3442
|
+
# parsing, a bad line becomes a diagnostic, never an exception.
|
|
3443
|
+
# Callers fold these into the consuming Run's own diagnostics.
|
|
3444
|
+
self.diagnostics: list[str] = diagnostics if diagnostics is not None else []
|
|
3445
|
+
|
|
3446
|
+
# --- incremental cache bookkeeping (docs/DESIGN.md 4.13.3, v0.3 N1)
|
|
3447
|
+
# irrelevant to an index built directly with pre-collected lists
|
|
3448
|
+
# (as tests/test_attribution.py does): populated only by _refresh.
|
|
3449
|
+
self._parsed_offset = 0
|
|
3450
|
+
self._next_line_no = 1
|
|
3451
|
+
self._prefix_digest: str | None = None
|
|
3452
|
+
self._inode: int | None = None
|
|
3453
|
+
self._seen_size = 0
|
|
3454
|
+
self.lines_parsed = 0
|
|
3455
|
+
self.rebuilds = 0
|
|
3456
|
+
# docs/DESIGN.md 4.13.3's Run lifecycle paragraph (N1N2 review
|
|
3457
|
+
# findings 1/7): true when the parent transcript (none at all counts
|
|
3458
|
+
# as complete, vacuously) is readable, has no undecodable line, and
|
|
3459
|
+
# every entry seen so far passes _entry_shape_ok -- computed inside
|
|
3460
|
+
# this same incremental pass, never by a second, independent full
|
|
3461
|
+
# re-read of the file. Reset to True by _reset_state before a
|
|
3462
|
+
# rebuild-from-zero reparses everything; otherwise only ever flips
|
|
3463
|
+
# False and stays there for the life of this cached index, the same
|
|
3464
|
+
# append-only accumulation lines_parsed/diagnostics already use --
|
|
3465
|
+
# except that a transient I/O failure (below) can force one more
|
|
3466
|
+
# full reinspection, which may then legitimately set it True again.
|
|
3467
|
+
self.inspection_complete = True
|
|
3468
|
+
# (N1N2 review round 2, new finding 1) True exactly when the most
|
|
3469
|
+
# recent _refresh() ended in an OSError from stat/rehash/reparse:
|
|
3470
|
+
# content validity (inspection_complete, above, and every per-line
|
|
3471
|
+
# diagnostic) is persistent -- it only ever reflects bytes this index
|
|
3472
|
+
# has actually read and checked -- while this flag is the current
|
|
3473
|
+
# *I/O availability* of the refresh itself, which is transient. A
|
|
3474
|
+
# true value forces the next _refresh() to do a full reinspection
|
|
3475
|
+
# from byte 0 regardless of what the ordinary inode/size/prefix-hash
|
|
3476
|
+
# comparison would otherwise conclude (that comparison can see no
|
|
3477
|
+
# change at all when the file itself never changed, which is exactly
|
|
3478
|
+
# the case a one-time, non-recurring I/O hiccup leaves behind), so
|
|
3479
|
+
# that every retained line is checked again before
|
|
3480
|
+
# inspection_complete is allowed to become True once more. Cleared
|
|
3481
|
+
# the moment a reinspection's own read succeeds, whether or not its
|
|
3482
|
+
# own needs_reset was already true for an ordinary reason.
|
|
3483
|
+
self._needs_full_recheck = False
|
|
3484
|
+
self._raw_diagnostics: list[str] = list(self.diagnostics)
|
|
3485
|
+
# (N1N2 review round 2, original finding 1) gen_diagnostics as last
|
|
3486
|
+
# computed by _rebuild_generations, kept separately from
|
|
3487
|
+
# _raw_diagnostics so self.diagnostics can be recomputed by
|
|
3488
|
+
# _recompute_diagnostics from both of these plus
|
|
3489
|
+
# _unreadable_diagnostic below, on demand and without a second,
|
|
3490
|
+
# independent full read -- including from inside _refresh's own
|
|
3491
|
+
# failure branches, which never reach _rebuild_generations at all.
|
|
3492
|
+
self._gen_diagnostics: list[str] = []
|
|
3493
|
+
# The same generation-building diagnostics, each with the run ids it
|
|
3494
|
+
# involves (N4 fix, A4), for discovery_diagnostics_for.
|
|
3495
|
+
self._gen_records: list[tuple[str, frozenset[str]]] = []
|
|
3496
|
+
# A single, stable, file-level diagnostic ("<name>: parent
|
|
3497
|
+
# transcript unreadable") while the most recent _refresh() ended in
|
|
3498
|
+
# an OSError from stat/rehash/reparse; None once a read succeeds
|
|
3499
|
+
# again. Never a growing list -- the same read failure recurring
|
|
3500
|
+
# across repeated refreshes is one fact, not one diagnostic per
|
|
3501
|
+
# refresh.
|
|
3502
|
+
self._unreadable_diagnostic: str | None = None
|
|
3503
|
+
self._launches_by_id: dict[str, list[_Launch]] = {}
|
|
3504
|
+
# (result line, toolUseResult, the line's parsed timestamp or None)
|
|
3505
|
+
# (result line, toolUseResult, the line's parsed timestamp or None,
|
|
3506
|
+
# a rejected launch, the result digest or None)
|
|
3507
|
+
self._results_by_id: dict[
|
|
3508
|
+
str, list[tuple[int, object, datetime | None, bool, str | None]]
|
|
3509
|
+
] = {}
|
|
3510
|
+
self._notification_candidates: list[_NotificationCandidate] = []
|
|
3511
|
+
self._first_origin_line: int | None = None
|
|
3512
|
+
# docs/DESIGN.md 4.2 (FA4): the physical lines 1.._skip_through are a fork's
|
|
3513
|
+
# inherited prefix and are never ingested; _boundary_settled is True once the
|
|
3514
|
+
# transcript's own first entry fixed that boundary for good.
|
|
3515
|
+
self._skip_through = 0
|
|
3516
|
+
self._boundary_settled = False
|
|
3517
|
+
# F fix 1 (S-m5): the fork scan's answer for one file fingerprint (inode, size,
|
|
3518
|
+
# mtime), so a poll of an unchanged file, and the two steps of one refresh, share a
|
|
3519
|
+
# single scan. Dropped on any read failure and bypassed while a recheck is pending.
|
|
3520
|
+
self._fork_fingerprint: tuple | None = None
|
|
3521
|
+
self._fork_result: tuple[int, bool] = (0, False)
|
|
3522
|
+
|
|
3523
|
+
@property
|
|
3524
|
+
def pending_full_recheck(self) -> bool:
|
|
3525
|
+
"""True while the last refresh ended in an I/O failure, so the next access reinspects
|
|
3526
|
+
from byte 0 whatever the file's stat says (docs/DESIGN.md 4.13.3). A caller that caches
|
|
3527
|
+
the file's stat signature must not cache it while this is true (watch, 4.12)."""
|
|
3528
|
+
return self._needs_full_recheck
|
|
3529
|
+
|
|
3530
|
+
@classmethod
|
|
3531
|
+
def for_path(cls, path: Path) -> "ParentIndex":
|
|
3532
|
+
path = Path(path)
|
|
3533
|
+
cached = cls._cache.get(path)
|
|
3534
|
+
if cached is None:
|
|
3535
|
+
cached = cls(path, [], [], [])
|
|
3536
|
+
cls._cache[path] = cached
|
|
3537
|
+
cached._refresh()
|
|
3538
|
+
return cached
|
|
3539
|
+
|
|
3540
|
+
@classmethod
|
|
3541
|
+
def _scan(cls, path: Path) -> "ParentIndex":
|
|
3542
|
+
"""A fresh, uncached, one-shot full scan of path -- used directly by
|
|
3543
|
+
tests that want a single ParentIndex snapshot without touching the
|
|
3544
|
+
process-level `for_path` cache."""
|
|
3545
|
+
index = cls(path, [], [], [])
|
|
3546
|
+
index._refresh()
|
|
3547
|
+
return index
|
|
3548
|
+
|
|
3549
|
+
def generations_for(self, run_id: str) -> list[Generation]:
|
|
3550
|
+
"""Generations of run_id specifically (docs/DESIGN.md 4.13.3),
|
|
3551
|
+
ordered by launch_line, a convenience filter over the flat
|
|
3552
|
+
`generations` attribute for a caller (N2's lifecycle computation)
|
|
3553
|
+
that only ever cares about one run at a time."""
|
|
3554
|
+
return [generation for generation in self.generations if generation.run_id == run_id]
|
|
3555
|
+
|
|
3556
|
+
def ambiguous_diagnostics_for(self, run_id: str) -> list[str]:
|
|
3557
|
+
"""docs/DESIGN.md 4.13.3's association rule 1 (v0.3 N2): the
|
|
3558
|
+
diagnostic(s) of every ambiguous launch that involves run_id -- one
|
|
3559
|
+
of its duplicate results names run_id as its own runId, or one of
|
|
3560
|
+
its duplicate launches resumes from run_id. [] when none do (the
|
|
3561
|
+
launch is simply ignored for this run, per rule 1)."""
|
|
3562
|
+
return [
|
|
3563
|
+
amb.diagnostic for amb in self.ambiguous_launches
|
|
3564
|
+
if run_id in amb.candidate_run_ids
|
|
3565
|
+
]
|
|
3566
|
+
|
|
3567
|
+
def discovery_diagnostics_for(self, run_id: str | None) -> list[str]:
|
|
3568
|
+
"""The parent diagnostics that reach run_id's `Run.discovery['parent']`
|
|
3569
|
+
(docs/DESIGN.md 4.13.1, N4 fix A4): every file-level diagnostic (an
|
|
3570
|
+
undecodable line, a malformed entry, an unreadable parent), then each
|
|
3571
|
+
generation-building diagnostic that involves run_id. run_id None (a
|
|
3572
|
+
session) gets the file-level ones only."""
|
|
3573
|
+
diagnostics = list(self._raw_diagnostics)
|
|
3574
|
+
diagnostics.extend(
|
|
3575
|
+
text for text, involved in self._gen_records
|
|
3576
|
+
if run_id is not None and run_id in involved
|
|
3577
|
+
)
|
|
3578
|
+
if self._unreadable_diagnostic is not None:
|
|
3579
|
+
diagnostics.append(self._unreadable_diagnostic)
|
|
3580
|
+
return diagnostics
|
|
3581
|
+
|
|
3582
|
+
def skipped_line_count(self) -> int:
|
|
3583
|
+
"""docs/DESIGN.md 4.1 (0.3.4): the complete lines of the parent transcript this index
|
|
3584
|
+
could not parse."""
|
|
3585
|
+
return sum(
|
|
3586
|
+
1 for text in self._raw_diagnostics
|
|
3587
|
+
if _PARENT_BAD_LINE in text or _PARENT_NOT_OBJECT in text
|
|
3588
|
+
)
|
|
3589
|
+
|
|
3590
|
+
# --- incremental scan ------------------------------------------------
|
|
3591
|
+
|
|
3592
|
+
def _reset_state(self) -> None:
|
|
3593
|
+
"""Discard every piece of state derived from the file's own bytes
|
|
3594
|
+
(docs/DESIGN.md 4.13.3's rebuild-from-zero), keeping path and the
|
|
3595
|
+
two counters (lines_parsed, rebuilds), which are cumulative for the
|
|
3596
|
+
life of this object."""
|
|
3597
|
+
self.task_stops = []
|
|
3598
|
+
self.kill_sentinels = []
|
|
3599
|
+
self.kill_timestamps = []
|
|
3600
|
+
self.compaction_timestamps = []
|
|
3601
|
+
self.tool_result_agent_ids = set()
|
|
3602
|
+
self.facts = returns.ReturnFacts(self.path)
|
|
3603
|
+
self.generations = []
|
|
3604
|
+
self.ambiguous_launches = []
|
|
3605
|
+
self._raw_diagnostics = []
|
|
3606
|
+
self._launches_by_id = {}
|
|
3607
|
+
self._results_by_id = {}
|
|
3608
|
+
self._notification_candidates = []
|
|
3609
|
+
self._first_origin_line = None
|
|
3610
|
+
self._skip_through = 0
|
|
3611
|
+
self._boundary_settled = False
|
|
3612
|
+
self._parsed_offset = 0
|
|
3613
|
+
self._next_line_no = 1
|
|
3614
|
+
self._prefix_digest = None
|
|
3615
|
+
# A rebuild-from-zero reparses the whole file from byte 0, so
|
|
3616
|
+
# completeness is recomputed from scratch too (N1N2 review finding
|
|
3617
|
+
# 1/7): this reset, like the others above, is not itself one of the
|
|
3618
|
+
# two counters _refresh's own docstring calls "cumulative for the
|
|
3619
|
+
# life of this object".
|
|
3620
|
+
self.inspection_complete = True
|
|
3621
|
+
|
|
3622
|
+
def _hash_prefix(self, offset: int) -> str:
|
|
3623
|
+
hasher = hashlib.sha256()
|
|
3624
|
+
if offset <= 0 or self.path is None:
|
|
3625
|
+
return hasher.hexdigest()
|
|
3626
|
+
with self.path.open("rb") as handle:
|
|
3627
|
+
remaining = offset
|
|
3628
|
+
while remaining > 0:
|
|
3629
|
+
chunk = handle.read(min(_HASH_CHUNK_SIZE, remaining))
|
|
3630
|
+
if not chunk:
|
|
3631
|
+
break
|
|
3632
|
+
hasher.update(chunk)
|
|
3633
|
+
remaining -= len(chunk)
|
|
3634
|
+
return hasher.hexdigest()
|
|
3635
|
+
|
|
3636
|
+
def _state_snapshot(self) -> dict:
|
|
3637
|
+
"""A shallow copy of every attribute a rebuild-from-zero touches
|
|
3638
|
+
(everything _reset_state() wipes, plus lines_parsed, which
|
|
3639
|
+
_parse_increment can advance before it raises) -- taken right before
|
|
3640
|
+
_reset_state() runs, restored verbatim by _restore_state if the
|
|
3641
|
+
rebuild's own read then fails (N1N2 fix review finding 1). Keeps a
|
|
3642
|
+
failed rebuild from committing _reset_state()'s wipe."""
|
|
3643
|
+
return {
|
|
3644
|
+
"task_stops": list(self.task_stops),
|
|
3645
|
+
"kill_sentinels": list(self.kill_sentinels),
|
|
3646
|
+
"kill_timestamps": list(self.kill_timestamps),
|
|
3647
|
+
"compaction_timestamps": list(self.compaction_timestamps),
|
|
3648
|
+
"tool_result_agent_ids": set(self.tool_result_agent_ids),
|
|
3649
|
+
"facts": self.facts,
|
|
3650
|
+
"generations": list(self.generations),
|
|
3651
|
+
"ambiguous_launches": list(self.ambiguous_launches),
|
|
3652
|
+
"_raw_diagnostics": list(self._raw_diagnostics),
|
|
3653
|
+
"_launches_by_id": dict(self._launches_by_id),
|
|
3654
|
+
"_results_by_id": dict(self._results_by_id),
|
|
3655
|
+
"_notification_candidates": list(self._notification_candidates),
|
|
3656
|
+
"_first_origin_line": self._first_origin_line,
|
|
3657
|
+
"_skip_through": self._skip_through,
|
|
3658
|
+
"_boundary_settled": self._boundary_settled,
|
|
3659
|
+
"_parsed_offset": self._parsed_offset,
|
|
3660
|
+
"_next_line_no": self._next_line_no,
|
|
3661
|
+
"_prefix_digest": self._prefix_digest,
|
|
3662
|
+
"lines_parsed": self.lines_parsed,
|
|
3663
|
+
}
|
|
3664
|
+
|
|
3665
|
+
def _restore_state(self, snapshot: dict) -> None:
|
|
3666
|
+
for name, value in snapshot.items():
|
|
3667
|
+
setattr(self, name, value)
|
|
3668
|
+
|
|
3669
|
+
def _recompute_diagnostics(self) -> None:
|
|
3670
|
+
"""The public, combined view (docs/DESIGN.md section 2: "a bad line
|
|
3671
|
+
becomes a diagnostic string on the Run, never an exception"):
|
|
3672
|
+
per-line decode/shape diagnostics, generation-building diagnostics,
|
|
3673
|
+
and -- last -- the single current file-level diagnostic, if any.
|
|
3674
|
+
Called from _rebuild_generations after a successful pass, and
|
|
3675
|
+
directly from _mark_unreadable so a failure branch that never
|
|
3676
|
+
reaches _rebuild_generations at all still leaves self.diagnostics
|
|
3677
|
+
current before _refresh returns (N1N2 review round 2, original
|
|
3678
|
+
finding 1)."""
|
|
3679
|
+
diagnostics = list(self._raw_diagnostics) + list(self._gen_diagnostics)
|
|
3680
|
+
if self._unreadable_diagnostic is not None:
|
|
3681
|
+
diagnostics.append(self._unreadable_diagnostic)
|
|
3682
|
+
self.diagnostics = diagnostics
|
|
3683
|
+
|
|
3684
|
+
def _fork_for(self, stat) -> tuple[int, bool]:
|
|
3685
|
+
"""`_fork_scan(self.path)` once per file fingerprint (S-m5)."""
|
|
3686
|
+
fingerprint = (stat.st_ino, stat.st_size, stat.st_mtime_ns)
|
|
3687
|
+
if fingerprint != self._fork_fingerprint:
|
|
3688
|
+
self._fork_result = _fork_scan(self.path)
|
|
3689
|
+
self._fork_fingerprint = fingerprint
|
|
3690
|
+
return self._fork_result
|
|
3691
|
+
|
|
3692
|
+
def _mark_unreadable(self) -> None:
|
|
3693
|
+
"""Record a transient I/O failure from anywhere in _refresh's own
|
|
3694
|
+
stat/rehash/reparse steps (N1N2 review round 2, new finding 1):
|
|
3695
|
+
inspection is no longer complete, and the *next* access must do a
|
|
3696
|
+
full reinspection from byte 0 regardless of what the ordinary
|
|
3697
|
+
inode/size/prefix-hash comparison would otherwise conclude -- that
|
|
3698
|
+
comparison sees no change at all when the file itself never changed,
|
|
3699
|
+
which is exactly what a one-time, non-recurring I/O hiccup leaves
|
|
3700
|
+
behind, so nothing else would ever re-trigger the recheck this needs.
|
|
3701
|
+
Also records the one stable, file-level diagnostic a read failure
|
|
3702
|
+
gets (original finding 1, PARTLY): never a growing list, since the
|
|
3703
|
+
same ongoing failure recurring across refreshes is one fact, and
|
|
3704
|
+
visible on self.diagnostics immediately -- this failure path never
|
|
3705
|
+
reaches _rebuild_generations, which is the only other place that
|
|
3706
|
+
would otherwise bring self.diagnostics up to date."""
|
|
3707
|
+
self.inspection_complete = False
|
|
3708
|
+
self._needs_full_recheck = True
|
|
3709
|
+
self._unreadable_diagnostic = f"{self.path.name}: parent transcript unreadable"
|
|
3710
|
+
self._recompute_diagnostics()
|
|
3711
|
+
|
|
3712
|
+
def _refresh(self) -> None:
|
|
3713
|
+
if self.path is None:
|
|
3714
|
+
return
|
|
3715
|
+
try:
|
|
3716
|
+
stat = self.path.stat()
|
|
3717
|
+
except FileNotFoundError:
|
|
3718
|
+
# Simply not there yet (docs/DESIGN.md section 2/4.6's
|
|
3719
|
+
# "unreadable, not merely absent" convention): tolerated,
|
|
3720
|
+
# complete vacuously, current state kept as-is.
|
|
3721
|
+
return
|
|
3722
|
+
except OSError:
|
|
3723
|
+
# Exists but could not even be stat'd -- unreadable, not merely
|
|
3724
|
+
# absent (N1N2 review finding 1): current state is kept as-is,
|
|
3725
|
+
# the same tolerant "do not raise" rule as every other branch of
|
|
3726
|
+
# this method, but inspection is no longer complete.
|
|
3727
|
+
self._mark_unreadable()
|
|
3728
|
+
return
|
|
3729
|
+
if not _is_file(self.path):
|
|
3730
|
+
return
|
|
3731
|
+
|
|
3732
|
+
inode = stat.st_ino
|
|
3733
|
+
size = stat.st_size
|
|
3734
|
+
# A prior transient failure always forces a full reinspection here,
|
|
3735
|
+
# skipping the ordinary comparison below entirely: that comparison
|
|
3736
|
+
# can only ever detect a *structural* change (a different inode, a
|
|
3737
|
+
# shrink, a rewritten prefix), so a one-time I/O hiccup against a
|
|
3738
|
+
# file that never actually changed would otherwise never be noticed
|
|
3739
|
+
# again, and inspection_complete would stay False forever.
|
|
3740
|
+
needs_reset = self._needs_full_recheck
|
|
3741
|
+
if needs_reset:
|
|
3742
|
+
self._fork_fingerprint = None # a pending recheck rescans (S-m5)
|
|
3743
|
+
if not needs_reset:
|
|
3744
|
+
try:
|
|
3745
|
+
if self._inode is not None and (inode != self._inode or size < self._seen_size):
|
|
3746
|
+
needs_reset = True
|
|
3747
|
+
elif self._parsed_offset > 0:
|
|
3748
|
+
digest = self._hash_prefix(self._parsed_offset)
|
|
3749
|
+
needs_reset = digest != self._prefix_digest
|
|
3750
|
+
if needs_reset:
|
|
3751
|
+
# R2-3: a structural change (a new inode, a shrink, a rewritten prefix)
|
|
3752
|
+
# can leave the (inode, size, mtime) fingerprint equal, so the cached
|
|
3753
|
+
# fork boundary is stale too: the settle scan below must be fresh.
|
|
3754
|
+
self._fork_fingerprint = None
|
|
3755
|
+
except OSError:
|
|
3756
|
+
# A read failure while re-hashing the already-parsed prefix,
|
|
3757
|
+
# with nothing reset yet: current state is kept as-is,
|
|
3758
|
+
# inspection is no longer complete.
|
|
3759
|
+
self._mark_unreadable()
|
|
3760
|
+
return
|
|
3761
|
+
|
|
3762
|
+
# N1N2 fix review finding 1: _reset_state() wipes generations/
|
|
3763
|
+
# task_stops/kill_sentinels/etc. before the rebuild's own read has
|
|
3764
|
+
# even been attempted. Snapshot first, so a read failure right after
|
|
3765
|
+
# the reset (a permission flap, a transient I/O error, a concurrent
|
|
3766
|
+
# edit mid-read) can be undone rather than committed -- the whole
|
|
3767
|
+
# reset-and-reparse only ever takes effect together.
|
|
3768
|
+
# FA4: a fork whose first own entry was not written yet inherited nothing; once it
|
|
3769
|
+
# appears the boundary moves and what was ingested so far must be rebuilt.
|
|
3770
|
+
if (
|
|
3771
|
+
not needs_reset
|
|
3772
|
+
and not self._boundary_settled
|
|
3773
|
+
and self._parsed_offset > 0
|
|
3774
|
+
and self._fork_for(stat)[0] != self._skip_through
|
|
3775
|
+
):
|
|
3776
|
+
needs_reset = True
|
|
3777
|
+
snapshot = self._state_snapshot() if needs_reset else None
|
|
3778
|
+
if needs_reset:
|
|
3779
|
+
self._reset_state()
|
|
3780
|
+
if not self._boundary_settled:
|
|
3781
|
+
self._skip_through, self._boundary_settled = self._fork_for(stat)
|
|
3782
|
+
try:
|
|
3783
|
+
self._parse_increment(size)
|
|
3784
|
+
except OSError:
|
|
3785
|
+
# A read failure partway through reading the new tail (N1N2
|
|
3786
|
+
# review finding 1: the concrete scenario is Path.open raising
|
|
3787
|
+
# for an existing file whose stat() still succeeded) never
|
|
3788
|
+
# raises past this point; the index simply stays incomplete and
|
|
3789
|
+
# keeps whatever state it already had -- restoring the
|
|
3790
|
+
# pre-reset snapshot first when this call had just reset it, so
|
|
3791
|
+
# "whatever state it already had" means what it had before this
|
|
3792
|
+
# _refresh() call, not the freshly wiped, still-empty state.
|
|
3793
|
+
if snapshot is not None:
|
|
3794
|
+
self._restore_state(snapshot)
|
|
3795
|
+
self._mark_unreadable()
|
|
3796
|
+
return
|
|
3797
|
+
# The read just succeeded, whether or not needs_reset was already
|
|
3798
|
+
# true for an ordinary reason: I/O availability is restored, and
|
|
3799
|
+
# inspection_complete now reflects this pass's own, freshly-checked
|
|
3800
|
+
# content-validity findings rather than a stale failure. The
|
|
3801
|
+
# file-level diagnostic clears with it -- _rebuild_generations below
|
|
3802
|
+
# recomputes self.diagnostics from the now-current raw/gen pieces.
|
|
3803
|
+
self._needs_full_recheck = False
|
|
3804
|
+
self._unreadable_diagnostic = None
|
|
3805
|
+
if needs_reset:
|
|
3806
|
+
self.rebuilds += 1
|
|
3807
|
+
|
|
3808
|
+
self._inode = inode
|
|
3809
|
+
self._seen_size = size
|
|
3810
|
+
self._rebuild_generations()
|
|
3811
|
+
|
|
3812
|
+
def _parse_increment(self, size: int) -> None:
|
|
3813
|
+
"""Parse only the complete lines in [parsed_offset, last newline
|
|
3814
|
+
before size) (docs/DESIGN.md 4.13.3): a partial trailing line is
|
|
3815
|
+
left for the next access. Updates parsed_offset, the prefix digest,
|
|
3816
|
+
and lines_parsed; every other raw accumulator is appended to, never
|
|
3817
|
+
replaced, so a later _rebuild_generations sees the full history.
|
|
3818
|
+
Physical line numbers continue from `_next_line_no` (reset to 1 by
|
|
3819
|
+
_reset_state) rather than being recounted from byte 0 each time, so
|
|
3820
|
+
parsing cost -- including line numbering -- stays proportional to
|
|
3821
|
+
new lines, never to parsed_offset itself."""
|
|
3822
|
+
if size <= self._parsed_offset:
|
|
3823
|
+
return
|
|
3824
|
+
with self.path.open("rb") as handle:
|
|
3825
|
+
handle.seek(self._parsed_offset)
|
|
3826
|
+
chunk = handle.read(size - self._parsed_offset)
|
|
3827
|
+
last_newline = chunk.rfind(b"\n")
|
|
3828
|
+
if last_newline == -1:
|
|
3829
|
+
return
|
|
3830
|
+
complete = chunk[: last_newline + 1]
|
|
3831
|
+
start_line_no = self._next_line_no
|
|
3832
|
+
lines = complete.split(b"\n")[:-1]
|
|
3833
|
+
for offset_in_chunk, raw_line in enumerate(lines):
|
|
3834
|
+
line_no = start_line_no + offset_in_chunk
|
|
3835
|
+
# docs/DESIGN.md 4.13.3: "lines_parsed adds one per complete line
|
|
3836
|
+
# parsed" -- every complete physical line counts, blank ones
|
|
3837
|
+
# included (N1N2 review finding 8), so the increment happens
|
|
3838
|
+
# before the blank-line skip below, not after it.
|
|
3839
|
+
self.lines_parsed += 1
|
|
3840
|
+
if line_no <= self._skip_through:
|
|
3841
|
+
continue
|
|
3842
|
+
text = raw_line.strip()
|
|
3843
|
+
if not text:
|
|
3844
|
+
continue
|
|
3845
|
+
try:
|
|
3846
|
+
entry = json.loads(text)
|
|
3847
|
+
except (UnicodeDecodeError, *_UNPARSEABLE) as exc:
|
|
3848
|
+
# docs/DESIGN.md 4.13.3's Run lifecycle paragraph: parent
|
|
3849
|
+
# inspection is complete only when the file "has no
|
|
3850
|
+
# undecodable line" (N1N2 review finding 1/7 -- computed
|
|
3851
|
+
# here, in the same incremental pass, rather than by a
|
|
3852
|
+
# second full re-read). docs/DESIGN.md section 2: a bad line
|
|
3853
|
+
# becomes a diagnostic too, never only a dropped flag (N1N2
|
|
3854
|
+
# review round 2, original finding 1) -- a fixed reason and
|
|
3855
|
+
# the line number only, never the undecodable text itself.
|
|
3856
|
+
self.inspection_complete = False
|
|
3857
|
+
reason = f": {_TOO_DEEP}" if isinstance(exc, RecursionError) else ""
|
|
3858
|
+
self._raw_diagnostics.append(
|
|
3859
|
+
f"{self.path.name}:{line_no}: {_PARENT_BAD_LINE}{reason}"
|
|
3860
|
+
)
|
|
3861
|
+
continue
|
|
3862
|
+
if not isinstance(entry, dict):
|
|
3863
|
+
# docs/DESIGN.md 4.1 (0.3.4): skipped and counted like the transcript reader's;
|
|
3864
|
+
# nothing in it was evidence, so inspection stays complete
|
|
3865
|
+
self._raw_diagnostics.append(f"{self.path.name}:{line_no}: {_PARENT_NOT_OBJECT}")
|
|
3866
|
+
continue
|
|
3867
|
+
if not _entry_shape_ok(entry):
|
|
3868
|
+
# Same rule, the shape-violation half (N1N2 review round 2,
|
|
3869
|
+
# original finding 1): a fixed reason and the line number
|
|
3870
|
+
# only, regardless of which of 4.13.3's sub-rules failed.
|
|
3871
|
+
self.inspection_complete = False
|
|
3872
|
+
self._raw_diagnostics.append(
|
|
3873
|
+
f"{self.path.name}:{line_no}: malformed parent transcript entry"
|
|
3874
|
+
)
|
|
3875
|
+
self._ingest_entry(entry, line_no)
|
|
3876
|
+
self._next_line_no = start_line_no + len(lines)
|
|
3877
|
+
self._parsed_offset += len(complete)
|
|
3878
|
+
self._prefix_digest = self._hash_prefix(self._parsed_offset)
|
|
3879
|
+
|
|
3880
|
+
def _ingest_entry(self, entry: dict, line_no: int) -> None:
|
|
3881
|
+
raw_type = entry.get("type")
|
|
3882
|
+
ts = _parse_ts(entry.get("timestamp"))
|
|
3883
|
+
|
|
3884
|
+
if raw_type == "system" and entry.get("subtype") == "compact_boundary":
|
|
3885
|
+
if ts is not None:
|
|
3886
|
+
self.compaction_timestamps.append(ts)
|
|
3887
|
+
return
|
|
3888
|
+
|
|
3889
|
+
if raw_type == "queue-operation":
|
|
3890
|
+
# docs/DESIGN.md 4.13.3: a queue-operation 'enqueue' entry whose
|
|
3891
|
+
# content is the envelope, one of the two notification delivery
|
|
3892
|
+
# forms -- never subject to the origin-line ordering rule.
|
|
3893
|
+
if entry.get("operation") == "enqueue" and isinstance(entry.get("content"), str):
|
|
3894
|
+
self._notification_candidates.append(
|
|
3895
|
+
_NotificationCandidate(line_no, "queue", entry["content"], ts)
|
|
3896
|
+
)
|
|
3897
|
+
return
|
|
3898
|
+
|
|
3899
|
+
message = _as_object(
|
|
3900
|
+
entry.get("message"), self.path, line_no, self._raw_diagnostics, "message"
|
|
3901
|
+
)
|
|
3902
|
+
content = message.get("content")
|
|
3903
|
+
|
|
3904
|
+
if raw_type == "assistant" and isinstance(content, list):
|
|
3905
|
+
for block in content:
|
|
3906
|
+
if not isinstance(block, dict) or block.get("type") != "tool_use":
|
|
3907
|
+
continue
|
|
3908
|
+
name = block.get("name")
|
|
3909
|
+
self.facts.note_use(name, block.get("id"), line_no, ts, block.get("input"))
|
|
3910
|
+
if name == "TaskStop":
|
|
3911
|
+
input_obj = block.get("input")
|
|
3912
|
+
input_dict = input_obj if isinstance(input_obj, dict) else {}
|
|
3913
|
+
input_text = json.dumps(input_dict, ensure_ascii=False)
|
|
3914
|
+
raw_task_id = input_dict.get("task_id")
|
|
3915
|
+
raw_shell_id = input_dict.get("shell_id")
|
|
3916
|
+
self.task_stops.append(_TaskStop(
|
|
3917
|
+
ts=ts, ids=_string_values(input_obj), line_no=line_no,
|
|
3918
|
+
text=_excerpt(input_text),
|
|
3919
|
+
task_id=raw_task_id if isinstance(raw_task_id, str) else None,
|
|
3920
|
+
shell_id=raw_shell_id if isinstance(raw_shell_id, str) else None,
|
|
3921
|
+
))
|
|
3922
|
+
elif name == "Workflow":
|
|
3923
|
+
tool_id = block.get("id")
|
|
3924
|
+
if not isinstance(tool_id, str):
|
|
3925
|
+
continue
|
|
3926
|
+
input_obj = block.get("input")
|
|
3927
|
+
input_dict = input_obj if isinstance(input_obj, dict) else {}
|
|
3928
|
+
raw_resume = input_dict.get("resumeFromRunId")
|
|
3929
|
+
launch = _Launch(
|
|
3930
|
+
line_no=line_no, tool_use_id=tool_id,
|
|
3931
|
+
resume_from=raw_resume if isinstance(raw_resume, str) else None,
|
|
3932
|
+
ts=ts,
|
|
3933
|
+
)
|
|
3934
|
+
self._launches_by_id.setdefault(tool_id, []).append(launch)
|
|
3935
|
+
return
|
|
3936
|
+
|
|
3937
|
+
if raw_type != "user":
|
|
3938
|
+
return
|
|
3939
|
+
|
|
3940
|
+
text = _flatten_block_text(content)
|
|
3941
|
+
if text.startswith(KILL_SENTINEL_PREFIX) and ts is not None:
|
|
3942
|
+
self.kill_sentinels.append(_KillSentinel(ts=ts, line_no=line_no, text=_excerpt(text)))
|
|
3943
|
+
self.kill_timestamps.append(ts)
|
|
3944
|
+
|
|
3945
|
+
origin = entry.get("origin")
|
|
3946
|
+
has_origin = isinstance(origin, dict)
|
|
3947
|
+
if has_origin and self._first_origin_line is None:
|
|
3948
|
+
self._first_origin_line = line_no
|
|
3949
|
+
if has_origin and origin.get("kind") == "task-notification":
|
|
3950
|
+
self._notification_candidates.append(
|
|
3951
|
+
_NotificationCandidate(line_no, "user_with_origin", message.get("content"), ts)
|
|
3952
|
+
)
|
|
3953
|
+
elif not has_origin:
|
|
3954
|
+
# docs/DESIGN.md 4.13.3: counted only once the first line in the
|
|
3955
|
+
# transcript carrying 'origin' is known, at generation-build
|
|
3956
|
+
# time (_accepted_notifications); recorded provisionally here.
|
|
3957
|
+
self._notification_candidates.append(
|
|
3958
|
+
_NotificationCandidate(line_no, "user_no_origin", message.get("content"), ts)
|
|
3959
|
+
)
|
|
3960
|
+
|
|
3961
|
+
if isinstance(content, list):
|
|
3962
|
+
for block in content:
|
|
3963
|
+
if not isinstance(block, dict) or block.get("type") != "tool_result":
|
|
3964
|
+
continue
|
|
3965
|
+
# docs/DESIGN.md 7.2 (D2): a tool_result block carrying the
|
|
3966
|
+
# agentId of the child that produced it -- the parent's own
|
|
3967
|
+
# record that a subagent already returned control to it.
|
|
3968
|
+
block_agent_id = block.get("agentId")
|
|
3969
|
+
if isinstance(block_agent_id, str) and block_agent_id:
|
|
3970
|
+
self.tool_result_agent_ids.add(block_agent_id)
|
|
3971
|
+
# docs/DESIGN.md 4.13.3 (v0.3 N1): a Workflow launch's own
|
|
3972
|
+
# result is the tool_result block, in a user entry, whose
|
|
3973
|
+
# tool_use_id equals the launch's id; the handshake payload
|
|
3974
|
+
# itself sits at the ENTRY level (entry["toolUseResult"]),
|
|
3975
|
+
# the same place an Agent-tool result does (docs/DESIGN.md
|
|
3976
|
+
# 7.2's D-A).
|
|
3977
|
+
raw_tool_use_id = block.get("tool_use_id")
|
|
3978
|
+
if not isinstance(raw_tool_use_id, str):
|
|
3979
|
+
self.facts.note_bad_result(raw_tool_use_id, block, entry.get("toolUseResult"))
|
|
3980
|
+
if isinstance(raw_tool_use_id, str):
|
|
3981
|
+
self.facts.note_result(
|
|
3982
|
+
raw_tool_use_id, block, entry.get("toolUseResult"),
|
|
3983
|
+
"toolUseResult" in entry, line_no,
|
|
3984
|
+
)
|
|
3985
|
+
raw_result = entry.get("toolUseResult")
|
|
3986
|
+
self._results_by_id.setdefault(raw_tool_use_id, []).append((
|
|
3987
|
+
line_no, raw_result, ts, _is_rejected_launch(block, raw_result),
|
|
3988
|
+
_result_digest(block, raw_result),
|
|
3989
|
+
))
|
|
3990
|
+
# docs/DESIGN.md 7.2 (D-A): the entry-level toolUseResult also
|
|
3991
|
+
# carries agentId directly for an Agent-tool result.
|
|
3992
|
+
tool_use_result = entry.get("toolUseResult")
|
|
3993
|
+
if isinstance(tool_use_result, dict):
|
|
3994
|
+
entry_agent_id = tool_use_result.get("agentId")
|
|
3995
|
+
if isinstance(entry_agent_id, str) and entry_agent_id:
|
|
3996
|
+
self.tool_result_agent_ids.add(entry_agent_id)
|
|
3997
|
+
|
|
3998
|
+
# --- generation building (docs/DESIGN.md 4.13.3, v0.3 N1) ------------
|
|
3999
|
+
|
|
4000
|
+
def _accepted_notifications(self) -> list[_Notification]:
|
|
4001
|
+
"""Every valid envelope from an accepted notification record (docs/
|
|
4002
|
+
DESIGN.md 4.13.3), the two delivery forms of one notification (same
|
|
4003
|
+
task id, tool-use id, status) collapsed into the earliest line."""
|
|
4004
|
+
parsed: list[_Notification] = []
|
|
4005
|
+
for candidate in self._notification_candidates:
|
|
4006
|
+
if candidate.form == "user_no_origin":
|
|
4007
|
+
if (
|
|
4008
|
+
self._first_origin_line is not None
|
|
4009
|
+
and candidate.line_no >= self._first_origin_line
|
|
4010
|
+
):
|
|
4011
|
+
continue
|
|
4012
|
+
envelope = _parse_envelope(candidate.content)
|
|
4013
|
+
if envelope is None:
|
|
4014
|
+
continue
|
|
4015
|
+
task_id, tool_use_id, status = envelope
|
|
4016
|
+
parsed.append(
|
|
4017
|
+
_Notification(candidate.line_no, task_id, tool_use_id, status, candidate.ts)
|
|
4018
|
+
)
|
|
4019
|
+
|
|
4020
|
+
best: dict[tuple[str, str, str], _Notification] = {}
|
|
4021
|
+
for notification in parsed:
|
|
4022
|
+
key = (notification.task_id, notification.tool_use_id, notification.status)
|
|
4023
|
+
current = best.get(key)
|
|
4024
|
+
if current is None or notification.line_no < current.line_no:
|
|
4025
|
+
best[key] = notification
|
|
4026
|
+
return list(best.values())
|
|
4027
|
+
|
|
4028
|
+
@staticmethod
|
|
4029
|
+
def _combine_terminal_evidence(
|
|
4030
|
+
records: list[tuple[str, str, int]],
|
|
4031
|
+
) -> tuple[str | None, str | None, int | None, list[str]]:
|
|
4032
|
+
"""docs/DESIGN.md 4.13.3's "Terminal evidence per generation" rules,
|
|
4033
|
+
given records of (value, source, line) already restricted to this
|
|
4034
|
+
generation and sorted by nothing in particular (sorted here)."""
|
|
4035
|
+
if not records:
|
|
4036
|
+
return None, None, None, []
|
|
4037
|
+
ordered = sorted(records, key=lambda record: record[2])
|
|
4038
|
+
values = {record[0] for record in ordered}
|
|
4039
|
+
if len(values) == 1:
|
|
4040
|
+
value, source, line = ordered[0]
|
|
4041
|
+
return value, source, line, []
|
|
4042
|
+
if (
|
|
4043
|
+
len(ordered) == 2
|
|
4044
|
+
and ordered[0][1] == "task_stop"
|
|
4045
|
+
and ordered[1][1] == "notification"
|
|
4046
|
+
and ordered[1][0] in ("completed", "failed")
|
|
4047
|
+
):
|
|
4048
|
+
value, source, line = ordered[1]
|
|
4049
|
+
return value, source, line, []
|
|
4050
|
+
baseline_value = ordered[0][0]
|
|
4051
|
+
conflict_line = next(
|
|
4052
|
+
record[2] for record in ordered[1:] if record[0] != baseline_value
|
|
4053
|
+
)
|
|
4054
|
+
return (
|
|
4055
|
+
"unknown", "conflict", conflict_line,
|
|
4056
|
+
[f"conflicting terminal evidence at line {conflict_line}"],
|
|
4057
|
+
)
|
|
4058
|
+
|
|
4059
|
+
def _generation_terminal(
|
|
4060
|
+
self,
|
|
4061
|
+
*,
|
|
4062
|
+
task_id: str,
|
|
4063
|
+
tool_use_id: str,
|
|
4064
|
+
result_line: int,
|
|
4065
|
+
launch_status: str,
|
|
4066
|
+
accepted_notifications: list[_Notification],
|
|
4067
|
+
) -> tuple[str | None, str | None, int | None, list[str]]:
|
|
4068
|
+
if launch_status not in _TERMINAL_STATUS_VOCAB and launch_status != "async_launched":
|
|
4069
|
+
# docs/DESIGN.md 4.13.3's Launch status paragraph gives this
|
|
4070
|
+
# exact literal string, with no line number (unlike the "...
|
|
4071
|
+
# at line N" diagnostics the same section gives for a
|
|
4072
|
+
# malformed or missing result); the line is still available on
|
|
4073
|
+
# terminal_line, so it is not lost by omitting it here.
|
|
4074
|
+
return (
|
|
4075
|
+
"unknown", "unrecognized_status", result_line,
|
|
4076
|
+
["unrecognized launch status"],
|
|
4077
|
+
)
|
|
4078
|
+
|
|
4079
|
+
records: list[tuple[str, str, int]] = []
|
|
4080
|
+
for notification in accepted_notifications:
|
|
4081
|
+
if (
|
|
4082
|
+
notification.task_id != task_id
|
|
4083
|
+
or notification.tool_use_id != tool_use_id
|
|
4084
|
+
or notification.line_no <= result_line
|
|
4085
|
+
):
|
|
4086
|
+
continue
|
|
4087
|
+
value = _TERMINAL_STATUS_VOCAB.get(notification.status, "unknown")
|
|
4088
|
+
records.append((value, "notification", notification.line_no))
|
|
4089
|
+
for task_stop in self.task_stops:
|
|
4090
|
+
if task_stop.line_no <= result_line:
|
|
4091
|
+
continue
|
|
4092
|
+
if task_stop.task_id == task_id or task_stop.shell_id == task_id:
|
|
4093
|
+
records.append(("stopped", "task_stop", task_stop.line_no))
|
|
4094
|
+
if launch_status != "async_launched":
|
|
4095
|
+
records.append((_TERMINAL_STATUS_VOCAB[launch_status], "launch_result", result_line))
|
|
4096
|
+
|
|
4097
|
+
return self._combine_terminal_evidence(records)
|
|
4098
|
+
|
|
4099
|
+
def _incomplete_generation(
|
|
4100
|
+
self, launch: _Launch, result_line: int | None, diagnostic: str,
|
|
4101
|
+
result_ts: datetime | None = None,
|
|
4102
|
+
) -> Generation:
|
|
4103
|
+
"""docs/DESIGN.md 4.13.3: an incomplete generation (association
|
|
4104
|
+
rules 4 and 5), only ever built when the launch's own
|
|
4105
|
+
resumeFromRunId names a run -- globally, that run is the only one
|
|
4106
|
+
this launch could ever be incomplete evidence for."""
|
|
4107
|
+
return Generation(
|
|
4108
|
+
launch_line=launch.line_no, tool_use_id=launch.tool_use_id,
|
|
4109
|
+
resume_from=launch.resume_from, result_line=result_line,
|
|
4110
|
+
run_id=launch.resume_from, task_id=None, launch_status=None,
|
|
4111
|
+
terminal="unknown", terminal_source="incomplete_generation",
|
|
4112
|
+
terminal_line=None, diagnostics=[diagnostic], result_ts=result_ts,
|
|
4113
|
+
launch_ts=launch.ts,
|
|
4114
|
+
)
|
|
4115
|
+
|
|
4116
|
+
@staticmethod
|
|
4117
|
+
def _involved(launch: "_Launch", results: list = ()) -> frozenset[str]:
|
|
4118
|
+
"""The runs a result-less, malformed, or rejected launch involves: the run
|
|
4119
|
+
its resumeFromRunId names, plus every string `toolUseResult.runId` on any
|
|
4120
|
+
of its results (A4, A5); none for an initial launch whose results name no
|
|
4121
|
+
run."""
|
|
4122
|
+
involved = set() if launch.resume_from is None else {launch.resume_from}
|
|
4123
|
+
for _line, raw_result, _ts, _rejected, _digest in results:
|
|
4124
|
+
if isinstance(raw_result, dict) and isinstance(raw_result.get("runId"), str):
|
|
4125
|
+
involved.add(raw_result["runId"])
|
|
4126
|
+
return frozenset(involved)
|
|
4127
|
+
|
|
4128
|
+
def _rebuild_generations(self) -> None:
|
|
4129
|
+
"""Recomputed fresh from the raw accumulators on every access (docs/
|
|
4130
|
+
DESIGN.md 4.13.3): cheap, bounded by the number of launches/
|
|
4131
|
+
notifications seen so far, never by file size -- only the line
|
|
4132
|
+
parsing above is incremental."""
|
|
4133
|
+
generations: list[Generation] = []
|
|
4134
|
+
gen_diagnostics: list[str] = []
|
|
4135
|
+
gen_records: list[tuple[str, frozenset[str]]] = []
|
|
4136
|
+
ambiguous_launches: list[_AmbiguousLaunch] = []
|
|
4137
|
+
accepted_notifications = self._accepted_notifications()
|
|
4138
|
+
|
|
4139
|
+
for tool_use_id, launches in self._launches_by_id.items():
|
|
4140
|
+
all_results = self._results_by_id.get(tool_use_id, [])
|
|
4141
|
+
# N4 fix, A4: byte-identical repeats of one result are one result.
|
|
4142
|
+
results = _distinct_results(all_results, lambda result: result[4])
|
|
4143
|
+
if len(launches) > 1 or len(results) > 1:
|
|
4144
|
+
lines = sorted(
|
|
4145
|
+
{launch.line_no for launch in launches} | {r[0] for r in all_results}
|
|
4146
|
+
)
|
|
4147
|
+
diagnostic = (
|
|
4148
|
+
f"{LAUNCH_DIAGNOSTIC_PREFIXES[0]} {tool_use_id!r}: lines "
|
|
4149
|
+
+ ", ".join(str(line) for line in lines)
|
|
4150
|
+
)
|
|
4151
|
+
# docs/DESIGN.md 4.13.3's association rule 1 (v0.3 N2): every
|
|
4152
|
+
# run id this ambiguous launch is evidence for -- each
|
|
4153
|
+
# launch's own resumeFromRunId, plus each well-formed
|
|
4154
|
+
# result's own runId -- so Lifecycle can tell whether it
|
|
4155
|
+
# "involves" a particular inspected run.
|
|
4156
|
+
candidate_run_ids: set[str] = set()
|
|
4157
|
+
for dup_launch in launches:
|
|
4158
|
+
if dup_launch.resume_from is not None:
|
|
4159
|
+
candidate_run_ids.add(dup_launch.resume_from)
|
|
4160
|
+
for _line, raw_result, _result_ts, _rejected, _digest in all_results:
|
|
4161
|
+
if isinstance(raw_result, dict) and isinstance(raw_result.get("runId"), str):
|
|
4162
|
+
candidate_run_ids.add(raw_result["runId"])
|
|
4163
|
+
gen_diagnostics.append(diagnostic)
|
|
4164
|
+
gen_records.append((diagnostic, frozenset(candidate_run_ids)))
|
|
4165
|
+
ambiguous_launches.append(_AmbiguousLaunch(
|
|
4166
|
+
tool_use_id=tool_use_id, diagnostic=diagnostic,
|
|
4167
|
+
candidate_run_ids=frozenset(candidate_run_ids),
|
|
4168
|
+
))
|
|
4169
|
+
continue
|
|
4170
|
+
|
|
4171
|
+
launch = launches[0]
|
|
4172
|
+
if not results:
|
|
4173
|
+
diagnostic = f"{LAUNCH_DIAGNOSTIC_PREFIXES[1]} at line {launch.line_no}"
|
|
4174
|
+
gen_diagnostics.append(diagnostic)
|
|
4175
|
+
# N4 fix, A4: it involves a run only through its resume.
|
|
4176
|
+
gen_records.append((diagnostic, self._involved(launch)))
|
|
4177
|
+
if launch.resume_from is not None:
|
|
4178
|
+
generations.append(self._incomplete_generation(launch, None, diagnostic))
|
|
4179
|
+
continue
|
|
4180
|
+
|
|
4181
|
+
result_line, raw_result, result_ts, rejected, _digest = results[0]
|
|
4182
|
+
if rejected:
|
|
4183
|
+
# N4 fix, A4 and A5: a rejected launch builds no generation. An
|
|
4184
|
+
# error result is not proof that no run exists, so one with a
|
|
4185
|
+
# resume stays involved with the run it names.
|
|
4186
|
+
if launch.resume_from is not None:
|
|
4187
|
+
diagnostic = f"{LAUNCH_DIAGNOSTIC_PREFIXES[2]} at line {result_line}"
|
|
4188
|
+
gen_diagnostics.append(diagnostic)
|
|
4189
|
+
gen_records.append((diagnostic, self._involved(launch)))
|
|
4190
|
+
continue
|
|
4191
|
+
well_formed = (
|
|
4192
|
+
isinstance(raw_result, dict)
|
|
4193
|
+
and isinstance(raw_result.get("runId"), str)
|
|
4194
|
+
and isinstance(raw_result.get("taskId"), str)
|
|
4195
|
+
and isinstance(raw_result.get("status"), str)
|
|
4196
|
+
)
|
|
4197
|
+
if not well_formed:
|
|
4198
|
+
diagnostic = f"{LAUNCH_DIAGNOSTIC_PREFIXES[3]} at line {result_line}"
|
|
4199
|
+
gen_diagnostics.append(diagnostic)
|
|
4200
|
+
gen_records.append((diagnostic, self._involved(launch, all_results)))
|
|
4201
|
+
if launch.resume_from is not None:
|
|
4202
|
+
generations.append(
|
|
4203
|
+
self._incomplete_generation(launch, result_line, diagnostic, result_ts)
|
|
4204
|
+
)
|
|
4205
|
+
continue
|
|
4206
|
+
|
|
4207
|
+
run_id = raw_result["runId"]
|
|
4208
|
+
task_id = raw_result["taskId"]
|
|
4209
|
+
launch_status = raw_result["status"]
|
|
4210
|
+
terminal, source, line, extra_diag = self._generation_terminal(
|
|
4211
|
+
task_id=task_id, tool_use_id=tool_use_id, result_line=result_line,
|
|
4212
|
+
launch_status=launch_status, accepted_notifications=accepted_notifications,
|
|
4213
|
+
)
|
|
4214
|
+
generations.append(Generation(
|
|
4215
|
+
launch_line=launch.line_no, tool_use_id=tool_use_id,
|
|
4216
|
+
resume_from=launch.resume_from, result_line=result_line,
|
|
4217
|
+
run_id=run_id, task_id=task_id, launch_status=launch_status,
|
|
4218
|
+
terminal=terminal, terminal_source=source, terminal_line=line,
|
|
4219
|
+
diagnostics=extra_diag, result_ts=result_ts, launch_ts=launch.ts,
|
|
4220
|
+
))
|
|
4221
|
+
|
|
4222
|
+
generations.sort(key=lambda generation: generation.launch_line)
|
|
4223
|
+
self.generations = generations
|
|
4224
|
+
self.ambiguous_launches = ambiguous_launches
|
|
4225
|
+
self._gen_diagnostics = gen_diagnostics
|
|
4226
|
+
self._gen_records = gen_records
|
|
4227
|
+
self._recompute_diagnostics()
|
|
4228
|
+
|
|
4229
|
+
|
|
4230
|
+
def _parent_transcript_path_for_workflow_dir(path: Path) -> Path | None:
|
|
4231
|
+
"""The <root>/<slug>/<session>.jsonl that sits next to the
|
|
4232
|
+
<session>/subagents/ directory a workflow run directory `path` was
|
|
4233
|
+
launched from (docs/DESIGN.md 4.2, 4.4, 4.13.3). None when `path` was
|
|
4234
|
+
not reached through that standard layout (a flat fixture, or a bare
|
|
4235
|
+
directory passed directly on the command line) -- there is simply no
|
|
4236
|
+
parent to attribute against, the same as one that does not exist yet.
|
|
4237
|
+
Takes a bare path (never a Run) so it works equally from an already-
|
|
4238
|
+
loaded Run's own run.path (_parent_transcript_path, below) and from
|
|
4239
|
+
workflow_lifecycle's computation straight off the run directory, with
|
|
4240
|
+
no Run object required."""
|
|
4241
|
+
parts = path.parts
|
|
4242
|
+
if "subagents" not in parts:
|
|
4243
|
+
return None
|
|
4244
|
+
idx = parts.index("subagents")
|
|
4245
|
+
if idx < 1:
|
|
4246
|
+
return None
|
|
4247
|
+
session_dir = Path(*parts[:idx])
|
|
4248
|
+
return session_dir.parent / f"{session_dir.name}.jsonl"
|
|
4249
|
+
|
|
4250
|
+
|
|
4251
|
+
def _parent_transcript_path(run: Run) -> Path | None:
|
|
4252
|
+
"""The main session transcript a killed attempt's cause is attributed
|
|
4253
|
+
against (docs/DESIGN.md 4.2, 4.4): run.path itself for a session (its
|
|
4254
|
+
subagents' parent is the session's own main transcript), or
|
|
4255
|
+
_parent_transcript_path_for_workflow_dir(run.path) for a workflow."""
|
|
4256
|
+
if run.kind == "session":
|
|
4257
|
+
return run.path
|
|
4258
|
+
return _parent_transcript_path_for_workflow_dir(run.path)
|
|
4259
|
+
|
|
4260
|
+
|
|
4261
|
+
def parent_transcript_path(run: Run) -> Path | None:
|
|
4262
|
+
"""Public wrapper around _parent_transcript_path, for a detector
|
|
4263
|
+
(docs/DESIGN.md 4.13.3's workflow_failed, N2) that needs the parent
|
|
4264
|
+
transcript's own path to build a 'path:line' evidence string without
|
|
4265
|
+
re-deriving the standard-layout rule itself."""
|
|
4266
|
+
return _parent_transcript_path(run)
|
|
4267
|
+
|
|
4268
|
+
|
|
4269
|
+
def lifecycle_terminal_line(run: Run) -> int | None:
|
|
4270
|
+
"""The physical parent-transcript line that decided run.lifecycle's own
|
|
4271
|
+
terminal (docs/DESIGN.md 4.13.3), read straight off the private
|
|
4272
|
+
`_terminal_line` already captured in the exact snapshot that produced
|
|
4273
|
+
`run.lifecycle` (N1N2 review finding 2) -- never re-derived by a fresh
|
|
4274
|
+
ParentIndex query, which could by now reflect a newer generation
|
|
4275
|
+
appended to the parent transcript after load_run() computed
|
|
4276
|
+
`run.lifecycle`. Lifecycle's own public shape still has exactly the five
|
|
4277
|
+
fields docs/DESIGN.md 4.1/5.3b document; this reads the one internal
|
|
4278
|
+
field alongside them. None when run.lifecycle is None (a session) or
|
|
4279
|
+
when no generation ever decided the stored terminal."""
|
|
4280
|
+
if run.lifecycle is None:
|
|
4281
|
+
return None
|
|
4282
|
+
return run.lifecycle._terminal_line
|
|
4283
|
+
|
|
4284
|
+
|
|
4285
|
+
def _parent_index_for(run: Run) -> "ParentIndex":
|
|
4286
|
+
"""The one parent-index snapshot a run load uses: the run's parent
|
|
4287
|
+
transcript's index, or an empty path-less one when there is none."""
|
|
4288
|
+
parent_path = _parent_transcript_path(run)
|
|
4289
|
+
return (
|
|
4290
|
+
ParentIndex.for_path(parent_path)
|
|
4291
|
+
if parent_path is not None
|
|
4292
|
+
else ParentIndex(None, [], [], [])
|
|
4293
|
+
)
|
|
4294
|
+
|
|
4295
|
+
|
|
4296
|
+
def _attribute_interrupts(run: Run, parent_index: "ParentIndex | None" = None) -> None:
|
|
4297
|
+
"""Fill in cause and cause_evidence on every attempt.interrupt of run
|
|
4298
|
+
(docs/DESIGN.md 4.4), via detectors.base.attribute_interrupt. Called
|
|
4299
|
+
once a Run's attempts are fully assembled: the runtime cause needs to
|
|
4300
|
+
know whether a key has a later attempt, and the parent transcript (if
|
|
4301
|
+
any) is scanned once and shared across every attempt here. run.main is
|
|
4302
|
+
never touched: whether a main-session interrupt was human is not
|
|
4303
|
+
knowable from the transcript (docs/DESIGN.md 4.3's documented limit)."""
|
|
4304
|
+
if parent_index is None:
|
|
4305
|
+
parent_index = _parent_index_for(run)
|
|
4306
|
+
# A malformed or unreadable parent transcript never aborts attribution
|
|
4307
|
+
# (it already falls back to 'unattributed' below); its diagnostics still
|
|
4308
|
+
# surface on the Run (docs/DESIGN.md section 2's tolerant-parsing rule).
|
|
4309
|
+
# This alone does not flip Run.status to INCOMPLETE: docs/DESIGN.md 4.6
|
|
4310
|
+
# scopes that to the journal and the transcripts the journal itself
|
|
4311
|
+
# names, and the parent transcript consulted here is neither -- it is
|
|
4312
|
+
# supplementary evidence for a cause that safely defaults to
|
|
4313
|
+
# 'unattributed' when it cannot be read.
|
|
4314
|
+
run.diagnostics.extend(parent_index.diagnostics)
|
|
4315
|
+
if run.kind == "workflow":
|
|
4316
|
+
# docs/DESIGN.md 4.1 (0.3.4): a session's parent is its own transcript, counted already
|
|
4317
|
+
_skipped_line(parent_index.skipped_line_count())
|
|
4318
|
+
# docs/DESIGN.md 4.13.1 (N4 fix, A4): the discovery list holds only the
|
|
4319
|
+
# parent diagnostics that involve this run (a session involves none of the
|
|
4320
|
+
# launch ones: its own launch table reports them).
|
|
4321
|
+
run.discovery["parent"] = parent_index.discovery_diagnostics_for(
|
|
4322
|
+
run.run_id if run.kind == "workflow" else None
|
|
4323
|
+
)
|
|
4324
|
+
for attempt in run.attempts:
|
|
4325
|
+
if attempt.interrupt is None:
|
|
4326
|
+
continue
|
|
4327
|
+
cause, evidence = attribute_interrupt(run, attempt, parent_index)
|
|
4328
|
+
attempt.interrupt.cause = cause
|
|
4329
|
+
attempt.interrupt.cause_evidence = evidence
|
|
4330
|
+
|
|
4331
|
+
|
|
4332
|
+
# --- run lifecycle (docs/DESIGN.md 4.13.3, v0.3 N2) -------------------------
|
|
4333
|
+
|
|
4334
|
+
# docs/DESIGN.md 4.13.3: Run.finished is true for exactly these lifecycle
|
|
4335
|
+
# terminals; false for 'unknown'/'none'; None (handled separately by every
|
|
4336
|
+
# caller) for a session, which never has a Lifecycle at all.
|
|
4337
|
+
_LIFECYCLE_FINISHED_TERMINALS = frozenset({"completed", "failed", "stopped", "journal_resolved"})
|
|
4338
|
+
|
|
4339
|
+
|
|
4340
|
+
def _lifecycle_from_parts(
|
|
4341
|
+
path: Path, run_id: str, journal_resolved: bool,
|
|
4342
|
+
parent_index: "ParentIndex | None" = None, after_launch_line: int = 0,
|
|
4343
|
+
) -> Lifecycle:
|
|
4344
|
+
"""docs/DESIGN.md 4.13.3's run lifecycle table, evaluated from the newest
|
|
4345
|
+
generation (by launch_line) associated with run_id. Needs only the run
|
|
4346
|
+
directory, its run id, and whether the journal is resolved -- never an
|
|
4347
|
+
attempt's own transcript -- which is what lets workflow_lifecycle (below)
|
|
4348
|
+
compute a full Lifecycle straight from a run directory, and lets
|
|
4349
|
+
_load_workflow reuse the exact same table logic for an already-loaded
|
|
4350
|
+
Run without a second, independently-written copy of it.
|
|
4351
|
+
|
|
4352
|
+
`after_launch_line` (docs/DESIGN.md 4.12, N8b K5.4, watch's journal epochs) leaves out every
|
|
4353
|
+
generation whose launch line is not greater than it: the generations of an ended journal
|
|
4354
|
+
epoch. 0, the default, leaves out none."""
|
|
4355
|
+
if parent_index is None:
|
|
4356
|
+
parent_path = _parent_transcript_path_for_workflow_dir(path)
|
|
4357
|
+
parent_index = (
|
|
4358
|
+
ParentIndex.for_path(parent_path)
|
|
4359
|
+
if parent_path is not None
|
|
4360
|
+
else ParentIndex(None, [], [], [])
|
|
4361
|
+
)
|
|
4362
|
+
generations = [
|
|
4363
|
+
generation for generation in parent_index.generations_for(run_id)
|
|
4364
|
+
if generation.launch_line > after_launch_line
|
|
4365
|
+
]
|
|
4366
|
+
activity = _workflow_activity(path)
|
|
4367
|
+
|
|
4368
|
+
# docs/DESIGN.md 4.13.3's Run lifecycle paragraph (N1N2 review findings
|
|
4369
|
+
# 1/7): completeness is the cached snapshot's own flag, computed inside
|
|
4370
|
+
# ParentIndex's incremental pass as it parsed the file -- never a second,
|
|
4371
|
+
# independent full re-read here. A missing parent_path builds a bare,
|
|
4372
|
+
# path-less ParentIndex above, whose inspection_complete defaults to
|
|
4373
|
+
# True (vacuous, 4.6's "unreadable, not merely absent" convention).
|
|
4374
|
+
if not parent_index.inspection_complete:
|
|
4375
|
+
return Lifecycle(
|
|
4376
|
+
terminal="unknown", source="parent_unavailable", activity=activity,
|
|
4377
|
+
generations=len(generations), diagnostics=[],
|
|
4378
|
+
_generations=list(generations),
|
|
4379
|
+
)
|
|
4380
|
+
|
|
4381
|
+
ambiguous_diagnostics = parent_index.ambiguous_diagnostics_for(run_id)
|
|
4382
|
+
if ambiguous_diagnostics:
|
|
4383
|
+
return Lifecycle(
|
|
4384
|
+
terminal="unknown", source="ambiguous_launch", activity=activity,
|
|
4385
|
+
generations=len(generations), diagnostics=list(ambiguous_diagnostics),
|
|
4386
|
+
_generations=list(generations),
|
|
4387
|
+
)
|
|
4388
|
+
|
|
4389
|
+
newest = max(generations, key=lambda generation: generation.launch_line, default=None)
|
|
4390
|
+
|
|
4391
|
+
if newest is not None and newest.terminal is not None:
|
|
4392
|
+
return Lifecycle(
|
|
4393
|
+
terminal=newest.terminal, source=newest.terminal_source, activity=activity,
|
|
4394
|
+
generations=len(generations), diagnostics=list(newest.diagnostics),
|
|
4395
|
+
# N1N2 review finding 2: captured in this same snapshot so
|
|
4396
|
+
# workflow_failed's evidence never drifts to a newer generation
|
|
4397
|
+
# a later ParentIndex refresh might see.
|
|
4398
|
+
_terminal_line=newest.terminal_line,
|
|
4399
|
+
_generations=list(generations),
|
|
4400
|
+
)
|
|
4401
|
+
|
|
4402
|
+
if journal_resolved:
|
|
4403
|
+
return Lifecycle(
|
|
4404
|
+
terminal="journal_resolved", source="journal", activity=activity,
|
|
4405
|
+
generations=len(generations), diagnostics=[],
|
|
4406
|
+
_generations=list(generations),
|
|
4407
|
+
)
|
|
4408
|
+
|
|
4409
|
+
return Lifecycle(
|
|
4410
|
+
terminal="none", source=None, activity=activity,
|
|
4411
|
+
generations=len(generations), diagnostics=[],
|
|
4412
|
+
_generations=list(generations),
|
|
4413
|
+
)
|
|
4414
|
+
|
|
4415
|
+
|
|
4416
|
+
def workflow_lifecycle(path: Path) -> Lifecycle:
|
|
4417
|
+
"""docs/DESIGN.md section 3 and 4.13.3 (N2 root-cause fix): the one
|
|
4418
|
+
public function that computes a workflow run directory's Lifecycle from
|
|
4419
|
+
the run directory alone -- journal.jsonl, the parent index, and the run
|
|
4420
|
+
directory's own file mtimes -- parsing no agent transcript. _load_workflow
|
|
4421
|
+
calls this for Run.lifecycle, and hooks_cli._workflow_finished (7.2)
|
|
4422
|
+
calls this too (through lifecycle_finished), so a hook's notion of
|
|
4423
|
+
'finished' is never a second, independently maintained rule -- the same
|
|
4424
|
+
"one function is the only definition of this rule" docs/DESIGN.md
|
|
4425
|
+
section 3 states for Run.finished itself.
|
|
4426
|
+
|
|
4427
|
+
`path` need not exist yet, and its journal.jsonl need not either (a
|
|
4428
|
+
workflow directory can exist before its journal does): both are read the
|
|
4429
|
+
same tolerant way _load_workflow's own journal parsing is."""
|
|
4430
|
+
path = Path(path)
|
|
4431
|
+
run_id = _valid_workflow_run_id(path.name, [])
|
|
4432
|
+
journal, _started_events, _journal_ok, journal_line_numbers = _read_journal(path, [])
|
|
4433
|
+
journal_attempts, _diagnostics = _run_journal_state_machine(journal, journal_line_numbers)
|
|
4434
|
+
journal_resolved = _workflow_finished_from_occurrences(journal_attempts)
|
|
4435
|
+
return _lifecycle_from_parts(path, run_id, journal_resolved)
|
|
4436
|
+
|
|
4437
|
+
|
|
4438
|
+
def lifecycle_from_journal_state(
|
|
4439
|
+
path: Path, run_id: str, journal_resolved: bool, parent_index: "ParentIndex",
|
|
4440
|
+
*, after_launch_line: int = 0,
|
|
4441
|
+
) -> Lifecycle:
|
|
4442
|
+
"""docs/DESIGN.md 4.12, 4.13.3 (N8b K5.4): the run lifecycle table evaluated from a fact the
|
|
4443
|
+
caller already has. `watch` follows a growing journal incrementally, so it hands in
|
|
4444
|
+
`journal_resolved` computed from its own in-memory occurrences and the parent index it
|
|
4445
|
+
refreshes itself, instead of paying for `workflow_lifecycle`'s full journal read and
|
|
4446
|
+
state-machine run on every poll. `after_launch_line` leaves out the generations of an ended
|
|
4447
|
+
journal epoch (see `_lifecycle_from_parts`)."""
|
|
4448
|
+
return _lifecycle_from_parts(
|
|
4449
|
+
Path(path), run_id, journal_resolved, parent_index, after_launch_line,
|
|
4450
|
+
)
|
|
4451
|
+
|
|
4452
|
+
|
|
4453
|
+
def journal_resolved_from_occurrences(occurrences: list) -> bool:
|
|
4454
|
+
"""The journal-only `finished` fact (docs/DESIGN.md 4.1, 4.12) for occurrences a caller
|
|
4455
|
+
already holds from `_run_journal_state_machine`: true when every key the journal started has
|
|
4456
|
+
no open attempt."""
|
|
4457
|
+
return _workflow_finished_from_occurrences(occurrences)
|
|
4458
|
+
|
|
4459
|
+
|
|
4460
|
+
def lifecycle_finished(lifecycle: Lifecycle) -> bool:
|
|
4461
|
+
"""docs/DESIGN.md section 3 (N2 root-cause fix): the one rule for whether
|
|
4462
|
+
a workflow run's own Lifecycle counts as finished -- true for
|
|
4463
|
+
'completed', 'failed', 'stopped', and 'journal_resolved'; false for
|
|
4464
|
+
'unknown' and 'none'. Lives next to _LIFECYCLE_FINISHED_TERMINALS so
|
|
4465
|
+
_load_workflow's Run.finished and hooks_cli's workflow-unit finished
|
|
4466
|
+
check both call this one function instead of each re-deriving the
|
|
4467
|
+
terminal set on its own."""
|
|
4468
|
+
return lifecycle.terminal in _LIFECYCLE_FINISHED_TERMINALS
|
|
4469
|
+
|
|
4470
|
+
|
|
4471
|
+
def attempt_unresolved_in_journal(journal: list[dict], key: str | None, ordinal: int) -> bool:
|
|
4472
|
+
"""Public counterpart of the per-key journal state machine's own
|
|
4473
|
+
closed_by (docs/DESIGN.md 4.13.3): true when the ordinal-th occurrence of
|
|
4474
|
+
key was never closed by a 'result' or 'failed' event -- still open at the
|
|
4475
|
+
end of the journal the reader actually read. report.py's AGENT table END
|
|
4476
|
+
column (4.7) uses this to show 'no result' instead of the 'completed'
|
|
4477
|
+
_compute_outcome falls back to for an open attempt whose own transcript
|
|
4478
|
+
simply has not (yet) ended in the kill sentinel."""
|
|
4479
|
+
if key is None:
|
|
4480
|
+
return False
|
|
4481
|
+
occurrences, _diagnostics = _run_journal_state_machine(journal)
|
|
4482
|
+
for occurrence in occurrences:
|
|
4483
|
+
if occurrence.key == key and occurrence.ordinal == ordinal:
|
|
4484
|
+
return occurrence.closed_by is None
|
|
4485
|
+
return False
|
|
4486
|
+
|
|
4487
|
+
|
|
4488
|
+
# --- identity: the run cwd set and agent ids (docs/DESIGN.md 4.13.6, N6) -----------
|
|
4489
|
+
|
|
4490
|
+
|
|
4491
|
+
def run_cwd_set(run: Run) -> frozenset[str]:
|
|
4492
|
+
"""Every top-level non-empty string `cwd` the run's own transcripts carried: a
|
|
4493
|
+
session's main transcript and each child attempt's, or a workflow run's attempt
|
|
4494
|
+
transcripts. Never the journal, a meta file, or a workflow's parent session
|
|
4495
|
+
transcript (the reader collects `Attempt.entry_cwds` only from an attempt's own
|
|
4496
|
+
transcript)."""
|
|
4497
|
+
cwds: set[str] = set()
|
|
4498
|
+
if run.main is not None:
|
|
4499
|
+
cwds.update(run.main.entry_cwds)
|
|
4500
|
+
for attempt in run.attempts:
|
|
4501
|
+
cwds.update(attempt.entry_cwds)
|
|
4502
|
+
return frozenset(cwds)
|
|
4503
|
+
|
|
4504
|
+
|
|
4505
|
+
def run_matches_cwd(run: Run, caller: str) -> bool:
|
|
4506
|
+
"""`--here` containment (docs/DESIGN.md 4.13.11, N6): both the caller's directory and
|
|
4507
|
+
each candidate cwd are made absolute and normalized (no `~` expansion, no symlink
|
|
4508
|
+
resolution); the run matches when the caller equals or lies below some candidate.
|
|
4509
|
+
A candidate that cannot be compared (ValueError, OSError) is no match for that
|
|
4510
|
+
candidate, and a run with an empty cwd set never matches."""
|
|
4511
|
+
candidates = run_cwd_set(run)
|
|
4512
|
+
if not candidates:
|
|
4513
|
+
return False
|
|
4514
|
+
try:
|
|
4515
|
+
caller_path = os.path.normpath(os.path.abspath(caller))
|
|
4516
|
+
except (ValueError, OSError):
|
|
4517
|
+
return False
|
|
4518
|
+
for candidate in candidates:
|
|
4519
|
+
try:
|
|
4520
|
+
candidate_path = os.path.normpath(os.path.abspath(candidate))
|
|
4521
|
+
if os.path.commonpath([candidate_path, caller_path]) == candidate_path:
|
|
4522
|
+
return True
|
|
4523
|
+
except (ValueError, OSError):
|
|
4524
|
+
continue
|
|
4525
|
+
return False
|
|
4526
|
+
|
|
4527
|
+
|
|
4528
|
+
def caller_workflow_paths(
|
|
4529
|
+
root: Path, session_id: str | None, caller: str | None,
|
|
4530
|
+
) -> frozenset[Path]:
|
|
4531
|
+
"""The workflow run directories `--here` adds for the caller's own session (docs/DESIGN.md
|
|
4532
|
+
4.13.6, G5): the `wf_*` directories under `<project>/<session>/subagents/workflows` of the one
|
|
4533
|
+
session run under `root` whose transcript stem equals `session_id` (a canonical UUID,
|
|
4534
|
+
lowercase), when that session run itself passes `run_matches_cwd` for `caller`. Empty for no
|
|
4535
|
+
id, no caller directory, no or several such session runs, a session that does not work here,
|
|
4536
|
+
or a session that cannot be loaded."""
|
|
4537
|
+
if session_id is None or caller is None or not _UUID_RE.fullmatch(session_id):
|
|
4538
|
+
return frozenset()
|
|
4539
|
+
owners = [
|
|
4540
|
+
ref for ref in discover_runs(Path(root), kinds=frozenset({"session"}))
|
|
4541
|
+
if ref.path.stem.lower() == session_id.lower()
|
|
4542
|
+
]
|
|
4543
|
+
if len(owners) != 1:
|
|
4544
|
+
return frozenset()
|
|
4545
|
+
owner = owners[0]
|
|
4546
|
+
try:
|
|
4547
|
+
run = load_run(owner)
|
|
4548
|
+
except (NotARun, RunUnreadable):
|
|
4549
|
+
return frozenset()
|
|
4550
|
+
if not run_matches_cwd(run, caller):
|
|
4551
|
+
return frozenset()
|
|
4552
|
+
workflows = owner.path.parent / owner.path.stem / "subagents" / "workflows"
|
|
4553
|
+
try:
|
|
4554
|
+
return frozenset(path for path in workflows.glob("wf_*") if _is_dir(path))
|
|
4555
|
+
except OSError:
|
|
4556
|
+
return frozenset()
|
|
4557
|
+
|
|
4558
|
+
|
|
4559
|
+
def agent_id_candidates(ref: RunRef) -> frozenset[str]:
|
|
4560
|
+
"""A superset, found without parsing any transcript, of every agent id the loader
|
|
4561
|
+
can turn into an attempt of this run (docs/DESIGN.md 4.8, N6). A session: the stem id
|
|
4562
|
+
of every `subagents/agent-*.jsonl` the session loader would discover. A workflow:
|
|
4563
|
+
the `agentId` string of every journal `started` event `_read_journal` returns, plus
|
|
4564
|
+
the stem id of every `agent-*.jsonl` in the run directory. An id that is not
|
|
4565
|
+
canonical is dropped; a directory that cannot be listed contributes nothing."""
|
|
4566
|
+
found: set[str] = set()
|
|
4567
|
+
if ref.kind == "session":
|
|
4568
|
+
try:
|
|
4569
|
+
paths = _discover_session_agent_paths(ref.path)
|
|
4570
|
+
except OSError:
|
|
4571
|
+
paths = []
|
|
4572
|
+
found.update(path.stem.removeprefix("agent-") for path in paths)
|
|
4573
|
+
else:
|
|
4574
|
+
_journal, started_events, _ok, _lines = _read_journal(ref.path, [])
|
|
4575
|
+
for event in started_events:
|
|
4576
|
+
agent_id = event.get("agentId")
|
|
4577
|
+
if isinstance(agent_id, str):
|
|
4578
|
+
found.add(agent_id)
|
|
4579
|
+
try:
|
|
4580
|
+
for path in ref.path.glob("agent-*.jsonl"):
|
|
4581
|
+
if _is_file(path):
|
|
4582
|
+
found.add(path.stem.removeprefix("agent-"))
|
|
4583
|
+
except OSError:
|
|
4584
|
+
pass
|
|
4585
|
+
return frozenset(agent_id for agent_id in found if _AGENT_ID_RE.fullmatch(agent_id))
|
|
4586
|
+
|
|
4587
|
+
|
|
4588
|
+
def run_holds_agent(ref: RunRef, agent_ids) -> bool:
|
|
4589
|
+
"""The confirmation step of agent resolution: the run, loaded, holds an attempt
|
|
4590
|
+
whose agent id is one of `agent_ids`. A ref that is not a run is no match. A run that
|
|
4591
|
+
cannot be read cannot refute the candidate check, so it stays a match: the command that
|
|
4592
|
+
loads it then says it could not be read (docs/DESIGN.md 4.8, 0.3.4)."""
|
|
4593
|
+
wanted = set(agent_ids)
|
|
4594
|
+
try:
|
|
4595
|
+
run = load_run(ref)
|
|
4596
|
+
except NotARun:
|
|
4597
|
+
return False
|
|
4598
|
+
except RunUnreadable:
|
|
4599
|
+
return True
|
|
4600
|
+
return any(attempt.agent_id in wanted for attempt in run.attempts)
|
|
4601
|
+
|
|
4602
|
+
|
|
4603
|
+
def runs_holding_agent(refs, agent_id: str) -> list[RunRef]:
|
|
4604
|
+
"""The refs, in the order given, whose loaded Run holds an attempt with this
|
|
4605
|
+
canonical agent id: a cheap candidate check, then the exact confirmation."""
|
|
4606
|
+
if not _AGENT_ID_RE.fullmatch(agent_id):
|
|
4607
|
+
return []
|
|
4608
|
+
return [
|
|
4609
|
+
ref for ref in refs
|
|
4610
|
+
if agent_id in agent_id_candidates(ref) and run_holds_agent(ref, (agent_id,))
|
|
4611
|
+
]
|