diff --git a/SCOPE.md b/SCOPE.md index c0ba7f7..1f145dd 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -28,7 +28,9 @@ by the maturity target in `INTENT.md`. No committed profile is `ready`. - A synchronous outer lifecycle: resolve, create, start, dispatch `run_task`, summarize, clean up and destroy. Manager initialization failures return normalized creation-failure evidence. Cleanup and destruction are attempted - after session failures; this is not a durable recovery/retry service. + after session failures, with independent cleanup/destruction outcomes even + when an earlier execution error remains primary. This is not a durable + recovery/retry service. - Two concrete CLI adapters: `ReinAharness` and `ReinOpenWeights`. Each session retains its own parsed outcome, model and usage even when an adapter is reused. The rein owns the inner tool loop and tool-policy enforcement. @@ -49,7 +51,8 @@ by the maturity target in `INTENT.md`. No committed profile is `ready`. - Direct-caller output/errors plus compact State Hub progress evidence. Ordinary execution failures are summarized for Hub reporting; raw tool output and event payloads are not included in that report. Reporting is - best-effort HTTP, with no durable outbox, retry or delivery guarantee. + best-effort HTTP; direct results now distinguish accepted, failed and disabled + reporting. There is no durable outbox, retry or delivery guarantee. - Acceptance tooling for owner-boundary probes, runtime inventory and a real local-rein proof. The latter validates artifact content and a single commit before teardown; having the runner does not establish a passing real run. @@ -64,7 +67,7 @@ universal redaction guarantee for arbitrary caller/provider data. | Surface | Evidence available | Current limit | |---|---|---| -| Catalog and adapters | 110 tests passed at the latest code review; packaged catalog validation passed | Tests do not prove provider authentication or deployed runtime readiness | +| Catalog and adapters | 121 tests passed after GLAS-WP-0013; packaged catalog validation passed | Tests do not prove provider authentication or deployed runtime readiness | | Same-host owner boundary | Non-secret live proof: source absent, mutation denied, sandbox-only commit, private task removal and workspace destruction | Deterministic dispatch, not a real model session | | Standalone runtime and private state | Owner proof of rein CLI startup with pinned read-only Python runtime and private HOME/XDG/TMP state | Temporary proof artifact; pinned Claude executable and production selection still pending | | Provider egress | Owner extension and persisted-manager proofs of provider TLS reachability, undeclared host/direct-IP denial and cleanup | Destination control, not HTTP-path or TLS-SNI filtering; not a complete real-rein proof | @@ -134,3 +137,10 @@ See [INTENT.md](INTENT.md) for direction, the [contract](docs/harness-contract.md) and [profile documentation](docs/execution-profiles.md) for interfaces, and [workplans](workplans/) for accepted work. + +GLAS-WP-0013 closed the independent cleanup/reporting-outcome gap on 2026-09-06. +Old evidence without the new fields remains explicitly unknown. See +[the contract update](docs/harness-contract.md) for schema compatibility and +acknowledgement semantics. The timestamped intent assessment remains a +historical baseline; broader audit, aggregate budgets and session features are +still gaps. diff --git a/docs/harness-contract.md b/docs/harness-contract.md index e24f41b..6ad7abe 100644 --- a/docs/harness-contract.md +++ b/docs/harness-contract.md @@ -144,3 +144,28 @@ The contract does not schedule work, source blueprints, allocate workers, resolve leadership, broker credentials, select an unapproved model by price, or standardize rein internals. Composable rein middleware remains deferred by ADR-004 until a second concrete need exists. + +## Cleanup and reporting outcomes (GLAS-WP-0013) + +`ExecutionEvidence.session_cleanup` and `sandbox_destroy` independently report +`not_attempted`, `succeeded` or `failed`. If execution fails and cleanup also +fails, the original failure stage/error remains primary and both teardown +outcomes remain visible. Destroy is attempted even after cleanup failure. +A teardown failure following successful execution makes the run unsuccessful. +A successful method return is the owner's reported result, not an additional +filesystem/process verification by the gateway. + +`GatewayResult.hub_report_status` reports `not_requested`, `accepted` or `failed` +to the direct caller (including CLI JSON). `accepted` means the progress HTTP +request returned successfully. It does not guarantee durable audit storage or +exactly-once delivery. A timeout may occur after the server accepted a request; +`failed` means acknowledgement was not obtained. Reporting failure does not +rerun the agent or change its execution outcome. There is no automatic retry +or durable outbox. The status is outside ExecutionEvidence because it is known +only after the evidence report is attempted. + +These are additive defaulted fields in the current contract models. Old records +without them load as `unknown`, rather than implying that cleanup or reporting +occurred. Consumers using older strict schemas must update to accept these +fields before consuming new serialized results. No profile pins or readiness +states change with this addition. diff --git a/src/glas_harness/contract.py b/src/glas_harness/contract.py index c386435..0a35d8d 100644 --- a/src/glas_harness/contract.py +++ b/src/glas_harness/contract.py @@ -216,11 +216,14 @@ class ExecutionEvidence(ContractModel): tool_events_count: int = Field(default=0, ge=0) tool_events_completeness: Literal["complete", "partial", "unavailable"] = "unavailable" refs: dict[str, Any] = Field(default_factory=dict) + session_cleanup: Literal["unknown", "not_attempted", "succeeded", "failed"] = "unknown" + sandbox_destroy: Literal["unknown", "not_attempted", "succeeded", "failed"] = "unknown" class GatewayResult(ContractModel): ok: bool evidence: ExecutionEvidence + hub_report_status: Literal["unknown", "not_requested", "accepted", "failed"] = "unknown" # Direct caller output only. gateway.py deliberately excludes these fields # from State Hub detail because they can contain prompts/model responses. tool_output: str = "" diff --git a/src/glas_harness/gateway.py b/src/glas_harness/gateway.py index a84b59c..ce562c8 100644 --- a/src/glas_harness/gateway.py +++ b/src/glas_harness/gateway.py @@ -110,6 +110,8 @@ def run_execution( status = None session = None + session_cleanup = "not_attempted" + sandbox_destroy = "not_attempted" try: try: manager = manager if manager is not None else SandboxManager() @@ -198,7 +200,9 @@ def run_execution( if session is not None: try: selected_rein.cleanup_session(session) + session_cleanup = "succeeded" except Exception as exc: + session_cleanup = "failed" if outcome == "succeeded" or not error: outcome = "failed" failure_stage = "teardown" @@ -206,7 +210,9 @@ def run_execution( if status is not None: try: manager.destroy(status.sandbox_id) + sandbox_destroy = "succeeded" except Exception as exc: + sandbox_destroy = "failed" if outcome == "succeeded" or not error: outcome = "failed" failure_stage = "teardown" @@ -226,6 +232,8 @@ def run_execution( tool_result=tool_result, summary=summary, refs=refs, + session_cleanup=session_cleanup, + sandbox_destroy=sandbox_destroy, ) _report(request, result) return result @@ -282,6 +290,8 @@ def _build_result( tool_result: ToolResult | None, summary: ExecutionSummary | None, refs: dict, + session_cleanup: str = "not_attempted", + sandbox_destroy: str = "not_attempted", ) -> GatewayResult: duration = max(0.0, time.monotonic() - started) resolved_model = ( @@ -331,6 +341,8 @@ def _build_result( tool_result.events_completeness if tool_result else "unavailable" ), refs=refs, + session_cleanup=session_cleanup, + sandbox_destroy=sandbox_destroy, ) return GatewayResult( ok=outcome == "succeeded", @@ -342,9 +354,10 @@ def _build_result( def _report(request: ExecutionRequest, result: GatewayResult) -> None: if not request.report_to_hub: + result.hub_report_status = "not_requested" return evidence = result.evidence.model_dump(mode="json", exclude_none=True) - hub.post_progress_event( + accepted = hub.post_progress_event( summary=( f"gateway run: {request.title} " f"({'ok' if result.ok else result.evidence.outcome})" @@ -352,3 +365,4 @@ def _report(request: ExecutionRequest, result: GatewayResult) -> None: event_type="gateway_run", detail=evidence, ) + result.hub_report_status = "accepted" if accepted else "failed" diff --git a/tests/test_gateway.py b/tests/test_gateway.py index a2d77a5..3927a0f 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -201,6 +201,8 @@ def test_owner_session_failure_still_destroys_sandbox(failure): rein=ReinAharness(), manager=manager) assert not result.ok assert result.evidence.failure_stage == "session_start" + assert result.evidence.session_cleanup == "not_attempted" + assert result.evidence.sandbox_destroy == "succeeded" manager.destroy.assert_called_once_with("sbx1") host_run.assert_not_called() @@ -415,3 +417,105 @@ def test_manager_initialization_failure_returns_redacted_evidence(): assert result.tool_error == "private manager configuration" assert "private manager configuration" not in str(post.call_args) post.assert_called_once() + + +@pytest.mark.parametrize('cleanup_fails,destroy_fails', [(True, False), (False, True), (True, True)]) +def test_teardown_outcomes_survive_original_execution_failure(cleanup_fails, destroy_fails): + manager = MagicMock() + manager.create.return_value = _fake_status() + if destroy_fails: + manager.destroy.side_effect = RuntimeError('private destroy failure') + + class FailingRein(_FakeRein): + def dispatch_tool(self, session, tool_call): + raise RuntimeError('original execution failure') + + def cleanup_session(self, session): + if cleanup_fails: + raise RuntimeError('private cleanup failure') + + with patch('glas_harness.gateway.hub.post_progress_event', return_value=True) as post: + result = run_execution(_request(report_to_hub=True), catalog=_catalog_with_readiness(), + rein=FailingRein(), manager=manager) + assert not result.ok + assert result.evidence.failure_stage == 'execution' + assert result.tool_error == 'original execution failure' + assert result.evidence.session_cleanup == ('failed' if cleanup_fails else 'succeeded') + assert result.evidence.sandbox_destroy == ('failed' if destroy_fails else 'succeeded') + detail = post.call_args.kwargs['detail'] + assert detail['session_cleanup'] == result.evidence.session_cleanup + assert detail['sandbox_destroy'] == result.evidence.sandbox_destroy + assert 'private' not in str(detail) + manager.destroy.assert_called_once_with('sbx1') + + +@pytest.mark.parametrize('report,accepted,expected', [ + (False, True, 'not_requested'), (True, True, 'accepted'), (True, False, 'failed') +]) +def test_reporting_outcome_does_not_change_execution(report, accepted, expected): + manager = MagicMock() + manager.create.return_value = _fake_status() + rein = _FakeRein() + with patch('glas_harness.gateway.hub.post_progress_event', return_value=accepted) as post: + result = run_execution(_request(report_to_hub=report), catalog=_catalog_with_readiness(), + rein=rein, manager=manager) + assert result.ok + assert result.hub_report_status == expected + assert result.evidence.session_cleanup == 'succeeded' + assert result.evidence.sandbox_destroy == 'succeeded' + assert post.call_count == int(report) + assert rein.calls.count('dispatch_tool') == 1 + + +def test_refusal_reports_no_teardown_attempt_and_reporting_failure(): + with patch('glas_harness.gateway.hub.post_progress_event', return_value=False): + result = run_execution(_request(report_to_hub=True)) + assert result.evidence.outcome == 'refused' + assert result.evidence.session_cleanup == 'not_attempted' + assert result.evidence.sandbox_destroy == 'not_attempted' + assert result.hub_report_status == 'failed' + + +@pytest.mark.parametrize('http_failure', ['timeout', 'status']) +def test_http_reporting_failure_preserves_success(http_failure): + import httpx + manager = MagicMock() + manager.create.return_value = _fake_status() + with patch('glas_harness.hub.httpx.post') as post: + if http_failure == 'timeout': + post.side_effect = httpx.ConnectTimeout('private transport detail') + else: + post.return_value = httpx.Response(503, request=httpx.Request('POST', 'http://hub/progress/')) + result = run_execution(_request(report_to_hub=True), catalog=_catalog_with_readiness(), + rein=_FakeRein(), manager=manager) + assert result.ok + assert result.hub_report_status == 'failed' + assert 'private transport detail' not in result.model_dump_json() + post.assert_called_once() + manager.destroy.assert_called_once_with('sbx1') + + +def test_cleanup_failure_after_success_still_destroys_sandbox(): + manager = MagicMock() + manager.create.return_value = _fake_status() + rein = _FakeRein() + rein.cleanup_session = MagicMock(side_effect=RuntimeError('cleanup failed')) + result = run_execution(_request(), catalog=_catalog_with_readiness(), + rein=rein, manager=manager) + assert not result.ok + assert result.evidence.failure_stage == 'teardown' + assert result.evidence.session_cleanup == 'failed' + assert result.evidence.sandbox_destroy == 'succeeded' + manager.destroy.assert_called_once_with('sbx1') + + +def test_old_result_records_have_unknown_outcomes(): + from glas_harness.contract import GatewayResult + old_record = run_execution(_request()).model_dump() + old_record.pop('hub_report_status') + old_record['evidence'].pop('session_cleanup') + old_record['evidence'].pop('sandbox_destroy') + restored = GatewayResult.model_validate(old_record) + assert restored.hub_report_status == 'unknown' + assert restored.evidence.session_cleanup == 'unknown' + assert restored.evidence.sandbox_destroy == 'unknown' diff --git a/workplans/GLAS-WP-0013-execution-outcome-evidence.md b/workplans/GLAS-WP-0013-execution-outcome-evidence.md index 546047d..c1f8f84 100644 --- a/workplans/GLAS-WP-0013-execution-outcome-evidence.md +++ b/workplans/GLAS-WP-0013-execution-outcome-evidence.md @@ -4,7 +4,7 @@ type: workplan title: "Expose cleanup and reporting outcomes to execution consumers" domain: infotech repo: glas-harness -status: active +status: finished owner: codex topic_slug: execution-outcome-evidence created: "2026-09-06" @@ -33,7 +33,7 @@ not work authorized by this bounded plan. No profile readiness changes. ```task id: GLAS-WP-0013-T01 -status: progress +status: done priority: high state_hub_task_id: "c4cc126f-4ea6-54f7-b4e2-dc2772da57b9" ``` @@ -48,7 +48,7 @@ Test normal, pre-creation refusal, startup failure and combined failure paths. ```task id: GLAS-WP-0013-T02 -status: todo +status: done priority: high state_hub_task_id: "d7456f89-adf1-5b55-b84e-5b0f07c9ace7" ``` @@ -59,3 +59,17 @@ rerun the task or change its execution outcome. Test successful reporting, HTTP refusal/unavailability, disabled reporting and failure evidence redaction. Document that acceptance is an HTTP result, not durable audit delivery or an exactly-once guarantee. Run full tests and catalog validation; update SCOPE. + +## Completion evidence + +Completed 2026-09-06. Added independent cleanup/destruction outcomes and direct +caller Hub acknowledgement status. Old result records default to unknown. +Regression coverage includes combined execution/cleanup/destroy failures, +startup/refusal paths, successful runs with teardown failures, HTTP timeout and +503 responses, disabled reporting and old-record decoding. Full suite: 121 +passed; catalog validation and diff checks passed. No model or credential call. + +No residual implementation work from this bounded plan. First real-profile +acceptance remains live in GLAS-WP-0012/GLAS-IN-0002. Durable audit delivery, +aggregate budgets and broader session features remain assessment gaps outside +this plan; no capability or approved backlog is implied.