Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,27 @@ breaking changes may land in a minor release.

## [Unreleased]

### Added

- Journal a session's idle stretches (#680). The tmux adapter stats the live transcript
on the heartbeat cadence, stamps `transcript_idle_s` on `heartbeat.json`, and — with
the engine's journal attached (`CodingCLIAdapter.journal`) — writes one `session-idle`
when the age crosses `limits.dev_stall_grace_s` and one `session-active` when the
transcript moves again; `0` disables the pair. The TUI agent line shows the open
stretch as `· idle <age>`. Observability only: nothing bounds the stretch.

### Fixed

- Count Copilot shutdown metrics and increased Codex output-token totals as work
when a dev session exits before the next transcript heartbeat (#822).

- Pause a dev session with no confirmed work instead of retrying into the same wall
(#727). `SessionResult.produced_work` is `false` when no turn ended and no
qualifying pane, transcript, or usage activity was observed (a permission
dialog, a login, a dead-on-arrival window); `decide_dev` pauses ahead of the budget as an
environment fault does, `dev-decision` and `session-end` carry the flag, and re-arm
resets the attempt.

## [0.12.0] — 2026-09-20

### Added
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,7 @@ session_timeout_min = 90
git_timeout_s = 120 # bound on any single git subprocess; exceeding it pauses/degrades, never crashes the run
teardown_grace_s = 20 # verified session teardown: poll a killed session up to this long, then force-kill its pane pids and re-kill; 0 = one best-effort kill
stop_without_result_nudges = 1 # times to re-prompt a session that stopped with no result.json
dev_stall_grace_s = 600 # silence grace armed at dev/review launch; transport activity or fresh Stop/idle evidence re-arms it; 0 = no launch timer, but a result-less turn end still fails fast
dev_stall_grace_s = 600 # silence grace armed at dev/review launch; transport activity or fresh Stop/idle evidence re-arms it; 0 = no launch timer, but a result-less turn end still fails fast. Also the transcript-idle notice threshold (journal session-idle/session-active, TUI `idle <age>`); 0 disables the notice
dev_stall_nudges = 2 # best-effort wake nudges per silent grace; fresh Stop/idle evidence restores this budget; 0 = stall on grace expiry
dev_stall_nudges_cap = 6 # total never-restored nudge bound per dev/review session; an accepted nudge does not guarantee a wake; 0 = stall on first grace expiry
workflow_stall_nudges_cap = 3 # same monotonic cap for an injected plugin-workflow session that finished but never wrote its completion marker
Expand Down
4 changes: 3 additions & 1 deletion docs/FEATURES.md

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions docs/tui-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,11 @@ cost-weighted total first (cache reads at `limits.cache_read_weight`), the
unweighted one in parentheses. Below the counts, an **agent line** names who is
driving: while a session is open it reads `agent <name> · <model> · <role>` (the
resolved adapter for the live stage — `model` omitted when the session ran the
CLI profile's default, `role` is the stage `dev` / `review` / `triage`); when no
CLI profile's default, `role` is the stage `dev` / `review` / `triage`), with a
yellow `· idle <age>` appended while the session's transcript has sat still past
`limits.dev_stall_grace_s` (#680 — derived from the journal's open
`session-idle`, cleared by its `session-active`; `<age>` is whole minutes, or
`1h05m` above an hour); when no
session is open it falls back to the run's configured adapters, rebuilt from the
run's policy snapshot — `agents <name·model>` when dev and review resolve alike,
else `agents dev <name·model> review <name·model>`, plus a `triage <name·model>`
Expand Down Expand Up @@ -653,7 +657,7 @@ behavior.
| `limits.git_timeout_s` | int ≥ 1 | 120 | bound on any single git subprocess; exceeding it pauses/degrades, never crashes the run — raise on a loaded host or a very large worktree |
| `limits.teardown_grace_s` | int ≥ 0 | 20 | verified teardown: poll a killed session up to this long, then force-kill its pane pids and re-kill · 0 = single unverified best-effort kill |
| `limits.stop_without_result_nudges` | int ≥ 0 | 1 | nudges when a session stops without result.json |
| `limits.dev_stall_grace_s` | int ≥ 0 | 600 | silence grace armed at dev/review launch and re-armed by transport activity or fresh Stop/idle evidence · 0 = no launch timer, but a result-less turn end still fails fast |
| `limits.dev_stall_grace_s` | int ≥ 0 | 600 | silence grace armed at dev/review launch and re-armed by transport activity or fresh Stop/idle evidence · also the transcript-idle notice threshold (journal `session-idle`/`session-active`, TUI idle age) · 0 = no launch timer (and no idle notice), but a result-less turn end still fails fast |
| `limits.dev_stall_nudges` | int ≥ 0 | 2 | best-effort wake nudges per silent grace before stalling; fresh Stop/idle evidence restores this budget · 0 = stall on grace expiry |
| `limits.dev_stall_nudges_cap` | int ≥ 0 | 6 | total (never-restored) nudge bound per dev/review session — an accepted nudge does not guarantee a wake · 0 = stall on first grace expiry |
| `limits.workflow_stall_nudges_cap` | int ≥ 0 | 3 | same monotonic cap for an injected plugin-workflow session that finished its work but never wrote its completion marker · 0 = stall on first grace expiry |
Expand Down
33 changes: 32 additions & 1 deletion src/bmad_loop/adapters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,17 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from typing import TYPE_CHECKING, Any

from ..model import TokenUsage
from ..platform_util import is_link_like, safe_segment

if TYPE_CHECKING:
# `journal.py` imports nothing from `adapters/`, so the runtime import would be
# cycle-free too; TYPE_CHECKING keeps the adapter seam's import graph as thin as
# it was (journal pulls in model + platform_util) for the annotation alone.
from ..journal import Journal


class AdapterTaskDirectoryError(ValueError):
"""A built-in adapter refused an unsafe or redirected task directory."""
Expand Down Expand Up @@ -250,13 +256,38 @@ class SessionResult:
# stalled/timeout/over_budget, which this flag can never accompany; add it
# there if `crashed` ever joins that rescue set.
session_vanished: bool = False
# Whether the session showed ANY sign of working before it ended on a
# non-completed verdict (#727). `True` when a `Stop` arrived, when the adapter
# has no pane log to read (opencode-http, unit fixtures — "unknown never
# blocks"), when the pane log changed on a tick later than
# `generic.FIRST_FRAME_S` after the wait loop started and before the first
# stall wake nudge was sent, or when the CLI's own transcript changed after
# its first sample / the usage sampler read a nonzero spend from it (writes a
# misbound pane sink cannot hide). `False` means the CLI painted at most its
# first frame and then sat still until the grace, the nudge and the exit: a
# permission dialog, a login prompt, a dead-on-arrival window. `decide_dev`
# PAUSEs such a session ahead of the attempt budget, the way an environment
# fault does, so re-arm restores the attempt instead of a fresh session being
# launched into the identical wall. Distinct from `stop_seen` (the hook half
# alone) and from `_ResultFileMixin._produced_work` (the #261 read-back gate's
# byte floor, which a rendered dialog clears). Default `True` so every
# positional construction keeps today's routing. APPENDED, never inserted.
produced_work: bool = True


class CodingCLIAdapter(ABC):
name: str = "abstract"
injection: str = ""
observation: str = ""
state: str = ""
# The run's journal, attached by the engine to every adapter it owns so the
# adapter can record what only it can see (the #680 `session-idle` /
# `session-active` pair). None outside an engine — `resolve.run_session`,
# `probe`, unit fixtures — and every adapter-side emit is gated on it: no
# journal, no entry. The wait loop runs on the engine thread while the
# engine's own journal is quiescent, so this adds no second writer, and
# entries keep the engine's `log_task`/`log_pos` stamps.
journal: Journal | None = None

@abstractmethod
def start_session(self, spec: SessionSpec) -> SessionHandle: ...
Expand Down
Loading
Loading