diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f08c18d..4025140c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,10 @@ breaking changes may land in a minor release. start whose effort is not one of the model's variants (#847). - Write each OpenCode 2.x provider-error event to `logs/.provider-errors.log` and classify rate-limit, quota and transport outages there as environment faults (#847). +- Add the `subagent_transcript_dir` CLI profile field: a subagent transcript tree whose + writes count as session activity for the idle notice (#858). +- Name the tool call an idle Claude session is waiting on: `session-idle` carries + `in_tool`, and the TUI shows `· idle · in ` (#858). ### Changed @@ -102,6 +106,8 @@ breaking changes may land in a minor release. (DW-538): the stale admin entry is now pruned; a prune that fails is reported. - Make the test suite pass on WSL2: skip the pidfd reap tests when the Python build has no `os.pidfd_open`, and spawn `sys.executable` instead of a bare `python` (#883). +- Count Claude subagent and workflow-agent transcript writes as session activity, so a + session waiting on a foreground subagent no longer journals `session-idle` (#858). ### Security diff --git a/docs/adapter-authoring-guide.md b/docs/adapter-authoring-guide.md index 4ad680d5..56ec79f4 100644 --- a/docs/adapter-authoring-guide.md +++ b/docs/adapter-authoring-guide.md @@ -302,33 +302,37 @@ a new `name` extends the set. The aliases `claude-code-tmux` and `opencode` reso ### `CLIProfile` -| Field | Required | Default | Meaning | -| ---------------------------------- | -------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `name` | ✅ | — | Profile id: the `--cli` value, the policy `[adapter] name`, and the override key. | -| `binary` | ✅ | — | Executable resolved on `PATH`. `validate` runs `--version` only on binary names a packaged profile declares (`adapter.binary-unrunnable`). | -| `[hooks]` | ✅ | — | The [`HookSpec`](#hookspec-the-hooks-table) table. | -| `adapter` | | `generic` (`opencode-http` if `dialect=none`) | Adapter kind, resolved against the live [registry](#shipping-a-new-adapter-class-out-of-tree) at run start and by `validate` (`adapter.kind`). `generic` with `dialect = "none"` is refused. | -| `skill_tree` | | `.claude/skills` | Project-relative tree the CLI reads skills from; `init` installs the `bmad-loop-*` skills here. Must stay inside the project. | -| `prompt_template` | | `{prompt}` | Renders the canonical `/skill args` prompt. Placeholders: `{prompt}` (whole string), `{skill}` (command name, no `/`), `{args}` (the rest). | -| `launch_args` | | `[]` | Argv after `binary` (`["-i"]` keeps gemini/copilot interactive). Under `opencode-http` it precedes `serve`; `validate` warns on unservable shapes. | -| `bypass_args` | | `[]` | Permission-bypass flags for unattended runs. A policy `extra_args` replaces them; `validate` warns (`policy.bypass-dropped`) when one is dropped. | -| `model_flag` | | `--model` | Flag passed with the model name when one is configured. | -| `env` | | `{}` | `[env]` table of extra session environment variables. It cannot override the run's state-root pin. | -| `usage_parser` | | `none` | Transcript token parser: `claude-jsonl`, `codex-rollout`, `gemini-chat`, `copilot-events` or `none`. | -| `usage_grace_s` | | `0.0` | Seconds to keep polling the transcript for token totals after the session ends; `0` reads once. Must be ≥ 0. | -| `stop_without_result_nudges` | | unset | Per-profile default for Stop-without-result nudges. An `[adapter]` / `[adapter.]` value overrides it; unset inherits `[limits] stop_without_result_nudges`. Must be ≥ 0. | -| `subagent_stop_without_transcript` | | `false` | When `true`, a `Stop` with no transcript path is a subagent turn-end and is ignored. | -| `first_run_note` | | `""` | Note `init` prints about a manual first-run or auth step. | -| `seed_files` | | `[]` | Project-relative gitignored files copied into each provisioned worktree. Must stay inside the project. | -| `env_fault_patterns` | | `[]` | Regexes that classify a non-completed session as an environment fault. See [Environment-fault patterns](#environment-fault-patterns). | -| `parked_prompt_patterns` | | `[]` | Regexes matched against the visible pane at each stall-grace expiry. See [Parked-session signals](#parked-session-signals). | -| `[workspace_trust]` | | unset | The [`WorkspaceTrustSpec`](#workspacetrustspec-the-workspace_trust-table) table. Unset: nothing under `~` is read or written. | -| `session_id_flag` | | `""` | Launch flag for a caller-chosen session id (claude: `--session-id`). The generic adapter appends `[flag, uuid4]` and pins attribution to that id. | +| Field | Required | Default | Meaning | +| ---------------------------------- | -------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | ✅ | — | Profile id: the `--cli` value, the policy `[adapter] name`, and the override key. | +| `binary` | ✅ | — | Executable resolved on `PATH`. `validate` runs `--version` only on binary names a packaged profile declares (`adapter.binary-unrunnable`). | +| `[hooks]` | ✅ | — | The [`HookSpec`](#hookspec-the-hooks-table) table. | +| `adapter` | | `generic` (`opencode-http` if `dialect=none`) | Adapter kind, resolved against the live [registry](#shipping-a-new-adapter-class-out-of-tree) at run start and by `validate` (`adapter.kind`). `generic` with `dialect = "none"` is refused. | +| `skill_tree` | | `.claude/skills` | Project-relative tree the CLI reads skills from; `init` installs the `bmad-loop-*` skills here. Must stay inside the project. | +| `prompt_template` | | `{prompt}` | Renders the canonical `/skill args` prompt. Placeholders: `{prompt}` (whole string), `{skill}` (command name, no `/`), `{args}` (the rest). | +| `launch_args` | | `[]` | Argv after `binary` (`["-i"]` keeps gemini/copilot interactive). Under `opencode-http` it precedes `serve`; `validate` warns on unservable shapes. | +| `bypass_args` | | `[]` | Permission-bypass flags for unattended runs. A policy `extra_args` replaces them; `validate` warns (`policy.bypass-dropped`) when one is dropped. | +| `model_flag` | | `--model` | Flag passed with the model name when one is configured. | +| `env` | | `{}` | `[env]` table of extra session environment variables. It cannot override the run's state-root pin. | +| `usage_parser` | | `none` | Transcript token parser: `claude-jsonl`, `codex-rollout`, `gemini-chat`, `copilot-events` or `none`. | +| `usage_grace_s` | | `0.0` | Seconds to keep polling the transcript for token totals after the session ends; `0` reads once. Must be ≥ 0. | +| `stop_without_result_nudges` | | unset | Per-profile default for Stop-without-result nudges. An `[adapter]` / `[adapter.]` value overrides it; unset inherits `[limits] stop_without_result_nudges`. Must be ≥ 0. | +| `subagent_stop_without_transcript` | | `false` | When `true`, a `Stop` with no transcript path is a subagent turn-end and is ignored. | +| `first_run_note` | | `""` | Note `init` prints about a manual first-run or auth step. | +| `seed_files` | | `[]` | Project-relative gitignored files copied into each provisioned worktree. Must stay inside the project. | +| `env_fault_patterns` | | `[]` | Regexes that classify a non-completed session as an environment fault. See [Environment-fault patterns](#environment-fault-patterns). | +| `parked_prompt_patterns` | | `[]` | Regexes matched against the visible pane at each stall-grace expiry. See [Parked-session signals](#parked-session-signals). | +| `[workspace_trust]` | | unset | The [`WorkspaceTrustSpec`](#workspacetrustspec-the-workspace_trust-table) table. Unset: nothing under `~` is read or written. | +| `session_id_flag` | | `""` | Launch flag for a caller-chosen session id (claude: `--session-id`). The generic adapter appends `[flag, uuid4]` and pins attribution to that id. | +| `subagent_transcript_dir` | | derived from `usage_parser` (`{stem}/subagents` for `claude-jsonl`, else `""`) | Subagent transcript tree beside the transcript, walked recursively and stat-only; its writes count as idle-notice activity. Must contain `{stem}` (the transcript's filename stem) and stay relative; `""` = off. | `session_id_flag` must be one `-`-prefixed token with no whitespace or `=`, and is refused on hookless profiles. Set it only when the CLI's `SessionStart` payload reports the id you passed. +An overlay that changes `usage_parser` away from `claude-jsonl` must set +`subagent_transcript_dir` itself; it is refused on hookless profiles. + ### `HookSpec` (the `[hooks]` table) | Field | Required | Default | Meaning | diff --git a/docs/tui-guide.md b/docs/tui-guide.md index 81e69ba7..9873d563 100644 --- a/docs/tui-guide.md +++ b/docs/tui-guide.md @@ -176,8 +176,10 @@ The **agent line** names who is driving. While a session is open it reads `agent · · ` (`model` omitted when the CLI profile's default is used; `role` is the session's stage, such as `dev`, `review`, `triage` or `retro`), with a yellow -`· idle ` once the session's transcript has been quiet past -`limits.dev_stall_grace_s`. Between sessions it shows the run's configured +`· idle ` once the session's transcript (and any subagent transcripts its +profile declares) has been quiet past +`limits.dev_stall_grace_s`, followed by `· in ` when the session is +waiting on a tool call (`unknown` if the transcript could not be read). Between sessions it shows the run's configured adapters from its policy snapshot. A run with no snapshot shows no agent line; an unreadable session identity shows `agent unreadable — `. diff --git a/src/bmad_loop/adapters/generic.py b/src/bmad_loop/adapters/generic.py index b61c685c..5461bd13 100644 --- a/src/bmad_loop/adapters/generic.py +++ b/src/bmad_loop/adapters/generic.py @@ -8,7 +8,8 @@ (mtime/size, never the bytes — see ``_log_activity_key``) is read as a liveness signal to re-arm the dev-stall grace window and, on a separate timeline, as the #727 no-work verdict (``SessionResult.produced_work`` — see ``_work_verdict``); -the live transcript's growth is likewise stat'ed, never parsed, for the #680 +the live transcript's growth — and its profile's subagent transcript tree's +(``subagent_transcript_dir``) — is likewise stat'ed, never parsed, for the #680 idle notice (``_sample_transcript_idle``). The one exception is post-mortem: after the verdict and reconcile have settled, a single tail read of the log classifies a transport-failure environment fault (#194, see @@ -150,7 +151,17 @@ class _IdleTracker: `heartbeat.json` reports), None until the first sample. `open_since` is the wall time the currently open idle stretch began — the `since_ts` its `session-idle` carried — or None between stretches: the latch that makes the - pair one-per-stretch.""" + pair one-per-stretch. + + `last_key` stays the PARENT transcript's key alone even when the profile + declares a subagent tree: the wait loop's `sample_transcript` reads + `last_key[1]` as the parent's byte size — the #727 growth test and the scan + offset for `_transcript_has_assistant_activity` — so a folded key would credit + subagent bytes as parent growth and scan from a wrong offset. The tree gets + its own `sub_key`, OR-ed only into the idle clock's change test. + + `tool_scan_failures` is the streak of `session-idle` `in_tool` reads that + raised; it never moves the clock, only the `tool-scan-*` crumbs.""" # The transcript path the samples below belong to. A later hook event that # names a DIFFERENT path rebaselines the tracker (`_sample_transcript_idle`): @@ -168,6 +179,19 @@ class _IdleTracker: # created). The next successful sample is a change from absence, not a # pre-existing file's baseline. seen_absent: bool = False + # The subagent tree's (count, total_bytes, newest_mtime_ns) at the last clean + # walk; None = not walked on this path yet, or the profile declares no tree. + # Reset on a re-point; kept through a failed walk, so writes made during a + # fault count as movement once the walk recovers. + sub_key: tuple[int, int, int] | None = None + # The running streak of subagent-tree walks that raised: crumbed at its + # transitions (`subagent-scan-failed` / `subagent-scan-recovered`). NOT reset + # on a re-point, so a streak open across one still gets its recovery crumb. + sub_scan_failures: int = 0 + # The running streak of `in_tool` reads that raised (`_idle_tool_label`): + # crumbed at its transitions (`tool-scan-failed` / `tool-scan-recovered`). + # NOT reset on a re-point, for the same reason as `sub_scan_failures`. + tool_scan_failures: int = 0 # min spacing between heartbeat.json overwrites in wait_for_completion; the @@ -310,6 +334,95 @@ def _is_regular_file(path: str | None) -> bool: return False +def _subagent_tree_files(root: Path) -> list[tuple[Path, os.stat_result]]: + """Every subagent transcript (``*.jsonl``) under ``root``, recursively, with + its stat — the stat-only walk behind the idle notice's subagent fold. + + Recursive because workflow agents nest under ``workflows/wf_/`` (the + reason ``tokens.tally`` rglobs the same anchor). Symlinks are neither + descended nor counted; each file is ``os.stat``'ed by path (a cached + ``DirEntry.stat()`` can be stale on Windows) and never opened, so a FIFO + cannot block the wait loop. ``FileNotFoundError``/``NotADirectoryError`` — + a root or entry that is not there, or not a directory — are skipped, so a + missing tree is empty; every other ``OSError`` propagates for the caller to + crumb. Uncapped: a cap would silently truncate the signal.""" + files: list[tuple[Path, os.stat_result]] = [] + stack = [root] + while stack: + current = stack.pop() + try: + with os.scandir(current) as it: + entries = list(it) + except (FileNotFoundError, NotADirectoryError): + continue + for entry in entries: + if entry.is_dir(follow_symlinks=False): + stack.append(Path(entry.path)) + elif entry.name.endswith(".jsonl") and entry.is_file(follow_symlinks=False): + try: + st = os.stat(entry.path) + except (FileNotFoundError, NotADirectoryError): + continue + files.append((Path(entry.path), st)) + return files + + +def _subagent_tree_key(root: Path) -> tuple[int, int, int]: + """Activity signature of a subagent transcript tree: (count, total_bytes, + newest_mtime_ns) over `_subagent_tree_files`; (0, 0, 0) when it is absent. An + aggregate rather than the newest file's key, so an append to a non-newest + file inside the same mtime tick still moves `total_bytes`.""" + files = _subagent_tree_files(root) + return ( + len(files), + sum(st.st_size for _, st in files), + max((st.st_mtime_ns for _, st in files), default=0), + ) + + +# The tool names `session-idle`'s `in_tool` may carry verbatim: Claude Code's +# built-ins. A closed slug chosen by code, so the journal never carries +# transcript text or MCP server names — `_idle_tool_slug` maps every other name +# to "mcp" or "other". +_IDLE_TOOL_SLUGS = frozenset( + { + "Bash", + "PowerShell", + "Agent", + "Task", + "Workflow", + "SendMessage", + "TaskOutput", + "TaskStop", + "Monitor", + "AskUserQuestion", + "ExitPlanMode", + "EnterPlanMode", + "WebFetch", + "WebSearch", + "Read", + "Write", + "Edit", + "NotebookEdit", + "Grep", + "Glob", + "Skill", + "ToolSearch", + } +) + + +def _idle_tool_slug(name: str) -> str: + """The closed `in_tool` slug for a transcript's tool name: a built-in in + `_IDLE_TOOL_SLUGS` passes through, an `mcp____` name is "mcp" + (the server name stays out of the journal), anything else is "other".""" + if name in _IDLE_TOOL_SLUGS: + return name + if name.startswith("mcp__"): + return "mcp" + return "other" + + class _ResultFileMixin: """Result-file read-back and verdict finalization: acquire the skill-written result dict and fold it into the session's final @@ -536,6 +649,70 @@ def _transcript_has_assistant_activity(transcript_path: str, since_size: int) -> return True return False + @staticmethod + def _open_tool_call(paths: list[Path]) -> str | None: + """The raw name of the newest unanswered Claude tool call across + `paths`, or None when every call has its result. + + A `tool_use` content item opens a call under its `id`; a `tool_result` + whose `tool_use_id` names it closes it in EVERY file, whatever the read + order: a forked subagent's transcript copies the parent's still-open + `Agent` call under the same id, and the parent's later result must close + that copy too. Of the calls still open, the one + whose entry carries the newest `timestamp` wins, across ALL files + (ISO-8601 strings compare chronologically; a missing one sorts oldest), so + a parent's open `Agent` loses to its subagent's later open `Bash`: the + innermost wait. Content-reading by design, unlike the stat-only idle + clock — only `_idle_tool_label` calls it, once per opened stretch. + Tolerant like the usage parsers: a torn, undecodable or non-dict line is + skipped. A path that is not a regular file is skipped (a FIFO would + block the wait loop): `is_file` filters it before the open, and the + non-blocking open is re-checked with `fstat`, so one swapped in between + is skipped too. An ``OSError`` propagates for the caller to crumb.""" + calls: dict[str, tuple[str, str]] = {} + closed: set[str] = set() + for path in paths: + if not path.is_file(): + continue + fd = os.open(path, os.O_RDONLY | getattr(os, "O_NONBLOCK", 0)) + with os.fdopen(fd, "rb") as stream: + if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode): + continue + while line := stream.readline(): + try: + entry = json.loads(line) + except (json.JSONDecodeError, UnicodeDecodeError): + continue + if not isinstance(entry, dict): + continue + message = entry.get("message") + content = message.get("content") if isinstance(message, dict) else None + if not isinstance(content, list): + continue + timestamp = entry.get("timestamp") + if not isinstance(timestamp, str): + timestamp = "" + for item in content: + if not isinstance(item, dict): + continue + if item.get("type") == "tool_use": + call_id = item.get("id") + name = item.get("name") + if isinstance(call_id, str): + calls[call_id] = ( + name if isinstance(name, str) else "", + timestamp, + ) + elif item.get("type") == "tool_result": + call_id = item.get("tool_use_id") + if isinstance(call_id, str): + closed.add(call_id) + open_calls = {call_id: call for call_id, call in calls.items() if call_id not in closed} + if not open_calls: + return None + name, _ = max(open_calls.values(), key=lambda call: call[1]) + return name + def _session_vanished(self, task_id: str) -> bool: """Whether the whole multiplexer session is gone, asked only once a crash verdict has already been reached (#489). Base: False — an adapter @@ -1124,9 +1301,10 @@ def sample_frame() -> None: activity_seen = True frame_key = tick_key - # Idle detection (#680): the live transcript's (mtime_ns, size), sampled on - # the heartbeat cadence from the first tick that knows `transcript_path` - # — see `_sample_transcript_idle`. Observes only: nothing here nudges, + # Idle detection (#680): the live transcript's (mtime_ns, size) — plus the + # profile's subagent transcript tree, whose writes also count as activity + # — sampled on the heartbeat cadence from the first tick that knows + # `transcript_path`; see `_sample_transcript_idle`. Observes only: nothing here nudges, # stalls or kills (#680 item 2 stays open), and `stall_deadline` is # neither consulted nor touched. idle = _IdleTracker() @@ -1312,8 +1490,9 @@ def produced_work() -> bool: "stall_nudges_sent": stall_nudges_sent, # stall nudges whose send raised (DW-449) "stall_nudges_failed": stall_nudges_failed, - # seconds since the live transcript last changed (#680); - # null until a hook event has named the transcript. + # seconds since the live transcript (or its profile's + # subagent tree) last changed (#680); null until a hook + # event has named the transcript. "transcript_idle_s": idle.idle_s, # the running liveness-probe failure streak (DW-447) "probe_failures": probe_failures, @@ -1901,13 +2080,91 @@ def _transcript_activity_key(transcript_path: str) -> tuple[int, int] | None: log also grows for a spinner repaint. Deliberately never parsed: `_sample_weighted_usage` yields no sample for `usage_parser = "none"`, and idle detection has to work for that profile too. None is "no sample", never - "idle" — the caller skips the tick.""" + "idle" — the caller skips the tick. Parent-only by design: the subagent + tree is keyed separately (`_sample_subagent_tree`), because the wait + loop reads this key's size as the #727 scan offset.""" try: st = Path(transcript_path).stat() except OSError: return None return (st.st_mtime_ns, st.st_size) + def _subagent_root(self, transcript_path: str) -> Path | None: + """The subagent transcript tree the profile declares beside + `transcript_path` (`CLIProfile.subagent_transcript_root`, "{stem}" = the + transcript's filename stem), or None when the profile declares none or the + transcript is not a regular file — a FIFO or directory has no tree.""" + template = self.profile.subagent_transcript_root + if not template or not _is_regular_file(transcript_path): + return None + path = Path(transcript_path) + return path.parent / template.format(stem=path.stem) + + def _sample_subagent_tree( + self, task_id: str, transcript_path: str, idle: _IdleTracker + ) -> tuple[int, int, int] | None: + """The subagent tree's `_subagent_tree_key` this tick, or None when there + is no tree to watch or the walk raised. A raising walk degrades the tick + to parent-only (the pre-subagent behaviour) and is crumbed at the streak's + transitions, as the wait loop's transcript scan is: + `subagent-scan-failed` (`path`, `error`) at failure 1, + `subagent-scan-recovered` (`failures`) at the next clean walk.""" + root = self._subagent_root(transcript_path) + if root is None: + return None + try: + key = _subagent_tree_key(root) + except OSError as e: + idle.sub_scan_failures += 1 + if idle.sub_scan_failures == 1: + self._note_lifecycle( + task_id, + "subagent-scan-failed", + path=str(root), + error=f"{type(e).__name__}: {e}", + ) + return None + if idle.sub_scan_failures: + self._note_lifecycle( + task_id, "subagent-scan-recovered", failures=idle.sub_scan_failures + ) + idle.sub_scan_failures = 0 + return key + + def _idle_tool_label( + self, task_id: str, transcript_path: str, idle: _IdleTracker + ) -> str | None: + """The `in_tool` slug a `session-idle` about to open carries: what the + silence is waiting on, read once per stretch. A silent transcript holds + still, so the state read here holds for the whole stretch — a + `tool_result` landing moves the file and closes it. + + None (the field is omitted) unless the profile parses `claude-jsonl`: + the tool_use/tool_result shape is Claude's, so an absent field means + "this CLI cannot tell". Otherwise `_idle_tool_slug` of the newest open + call across the parent and its subagent tree (`_open_tool_call`), or + "none" when nothing is open. A read or tree-walk fault is "unknown" — + never folded into "none" — crumbed at the streak's transitions: + `tool-scan-failed` (`error`) at failure 1, `tool-scan-recovered` + (`failures`) at the next clean read.""" + if self.profile.usage_parser != "claude-jsonl": + return None + try: + paths = [Path(transcript_path)] + root = self._subagent_root(transcript_path) + if root is not None: + paths.extend(path for path, _ in _subagent_tree_files(root)) + name = self._open_tool_call(paths) + except OSError as e: + idle.tool_scan_failures += 1 + if idle.tool_scan_failures == 1: + self._note_lifecycle(task_id, "tool-scan-failed", error=f"{type(e).__name__}: {e}") + return "unknown" + if idle.tool_scan_failures: + self._note_lifecycle(task_id, "tool-scan-recovered", failures=idle.tool_scan_failures) + idle.tool_scan_failures = 0 + return "none" if name is None else _idle_tool_slug(name) + def _sample_transcript_idle( self, task_id: str, transcript_path: str, idle: _IdleTracker, now: float ) -> None: @@ -1929,7 +2186,23 @@ def _sample_transcript_idle( attached (`resolve.run_session`, `probe`, fixtures) means no events; the age is still measured for `heartbeat.json`. Every write is best-effort — an unwritable journal must not end a session that is, by this very - evidence, alive.""" + evidence, alive. + + The profile's subagent transcript tree (`_sample_subagent_tree`) counts + too: a parent blocked on a foreground subagent writes nothing to its own + transcript, so a tree that moved since the last clean walk is a change + like a parent key that moved. Its first walk on a path is a baseline. A + walk that raises degrades the tick to parent-only, crumbed as + `subagent-scan-failed` / `subagent-scan-recovered`; `idle.last_key` stays + the parent's key either way. + + On a `claude-jsonl` profile, `session-idle` also carries `in_tool` + (`_idle_tool_label`), read once as the stretch opens: the closed slug of + the unanswered tool call the silence is waiting on — a Claude built-in + name, "mcp", "other", "none" (nothing open) or "unknown" (the read + raised, crumbed as `tool-scan-failed` / `tool-scan-recovered`). It + labels, never suppresses: a running tool and a hung one leave identical + transcripts. Other parsers omit the field.""" def flush_active() -> None: if idle.pending_active_s is None or self.journal is None: @@ -1954,13 +2227,18 @@ def flush_active() -> None: idle.idle_s = None idle.open_since = None idle.seen_absent = False + idle.sub_key = None key = self._transcript_activity_key(transcript_path) flush_active() if key is None: if idle.last_key is None: idle.seen_absent = True return - if key != idle.last_key: + sub = self._sample_subagent_tree(task_id, transcript_path, idle) + sub_moved = sub is not None and idle.sub_key is not None and sub != idle.sub_key + if sub is not None: + idle.sub_key = sub + if key != idle.last_key or sub_moved: if idle.open_since is not None: idle.pending_active_s = round(now - idle.last_change_mono, 3) flush_active() @@ -1976,6 +2254,8 @@ def flush_active() -> None: and self._idle_threshold_s > 0 and idle.idle_s >= self._idle_threshold_s ): + label = self._idle_tool_label(task_id, transcript_path, idle) + extra = {} if label is None else {"in_tool": label} try: self.journal.append( "session-idle", @@ -1983,6 +2263,7 @@ def flush_active() -> None: idle_s=idle.idle_s, since_ts=idle.last_change_wall, threshold_s=self._idle_threshold_s, + **extra, ) except OSError: pass diff --git a/src/bmad_loop/adapters/profile.py b/src/bmad_loop/adapters/profile.py index a05518f9..0024b1e6 100644 --- a/src/bmad_loop/adapters/profile.py +++ b/src/bmad_loop/adapters/profile.py @@ -33,6 +33,7 @@ from __future__ import annotations import importlib.metadata +import string import tomllib from dataclasses import dataclass, field, replace from importlib import resources @@ -51,6 +52,11 @@ from .entrypoints import record_load_error USAGE_PARSERS = {"claude-jsonl", "codex-rollout", "gemini-chat", "copilot-events", "none"} +# The subagent transcript tree a usage parser's CLI writes beside its session +# transcript, for profiles that leave `subagent_transcript_dir` unset. Mirrors +# the subagent anchor tokens.tally already sums (`/subagents`, walked +# recursively); "{stem}" is the transcript's filename stem. +SUBAGENT_TRANSCRIPT_DIR_DEFAULTS = {"claude-jsonl": "{stem}/subagents"} HOOK_DIALECTS = { "claude-settings-json", "codex-hooks-json", @@ -252,6 +258,13 @@ class CLIProfile: # An argv flag, so it is part of runsetup.config_digest's launch surface. # APPENDED after workspace_trust for the same positional-compatibility reason. session_id_flag: str = "" + # Template, relative to the transcript's directory, of the subagent transcript + # tree the CLI writes beside the session transcript ("{stem}" = the + # transcript's filename stem). The idle notice stat-walks it recursively and + # counts its writes as session activity — never read, never usage. None = + # derive from usage_parser (SUBAGENT_TRANSCRIPT_DIR_DEFAULTS); "" = off. + # APPENDED after session_id_flag for the same positional-compatibility reason. + subagent_transcript_dir: str | None = None @property def hookless(self) -> bool: @@ -260,6 +273,14 @@ def hookless(self) -> bool: validate, or git-exclude.""" return self.hooks.dialect == "none" + @property + def subagent_transcript_root(self) -> str: + """The effective subagent-tree template: the declared + `subagent_transcript_dir`, else the usage parser's default, else "" (off).""" + if self.subagent_transcript_dir is not None: + return self.subagent_transcript_dir + return SUBAGENT_TRANSCRIPT_DIR_DEFAULTS.get(self.usage_parser, "") + def render_prompt(self, prompt: str) -> str: """Render the engine's canonical "/skill args" prompt for this CLI. @@ -449,6 +470,39 @@ def fail(msg: str) -> ProfileError: "it pins hook-event attribution, and a hookless profile has no hook events" ) + # Only an explicit non-empty template is checked: None derives from a known + # usage_parser, "" is off. "{stem}" is required — a tree shared by sibling + # sessions would let another session's writes mask this one's idleness — and + # the rendered path must stay under the transcript's directory. + subdir = profile.subagent_transcript_dir + if subdir: + # TOML accepts a "\u0000" escape; the rendered path would make the + # tree walk raise ValueError, not the OSError it degrades. + if "\0" in subdir: + raise fail(f"subagent_transcript_dir must not contain a NUL character: got {subdir!r}") + try: + parts = list(string.Formatter().parse(subdir)) + except ValueError as e: + raise fail(f"subagent_transcript_dir is not a valid template: {e}: got {subdir!r}") + fields = [(name, spec, conv) for _, name, spec, conv in parts if name is not None] + if not fields or any(name != "stem" or spec or conv for name, spec, conv in fields): + raise fail( + 'subagent_transcript_dir must use exactly the placeholder "{stem}" (the ' + "transcript's filename stem), with no other field, format spec or " + f"conversion: got {subdir!r}" + ) + if is_absolute_path(subdir) or has_parent_ref(subdir): + raise fail( + "subagent_transcript_dir must be a relative path under the transcript's " + f"directory, with no '..': got {subdir!r}" + ) + if profile.hookless: + raise fail( + 'hookless profiles (dialect = "none") must not set subagent_transcript_dir: ' + "it extends the transcript idle notice, and a hookless profile has no " + "transcript path" + ) + if profile.usage_parser not in USAGE_PARSERS: raise fail( f"usage_parser must be one of {sorted(USAGE_PARSERS)}: got {profile.usage_parser!r}" @@ -621,6 +675,14 @@ def str_list(key: str) -> tuple[str, ...]: if not isinstance(raw_session_id_flag, str): raise fail(f"session_id_flag must be a string: got {type(raw_session_id_flag).__name__}") + # None (absent) is meaningful — it derives the default from usage_parser — so + # it is kept, and anything present must already be a string. + raw_subagent_dir = doc.get("subagent_transcript_dir") + if raw_subagent_dir is not None and not isinstance(raw_subagent_dir, str): + raise fail( + f"subagent_transcript_dir must be a string: got {type(raw_subagent_dir).__name__}" + ) + profile = CLIProfile( name=str(doc.get("name", "")).strip(), binary=str(doc.get("binary", "")).strip(), @@ -652,6 +714,7 @@ def str_list(key: str) -> tuple[str, ...]: env_fault_patterns=str_list("env_fault_patterns"), parked_prompt_patterns=str_list("parked_prompt_patterns"), workspace_trust=workspace_trust, + subagent_transcript_dir=raw_subagent_dir, ) _validate_profile(profile, source) return profile diff --git a/src/bmad_loop/policy.py b/src/bmad_loop/policy.py index 671344af..49aff59f 100644 --- a/src/bmad_loop/policy.py +++ b/src/bmad_loop/policy.py @@ -116,7 +116,8 @@ class LimitsPolicy: # Stop/idle evidence, so productive work keeps extending it. Bounded by # session_timeout_min. Also the transcript-idle notice threshold (#680): the # journal's session-idle/session-active pair and the TUI's idle age fire when - # the live transcript sits still this long. 0 disables the launch timer (and + # the live transcript (and its profile's subagent transcript tree) sits still + # this long. 0 disables the launch timer (and # the idle notice) while retaining fail-fast handling when a turn ends without # a terminal spec/result. dev_stall_grace_s: int = 600 diff --git a/src/bmad_loop/tui/data.py b/src/bmad_loop/tui/data.py index bd498d55..0ba7ff45 100644 --- a/src/bmad_loop/tui/data.py +++ b/src/bmad_loop/tui/data.py @@ -724,6 +724,26 @@ def _idle_since(journal_entries: list[dict[str, Any]], start: int, task_id: str) return since +def _idle_tool(journal_entries: list[dict[str, Any]], start: int, task_id: str) -> str | None: + """The tool call the open session's current idle stretch is waiting on, or + None: the string `in_tool` of the last `session-idle` for `task_id` after + index `start` — a closed slug the adapter chose — unless a later + `session-active` for the same task closed the stretch. None too when that + `session-idle` carries no `in_tool` (a CLI that cannot tell) or a non-string + one. `_idle_since`'s walk; never raises on a malformed entry.""" + tool: str | None = None + for entry in journal_entries[start + 1 :]: + if str(entry.get("task_id")) != task_id: + continue + kind = entry.get("kind") + if kind == "session-idle": + raw = entry.get("in_tool") + tool = raw if isinstance(raw, str) else None + elif kind == "session-active": + tool = None + return tool + + def active_task_id(run_dir: Path, journal_entries: list[dict[str, Any]]) -> str | None: """Task whose agent session is currently open: the last session-start without a later session-end. Falls back to the newest file in logs/ — @@ -775,6 +795,12 @@ class ActiveAgent: # while the transcript is moving. The header renders `· idle ` from it # only when set. APPENDED, so every positional construction stays valid. idle_since: float | None = None + # The tool call that open idle stretch is waiting on — its `session-idle`'s + # `in_tool` slug (a Claude built-in name, `mcp`, `other`, `none`, `unknown`) + # — or None when no stretch is open or the CLI cannot tell. The header + # appends `· in ` from it unless it is `none`. APPENDED, like + # `idle_since`. + idle_tool: str | None = None def _story_key_from_task_id(task_id: str, role: str) -> str: @@ -837,6 +863,7 @@ def active_agent( name=name, model=model, idle_since=_idle_since(journal_entries, start_index, task_id), + idle_tool=_idle_tool(journal_entries, start_index, task_id), ) except _MALFORMED_AGENT_FAULTS as e: return UnreadableAgent(error=f"malformed session-start or snapshot: {_describe(e)}") diff --git a/src/bmad_loop/tui/widgets.py b/src/bmad_loop/tui/widgets.py index 7ae8358e..22aac4fe 100644 --- a/src/bmad_loop/tui/widgets.py +++ b/src/bmad_loop/tui/widgets.py @@ -231,10 +231,15 @@ def show_run( # pane may still be repainting a spinner, so this is the one # surface that separates a session working from one parked in a # tool call. Yellow, not red — it is a notice, not a verdict, and - # nothing bounds the stretch. + # nothing bounds the stretch. `· in ` names the unanswered + # tool call the silence is waiting on (a running tool and a hung + # one look alike, so it says which to suspect); `none` — the turn + # is over — adds nothing, and `unknown` means the read failed. text.append( f" · idle {_format_age(time.time() - agent.idle_since)}", style="yellow" ) + if agent.idle_tool and agent.idle_tool != "none": + text.append(f" · in {agent.idle_tool}", style="yellow") else: # No session open: show the configured adapters from the run's policy # snapshot. Skip the line entirely when the snapshot can't be rebuilt diff --git a/tests/test_generic_tmux.py b/tests/test_generic_tmux.py index 2457ff90..c891d61f 100644 --- a/tests/test_generic_tmux.py +++ b/tests/test_generic_tmux.py @@ -10285,6 +10285,7 @@ def script(call_n): "idle_s": 60.0, "since_ts": 5000.0, # wall time the transcript was first named (mono 1000) "threshold_s": 60.0, + "in_tool": "none", # claude-jsonl: the transcript holds no open tool call } assert active == {"kind": "session-active", "task_id": "3-1-dev-1", "idle_s": 150.0} assert second["since_ts"] == 5150.0 # the move at mono 1150 started the new stretch @@ -10601,6 +10602,821 @@ def append(self, kind, **fields): assert [entry["kind"] for entry in journal.entries] == ["session-idle", "session-active"] +# ------------------------------------------------- idle notice: subagent tree +# A parent blocked on a foreground subagent writes nothing to its own +# transcript; the profile's subagent tree (claude: `/subagents`, walked +# recursively and stat-only) keeps the idle clock moving. Its key is separate +# from `idle.last_key`, which stays the parent's #727 scan offset. + + +def _subagent_file(transcript: Path, rel: str, payload: bytes = b'{"type":"user"}\n') -> Path: + """A subagent transcript at `/subagents/` — the tree + claude's derived `subagent_transcript_dir` names.""" + path = transcript.parent / transcript.stem / "subagents" / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(payload) + return path + + +@pytest.mark.parametrize( + "rel", + ["agent-a.jsonl", "workflows/wf_x/agent-b.jsonl"], + ids=["direct", "workflow"], +) +def test_subagent_writes_keep_a_static_parent_active(tmp_path, monkeypatch, rel): + """The parent transcript never moves while a subagent's transcript grows on + every heartbeat — directly under `subagents/` or nested in a workflow dir: + the session is active throughout, so no `session-idle` and every age after + the naming heartbeat is 0. + + ABLATION: drop `or sub_moved` and both rows open a stretch; stop descending + into subdirectories and the workflow row does.""" + adapter, mux, log, transcript, clock, heartbeats = _idle_adapter(tmp_path, monkeypatch) + agent = _subagent_file(transcript, rel) + + def script(call_n): + if call_n >= 2: + clock["t"] += generic.HEARTBEAT_INTERVAL_S + _grow(log, "\r⠋".encode()) + _grow(agent, b'{"type":"assistant"}\n') + if call_n == 8: + clock["t"] += 10_000.0 + + adapter.watcher = _ScriptedWatcher( + [_session_start("3-1-dev-1", str(transcript))], on_call=script + ) + spec = dataclasses.replace(_dev_spec(tmp_path), timeout_s=5000.0) + result = adapter.wait_for_completion(_dev_handle(), spec) + + assert result.status == "timeout" + assert mux.sent == [] + assert adapter.journal.entries == [] + assert heartbeats[0]["transcript_idle_s"] is None # predates the naming + ages = [hb["transcript_idle_s"] for hb in heartbeats[1:]] + assert len(ages) >= 5 and all(age == 0.0 for age in ages), ages + + +def test_subagent_tree_appearing_mid_run_closes_the_stretch(tmp_path, monkeypatch): + """No tree when the parent goes quiet, so the stretch opens at the grace + crossing; the first subagent then creates the tree, which reads as movement + from an absent tree's (0, 0, 0): one `session-active` carrying the + stretch's full length. + + ABLATION: drop `or sub_moved` and the `session-active` is missing.""" + adapter, _, log, transcript, clock, _ = _idle_adapter(tmp_path, monkeypatch) + agent = None + + def script(call_n): + nonlocal agent + if call_n >= 2: + clock["t"] += generic.HEARTBEAT_INTERVAL_S # ages 30, 60 (crossing), 90 + _grow(log, "\r⠋".encode()) + if call_n == 5: + agent = _subagent_file(transcript, "agent-a.jsonl") # heartbeat 5: 120 s + if call_n > 5: + _grow(agent, b'{"type":"assistant"}\n') # keeps working to the end + if call_n == 7: + clock["t"] += 10_000.0 + + adapter.watcher = _ScriptedWatcher( + [_session_start("3-1-dev-1", str(transcript))], on_call=script + ) + spec = dataclasses.replace(_dev_spec(tmp_path), timeout_s=5000.0) + adapter.wait_for_completion(_dev_handle(), spec) + entries = adapter.journal.entries + assert [e["kind"] for e in entries] == ["session-idle", "session-active"] + assert entries[0]["since_ts"] == 5000.0 + assert entries[1]["idle_s"] == 120.0 # the stretch: naming (mono 1000) to mono 1120 + + +def test_silent_subagent_tree_still_opens_one_idle_stretch(tmp_path, monkeypatch): + """A subagent tree that exists but sits as still as the parent is no + activity: its first walk is a baseline, not a change, so exactly one + `session-idle` opens at the grace crossing. + + ABLATION: count every walk as movement (`sub_moved = sub is not None`) and + no stretch ever opens.""" + adapter, _, log, transcript, clock, _ = _idle_adapter(tmp_path, monkeypatch) + _subagent_file(transcript, "agent-a.jsonl") + _subagent_file(transcript, "workflows/wf_x/agent-b.jsonl") + + def script(call_n): + if call_n >= 2: + clock["t"] += generic.HEARTBEAT_INTERVAL_S + _grow(log, "\r⠋".encode()) + if call_n == 7: + clock["t"] += 10_000.0 + + adapter.watcher = _ScriptedWatcher( + [_session_start("3-1-dev-1", str(transcript))], on_call=script + ) + spec = dataclasses.replace(_dev_spec(tmp_path), timeout_s=5000.0) + adapter.wait_for_completion(_dev_handle(), spec) + entries = adapter.journal.entries + assert [e["kind"] for e in entries] == ["session-idle"] + assert entries[0]["since_ts"] == 5000.0 + + +def _faulting_tree_key(monkeypatch, fail_roots: dict[Path, int]): + """Patch the module-level `_subagent_tree_key` (never `generic.os`, the global + module) to raise PermissionError for the next N walks of each named root.""" + real = generic._subagent_tree_key + calls: list[Path] = [] + + def key(root): + calls.append(root) + if fail_roots.get(root, 0) > 0: + fail_roots[root] -= 1 + raise PermissionError(13, "Permission denied") + return real(root) + + monkeypatch.setattr(generic, "_subagent_tree_key", key) + return calls + + +def test_repointed_transcript_rebaselines_the_subagent_tree(tmp_path, monkeypatch): + """A hook event re-points the transcript to one with its own, different, + static tree, and the walks of that tree fault on the re-pointing tick. The + first clean walk must be the new tree's baseline — never compared with the + OLD tree's key — so the static session reads idle from the re-pointing. + + ABLATION: drop `idle.sub_key = None` from the rebaseline and the recovered + walk differs from the old tree's key, resetting the age to 0.""" + adapter, _, _log, transcript, clock, heartbeats = _idle_adapter( + tmp_path, monkeypatch, journal=False + ) + adapter._stall_grace_s = 0.0 + _subagent_file(transcript, "agent-a.jsonl") + other = tmp_path / "other.jsonl" + other.write_bytes(b'{"type":"user","other":true}\n') + _subagent_file(other, "agent-x.jsonl", b'{"type":"user","a":"longer payload"}\n') + # both walks of the re-pointing tick fault: its hook-event sample and its + # heartbeat sample + calls = _faulting_tree_key(monkeypatch, {tmp_path / "other" / "subagents": 2}) + + def script(call_n): + if call_n >= 2: + clock["t"] += generic.HEARTBEAT_INTERVAL_S + if call_n == 6: + clock["t"] += 10_000.0 + + events = [ + _session_start("3-1-dev-1", str(transcript)), + None, + _session_start("3-1-dev-1", str(other)), # re-pointed on tick 3 + ] + adapter.watcher = _ScriptedWatcher(events, on_call=script) + spec = dataclasses.replace(_dev_spec(tmp_path), timeout_s=5000.0) + result = adapter.wait_for_completion(_dev_handle(), spec) + assert tmp_path / "other" / "subagents" in calls + # the re-pointed file's own clock: 0 at the heartbeat that first sampled it + # (its tree walk faulted), then climbing through the recovered walk + assert [hb["transcript_idle_s"] for hb in heartbeats] == [None, 30.0, 0.0, 30.0, 60.0] + assert (result.status, result.produced_work) == ("timeout", False) + + +def test_subagent_growth_is_not_transcript_work(tmp_path, monkeypatch): + """A growing subagent tree under a static pane and a static parent keeps the + idle clock moving but is not #727 work: the parent's `last_key` never moved, + so the assistant-activity scan never runs and `produced_work` stays False. + + ABLATION: fold the subagent change into `idle.last_key` and the scan runs on + credited bytes.""" + adapter, _, _log, transcript, clock, heartbeats = _idle_adapter( + tmp_path, monkeypatch, journal=False + ) + adapter._stall_grace_s = 0.0 + agent = _subagent_file(transcript, "agent-a.jsonl") + scans: list[tuple[str, int]] = [] + + def scan(path, since_size): + scans.append((path, since_size)) + return True + + adapter._transcript_has_assistant_activity = scan + + def script(call_n): + if call_n >= 2: + clock["t"] += generic.HEARTBEAT_INTERVAL_S + _grow(agent, b'{"type":"assistant"}\n') + if call_n == 6: + clock["t"] += 10_000.0 + + adapter.watcher = _ScriptedWatcher( + [_session_start("3-1-dev-1", str(transcript))], on_call=script + ) + spec = dataclasses.replace(_dev_spec(tmp_path), timeout_s=5000.0) + result = adapter.wait_for_completion(_dev_handle(), spec) + assert all(hb["transcript_idle_s"] == 0.0 for hb in heartbeats[1:]) # active + assert scans == [] + assert (result.status, result.produced_work) == ("timeout", False) + + +def test_subagent_scan_failure_is_crumbed_once_and_degrades_to_the_parent(tmp_path, monkeypatch): + """A tree walk that raises degrades the tick to the parent alone — the + pre-subagent behaviour — so a silent parent still opens its stretch on time. + The streak is crumbed at its transitions only: one `subagent-scan-failed` + (`path`, `error`) at failure 1 and one `subagent-scan-recovered` + (`failures`) at the first clean walk, which is a baseline (the faulted + baseline walk left no key to compare against), not movement. + + ABLATION: swallow the fault into (0, 0, 0) and neither crumb is written; + drop the `idle.sub_key is not None` guard and the recovered walk reads as + movement, moving `since_ts`.""" + adapter, _, log, transcript, clock, _ = _idle_adapter(tmp_path, monkeypatch) + _subagent_file(transcript, "agent-a.jsonl") + root = tmp_path / "transcript" / "subagents" + calls = _faulting_tree_key(monkeypatch, {root: 2}) + + def script(call_n): + if call_n >= 2: + clock["t"] += generic.HEARTBEAT_INTERVAL_S + _grow(log, "\r⠋".encode()) + if call_n == 7: + clock["t"] += 10_000.0 + + adapter.watcher = _ScriptedWatcher( + [_session_start("3-1-dev-1", str(transcript))], on_call=script + ) + spec = dataclasses.replace(_dev_spec(tmp_path), timeout_s=5000.0) + adapter.wait_for_completion(_dev_handle(), spec) + + assert len(calls) > 3 # the streak closed before the session ended + entries = adapter.journal.entries + assert [e["kind"] for e in entries] == ["session-idle"] + assert entries[0]["since_ts"] == 5000.0 + (failed,) = _lifecycle_events(adapter, "subagent-scan-failed") + assert failed["path"] == str(root) + assert failed["error"] == "PermissionError: [Errno 13] Permission denied" + (recovered,) = _lifecycle_events(adapter, "subagent-scan-recovered") + assert recovered["failures"] == 2 + + +def test_subagent_failure_streak_survives_a_repoint(tmp_path, monkeypatch): + """A streak open when a hook event re-points the transcript still gets its + recovery crumb from the new tree's first clean walk: the rebaseline resets + the key, never the streak. + + ABLATION: reset `sub_scan_failures` in the rebaseline and the recovery crumb + is lost.""" + adapter, _, _log, transcript, clock, _ = _idle_adapter(tmp_path, monkeypatch, journal=False) + _subagent_file(transcript, "agent-a.jsonl") + other = tmp_path / "other.jsonl" + other.write_bytes(b'{"type":"user"}\n') + _faulting_tree_key(monkeypatch, {tmp_path / "transcript" / "subagents": 99}) + + def script(call_n): + if call_n >= 2: + clock["t"] += generic.HEARTBEAT_INTERVAL_S + if call_n == 6: + clock["t"] += 10_000.0 + + events = [ + _session_start("3-1-dev-1", str(transcript)), + None, + _session_start("3-1-dev-1", str(other)), # re-pointed on tick 3 + ] + adapter.watcher = _ScriptedWatcher(events, on_call=script) + spec = dataclasses.replace(_dev_spec(tmp_path), timeout_s=5000.0) + adapter.wait_for_completion(_dev_handle(), spec) + (failed,) = _lifecycle_events(adapter, "subagent-scan-failed") + assert failed["path"] == str(tmp_path / "transcript" / "subagents") + (recovered,) = _lifecycle_events(adapter, "subagent-scan-recovered") + assert recovered["failures"] == 2 # ticks 1 and 2 on the first tree + + +@pytest.mark.parametrize("profile_name", ["gemini", "codex", "copilot"]) +def test_subagent_fold_is_off_without_a_declared_layout(tmp_path, monkeypatch, profile_name): + """Profiles whose usage parser derives no tree, and that declare none, keep + the parent-only idle clock: a growing `/subagents/` file beside their + transcript is not watched, so the silent parent opens its stretch, and no + walk ever runs (no `subagent-scan-*` crumb). + + ABLATION: hard-code claude's `{stem}/subagents` root and no stretch opens.""" + adapter, _, log, transcript, clock, _ = _idle_adapter( + tmp_path, monkeypatch, profile_name=profile_name + ) + assert adapter.profile.subagent_transcript_root == "" + agent = _subagent_file(transcript, "agent.jsonl") + + def script(call_n): + if call_n >= 2: + clock["t"] += generic.HEARTBEAT_INTERVAL_S + _grow(log, "\r⠋".encode()) + _grow(agent, b'{"type":"assistant"}\n') + if call_n == 7: + clock["t"] += 10_000.0 + + adapter.watcher = _ScriptedWatcher( + [_session_start("3-1-dev-1", str(transcript))], on_call=script + ) + spec = dataclasses.replace(_dev_spec(tmp_path), timeout_s=5000.0) + adapter.wait_for_completion(_dev_handle(), spec) + assert [e["kind"] for e in adapter.journal.entries] == ["session-idle"] + crumbs = [ln["event"] for ln in _lifecycle_lines(adapter, "3-1-dev-1")] + assert not [c for c in crumbs if c.startswith("subagent-scan-")] + + +def test_declared_subagent_dir_folds_under_usage_parser_none(tmp_path, monkeypatch): + """The field, not the usage parser, gates the fold: a profile that reads no + usage but declares a tree has its writes counted. + + ABLATION: resolve the root from usage_parser alone and the stretch opens.""" + adapter, _, log, transcript, clock, heartbeats = _idle_adapter(tmp_path, monkeypatch) + adapter.profile = dataclasses.replace( + adapter.profile, usage_parser="none", subagent_transcript_dir="{stem}/subagents" + ) + agent = _subagent_file(transcript, "agent-a.jsonl") + + def script(call_n): + if call_n >= 2: + clock["t"] += generic.HEARTBEAT_INTERVAL_S + _grow(log, "\r⠋".encode()) + _grow(agent, b'{"type":"assistant"}\n') + if call_n == 7: + clock["t"] += 10_000.0 + + adapter.watcher = _ScriptedWatcher( + [_session_start("3-1-dev-1", str(transcript))], on_call=script + ) + spec = dataclasses.replace(_dev_spec(tmp_path), timeout_s=5000.0) + adapter.wait_for_completion(_dev_handle(), spec) + assert adapter.journal.entries == [] + assert all(hb["transcript_idle_s"] == 0.0 for hb in heartbeats[1:]) + + +def test_subagent_activity_in_the_final_interval_closes_the_stretch(tmp_path, monkeypatch): + """An open stretch whose subagent tree moves after the last heartbeat, with + the deadline elapsing before another: the exit-time sample walks the tree + too, so the journal reads `session-idle` then `session-active`. + + ABLATION: drop `or sub_moved` and the `session-active` is missing.""" + adapter, _, log, transcript, clock, _ = _idle_adapter(tmp_path, monkeypatch) + agent = _subagent_file(transcript, "agent-a.jsonl") + + def script(call_n): + if call_n >= 2: + _grow(log, "\r⠋".encode()) + if 2 <= call_n <= 4: + clock["t"] += generic.HEARTBEAT_INTERVAL_S # ages 30, 60 (crossing), 90 + if call_n == 5: + clock["t"] += 10.0 # inside the final interval: no heartbeat fires + _grow(agent, b'{"type":"assistant"}\n') + if call_n == 6: + clock["t"] += 10_000.0 + + adapter.watcher = _ScriptedWatcher( + [_session_start("3-1-dev-1", str(transcript))], on_call=script + ) + spec = dataclasses.replace(_dev_spec(tmp_path), timeout_s=5000.0) + result = adapter.wait_for_completion(_dev_handle(), spec) + assert result.status == "timeout" + assert [e["kind"] for e in adapter.journal.entries] == ["session-idle", "session-active"] + + +# `_subagent_tree_key` directly: the stat-only walk's skip/raise rules. + + +def test_subagent_tree_key_of_a_missing_or_file_root_is_empty(tmp_path): + assert generic._subagent_tree_key(tmp_path / "absent" / "subagents") == (0, 0, 0) + not_a_dir = tmp_path / "file" + not_a_dir.write_bytes(b"x") + assert generic._subagent_tree_key(not_a_dir) == (0, 0, 0) + + +def test_subagent_tree_key_counts_only_jsonl_transcripts(tmp_path): + """Recursive, and `*.meta.json` siblings (and any non-jsonl) are not + transcripts.""" + root = tmp_path / "subagents" + (root / "workflows" / "wf_1").mkdir(parents=True) + (root / "agent-a.jsonl").write_bytes(b"12345") + (root / "agent-a.meta.json").write_bytes(b'{"agentType":"x"}') + (root / "workflows" / "wf_1" / "agent-b.jsonl").write_bytes(b"123") + (root / "workflows" / "wf_1" / "notes.txt").write_bytes(b"ignored") + count, total, newest = generic._subagent_tree_key(root) + assert (count, total) == (2, 8) + assert newest == max( + (root / "agent-a.jsonl").stat().st_mtime_ns, + (root / "workflows" / "wf_1" / "agent-b.jsonl").stat().st_mtime_ns, + ) + + +def test_subagent_tree_key_skips_a_file_that_vanishes_mid_walk(tmp_path, monkeypatch): + """A file listed by the walk but gone by its stat (a subagent's transcript + rotated away) is skipped, not raised. + + ABLATION: let FileNotFoundError propagate and this raises.""" + root = tmp_path / "subagents" + root.mkdir() + (root / "a.jsonl").write_bytes(b"12345") + gone = root / "b.jsonl" + gone.write_bytes(b"123") + real_stat = os.stat + + def stat(path, *args, **kwargs): # delegates for every other path + if os.fspath(path) == str(gone): + raise FileNotFoundError(2, "No such file or directory", str(gone)) + return real_stat(path, *args, **kwargs) + + monkeypatch.setattr(generic.os, "stat", stat) + count, total, _ = generic._subagent_tree_key(root) + assert (count, total) == (1, 5) + + +def test_subagent_tree_key_raises_a_non_enoent_fault(tmp_path, monkeypatch): + """Any other OSError is a real fault for the caller to crumb, never folded + into an empty-looking tree.""" + root = tmp_path / "subagents" + root.mkdir() + denied = root / "a.jsonl" + denied.write_bytes(b"12345") + real_stat = os.stat + + def stat(path, *args, **kwargs): + if os.fspath(path) == str(denied): + raise PermissionError(13, "Permission denied", str(denied)) + return real_stat(path, *args, **kwargs) + + monkeypatch.setattr(generic.os, "stat", stat) + with pytest.raises(PermissionError): + generic._subagent_tree_key(root) + + +def test_subagent_tree_key_moves_on_an_append_inside_one_mtime_tick(tmp_path): + """An append to a NON-newest file whose mtime does not advance (a coarse + clock tick) still moves the key: it is an aggregate, not the newest file's + (mtime, size).""" + root = tmp_path / "subagents" + root.mkdir() + older, newer = root / "a.jsonl", root / "b.jsonl" + older.write_bytes(b"1") + newer.write_bytes(b"2") + base_ns = 1_700_000_000_000_000_000 + os.utime(older, ns=(base_ns, base_ns)) + os.utime(newer, ns=(base_ns + 1_000, base_ns + 1_000)) + before = generic._subagent_tree_key(root) + _grow(older, b"more") + os.utime(older, ns=(base_ns, base_ns)) + after = generic._subagent_tree_key(root) + assert before[2] == after[2] == base_ns + 1_000 + assert after != before + + +def test_subagent_tree_key_does_not_follow_a_symlinked_dir(tmp_path): + """A symlink is neither descended nor counted: the tree is the CLI's own + files, never a link out of it.""" + root = tmp_path / "subagents" + root.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + (outside / "x.jsonl").write_bytes(b"12345") + try: + os.symlink(outside, root / "link", target_is_directory=True) + except OSError: + pytest.skip("symlinks unavailable") + assert generic._subagent_tree_key(root) == (0, 0, 0) + + +# `session-idle`'s `in_tool` label: the unanswered tool call a claude-jsonl +# stretch is waiting on, read once as the stretch opens. + + +def _tool_use(call_id: str, name: str, ts: str) -> bytes: + """A claude assistant line opening tool call `call_id`.""" + entry = { + "type": "assistant", + "timestamp": ts, + "message": {"content": [{"type": "tool_use", "id": call_id, "name": name}]}, + } + return json.dumps(entry).encode() + b"\n" + + +def _tool_result(call_id: str) -> bytes: + """A claude user line answering tool call `call_id`.""" + entry = { + "type": "user", + "message": {"content": [{"type": "tool_result", "tool_use_id": call_id}]}, + } + return json.dumps(entry).encode() + b"\n" + + +def _run_silent_stretch(tmp_path, adapter, log, transcript, clock, *, end_call=7): + """Drive one silent stretch: the pane keeps repainting, the transcript (and any + tree) holds still past the 60 s grace, then the deadline ends the loop.""" + + def script(call_n): + if call_n >= 2: + clock["t"] += generic.HEARTBEAT_INTERVAL_S + _grow(log, "\r⠋".encode()) + if call_n == end_call: + clock["t"] += 10_000.0 + + adapter.watcher = _ScriptedWatcher( + [_session_start("3-1-dev-1", str(transcript))], on_call=script + ) + spec = dataclasses.replace(_dev_spec(tmp_path), timeout_s=5000.0) + return adapter.wait_for_completion(_dev_handle(), spec) + + +def test_idle_notice_names_the_open_tool_call(tmp_path, monkeypatch): + """A parent whose last line opens a `Bash` call and then goes silent: the + stretch's `session-idle` names it. + + ABLATION: drop the `in_tool` kwarg from the append and the key is missing.""" + adapter, _, log, transcript, clock, _ = _idle_adapter(tmp_path, monkeypatch) + _grow(transcript, _tool_use("t1", "Bash", "2026-01-01T00:00:01Z")) + _run_silent_stretch(tmp_path, adapter, log, transcript, clock) + (idle,) = adapter.journal.entries + assert idle["kind"] == "session-idle" + assert idle["in_tool"] == "Bash" + + +def test_idle_notice_reports_none_when_no_tool_is_open(tmp_path, monkeypatch): + """Every call answered — the turn is over — reads "none", explicitly, not + an absent field (absent means a CLI that cannot tell). + + ABLATION: return None for an empty open set and the field disappears.""" + adapter, _, log, transcript, clock, _ = _idle_adapter(tmp_path, monkeypatch) + _grow(transcript, _tool_use("t1", "Bash", "2026-01-01T00:00:01Z") + _tool_result("t1")) + _run_silent_stretch(tmp_path, adapter, log, transcript, clock) + (idle,) = adapter.journal.entries + assert idle["in_tool"] == "none" + + +def test_idle_notice_names_the_innermost_wait(tmp_path, monkeypatch): + """The parent waits on an `Agent` call (older), whose subagent waits on a + `Bash` call (newer); both sit silent. The label names the innermost wait — + the newest open call across the parent AND its subagent tree. + + ABLATION: read the parent only and the label is `Agent`.""" + adapter, _, log, transcript, clock, _ = _idle_adapter(tmp_path, monkeypatch) + _grow(transcript, _tool_use("p1", "Agent", "2026-01-01T00:00:01Z")) + _subagent_file( + transcript, + "workflows/wf_x/agent-a.jsonl", + _tool_use("s1", "Read", "2026-01-01T00:00:02Z") + + _tool_result("s1") + + _tool_use("s2", "Bash", "2026-01-01T00:00:03Z"), + ) + _run_silent_stretch(tmp_path, adapter, log, transcript, clock) + (idle,) = adapter.journal.entries + assert idle["in_tool"] == "Bash" + + +@pytest.mark.parametrize( + ("name", "slug"), + [("mcp__srv__x", "mcp"), ("WeirdTool", "other"), ("Bash", "Bash"), ("", "other")], +) +def test_idle_tool_slugs_are_closed(name, slug): + """Only a Claude built-in passes through: an MCP tool collapses to "mcp" + (its server name is operator config, never journaled) and any other name to + "other".""" + assert generic._idle_tool_slug(name) == slug + + +def test_idle_notice_maps_an_mcp_call_to_its_slug(tmp_path, monkeypatch): + """End to end, an open MCP call journals "mcp" — the server name never + reaches the journal.""" + adapter, _, log, transcript, clock, _ = _idle_adapter(tmp_path, monkeypatch) + _grow(transcript, _tool_use("t1", "mcp__secret-server__query", "2026-01-01T00:00:01Z")) + _run_silent_stretch(tmp_path, adapter, log, transcript, clock) + (idle,) = adapter.journal.entries + assert idle["in_tool"] == "mcp" + assert "secret-server" not in json.dumps(idle) + + +@pytest.mark.parametrize("profile_name", ["gemini", "codex", "copilot"]) +def test_idle_notice_omits_in_tool_for_other_parsers(tmp_path, monkeypatch, profile_name): + """Only a `claude-jsonl` profile is labelled: the tool_use/tool_result shape is + claude's, so every other parser omits `in_tool` — even over a transcript that + happens to hold a claude-shaped open call. + + ABLATION: drop the `usage_parser` gate and the field appears ("Bash").""" + adapter, _, log, transcript, clock, _ = _idle_adapter( + tmp_path, monkeypatch, profile_name=profile_name + ) + assert adapter.profile.usage_parser != "claude-jsonl" + _grow(transcript, _tool_use("t1", "Bash", "2026-01-01T00:00:01Z")) + _run_silent_stretch(tmp_path, adapter, log, transcript, clock) + (idle,) = adapter.journal.entries + assert idle["kind"] == "session-idle" + assert "in_tool" not in idle + + +def test_tool_scan_fault_reports_unknown_and_crumbs_once(tmp_path, monkeypatch): + """A raising read labels its stretch "unknown" — never "none", never an + absent field — and the streak is crumbed at its transitions only: one + `tool-scan-failed` (`error`) across two faulted stretches, then one + `tool-scan-recovered` (`failures`) and a real slug on the third. + + ABLATION: fold the fault into "none" and the first two labels read "none".""" + adapter, _, log, transcript, clock, _ = _idle_adapter(tmp_path, monkeypatch) + real = generic.GenericAdapter._open_tool_call + faults = {"left": 2} + + def open_tool_call(paths): + if faults["left"]: + faults["left"] -= 1 + raise PermissionError(13, "Permission denied") + return real(paths) + + # the staticmethod, never `generic.os` (the global module) + monkeypatch.setattr(generic.GenericAdapter, "_open_tool_call", staticmethod(open_tool_call)) + + def script(call_n): + if call_n >= 2: + clock["t"] += generic.HEARTBEAT_INTERVAL_S # stretch crossings at 3, 7, 11 + _grow(log, "\r⠋".encode()) + if call_n == 5: + _grow(transcript, b'{"type":"assistant"}\n') # closes stretch 1 + if call_n == 9: + _grow(transcript, _tool_use("t1", "Bash", "2026-01-01T00:00:01Z")) # closes 2 + if call_n == 12: + clock["t"] += 10_000.0 + + adapter.watcher = _ScriptedWatcher( + [_session_start("3-1-dev-1", str(transcript))], on_call=script + ) + spec = dataclasses.replace(_dev_spec(tmp_path), timeout_s=5000.0) + adapter.wait_for_completion(_dev_handle(), spec) + + idles = [e for e in adapter.journal.entries if e["kind"] == "session-idle"] + assert [e["in_tool"] for e in idles] == ["unknown", "unknown", "Bash"] + (failed,) = _lifecycle_events(adapter, "tool-scan-failed") + assert failed["error"] == "PermissionError: [Errno 13] Permission denied" + (recovered,) = _lifecycle_events(adapter, "tool-scan-recovered") + assert recovered["failures"] == 2 + + +def test_tool_scan_counts_a_subagent_walk_fault(tmp_path, monkeypatch): + """A fault listing the subagent tree is a scan fault too: "unknown", crumbed — + never a parent-only label that could name the wrong (outer) wait. + + ABLATION: catch the walk's fault inside the path listing and the label reads + the parent's "Agent".""" + adapter, _, log, transcript, clock, _ = _idle_adapter(tmp_path, monkeypatch) + _grow(transcript, _tool_use("p1", "Agent", "2026-01-01T00:00:01Z")) + + def tree_files(root): + raise PermissionError(13, "Permission denied") + + monkeypatch.setattr(generic, "_subagent_tree_files", tree_files) + _run_silent_stretch(tmp_path, adapter, log, transcript, clock) + (idle,) = adapter.journal.entries + assert idle["in_tool"] == "unknown" + (failed,) = _lifecycle_events(adapter, "tool-scan-failed") + assert failed["error"] == "PermissionError: [Errno 13] Permission denied" + + +def test_session_active_is_unchanged_by_the_label(tmp_path, monkeypatch): + """The label rides `session-idle` alone: the `session-active` closing a + labelled stretch carries exactly the fields it always did.""" + adapter, _, log, transcript, clock, _ = _idle_adapter(tmp_path, monkeypatch) + _grow(transcript, _tool_use("t1", "Bash", "2026-01-01T00:00:01Z")) + + def script(call_n): + if call_n >= 2: + clock["t"] += generic.HEARTBEAT_INTERVAL_S + _grow(log, "\r⠋".encode()) + if call_n == 5: + _grow(transcript, _tool_result("t1")) # the call returns: stretch closes + if call_n == 6: + clock["t"] += 10_000.0 + + adapter.watcher = _ScriptedWatcher( + [_session_start("3-1-dev-1", str(transcript))], on_call=script + ) + spec = dataclasses.replace(_dev_spec(tmp_path), timeout_s=5000.0) + adapter.wait_for_completion(_dev_handle(), spec) + # the deadline's jump lets the exit-time sample open a later stretch; the + # labelled stretch and its close come first + idle, active = adapter.journal.entries[:2] + assert idle["in_tool"] == "Bash" + assert active == {"kind": "session-active", "task_id": "3-1-dev-1", "idle_s": 120.0} + + +# `_open_tool_call` directly: the tolerant reader's skip rules. + + +def test_open_tool_call_tolerates_torn_and_garbage_lines(tmp_path): + """Undecodable bytes, non-JSON, non-dict JSON, odd `message`/`content` + shapes and a torn final line are skipped; the well-formed lines still + decide. A missing timestamp sorts oldest. + + ABLATION: let a decode error propagate and this raises.""" + path = tmp_path / "t.jsonl" + path.write_bytes( + b"\xff\xfe not utf-8\n" + b"not json\n" + b"[1, 2]\n" + b'{"message": "str"}\n' + b'{"message": {"content": "str"}}\n' + b'{"message": {"content": [7, {"type": "tool_use", "name": "Edit"}]}}\n' + + _tool_use("t1", "Bash", "2026-01-01T00:00:02Z") + + b'{"type":"assistant","message":{"content":[{"type":"tool_use","id":"t0","name":"Grep"}]}}\n' + + _tool_use("t2", "Read", "2026-01-01T00:00:01Z") + + b'{"type":"user","message":{"content":[{"type":"tool_res' + ) + assert generic.GenericAdapter._open_tool_call([path]) == "Bash" + _grow(path, b"\n" + _tool_result("t1")) + assert generic.GenericAdapter._open_tool_call([path]) == "Read" + _grow(path, _tool_result("t2")) + assert generic.GenericAdapter._open_tool_call([path]) == "Grep" # no timestamp: oldest + _grow(path, _tool_result("t0")) + assert generic.GenericAdapter._open_tool_call([path]) is None + assert generic.GenericAdapter._open_tool_call([tmp_path / "absent.jsonl"]) is None + + +def test_open_tool_call_closes_a_forked_copy_from_the_parent(tmp_path): + """A forked subagent's transcript copies the parent's open `Agent` call + under the same id; once the parent records its result, the copy is closed + too, though the child is read after the parent. + + ABLATION: close calls per file in read order and this returns "Agent".""" + parent = tmp_path / "transcript.jsonl" + parent.write_bytes(_tool_use("t1", "Agent", "2026-01-01T00:00:01Z") + _tool_result("t1")) + child = tmp_path / "agent.jsonl" + child.write_bytes(_tool_use("t1", "Agent", "2026-01-01T00:00:01Z")) + assert generic.GenericAdapter._open_tool_call([parent, child]) is None + _grow(child, _tool_use("t2", "Bash", "2026-01-01T00:00:02Z")) + assert generic.GenericAdapter._open_tool_call([parent, child]) == "Bash" + + +@pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="named pipes unavailable") +def test_open_tool_call_skips_a_fifo(tmp_path, monkeypatch): + """A FIFO among the paths is never opened — the open would block the wait + loop — while the regular files still decide. + + ABLATION: remove the regular-file guard and the stubbed open fails.""" + fifo = tmp_path / "agent.jsonl" + os.mkfifo(fifo) + regular = tmp_path / "transcript.jsonl" + regular.write_bytes(_tool_use("t1", "Bash", "2026-01-01T00:00:01Z")) + original_open = os.open + + def guarded_open(path, *args, **kwargs): + if path == fifo: + raise AssertionError("FIFO would block on open") + return original_open(path, *args, **kwargs) + + monkeypatch.setattr(os, "open", guarded_open) + assert generic.GenericAdapter._open_tool_call([regular, fifo]) == "Bash" + + +@pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="named pipes unavailable") +def test_open_tool_call_skips_a_fifo_swapped_in_after_the_check(tmp_path, monkeypatch): + """A FIFO that replaces a transcript between the `is_file` check and the + open neither blocks the open nor gets read: the open is non-blocking and + the opened descriptor is re-checked with `fstat`. + + ABLATION: drop O_NONBLOCK and the open-flags assertion fails; drop the + `fstat` check and the FIFO's buffered "Write" call wins.""" + fifo = tmp_path / "agent.jsonl" + os.mkfifo(fifo) + regular = tmp_path / "transcript.jsonl" + regular.write_bytes(_tool_use("t1", "Bash", "2026-01-01T00:00:01Z")) + original_is_file = Path.is_file + original_open = os.open + + def raced_is_file(path): + return True if path == fifo else original_is_file(path) + + def nonblocking_open(path, flags, *args, **kwargs): + if path == fifo: + assert flags & os.O_NONBLOCK, "FIFO would block on open" + return original_open(path, flags, *args, **kwargs) + + monkeypatch.setattr(Path, "is_file", raced_is_file) + monkeypatch.setattr(os, "open", nonblocking_open) + reader = original_open(fifo, os.O_RDONLY | os.O_NONBLOCK) + writer = original_open(fifo, os.O_WRONLY | os.O_NONBLOCK) + try: + os.write(writer, _tool_use("t2", "Write", "2026-01-01T00:00:02Z")) + assert generic.GenericAdapter._open_tool_call([regular, fifo]) == "Bash" + finally: + os.close(writer) + os.close(reader) + + +def test_open_tool_call_propagates_a_read_fault(tmp_path, monkeypatch): + """A read fault is the caller's to crumb as "unknown": it propagates, never + folds into None ("nothing open"). + + ABLATION: catch OSError in the reader and this returns None.""" + path = tmp_path / "t.jsonl" + path.write_bytes(_tool_use("t1", "Bash", "2026-01-01T00:00:01Z")) + original_open = os.open + + def failing_open(p, *args, **kwargs): + if p == path: + raise PermissionError(13, "Permission denied") + return original_open(p, *args, **kwargs) + + monkeypatch.setattr(os, "open", failing_open) + with pytest.raises(PermissionError): + generic.GenericAdapter._open_tool_call([path]) + + # ------------------------------------ parked-session signals (DW-348/DW-350) # # A CLI parked on a permission / idle / quota prompt looks exactly like a stalled diff --git a/tests/test_portability_guard.py b/tests/test_portability_guard.py index 02c0768f..76bada37 100644 --- a/tests/test_portability_guard.py +++ b/tests/test_portability_guard.py @@ -649,6 +649,12 @@ # `session-idle` / `session-active` (#680): seconds the live transcript has # sat still — a float the adapter measured from two stats, no identifier. "idle_s", + # `session-idle`'s waited-on tool (#858): a closed slug the adapter chose + # from a code-side set (`generic._IDLE_TOOL_SLUGS` — a Claude built-in + # name — or `mcp` | `other` | `none` | `unknown`), never transcript text. + # An MCP tool's name would carry the operator's configured server name, so + # it collapses to `mcp`; any name outside the set ships as `other`. + "in_tool", # `sweep-retro-ingest-unavailable`'s discriminator (DW-388): WHICH input the # retro action-item ingest could not use, as a closed three-value enum # (`sprint-status-unreadable` | `action-items-malformed` | @@ -1199,7 +1205,8 @@ # stretches, one `session-idle` at the crossing of `dev_stall_grace_s` and # one `session-active` when the transcript moves again. Both carry only # `task_id` (routed) and measured floats (`idle_s`, `since_ts`, - # `threshold_s`, declared benign). + # `threshold_s`, declared benign); a `claude-jsonl` `session-idle` adds + # `in_tool`, a closed slug the adapter chose (declared benign). "session-active", "session-idle", # cli.py diff --git a/tests/test_profile.py b/tests/test_profile.py index 64dc0741..c65113f9 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -1,4 +1,5 @@ import dataclasses +import re from importlib import resources from pathlib import Path @@ -500,6 +501,83 @@ def test_hookless_profile_cannot_set_a_session_id_flag(tmp_path): load_profiles(tmp_path) +def test_subagent_transcript_dir_defaults_from_usage_parser(tmp_path): + """Unset derives from usage_parser: claude-jsonl's tree, nothing for the rest.""" + profiles_dir = tmp_path / ".bmad-loop" / "profiles" + profiles_dir.mkdir(parents=True) + (profiles_dir / "mycli.toml").write_text(MINIMAL_PROFILE) + profiles = load_profiles(tmp_path) + assert profiles["claude"].subagent_transcript_dir is None + assert profiles["claude"].subagent_transcript_root == "{stem}/subagents" + assert profiles["mycli"].subagent_transcript_dir is None + assert profiles["mycli"].subagent_transcript_root == "" + (profiles_dir / "mycli.toml").write_text( + MINIMAL_PROFILE.replace("[hooks]", 'subagent_transcript_dir = "{stem}/agents"\n[hooks]') + ) + assert load_profiles(tmp_path)["mycli"].subagent_transcript_root == "{stem}/agents" + + +def test_explicit_empty_subagent_transcript_dir_disables_the_default(tmp_path): + profiles_dir = tmp_path / ".bmad-loop" / "profiles" + profiles_dir.mkdir(parents=True) + (profiles_dir / "mycli.toml").write_text( + MINIMAL_PROFILE.replace( + "[hooks]", 'usage_parser = "claude-jsonl"\nsubagent_transcript_dir = ""\n[hooks]' + ) + ) + mycli = load_profiles(tmp_path)["mycli"] + assert mycli.subagent_transcript_dir == "" + assert mycli.subagent_transcript_root == "" + + +@pytest.mark.parametrize( + ("value", "match"), + [ + # shape: never `str()`-coerced into a path template + ("5", "subagent_transcript_dir must be a string"), + ('["x"]', "subagent_transcript_dir must be a string"), + # value: relative, under the transcript's directory + ('"/abs/{stem}"', "relative path"), + ('"../{stem}"', "relative path"), + # value: exactly the {stem} placeholder, so sibling sessions never share it + ('"subagents"', "{stem}"), + ('"{sid}/x"', "{stem}"), + ('"{stem"', "not a valid template"), + ('"{stem!r}/x"', "{stem}"), + # value: a NUL would crash the tree walk with ValueError, not OSError + ('"{stem}\\u0000/x"', "NUL"), + ], +) +def test_malformed_subagent_transcript_dir_is_a_profile_error(tmp_path, value, match): + profiles_dir = tmp_path / ".bmad-loop" / "profiles" + profiles_dir.mkdir(parents=True) + (profiles_dir / "bad.toml").write_text( + MINIMAL_PROFILE.replace("[hooks]", f"subagent_transcript_dir = {value}\n[hooks]") + ) + with pytest.raises(ProfileError, match=re.escape(match)): + load_profiles(tmp_path) + + +def test_hookless_profile_cannot_set_a_subagent_transcript_dir(tmp_path): + """The tree extends the transcript idle notice, and a hookless profile has no + transcript path.""" + profiles_dir = tmp_path / ".bmad-loop" / "profiles" + profiles_dir.mkdir(parents=True) + (profiles_dir / "bad.toml").write_text( + HOOKLESS_PROFILE.replace("[hooks]", 'subagent_transcript_dir = "{stem}/x"\n[hooks]') + ) + with pytest.raises(ProfileError, match="must not set subagent_transcript_dir"): + load_profiles(tmp_path) + + +def test_claude_subagent_root_matches_the_tally_anchor(tmp_path): + """The idle notice watches the same tree tokens.tally sums for claude.""" + transcript = tmp_path / "x.jsonl" + template = get_profile("claude").subagent_transcript_root + rendered = transcript.parent / template.format(stem=transcript.stem) + assert rendered == transcript.parent / transcript.stem / "subagents" + + def test_render_prompt_passthrough_and_template(): claude = get_profile("claude") assert claude.render_prompt("/bmad-dev-auto 1-1-a") == "/bmad-dev-auto 1-1-a" diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 39b8fe3b..9b067d12 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -7047,6 +7047,52 @@ async def test_header_agent_line_shows_open_idle_stretch(project_tree, monkeypat assert "idle" not in str(header.content) +async def test_header_agent_line_names_the_waited_tool(project_tree, monkeypatch): + """An open stretch's `in_tool` slug follows the age as `· in `; "none" + (the turn is over) and an unlabelled stretch render the age alone, and the + label never appears without an open stretch. + + ABLATION: drop the `!= "none"` test in `show_run` and the "none" row renders + `· in none`; drop the `idle_tool` branch and the first assertion reddens.""" + monkeypatch.setattr(widgets.time, "time", lambda: 10_000.0) + state = RunState( + run_id="r1", + project=str(project_tree.project), + started_at="now", + tasks={"1-1-alpha": StoryTask(story_key="1-1-alpha", epic=1, phase=Phase.DEV_RUNNING)}, + ) + idle = data.ActiveAgent( + task_id="1-1-alpha-dev-1", + story_key="1-1-alpha", + role="dev", + name="claude", + model="opus", + idle_since=10_000.0 - 71 * 60, + ) + app = BmadLoopApp(project_tree.project) + async with app.run_test(): + header = dashboard(app).query_one("#runheader", RunHeader) + header.show_run( + "r1", data.RUNNING, state, agent=dataclasses.replace(idle, idle_tool="Bash") + ) + assert "· idle 1h11m · in Bash" in str(header.content) + + header.show_run( + "r1", data.RUNNING, state, agent=dataclasses.replace(idle, idle_tool="unknown") + ) + assert "· idle 1h11m · in unknown" in str(header.content) + + header.show_run( + "r1", data.RUNNING, state, agent=dataclasses.replace(idle, idle_tool="none") + ) + assert "· idle 1h11m" in str(header.content) + assert "· in" not in str(header.content) + + header.show_run("r1", data.RUNNING, state, agent=idle) # unlabelled CLI + assert "· idle 1h11m" in str(header.content) + assert "· in" not in str(header.content) + + async def test_header_marks_stale_state_unreadable_agent_and_read_faults(project_tree): """DW-472/474/475 at the header: a stale last-good state gets its own warning line, a state never parsed names why, an unreadable agent says so instead of diff --git a/tests/test_tui_data.py b/tests/test_tui_data.py index 853803cf..89120166 100644 --- a/tests/test_tui_data.py +++ b/tests/test_tui_data.py @@ -1074,6 +1074,58 @@ def test_active_agent_idle_since_never_raises_on_malformed_entry(): assert agent is not None and agent.idle_since == 5030.0 +def test_active_agent_idle_tool_from_open_idle_stretch(): + """The open stretch's `session-idle` `in_tool` slug sets `idle_tool`; a + `session-idle` without one (a CLI that cannot tell) and no stretch at all + both leave it None, the default. + + ABLATION: drop the `_idle_tool` derivation and the first assertion reddens.""" + idle = { + "kind": "session-idle", + "task_id": "1-1-alpha-dev-3", + "since_ts": 5030.0, + "in_tool": "Bash", + } + agent = data.active_agent([_stamped_start(), idle], None) + assert agent is not None and agent.idle_tool == "Bash" + unlabelled = {"kind": "session-idle", "task_id": "1-1-alpha-dev-3", "since_ts": 5030.0} + agent = data.active_agent([_stamped_start(), unlabelled], None) + assert agent is not None and agent.idle_tool is None + assert data.active_agent([_stamped_start()], None).idle_tool is None + + +def test_active_agent_idle_tool_cleared_by_session_active(): + """A `session-active` closes the stretch and its label; a later stretch + carries its own, and a later UNLABELLED one never inherits the old slug. + + ABLATION: drop the `session-active` arm of `_idle_tool` and the first + assertion reddens.""" + idle = {"kind": "session-idle", "task_id": "1-1-alpha-dev-3", "in_tool": "Bash"} + active = {"kind": "session-active", "task_id": "1-1-alpha-dev-3", "idle_s": 120.0} + agent = data.active_agent([_stamped_start(), idle, active], None) + assert agent is not None and agent.idle_tool is None + later = {"kind": "session-idle", "task_id": "1-1-alpha-dev-3", "in_tool": "mcp"} + agent = data.active_agent([_stamped_start(), idle, active, later], None) + assert agent is not None and agent.idle_tool == "mcp" + bare = {"kind": "session-idle", "task_id": "1-1-alpha-dev-3", "since_ts": 5150.0} + agent = data.active_agent([_stamped_start(), idle, active, bare], None) + assert agent is not None and agent.idle_tool is None + other = {"kind": "session-idle", "task_id": "2-2-beta-dev-1", "in_tool": "Bash"} + agent = data.active_agent([_stamped_start(), other], None) + assert agent is not None and agent.idle_tool is None + + +def test_active_agent_idle_tool_never_raises_on_malformed_entry(): + """A non-string `in_tool` reads as None — never rendered, never raised — + and the agent itself is still derived. + + ABLATION: pass the raw value through and the non-string rows leak it.""" + for raw in (None, 7, True, ["Bash"], {"name": "Bash"}): + bad = {"kind": "session-idle", "task_id": "1-1-alpha-dev-3", "in_tool": raw} + agent = data.active_agent([_stamped_start(), bad], None) + assert isinstance(agent, data.ActiveAgent) and agent.idle_tool is None + + def test_active_agent_none_without_resolvable_snapshot(): # Unstamped entry + no trustworthy snapshot: an all-"claude" reconstruction # would mislabel a run that predates stamping, so the agent is unknown.