feat: expose cleanup and reporting outcomes for gateway runs
Some checks failed
ci / validate (push) Has been cancelled

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0726e-5232-73f2-aaca-2c05ceb62efb
This commit is contained in:
tegwick 2026-09-06 11:12:30 +02:00
parent f76cb7d955
commit 356e34992a
6 changed files with 177 additions and 7 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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 = ""

View file

@ -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"

View file

@ -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'

View file

@ -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.