feat: expose cleanup and reporting outcomes for gateway runs
Some checks failed
ci / validate (push) Has been cancelled
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:
parent
f76cb7d955
commit
356e34992a
6 changed files with 177 additions and 7 deletions
16
SCOPE.md
16
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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue