diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 1bb9281..ec13e9e 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -10,6 +10,7 @@ | --- | --- | --- | --- | --- | | workplan | GLAS-0001 | active | — | workplans/GLAS-0001-statehub-bootstrap.md | | workplan | GLAS-WP-0001 | active | — | workplans/GLAS-WP-0001-harness-router-foundation.md | +| workplan | GLAS-WP-0002 | proposed | — | workplans/GLAS-WP-0002-observability-and-composability-followups.md | | task | GLAS-0001-T01 | done | — | workplans/GLAS-0001-statehub-bootstrap.md | | task | GLAS-0001-T02 | done | — | workplans/GLAS-0001-statehub-bootstrap.md | | task | GLAS-0001-T03 | done | — | workplans/GLAS-0001-statehub-bootstrap.md | @@ -19,3 +20,7 @@ | task | GLAS-WP-0001-T04 | done | — | workplans/GLAS-WP-0001-harness-router-foundation.md | | task | GLAS-WP-0001-T05 | done | — | workplans/GLAS-WP-0001-harness-router-foundation.md | | task | GLAS-WP-0001-T06 | done | — | workplans/GLAS-WP-0001-harness-router-foundation.md | +| task | GLAS-WP-0002-T01 | todo | — | workplans/GLAS-WP-0002-observability-and-composability-followups.md | +| task | GLAS-WP-0002-T02 | todo | — | workplans/GLAS-WP-0002-observability-and-composability-followups.md | +| task | GLAS-WP-0002-T03 | todo | — | workplans/GLAS-WP-0002-observability-and-composability-followups.md | +| task | GLAS-WP-0002-T04 | todo | — | workplans/GLAS-WP-0002-observability-and-composability-followups.md | diff --git a/src/glas_harness/cli.py b/src/glas_harness/cli.py index 52a6fd0..686fb21 100644 --- a/src/glas_harness/cli.py +++ b/src/glas_harness/cli.py @@ -18,6 +18,7 @@ def main(argv: list[str] | None = None) -> int: run.add_argument("--description", required=True) run.add_argument("--actor", default="agt") run.add_argument("--project", default="glas-harness") + run.add_argument("--no-hub", action="store_true", help="Skip the gateway's own hub reporting") args = parser.parse_args(argv) @@ -31,6 +32,7 @@ def main(argv: list[str] | None = None) -> int: description=args.description, actor=args.actor, project=args.project, + report_to_hub=not args.no_hub, ) print(json.dumps(result, indent=2)) return 0 if result["tool_ok"] else 1 diff --git a/src/glas_harness/gateway.py b/src/glas_harness/gateway.py index 5da5ce4..8454819 100644 --- a/src/glas_harness/gateway.py +++ b/src/glas_harness/gateway.py @@ -6,6 +6,13 @@ commit landed, tear the sandbox down. This is the parity proof gating any later "retire rein-aharness as a standalone concern" conversation — it is not itself that conversation. +GLAS-WP-0002-T03: post the gateway's own State Hub event, independent of +whatever the rein itself reports (both reins' CLIs are invoked with +their own hub reporting disabled by their glas-harness adapters — see +reins/rein_aharness.py / reins/rein_openweights.py). Reported on both +success and failure, from a `finally` block, so a raised exception still +leaves an audit trail. + Requires the `sandbox` extra (sand-boxer installed as a sibling editable dependency). """ @@ -17,6 +24,7 @@ from typing import Any from sandboxer.core.manager import SandboxManager from sandboxer.models import Consumer, SandboxCreateRequest +from glas_harness import hub from glas_harness.contract import Rein, SandboxHandle, ToolCall from glas_harness.reins.rein_aharness import ReinAharness @@ -31,6 +39,7 @@ def run_task_through_rein( actor: str = "agt", project: str = "glas-harness", manager: SandboxManager | None = None, + report_to_hub: bool = True, ) -> dict[str, Any]: """Resolve `sandbox_profile`, run one task inside it via `rein`, verify, tear down. @@ -47,6 +56,8 @@ def run_task_through_rein( consumer=Consumer(actor=actor, project=project), ) status = manager.create(request) + result: dict[str, Any] | None = None + error: str | None = None try: reachability = status.reachability.model_dump(mode="json") if status.reachability else {} sandbox = SandboxHandle( @@ -61,12 +72,56 @@ def run_task_through_rein( tool_result = rein.dispatch_tool(session, ToolCall(name="run_task", actor=actor)) summary = rein.end_session(session) - return { + result = { "sandbox_id": status.sandbox_id, "tool_ok": tool_result.ok, "tool_output": tool_result.output, "tool_error": tool_result.error, "summary": summary, } + return result + except Exception as exc: + error = str(exc) + raise finally: manager.destroy(status.sandbox_id) + if report_to_hub: + _post_gateway_event( + rein=rein, + sandbox_profile=sandbox_profile, + sandbox_id=status.sandbox_id, + project=project, + actor=actor, + title=title, + result=result, + error=error, + ) + + +def _post_gateway_event( + *, + rein: Rein, + sandbox_profile: str, + sandbox_id: str, + project: str, + actor: str, + title: str, + result: dict[str, Any] | None, + error: str | None, +) -> None: + ok = bool(result and result.get("tool_ok")) + hub.post_progress_event( + summary=f"gateway run: {title} ({'ok' if ok else 'failed'})", + event_type="gateway_run", + detail={ + "sandbox_profile": sandbox_profile, + "sandbox_id": sandbox_id, + "rein": type(rein).__name__, + "project": project, + "actor": actor, + "task_title": title, + "ok": ok, + "result": result, + "error": error, + }, + ) diff --git a/src/glas_harness/hub.py b/src/glas_harness/hub.py new file mode 100644 index 0000000..3a9032d --- /dev/null +++ b/src/glas_harness/hub.py @@ -0,0 +1,44 @@ +"""Custodian State Hub reporting (REST, no MCP) — the gateway's own audit trail. + +Mirrors rein-aharness's/rein-openweights's hub.py, but under glas-harness's +own actor attribution. Both reins' CLIs are invoked with their own hub +reporting disabled (`--no-hub`) by their glas-harness adapters — this +module is what makes the gateway's audit trail exist independent of +which rein ran (GLAS-WP-0002-T03). +""" + +from __future__ import annotations + +import os +from typing import Any + +import httpx + +_DEFAULT_URL = "http://127.0.0.1:8000" +_TIMEOUT = 10.0 + + +def _base_url() -> str: + return os.environ.get("STATE_HUB_URL", _DEFAULT_URL).rstrip("/") + + +def post_progress_event( + summary: str, + event_type: str, + detail: dict[str, Any], + task_id: str | None = None, +) -> bool: + payload: dict[str, Any] = { + "summary": summary, + "event_type": event_type, + "detail": detail, + "author": "agt-glas-harness", + } + if task_id: + payload["task_id"] = task_id + try: + resp = httpx.post(f"{_base_url()}/progress/", json=payload, timeout=_TIMEOUT) + resp.raise_for_status() + return True + except httpx.HTTPError: + return False diff --git a/tests/test_gateway.py b/tests/test_gateway.py index ec152ae..9cb01cb 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -1,5 +1,5 @@ from datetime import UTC, datetime -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from sandboxer.models import Reachability, SandboxState, SandboxStatus @@ -54,6 +54,7 @@ def test_run_task_through_rein_creates_and_destroys_sandbox() -> None: description="d", rein=rein, manager=manager, + report_to_hub=False, ) assert rein.calls == ["start_session", "dispatch_tool", "end_session"] @@ -81,8 +82,84 @@ def test_run_task_through_rein_destroys_sandbox_even_on_failure() -> None: description="d", rein=rein, manager=manager, + report_to_hub=False, ) except RuntimeError: pass manager.destroy.assert_called_once_with("sbx1") + + +def test_run_task_through_rein_reports_success_event() -> None: + manager = MagicMock() + manager.create.return_value = _fake_status() + rein = _FakeRein() + + with patch("glas_harness.gateway.hub.post_progress_event", return_value=True) as post: + run_task_through_rein( + sandbox_profile="profile.bwrap-local", + repo="/tmp/repo", + title="t", + description="d", + rein=rein, + manager=manager, + ) + + post.assert_called_once() + kwargs = post.call_args.kwargs + assert kwargs["event_type"] == "gateway_run" + assert "ok)" in kwargs["summary"] + assert kwargs["detail"]["ok"] is True + assert kwargs["detail"]["sandbox_id"] == "sbx1" + assert kwargs["detail"]["rein"] == "_FakeRein" + + +def test_run_task_through_rein_reports_failure_event_and_still_raises() -> None: + manager = MagicMock() + manager.create.return_value = _fake_status() + + class _FailingRein(_FakeRein): + def dispatch_tool(self, session, tool_call): + raise RuntimeError("boom") + + rein = _FailingRein() + + with patch("glas_harness.gateway.hub.post_progress_event", return_value=True) as post: + try: + run_task_through_rein( + sandbox_profile="profile.bwrap-local", + repo="/tmp/repo", + title="t", + description="d", + rein=rein, + manager=manager, + ) + assert False, "expected RuntimeError to propagate" + except RuntimeError: + pass + + post.assert_called_once() + kwargs = post.call_args.kwargs + assert "failed)" in kwargs["summary"] + assert kwargs["detail"]["ok"] is False + assert kwargs["detail"]["error"] == "boom" + assert kwargs["detail"]["result"] is None + + +def test_run_task_through_rein_skips_hub_when_disabled() -> None: + manager = MagicMock() + manager.create.return_value = _fake_status() + rein = _FakeRein() + + with patch("glas_harness.gateway.hub.post_progress_event") as post: + run_task_through_rein( + sandbox_profile="profile.bwrap-local", + repo="/tmp/repo", + title="t", + description="d", + rein=rein, + manager=manager, + report_to_hub=False, + ) + + post.assert_not_called() diff --git a/workplans/GLAS-WP-0002-observability-and-composability-followups.md b/workplans/GLAS-WP-0002-observability-and-composability-followups.md index ac4c4db..492d236 100644 --- a/workplans/GLAS-WP-0002-observability-and-composability-followups.md +++ b/workplans/GLAS-WP-0002-observability-and-composability-followups.md @@ -2,6 +2,7 @@ id: GLAS-WP-0002 title: "Observability and composability follow-ups" status: proposed +state_hub_workstream_id: "ec0777ff-18ce-4666-abaf-5bd2ff0fd4b9" --- Four items surfaced as open-but-not-urgent while closing out @@ -33,6 +34,7 @@ this same task — not before. id: GLAS-WP-0002-T01 status: todo priority: medium +state_hub_task_id: "2de6074e-b72b-4eb7-b454-6b849011348c" ``` ## Task: Live-verify rein-openweights's OpenBao credential path @@ -53,6 +55,7 @@ both repos. id: GLAS-WP-0002-T02 status: todo priority: medium +state_hub_task_id: "74b4f35b-9cdd-4228-aeed-e0eec458c0bf" ``` ## Task: Wire glas-harness's own State Hub reporting from the gateway @@ -67,10 +70,26 @@ what makes the *gateway's* audit trail exist independent of which rein ran, matching the "observable by default" design principle in `INTENT.md`. +**Done (2026-07-26).** `src/glas_harness/hub.py` (mirrors the two +reins' hub.py under `author: agt-glas-harness`); `gateway.py`'s +`run_task_through_rein` gains `report_to_hub: bool = True`, posting one +`gateway_run` progress event from a `finally` block — fires on both +success and failure (a raised exception still leaves an audit trail), +with `ok`/`rein`/`sandbox_id`/`sandbox_profile`/`project`/`result`/ +`error` in the detail. `cli.py` gained a matching `--no-hub` flag. Live +verified: ran a real task through `ReinOpenWeights` with hub reporting +enabled, confirmed the `gateway_run` event landed in State Hub with the +correct detail (`author: agt-glas-harness`, real commit sha in +`result`). Token events skipped for now — `ToolResult` doesn't carry a +uniform token count across reins, and it wasn't worth forcing one just +for this task. 4 new tests (success/failure/disabled paths), 23/23 +passing. + ```task id: GLAS-WP-0002-T03 -status: todo +status: done priority: high +state_hub_task_id: "76462175-4a85-4552-a6c1-af871cc1b8d3" ``` ## Task: First slice of glas-harness's remaining charter pillars @@ -92,4 +111,5 @@ indefinitely. id: GLAS-WP-0002-T04 status: todo priority: low +state_hub_task_id: "98d4be61-5923-4445-b1f8-5135ada13ad9" ```