Skip to content

Commit 5f81fa7

Browse files
author
zxhwolfe
committed
fix: distinguish recovered Host stream interruptions
1 parent 76330d4 commit 5f81fa7

6 files changed

Lines changed: 450 additions & 20 deletions

File tree

‎docs/m4-host-integration.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,16 @@ Host-created worktree changes. Outer-shell feed checks do not establish that a
103103
Host sandbox can reach Radar; read-only, unrestricted, custom-origin, or
104104
expanded-allowlist runs are not release evidence.
105105

106+
The runner parses the Codex JSONL lifecycle rather than treating every transient
107+
response-stream interruption as terminal. A run may be `complete_clean` or
108+
`complete_after_recovery`; the latter still records that a disconnect occurred,
109+
but is accepted only when the process exits zero, the final agent message follows
110+
all tool activity, every started item has a terminal event, and the last event is
111+
exactly one `turn.completed`. Missing terminal events, `turn.failed`, non-stream
112+
errors, invalid/truncated JSONL, unfinished items, post-final-message tool calls,
113+
timeouts, or a dirty worktree invalidate the entire run. This lifecycle check
114+
does not relax the separate semantic grader or per-case manual review.
115+
106116
### Stage 1 — collect fresh host traces
107117

108118
Use:

‎release-evidence/README.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,10 @@ clean, detached `workspace-write` worktree. The launcher records
1717
allowlist `['aiworkstation.cn']`. Read-only or unrestricted sandboxes, missing or
1818
expanded allowlists, custom Radar origins, and any Host-created worktree changes
1919
are invalid evidence.
20+
21+
Raw reports also include a structured JSONL lifecycle classification. A transient
22+
Codex response-stream disconnect may be recorded as `complete_after_recovery`
23+
only when a complete, exit-zero turn still ends with `turn.completed`; it is not
24+
equivalent to a terminal failure. `incomplete_or_failed`, timeouts, `turn.failed`,
25+
non-stream errors, malformed/truncated JSONL, unfinished items, or tool activity
26+
after the final agent message are rejected by the verifier.

‎scripts/run_host_evals.py‎

Lines changed: 196 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,30 @@
4040
"AI_WORKSTATION_API_BASE_URL",
4141
"TOPIC_RADAR_BASE_URL",
4242
)
43+
PASSING_TRACE_INTEGRITY_STATUSES = {
44+
"complete_clean",
45+
"complete_after_recovery",
46+
}
47+
TOOL_ITEM_TYPES = {
48+
"command_execution",
49+
"file_change",
50+
"image_generation",
51+
"mcp_tool_call",
52+
"web_search",
53+
}
54+
STREAM_DISCONNECT_MARKERS = (
55+
"responsestreamdisconnected",
56+
"response_stream_disconnected",
57+
"response stream disconnected",
58+
"stream disconnected",
59+
"stream closed before response.completed",
60+
)
61+
STREAM_DISCONNECT_CODES = {
62+
"responsestreamdisconnected",
63+
"responsestreamdisconnect",
64+
"streamdisconnected",
65+
"streamdisconnect",
66+
}
4367

4468

4569
class HostEvalError(RuntimeError):
@@ -303,6 +327,167 @@ def trace_text(stdout: str, stderr: str) -> str:
303327
return "\n".join(collected)
304328

305329

330+
def _is_stream_disconnect_event(event: Mapping[str, Any]) -> bool:
331+
"""Recognize Codex response-stream interruptions, not helper/network errors."""
332+
333+
if event.get("type") != "error":
334+
return False
335+
336+
# Codex versions have emitted both a structured ``codexErrorInfo.code``
337+
# and a human-readable reconnect message. Prefer the structured code when
338+
# present, while retaining the known message forms for older launchers.
339+
def codes(value: Any) -> Iterable[str]:
340+
if isinstance(value, Mapping):
341+
for key, nested in value.items():
342+
normalized_key = str(key).lower().replace("-", "_")
343+
if normalized_key in {"code", "error_code", "codex_error_code"}:
344+
if isinstance(nested, str):
345+
yield nested
346+
yield from codes(nested)
347+
elif isinstance(value, list):
348+
for nested in value:
349+
yield from codes(nested)
350+
351+
for code in codes(event):
352+
normalized = "".join(char for char in code.lower() if char.isalnum())
353+
if normalized in STREAM_DISCONNECT_CODES:
354+
return True
355+
356+
text = "\n".join(_collect_strings(event)).lower().replace("-", "_")
357+
return any(marker in text for marker in STREAM_DISCONNECT_MARKERS)
358+
359+
360+
def analyze_jsonl_trace(
361+
stdout: str,
362+
*,
363+
exit_code: int | None,
364+
runtime_status: str,
365+
timed_out: bool,
366+
stdout_truncated: bool,
367+
stderr_truncated: bool,
368+
worktree_clean_after: bool,
369+
) -> dict[str, Any]:
370+
"""Classify one Codex JSONL lifecycle from observable process evidence."""
371+
372+
events: list[Mapping[str, Any]] = []
373+
invalid_jsonl_line_count = 0
374+
for line in stdout.splitlines():
375+
stripped = line.strip()
376+
if not stripped:
377+
continue
378+
try:
379+
value = json.loads(stripped)
380+
except json.JSONDecodeError:
381+
invalid_jsonl_line_count += 1
382+
continue
383+
if not isinstance(value, Mapping):
384+
invalid_jsonl_line_count += 1
385+
continue
386+
events.append(value)
387+
388+
turn_started_positions: list[int] = []
389+
turn_completed_positions: list[int] = []
390+
turn_failed_count = 0
391+
error_event_count = 0
392+
stream_disconnect_event_count = 0
393+
non_stream_error_event_count = 0
394+
started_items: dict[str, tuple[int, str]] = {}
395+
completed_item_ids: set[str] = set()
396+
tool_activity_positions: list[int] = []
397+
final_agent_message_position: int | None = None
398+
399+
for position, event in enumerate(events):
400+
event_type = str(event.get("type") or "")
401+
item_value = event.get("item")
402+
item = item_value if isinstance(item_value, Mapping) else {}
403+
item_type = str(item.get("type") or "")
404+
item_id_value = item.get("id")
405+
item_id = str(item_id_value) if item_id_value not in {None, ""} else ""
406+
407+
if event_type == "turn.started":
408+
turn_started_positions.append(position)
409+
elif event_type == "turn.completed":
410+
turn_completed_positions.append(position)
411+
elif event_type == "turn.failed":
412+
turn_failed_count += 1
413+
elif event_type == "error":
414+
error_event_count += 1
415+
if _is_stream_disconnect_event(event):
416+
stream_disconnect_event_count += 1
417+
else:
418+
non_stream_error_event_count += 1
419+
420+
if event_type == "item.started":
421+
key = item_id or f"<missing-id@{position}>"
422+
started_items[key] = (position, item_type)
423+
elif event_type == "item.completed" and item_id:
424+
completed_item_ids.add(item_id)
425+
426+
if item_type in TOOL_ITEM_TYPES and event_type in {"item.started", "item.completed"}:
427+
tool_activity_positions.append(position)
428+
if event_type == "item.completed" and item_type == "agent_message":
429+
final_agent_message_position = position
430+
431+
incomplete_item_ids = sorted(
432+
item_id for item_id in started_items if item_id not in completed_item_ids
433+
)
434+
tool_activity_after_final_agent_message = bool(
435+
final_agent_message_position is not None
436+
and any(position > final_agent_message_position for position in tool_activity_positions)
437+
)
438+
last_event_type = str(events[-1].get("type") or "") if events else None
439+
lifecycle_complete = bool(
440+
exit_code == 0
441+
and runtime_status == "completed"
442+
and not timed_out
443+
and not stdout_truncated
444+
and not stderr_truncated
445+
and invalid_jsonl_line_count == 0
446+
and len(turn_started_positions) == 1
447+
and len(turn_completed_positions) == 1
448+
and turn_failed_count == 0
449+
and non_stream_error_event_count == 0
450+
and not incomplete_item_ids
451+
and final_agent_message_position is not None
452+
and not tool_activity_after_final_agent_message
453+
and turn_completed_positions[0] > final_agent_message_position
454+
and last_event_type == "turn.completed"
455+
and worktree_clean_after
456+
)
457+
stream_disconnect_observed = stream_disconnect_event_count > 0
458+
stream_recovered = lifecycle_complete and stream_disconnect_observed
459+
stream_terminal_failure = stream_disconnect_observed and not stream_recovered
460+
if lifecycle_complete:
461+
trace_integrity_status = (
462+
"complete_after_recovery"
463+
if stream_disconnect_observed
464+
else "complete_clean"
465+
)
466+
else:
467+
trace_integrity_status = "incomplete_or_failed"
468+
469+
return {
470+
"jsonl_event_count": len(events),
471+
"invalid_jsonl_line_count": invalid_jsonl_line_count,
472+
"turn_started_count": len(turn_started_positions),
473+
"turn_completed_count": len(turn_completed_positions),
474+
"turn_failed_count": turn_failed_count,
475+
"error_event_count": error_event_count,
476+
"stream_disconnect_event_count": stream_disconnect_event_count,
477+
"non_stream_error_event_count": non_stream_error_event_count,
478+
"final_agent_message_observed": final_agent_message_position is not None,
479+
"tool_activity_after_final_agent_message": tool_activity_after_final_agent_message,
480+
"incomplete_item_ids": incomplete_item_ids,
481+
"last_event_type": last_event_type,
482+
"trace_integrity_status": trace_integrity_status,
483+
"stream_disconnect_observed": stream_disconnect_observed,
484+
"stream_recovered": stream_recovered,
485+
"stream_terminal_failure": stream_terminal_failure,
486+
# Preserve the old diagnostic field as an observation, not a gate.
487+
"stream_disconnected": stream_disconnect_observed,
488+
}
489+
490+
306491
def observe_tokens(text: str, expected_workflow: Sequence[str]) -> dict[str, Any]:
307492
observed_skills = [
308493
skill for skill in TOPIC_INTELLIGENCE_SKILLS if skill in text
@@ -343,7 +528,7 @@ def classify_observation(case: EvalCase, observation: Mapping[str, Any]) -> str:
343528
def _result_is_gate_failure(result: Mapping[str, Any], *, strict_observation: bool = False) -> bool:
344529
return str(result.get("runtime_status")) != "completed" or str(result.get("route_observation")) in {
345530
"fail_unexpected_skill", "fail_wrong_skill_observed", "partial_workflow_observed"
346-
} or bool(result.get("stream_disconnected")) or result.get("worktree_clean_after") is False or (strict_observation and str(result.get("route_observation")) in {"unobservable", "fail_unobservable"})
531+
} or result.get("trace_integrity_status") not in PASSING_TRACE_INTEGRITY_STATUSES or result.get("worktree_clean_after") is not True or (strict_observation and str(result.get("route_observation")) in {"unobservable", "fail_unobservable"})
347532

348533

349534
def _truncate(text: str, limit: int) -> tuple[str, bool]:
@@ -389,6 +574,7 @@ def run_case(
389574
"stdout_truncated": False,
390575
"stderr_truncated": False,
391576
"worktree_clean_after": None,
577+
"trace_integrity_status": "not_run",
392578
}
393579

394580
start = time.monotonic()
@@ -430,15 +616,14 @@ def run_case(
430616
stdout, stdout_truncated = _truncate(stdout, max_output_chars)
431617
stderr, stderr_truncated = _truncate(stderr, max_output_chars)
432618
clean_after = not _clean_worktree_status(cwd)
433-
lowered_trace = searchable.lower()
434-
stream_disconnected = any(
435-
marker in lowered_trace
436-
for marker in (
437-
"stream disconnected",
438-
"stream disconnect",
439-
"response stream disconnected",
440-
"stream closed before response.completed",
441-
)
619+
trace_integrity = analyze_jsonl_trace(
620+
stdout,
621+
exit_code=exit_code,
622+
runtime_status=runtime_status,
623+
timed_out=timed_out,
624+
stdout_truncated=stdout_truncated,
625+
stderr_truncated=stderr_truncated,
626+
worktree_clean_after=clean_after,
442627
)
443628

444629
return {
@@ -461,7 +646,7 @@ def run_case(
461646
"stdout_truncated": stdout_truncated,
462647
"stderr_truncated": stderr_truncated,
463648
"worktree_clean_after": clean_after,
464-
"stream_disconnected": stream_disconnected,
649+
**trace_integrity,
465650
}
466651

467652

‎scripts/verify_release_evidence.py‎

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
1919

2020
from scripts.grade_host_eval import EvidenceGradeError, PASS_GRADES, grade_report
21-
from scripts.run_host_evals import load_suite
21+
from scripts.run_host_evals import analyze_jsonl_trace, load_suite
2222

2323

2424
LIVE_RADAR_NETWORK_DOMAINS = ["aiworkstation.cn"]
@@ -27,6 +27,29 @@
2727
'network_proxy={enabled=true,allowed_domains=["aiworkstation.cn"]}',
2828
'approval_policy="never"',
2929
]
30+
PASSING_TRACE_INTEGRITY_STATUSES = {
31+
"complete_clean",
32+
"complete_after_recovery",
33+
}
34+
TRACE_FIELDS = (
35+
"jsonl_event_count",
36+
"invalid_jsonl_line_count",
37+
"turn_started_count",
38+
"turn_completed_count",
39+
"turn_failed_count",
40+
"error_event_count",
41+
"stream_disconnect_event_count",
42+
"non_stream_error_event_count",
43+
"final_agent_message_observed",
44+
"tool_activity_after_final_agent_message",
45+
"incomplete_item_ids",
46+
"last_event_type",
47+
"trace_integrity_status",
48+
"stream_disconnect_observed",
49+
"stream_recovered",
50+
"stream_terminal_failure",
51+
"stream_disconnected",
52+
)
3053

3154

3255
class ReleaseEvidenceError(RuntimeError):
@@ -186,6 +209,58 @@ def verify(root: Path, version: str) -> Path:
186209
if not isinstance(submitted, Mapping) or any(submitted.get(key) != value for key, value in required.items()):
187210
raise ReleaseEvidenceError(f"raw Host Eval case contract differs from eval definition: {expected.case_id}")
188211

212+
bad_trace = []
213+
for case in raw_cases:
214+
if not isinstance(case, Mapping):
215+
bad_trace.append(_case_id(case))
216+
continue
217+
regenerated_trace = analyze_jsonl_trace(
218+
str(case.get("stdout") or ""),
219+
exit_code=case.get("exit_code"),
220+
runtime_status=str(case.get("runtime_status") or ""),
221+
timed_out=case.get("timed_out") is True,
222+
stdout_truncated=case.get("stdout_truncated") is True,
223+
stderr_truncated=case.get("stderr_truncated") is True,
224+
worktree_clean_after=case.get("worktree_clean_after") is True,
225+
)
226+
if any(case.get(field) != regenerated_trace.get(field) for field in TRACE_FIELDS):
227+
bad_trace.append(_case_id(case))
228+
bad_trace.extend([
229+
_case_id(case)
230+
for case in raw_cases
231+
if not isinstance(case, Mapping)
232+
or case.get("trace_integrity_status") not in PASSING_TRACE_INTEGRITY_STATUSES
233+
or case.get("turn_started_count") != 1
234+
or case.get("turn_completed_count") != 1
235+
or case.get("turn_failed_count") != 0
236+
or case.get("non_stream_error_event_count") != 0
237+
or case.get("final_agent_message_observed") is not True
238+
or case.get("tool_activity_after_final_agent_message") is not False
239+
or case.get("incomplete_item_ids") != []
240+
or case.get("last_event_type") != "turn.completed"
241+
or (
242+
case.get("trace_integrity_status") == "complete_after_recovery"
243+
and (
244+
case.get("stream_disconnect_observed") is not True
245+
or case.get("stream_recovered") is not True
246+
or case.get("stream_terminal_failure") is not False
247+
)
248+
)
249+
or (
250+
case.get("trace_integrity_status") == "complete_clean"
251+
and (
252+
case.get("stream_disconnect_observed") is not False
253+
or case.get("stream_recovered") is not False
254+
or case.get("stream_terminal_failure") is not False
255+
or case.get("error_event_count") != 0
256+
)
257+
)
258+
])
259+
bad_trace = sorted(set(bad_trace))
260+
if bad_trace:
261+
raise ReleaseEvidenceError(
262+
"Host Eval trace lifecycle is incomplete or invalid: " + ", ".join(bad_trace)
263+
)
189264
bad_runtime = [
190265
_case_id(case)
191266
for case in raw_cases
@@ -197,7 +272,6 @@ def verify(root: Path, version: str) -> Path:
197272
or case.get("stdout_truncated") is not False
198273
or case.get("stderr_truncated") is not False
199274
or case.get("worktree_clean_after") is not True
200-
or case.get("stream_disconnected") is not False
201275
)
202276
]
203277
if bad_runtime:

0 commit comments

Comments
 (0)