fix(process): heartbeats wake the agent only on new output, and never as a user bubble
A `terminal(background=true, heartbeat=N)` tick queued a notification every N seconds
whether or not the process had printed anything, and every queued event costs the owning
session a full model turn. On Desktop and the TUI that turn painted the wake as a user
bubble ("[Background process ... heartbeat #9 ... (no new output since the last
heartbeat)]") followed by the model's "Still running normally." — over and over, for a
process whose row on the status stack already said it was running — and while the wake
held the session's turn, the user's own prompt sat queued behind it.
- `ProcessRegistry._emit_heartbeat` skips a tick with no new output. The sequence counts
delivered beats only; the "(no new output)" placeholder in the formatter is gone.
- TUI/Desktop type heartbeat rows `display_kind: hidden` (the kind both clients and the
transcript preview already honour); the CLI paints a one-line receipt and persists the
row hidden, so reopening the session in Desktop shows only the agent's reply.
- Desktop hydration drops heartbeat rows persisted by older backends the same way.
- `display.background_process_notifications: off` is honored by the TUI/Desktop poller and
the CLI drain, not just the messaging gateway. `off` mutes process-driven wakes only:
a finished `delegate_task(background=true)` still lands.
Supersedes #123123 (cherry-picked; scoped so `off` keeps subagent results) and #119202
(cherry-picked; `heartbeat: 0` is schema-valid so models that materialize every field
stop tripping the foreground guard).
This commit is contained in:
committed by
brooklyn!
parent
4317ed0e71
commit
8cb4fdc925
@@ -700,14 +700,19 @@ class ProcessRegistry(ProcessCheckpointMixin):
|
||||
self._emit_heartbeat(session, now)
|
||||
|
||||
def _emit_heartbeat(self, session: ProcessSession, now: float) -> None:
|
||||
"""Queue a heartbeat carrying the output since the last one. A tick with nothing new is
|
||||
skipped outright: every queued event costs the owner a full model turn, and "still running,
|
||||
no output" is already visible on the process surfaces (status stack, /agents dock)."""
|
||||
session._heartbeat_last = now
|
||||
with session._lock:
|
||||
delta = session.total_output_chars - session._heartbeat_total_at_last
|
||||
output = session.output_buffer[-delta:] if delta > 0 else ""
|
||||
session._heartbeat_total_at_last = session.total_output_chars
|
||||
if not output:
|
||||
return
|
||||
if len(output) > HEARTBEAT_OUTPUT_CHARS:
|
||||
cut = len(output) - HEARTBEAT_OUTPUT_CHARS
|
||||
output = f"...({cut} earlier characters omitted)\n" + output[-HEARTBEAT_OUTPUT_CHARS:]
|
||||
session._heartbeat_last = now
|
||||
session._heartbeat_seq += 1
|
||||
notification = {
|
||||
**self._watch_event_base(session),
|
||||
|
||||
@@ -349,6 +349,16 @@ def process_completion_display_text(events: list) -> str:
|
||||
return f"Background Process {outcome}{detail}: {cmd}" if cmd else f"Background Process {outcome}{detail}"
|
||||
|
||||
|
||||
HEARTBEAT_DISPLAY_KIND = "hidden" # a wake, not a message: no surface paints the row
|
||||
|
||||
|
||||
def heartbeat_display_text(evt: dict) -> str:
|
||||
"""One-line CLI receipt for a heartbeat wake; the row itself is hidden (``HEARTBEAT_DISPLAY_KIND``)."""
|
||||
cmd = _short_command(evt.get("command"))
|
||||
age = _format_age(float(evt.get("elapsed") or 0))
|
||||
return f"Background Process Output after {age}: {cmd}" if cmd else f"Background Process Output after {age}"
|
||||
|
||||
|
||||
class TimelineNotification(str):
|
||||
"""Queued model text that stays string-compatible, plus the display kind and compact human
|
||||
title the surface paints instead of the raw notification wall."""
|
||||
@@ -414,12 +424,11 @@ def format_process_notification(evt: dict) -> "str | None":
|
||||
_attribution = f"Handed off to you by a subagent before it finished. Purpose: {evt['handoff_note']}"
|
||||
attribution = f"{_attribution}\n" if _attribution else ""
|
||||
if evt_type == "heartbeat":
|
||||
_out = evt.get("output") or "(no new output since the last heartbeat)"
|
||||
return (
|
||||
f"[Background process {_sid} heartbeat #{evt.get('seq', '?')} — still running after "
|
||||
f"{_format_age(float(evt.get('elapsed') or 0))} (next in {evt.get('interval', '?')}s; "
|
||||
f"you will also be told when it exits).\n"
|
||||
f"{attribution}Command: {_cmd}\nOutput since last heartbeat:\n{_out}]")
|
||||
f"{_format_age(float(evt.get('elapsed') or 0))} (next in {evt.get('interval', '?')}s when there "
|
||||
f"is new output; you will also be told when it exits).\n"
|
||||
f"{attribution}Command: {_cmd}\nOutput since last heartbeat:\n{evt.get('output', '')}]")
|
||||
if evt_type == "watch_match":
|
||||
_sup = evt.get("suppressed", 0)
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user