Repository navigation
fix(adapters): capture codex usage when the hook path is unusable; count subagent rollouts (#775) #895
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix(adapters): capture codex usage when the hook path is unusable; count subagent rollouts (#775) #895
Changes from all commits
de63b97
5d46534
217dddd
b4514da
2546e9a
8857192
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -29,6 +29,7 @@ | |
| import enum | ||
| import hashlib | ||
| import json | ||
| import os | ||
| import shlex | ||
| import stat | ||
| import time | ||
|
|
@@ -41,7 +42,7 @@ | |
|
|
||
| import regex | ||
|
|
||
| from .. import devcontract, gates, runs | ||
| from .. import devcontract, gates, runs, tokens | ||
| from ..bmadconfig import ProjectPaths | ||
| from ..journal import LOGS_DIR, TASK_CYCLE_ARTIFACTS | ||
| from ..model import TokenUsage | ||
|
|
@@ -297,6 +298,18 @@ def load_result_document(tasks_dir: Path, task_id: str) -> dict | None: | |
| return data | ||
|
|
||
|
|
||
| def _is_regular_file(path: str | None) -> bool: | ||
| """Whether a hook-reported transcript path names a regular file. Any fault | ||
| reads as False: the caller's answer to False is a by-id lookup whose miss is | ||
| crumbed, so a metadata fault cannot hide.""" | ||
| if not path: | ||
| return False | ||
| try: | ||
| return stat.S_ISREG(Path(path).stat().st_mode) | ||
| except (OSError, ValueError): | ||
| return False | ||
|
|
||
|
|
||
| class _ResultFileMixin: | ||
| """Result-file read-back and verdict finalization: acquire the | ||
| skill-written result dict and fold it into the session's final | ||
|
|
@@ -990,6 +1003,15 @@ def wait_for_completion(self, handle: SessionHandle, spec: SessionSpec) -> Sessi | |
| # (its Stop is dropped), so it is crumbed once instead of staying silent. | ||
| pin_checked = handle.pinned_session_id is None | ||
| transcript_path: str | None = None | ||
| # A codex-rollout transcript can be found by session id when the hook's | ||
| # path is missing or names no file (a Windows relay decoding stdin with | ||
| # the locale code page garbles it). `resolved` is that path once found: | ||
| # it sticks against a later unusable hook path. `hook_transcript_path` is | ||
| # the last raw value a hook sent, for the crumbs. | ||
| resolvable = self.profile.usage_parser == "codex-rollout" | ||
| resolved: str | None = None | ||
| hook_transcript_path: str | None = None | ||
| unresolved_crumbed = False | ||
| nudges_left = self._stop_nudges | ||
| # Positive grace arms at launch for dev/review sessions, so a CLI that | ||
| # goes silent before its first Stop cannot burn the full wall timeout. A | ||
|
|
@@ -1155,6 +1177,38 @@ def sample_transcript(path: str, now: float) -> None: | |
| ) | ||
| scan_failures = 0 | ||
|
|
||
| def try_resolve() -> None: | ||
| """Resolve the transcript by session id when no readable one is known. | ||
| One lookup per call — each hook event, each heartbeat, and before | ||
| each exit that no hook event just preceded; a hit becomes the loop's transcript (so every | ||
| SessionResult and the budget guard see it) and is crumbed once — the | ||
| ``resolved is None`` gate makes the hit itself one-shot.""" | ||
| nonlocal transcript_path, resolved | ||
| if not resolvable or not session_id or resolved is not None: | ||
| return | ||
| if _is_regular_file(transcript_path): | ||
| return | ||
| # The pane's env as launch() builds it, over this process's own: the | ||
| # CLI reads CODEX_HOME from there. Known limit: a tmux pane inherits | ||
| # the tmux SERVER's env, so a server started under another CODEX_HOME | ||
| # is missed — and the miss is crumbed at Stop. | ||
| hit = tokens.resolve_transcript( | ||
| self.profile.usage_parser, | ||
| session_id, | ||
| {**os.environ, **self.profile.env, **spec.env}, | ||
| ) | ||
| if hit is None: | ||
| return | ||
| sample_transcript(str(hit), time.monotonic()) | ||
| transcript_path = resolved = str(hit) | ||
| self._note_lifecycle( | ||
| handle.task_id, | ||
| "transcript-resolved", | ||
| session_id=session_id, | ||
| hook_transcript_path=hook_transcript_path, | ||
| path=resolved, | ||
| ) | ||
|
|
||
| def produced_work() -> bool: | ||
| # Read at call time, after a final frame sample, so every exit below | ||
| # reports the loop's final view of the pane rather than the last tick's. | ||
|
|
@@ -1199,6 +1253,10 @@ def produced_work() -> bool: | |
| # own Stop may have been read as foreign) | ||
| foreign_hook_events=foreign_hook_events, | ||
| ) | ||
| # Every exit resolves first (a SessionStart that came before its | ||
| # rollout existed, then no further hook event): the exit's | ||
| # `transcript_path` argument is read before `produced_work()`. | ||
| try_resolve() | ||
| return SessionResult( | ||
| status="timeout", | ||
| session_id=session_id, | ||
|
|
@@ -1223,6 +1281,7 @@ def produced_work() -> bool: | |
| # left on disk for the engine to consume and attribute the stop. | ||
| if self._hard_stop_requested(): | ||
| self._note_lifecycle(handle.task_id, "stop-abort-fired") | ||
| try_resolve() | ||
| return SessionResult( | ||
| status="aborted", | ||
| session_id=session_id, | ||
|
|
@@ -1234,6 +1293,11 @@ def produced_work() -> bool: | |
| now = time.monotonic() | ||
| if last_heartbeat is None or now - last_heartbeat >= HEARTBEAT_INTERVAL_S: | ||
| last_heartbeat = now | ||
| # A rollout created after SessionStart, in a turn with no further | ||
| # hook event, is found here rather than at Stop — so the budget | ||
| # guard below is not blind for the whole turn. Bounded by the | ||
| # heartbeat cadence; a no-op once a readable transcript is known. | ||
| try_resolve() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When AGENTS.md reference: AGENTS.md:L86-L87 Useful? React with 👍 / 👎.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 8857192. The timeout exit and both hard-stop exits now call |
||
| # Transcript idle sample (#680), ahead of the heartbeat write so | ||
| # the payload carries this tick's age. Inert until a hook event | ||
| # has named the transcript. | ||
|
|
@@ -1430,6 +1494,7 @@ def produced_work() -> bool: | |
| # the pre-#319 outcome, never a worse one. | ||
| if self._hard_stop_requested(): | ||
| self._note_lifecycle(handle.task_id, "stop-abort-fired") | ||
| try_resolve() | ||
| return SessionResult( | ||
| status="aborted", | ||
| session_id=session_id, | ||
|
|
@@ -1463,7 +1528,12 @@ def produced_work() -> bool: | |
| ) | ||
| probe_failures = 0 | ||
| if not alive: | ||
| # died without a SessionEnd hook (killed, crashed hard) | ||
| # died without a SessionEnd hook (killed, crashed hard). A | ||
| # SessionStart that came before its rollout existed, then no | ||
| # Stop: resolve once more so the crash still carries the | ||
| # transcript. Before the return, not inside `produced_work()`: | ||
| # the exit's `transcript_path` argument is read first. | ||
| try_resolve() | ||
|
Comment on lines
1530
to
+1536
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This resolution call covers only the ordinary no-event death check. If the initial liveness probe sees the window alive but the stall-expiry re-probe later in the same iteration sees it dead, that separate crash return skips Useful? React with 👍 / 👎.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 2546e9a. The stall re-probe's crash return now calls |
||
| return self._final( | ||
| handle, | ||
| spec, | ||
|
|
@@ -1549,6 +1619,8 @@ def produced_work() -> bool: | |
| # persistent failure. A dead window is never labelled parked: | ||
| # nothing is waiting on a human once the CLI is gone. The | ||
| # probe fault is crumbed (`site="stall"`, DW-454). | ||
| # Both exits below (crashed or stalled) carry the rollout. | ||
| try_resolve() | ||
| if self._probe_liveness(handle, "stall") is False: | ||
| return self._final( | ||
| handle, | ||
|
|
@@ -1638,6 +1710,16 @@ def produced_work() -> bool: | |
| dropped_so_far=foreign_hook_events, | ||
| ) | ||
| continue | ||
| if event.payload_error: | ||
| # The relay could not read this hook's payload (events._read_payload), | ||
| # so the event carries nulls. Completion handling is unchanged; | ||
| # the crumb keeps the degrade visible. | ||
| self._note_lifecycle( | ||
| handle.task_id, | ||
| "hook-payload-unreadable", | ||
| hook_event=event.event, | ||
| payload_error=event.payload_error, | ||
| ) | ||
| if ( | ||
| event.event == "Stop" | ||
| and self.profile.subagent_stop_without_transcript | ||
|
|
@@ -1651,13 +1733,21 @@ def produced_work() -> bool: | |
| # real transcript is preserved for usage tallying. | ||
| continue | ||
| session_id = event.session_id or session_id | ||
| if event.transcript_path and event.transcript_path != transcript_path: | ||
| hook_transcript_path = event.transcript_path or hook_transcript_path | ||
| hook_path = event.transcript_path | ||
| if resolved and hook_path and not _is_regular_file(hook_path): | ||
| # A rollout resolved by id sticks: an unusable hook path on a | ||
| # later event (the same garbled path a Stop repeats) must not | ||
| # overwrite it. | ||
| hook_path = None | ||
| if hook_path and hook_path != transcript_path: | ||
| # Take the idle baseline as soon as a hook names a new transcript, | ||
| # including a later re-point (#680). A write before the next | ||
| # heartbeat is then a change for the #727 work check rather than | ||
| # being absorbed into the baseline at exit. | ||
| sample_transcript(event.transcript_path, time.monotonic()) | ||
| transcript_path = event.transcript_path or transcript_path | ||
| sample_transcript(hook_path, time.monotonic()) | ||
| transcript_path = hook_path or transcript_path | ||
| try_resolve() | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| if event.event == "SessionStart": | ||
| continue | ||
|
|
@@ -1686,6 +1776,16 @@ def produced_work() -> bool: | |
| # Latched after the subagent filter above, which rejects a stop that | ||
| # is not the main session's turn-end. Never cleared. | ||
| stop_seen = True | ||
| if resolvable and not unresolved_crumbed and not _is_regular_file(transcript_path): | ||
| # Not gated on session_id: a Stop whose payloads all came | ||
| # through as `{}` has none, and that miss must show too. | ||
| unresolved_crumbed = True | ||
| self._note_lifecycle( | ||
| handle.task_id, | ||
| "transcript-unresolved", | ||
| session_id=session_id, | ||
| hook_transcript_path=hook_transcript_path, | ||
| ) | ||
| result_json = self._result_json(handle, spec, wait=True) | ||
| if result_json is not None: | ||
| if transcript_path: | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.