Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ jobs:
python3 packages/ag2-sparrow/tests/test_event_consumer.py
python3 packages/ag2-sparrow/tests/test_event_wiring.py
python3 packages/ag2-sparrow/tests/test_human_action.py
python3 packages/ag2-sparrow/tests/test_launch_diagnostics.py
python3 packages/ag2-sparrow/tools/test_no_drift.py
python3 skills/agent-room-ops/test_room_ops.py
python3 skills/make-viral-video/scripts/test_source_tag.py
Expand Down
40 changes: 39 additions & 1 deletion packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,21 @@ def _getaddrinfo_prefer_v4(host, *args, **kwargs):
# after a healthy round-trip, reconnecting in the backoff branches.
GATEWAY_STATUS_FILE = _STATE / "gateway-status.json"

# Launch provenance + in-bridge file log. A supervisor that persists stdout
# (sutando's startup.sh redirects it to logs/remote-gateway-bridge.log) exports
# SUTANDO_SUPERVISED=1, and _log stays stdout-only — byte-identical to before.
# Launched any other way ("bare": a hand-run of the script, a debug shell, an
# app spawn that forgot the redirect), stdout persists nowhere — the exact
# diagnostic hole of the 2026-07-25 tester wedge (bridge stuck 21h, zero logs
# or discoverable status to read). So a bare launch ALSO appends every _log
# line to <state-parent>/logs/gateway-bridge.log (<workspace>/logs/ when
# sutando injects dirs, ~/.ag2-sparrow/logs/ under defaults), size-capped with
# a single .1 rotation, best-effort — log I/O must never break the bridge.
_LAUNCHED_VIA = "supervised" if os.environ.get("SUTANDO_SUPERVISED") else "bare"
_LOG_DIR = _STATE.parent / "logs"
_LOG_FILE = _LOG_DIR / "gateway-bridge.log"
_LOG_MAX_BYTES = 5 * 1024 * 1024

# AWP P0: the persistent event channel (if enabled) — a module-level handle so
# gateway-status can report per-channel health. None until _maybe_start_event_channel.
_EVENT_CHANNEL = None
Expand Down Expand Up @@ -581,7 +596,22 @@ def _redact_url(value: str) -> str:


def _log(msg: str) -> None:
print(f"[remote-gateway-bridge] {msg}", flush=True)
line = f"[remote-gateway-bridge] {msg}"
print(line, flush=True)
if _LAUNCHED_VIA == "supervised":
return # stdout already persisted by the supervisor's redirect
try:
_LOG_DIR.mkdir(parents=True, exist_ok=True)
try:
if _LOG_FILE.stat().st_size > _LOG_MAX_BYTES:
_LOG_FILE.replace(_LOG_FILE.with_suffix(".log.1"))
except FileNotFoundError:
pass
stamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
with open(_LOG_FILE, "a") as f:
f.write(f"{stamp} {line}\n")
except Exception: # noqa: BLE001 — logging must never break the bridge
pass


def _req(method: str, path: str, payload: dict | None = None, timeout: int = 35):
Expand Down Expand Up @@ -758,6 +788,7 @@ def _emit_gateway_status(connected: bool, *, error: str | None = None,
"backoff_s": int(backoff_s),
"error": _one_line(error) if error else None,
"gateway": _redact_url(URL),
"launched_via": _LAUNCHED_VIA,
"schema_version": 1,
}
# AWP P0 per-channel health: the task connection is `connected` above; the
Expand Down Expand Up @@ -1502,6 +1533,13 @@ def main() -> None:
abandoned_suspects: set[str] = set()
_log(f"starting — gateway={URL} provider={PROVIDER} tasks={TASKS_DIR} "
f"(restored {len(inflight)} in-flight)")
# Always name where the diagnostics live: after an incident this line is the
# trailhead (a bare-launched bridge under default dirs writes status to
# ~/.ag2-sparrow/state/, where nobody thinks to look).
_log(f"launched_via={_LAUNCHED_VIA} status={GATEWAY_STATUS_FILE}")
if _LAUNCHED_VIA == "bare":
_log(f"running unsupervised — output also logged to {_LOG_FILE}; "
f"prefer launching through startup.sh for full diagnostics")
backoff = 1
_emit_gateway_status(False, error="starting — not yet connected")
_maybe_start_event_channel() # additive/opt-in/isolated — never blocks the task loop
Expand Down
111 changes: 111 additions & 0 deletions packages/ag2-sparrow/tests/test_launch_diagnostics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""Launch diagnostics — the bridge must be diagnosable however it was started.

A supervised launch (SUTANDO_SUPERVISED=1, stdout persisted by the supervisor's
redirect) keeps _log stdout-only, byte-identical to the old behavior. A bare
launch (the 2026-07-25 tester wedge: bridge started outside startup.sh, 21h
stuck, zero logs to read) also tees every _log line to
<state-parent>/logs/gateway-bridge.log, and gateway-status.json carries
launched_via so a supervisor / health-check can flag unsupervised bridges.
"""
import importlib
import json
import os
import pathlib
import sys
import tempfile


def _load(state_dir, supervised):
os.environ["AGENT_CONNECT_STATE_DIR"] = str(state_dir)
os.environ.setdefault("REMOTE_TASK_URL", "https://gw.example/relay")
os.environ.setdefault("REMOTE_TASK_TOKEN", "dummy-secret")
if supervised:
os.environ["SUTANDO_SUPERVISED"] = "1"
else:
os.environ.pop("SUTANDO_SUPERVISED", None)
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
mod = importlib.import_module("ag2_sparrow.remote_gateway_bridge")
return importlib.reload(mod)


def test_bare_launch_tees_log_to_file():
with tempfile.TemporaryDirectory() as d:
state = pathlib.Path(d) / "state"
state.mkdir()
m = _load(state, supervised=False)
assert m._LAUNCHED_VIA == "bare"
m._log("hello from a bare launch")
log = pathlib.Path(d) / "logs" / "gateway-bridge.log"
assert log.exists(), "bare launch must create the in-bridge log file"
body = log.read_text()
assert "hello from a bare launch" in body
assert "[remote-gateway-bridge]" in body
print("PASS test_bare_launch_tees_log_to_file")


def test_supervised_launch_stays_stdout_only():
with tempfile.TemporaryDirectory() as d:
state = pathlib.Path(d) / "state"
state.mkdir()
m = _load(state, supervised=True)
assert m._LAUNCHED_VIA == "supervised"
m._log("hello from a supervised launch")
assert not (pathlib.Path(d) / "logs").exists(), \
"supervised launch must not duplicate stdout into a file"
print("PASS test_supervised_launch_stays_stdout_only")


def test_status_carries_launched_via():
with tempfile.TemporaryDirectory() as d:
state = pathlib.Path(d) / "state"
state.mkdir()
m = _load(state, supervised=False)
m._emit_gateway_status(True)
rec = json.loads(m.GATEWAY_STATUS_FILE.read_text())
assert rec["launched_via"] == "bare"
m2 = _load(state, supervised=True)
m2._emit_gateway_status(False, error="x", backoff_s=2)
rec2 = json.loads(m2.GATEWAY_STATUS_FILE.read_text())
assert rec2["launched_via"] == "supervised"
# additive: existing consumers' keys are all still present
for k in ("connected", "ts", "last_ok_ts", "backoff_s", "error",
"gateway", "schema_version"):
assert k in rec2
print("PASS test_status_carries_launched_via")


def test_log_rotates_past_cap():
with tempfile.TemporaryDirectory() as d:
state = pathlib.Path(d) / "state"
state.mkdir()
m = _load(state, supervised=False)
m._LOG_DIR.mkdir(parents=True, exist_ok=True)
m._LOG_FILE.write_text("x" * (m._LOG_MAX_BYTES + 1))
m._log("post-rotation line")
rotated = m._LOG_FILE.with_suffix(".log.1")
assert rotated.exists(), "oversized log must rotate to .1"
assert rotated.stat().st_size > m._LOG_MAX_BYTES
body = m._LOG_FILE.read_text()
assert "post-rotation line" in body
assert m._LOG_FILE.stat().st_size < 1024
print("PASS test_log_rotates_past_cap")


def test_log_write_failure_never_raises():
with tempfile.TemporaryDirectory() as d:
state = pathlib.Path(d) / "state"
state.mkdir()
m = _load(state, supervised=False)
# unwritable log destination → _log must swallow, never raise
m._LOG_DIR = pathlib.Path("/proc/nonexistent/dir")
m._LOG_FILE = m._LOG_DIR / "gateway-bridge.log"
m._log("this must not raise")
print("PASS test_log_write_failure_never_raises")


if __name__ == "__main__":
test_bare_launch_tees_log_to_file()
test_supervised_launch_stays_stdout_only()
test_status_carries_launched_via()
test_log_rotates_past_cap()
test_log_write_failure_never_raises()
5 changes: 4 additions & 1 deletion src/startup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1072,7 +1072,10 @@ if _RELAY_ENV="$(bash "$REPO/scripts/sutando-config.sh" claude-home-path channel
REMOTE_MEDIA_MARKER="${REMOTE_MEDIA_MARKER:-ag2space-media}"
export REMOTE_TASK_TOKEN REMOTE_TASK_TIER REMOTE_MEDIA_MARKER
if ! pgrep -f "remote-gateway-bridge" > /dev/null 2>&1; then
python3 "$REPO/src/remote-gateway-bridge.py" > "$LOGS_DIR/remote-gateway-bridge.log" 2>&1 &
# SUTANDO_SUPERVISED=1 marks the launch as supervised (stdout persisted by
# the redirect below); the bridge stamps launched_via into gateway-status
# and skips its own bare-launch file log. See remote_gateway_bridge._log.
SUTANDO_SUPERVISED=1 python3 "$REPO/src/remote-gateway-bridge.py" > "$LOGS_DIR/remote-gateway-bridge.log" 2>&1 &
echo " ✓ gateway bridge"
else
echo " ✓ gateway bridge (already running)"
Expand Down
Loading