fix(gateway): make bg-process reset TTL configurable + surface session-scoped processes

Follow-up to the cherry-picked #29212 (#29177):

- Promote the 24h stale-process threshold to config.yaml
  (session_reset.bg_process_max_age_hours) instead of a hardcoded
  constant. 0 disables the cutoff (legacy: any live process blocks reset).
  Wired through GatewayConfig.default_reset_policy in gateway/run.py.
- Bug 2: process(action=list) now resolves the gateway session_key from
  the contextvar and surfaces session-scoped background processes (a
  forgotten preview server under a different task), flagged
  session_scoped — so the agent/user can discover and kill the blocker.
  Previously the task-scoped list returned [] and the blocker was invisible.
- Tests: config round-trip for the new field, cross-task list visibility.
- Docs: messaging session-reset section.
This commit is contained in:
teknium1 2026-06-27 19:53:29 -07:00 committed by Teknium
parent 33d8b66d5b
commit a1ac6baac4
6 changed files with 101 additions and 13 deletions

View file

@ -58,7 +58,7 @@ CHECKPOINT_PATH = get_hermes_home() / "processes.json"
MAX_OUTPUT_CHARS = 200_000 # 200KB rolling output buffer
FINISHED_TTL_SECONDS = 1800 # Keep finished processes for 30 minutes
MAX_PROCESSES = 64 # Max concurrent tracked processes (LRU pruning)
MAX_ACTIVE_PROCESS_AGE = 86400 # 24h — stale processes no longer block session reset
MAX_ACTIVE_PROCESS_AGE = 86400 # 24h default — see session_reset.bg_process_max_age_hours (#29177)
# Watch pattern rate limiting — PER SESSION.
# Hard rule: at most ONE watch-match notification every WATCH_MIN_INTERVAL_SECONDS.
@ -1515,15 +1515,28 @@ class ProcessRegistry:
except Exception:
return 0
def list_sessions(self, task_id: str = None) -> list:
"""List all running and recently-finished processes."""
def list_sessions(self, task_id: str = None, session_key: str = None) -> list:
"""List all running and recently-finished processes.
When ``task_id`` is given, processes for that task are included. When
``session_key`` is also given, session-scoped background processes
(``background: true``) registered under that gateway session are
surfaced too, even if they belong to a different task so the agent
can discover a forgotten preview server that is blocking session
reset (#29177). Such cross-task entries are flagged with
``"session_scoped": true``.
"""
with self._lock:
all_sessions = list(self._running.values()) + list(self._finished.values())
all_sessions = [self._refresh_detached_session(s) for s in all_sessions]
if task_id:
all_sessions = [s for s in all_sessions if s.task_id == task_id]
if task_id or session_key:
all_sessions = [
s for s in all_sessions
if (task_id and s.task_id == task_id)
or (session_key and s.session_key == session_key)
]
result = []
for s in all_sessions:
@ -1537,6 +1550,11 @@ class ProcessRegistry:
"status": "exited" if s.exited else "running",
"output_preview": s.output_buffer[-200:] if s.output_buffer else "",
}
# Flag processes surfaced only because they share the gateway
# session (not the current task) — these are the long-lived
# background processes a user may have forgotten about (#29177).
if task_id and session_key and s.task_id != task_id and s.session_key == session_key:
entry["session_scoped"] = True
# Trigger metadata so a goal-loop judge can decide to wait on this
# process's OWN signal (a watch-pattern match or completion), not
# just its exit. A watcher with watch_patterns may never exit.
@ -2070,7 +2088,18 @@ def _handle_process(args, **kw):
session_id = str(args.get("session_id", "")) if args.get("session_id") is not None else ""
if action == "list":
return json.dumps({"processes": process_registry.list_sessions(task_id=task_id)}, ensure_ascii=False)
# Surface session-scoped background processes (e.g. a forgotten
# preview server) in addition to this task's own — they share the
# gateway session_key and can block session reset (#29177).
try:
from tools.approval import get_current_session_key
session_key = get_current_session_key(default="") or ""
except Exception:
session_key = ""
return json.dumps(
{"processes": process_registry.list_sessions(task_id=task_id, session_key=session_key or None)},
ensure_ascii=False,
)
elif action in {"poll", "log", "wait", "kill", "write", "submit", "close"}:
if not session_id:
return tool_error(f"session_id is required for {action}")