diff --git a/docs/evidence/2026-09-09-runtime-transfer.json b/docs/evidence/2026-09-09-runtime-transfer.json new file mode 100644 index 0000000..78ae64c --- /dev/null +++ b/docs/evidence/2026-09-09-runtime-transfer.json @@ -0,0 +1,40 @@ +{ + "schema": "hfact.runtime-transfer-verification.v1", + "date": "2026-09-09", + "rein_tests": 290, + "glas_tests": 133, + "actual_bwrap_owner_execution": true, + "task_authoring": "deterministic fixture", + "queue": "fixture client; heartbeat/close/replay exercised", + "artifact_tests": [ + "exact commit retained after sandbox destruction", + "granted path acceptance", + "one commit ancestry", + "corrupt and truncated bundle", + "symlink refusal", + "expanded object limit", + "wrong head", + "missing capture", + "teardown failure", + "lease loss", + "source changed externally", + "ignored cache excluded" + ], + "native_controls": [ + "profile USD and turn limits reach Claude CLI", + "version preflight", + "strict terminal cost/token/turn accounting", + "limit exhaustion refuses success" + ], + "provider_requests": 0, + "paid_factory_attempts": 0, + "profile_promoted": false, + "protected_runtime_rebuilt": false, + "remaining": [ + "durable daily/total budget reservations and uncertain outcome recovery", + "provider cap semantics and conservative EUR treatment", + "matching protected runtime pins and exact factory identity/placement", + "G0 operating admission", + "real model G1 and natural Railiance queue G2" + ] +} diff --git a/docs/execution-profiles.md b/docs/execution-profiles.md index cdbd8f4..951d6b3 100644 --- a/docs/execution-profiles.md +++ b/docs/execution-profiles.md @@ -113,3 +113,24 @@ price, or provider behavior. Rollback is selection-based: repin a caller to its last approved Glas profile. Do not bypass Glas with `--sandbox-profile` or add a hidden default rein. + +## Worker artifact return and native limits (2026-09-09) + +A trusted worker may supply `run_execution(..., artifact_capture=callback)`. +This callback is process-local and cannot be supplied in an ExecutionRequest. +It runs after successful session cleanup and before sandbox destruction. Capture +failure is recorded as `artifact_capture`; destruction is still attempted. The +worker imports only after both cleanup and destruction succeeded. The callback +must retain bounded bytes privately; GatewayResult/Hub evidence carries no Git +bundle. Rein-aharness owns baseline/grant validation, source import and durable +acceptance under its repository transaction. + +`ExecutionLimits.max_budget_usd` is an optional positive finite USD amount for +rein-aharness; unsupported reins refuse it. The existing positive integer +`max_turns` is now propagated to that rein. Glas generates the controlled task +file from profile limits and rejects a supplied task file when native limits +are set. Bounded `cost_usd` accounting is carried through tool, summary and +execution evidence. These changes require matching rein/Glas runtime pins. +No catalog profile is promoted or enabled by these fields. Factory admission +still requires provider cap semantics, daily/total reservations, currency policy, +credentials, placement and operating-owner acceptance. diff --git a/src/glas_harness/contract.py b/src/glas_harness/contract.py index 0a35d8d..fc5e35e 100644 --- a/src/glas_harness/contract.py +++ b/src/glas_harness/contract.py @@ -46,7 +46,8 @@ class ModelRoute(ContractModel): class ExecutionLimits(ContractModel): budget_tokens: int | None = Field(default=None, gt=0) timeout_seconds: int | None = Field(default=None, gt=0) - max_turns: int | None = Field(default=None, gt=0) + max_turns: int | None = Field(default=None, gt=0, strict=True) + max_budget_usd: float | None = Field(default=None, gt=0, allow_inf_nan=False, strict=True) class ReinSelection(ContractModel): @@ -136,6 +137,7 @@ class ToolResult(ContractModel): events: list[dict[str, Any]] = Field(default_factory=list) events_completeness: Literal["complete", "partial", "unavailable"] = "unavailable" tokens_spent: int | None = Field(default=None, ge=0) + cost_usd: float | None = Field(default=None, ge=0, allow_inf_nan=False) duration_s: float | None = Field(default=None, ge=0) resolved_model: str | None = None metadata: dict[str, Any] = Field(default_factory=dict) @@ -147,6 +149,7 @@ class ExecutionSummary(ContractModel): outcome: Literal["succeeded", "failed", "refused"] reason: str | None = None tokens_spent: int | None = Field(default=None, ge=0) + cost_usd: float | None = Field(default=None, ge=0, allow_inf_nan=False) duration_s: float | None = Field(default=None, ge=0) resolved_model: str | None = None artifacts: list[str] = Field(default_factory=list) @@ -203,13 +206,14 @@ class ExecutionEvidence(ContractModel): tool_profile: str | None = None outcome: Literal["succeeded", "failed", "refused"] failure_stage: Literal[ - "resolution", "sandbox_create", "session_start", "execution", "session_end", "teardown" + "resolution", "sandbox_create", "session_start", "execution", "session_end", "artifact_capture", "teardown" ] | None = None error: str | None = None started_at: str finished_at: str duration_s: float = Field(ge=0) tokens_spent: int | None = Field(default=None, ge=0) + cost_usd: float | None = Field(default=None, ge=0, allow_inf_nan=False) token_budget: int | None = Field(default=None, ge=0) commit_sha: str | None = None artifacts: list[str] = Field(default_factory=list) diff --git a/src/glas_harness/gateway.py b/src/glas_harness/gateway.py index de856d4..c6f28cd 100644 --- a/src/glas_harness/gateway.py +++ b/src/glas_harness/gateway.py @@ -7,6 +7,7 @@ There is deliberately no default rein or model in governed execution. from __future__ import annotations import time +from collections.abc import Callable import uuid from datetime import UTC, datetime @@ -63,6 +64,7 @@ def run_execution( catalog: ProfileCatalog | None = None, rein: Rein | None = None, manager: SandboxManager | None = None, + artifact_capture: Callable[[SandboxHandle, ExecutionSummary], None] | None = None, ) -> GatewayResult: """Resolve and run one request, returning evidence for every outcome.""" @@ -209,6 +211,13 @@ def run_execution( outcome = "failed" failure_stage = "teardown" error = str(exc) + if artifact_capture is not None and outcome == "succeeded" and summary is not None: + try: + artifact_capture(sandbox, summary) + except Exception as exc: + outcome = "failed" + failure_stage = "artifact_capture" + error = str(exc) if status is not None: try: manager.destroy(status.sandbox_id) @@ -336,6 +345,7 @@ def _build_result( duration_s=execution_duration if execution_duration is not None else duration, tokens_spent=tokens_spent, token_budget=profile.limits.budget_tokens if profile else None, + cost_usd=summary.cost_usd if summary and summary.cost_usd is not None else tool_result.cost_usd if tool_result else None, commit_sha=summary.commit_sha if summary else None, artifacts=summary.artifacts if summary else [], tool_events_count=len(tool_result.events) if tool_result else 0, diff --git a/src/glas_harness/profiles.py b/src/glas_harness/profiles.py index daa2b1c..d09faf2 100644 --- a/src/glas_harness/profiles.py +++ b/src/glas_harness/profiles.py @@ -178,6 +178,9 @@ class ProfileCatalog: f"gateway supports {CONTRACT_VERSION}" ) + if profile.limits.max_budget_usd is not None and profile.rein.id != "rein-aharness": + raise IncompatibleProfileError("native USD limits are implemented only by rein-aharness") + descriptor = self.reins().get(profile.rein.id) if descriptor is None: raise IncompatibleProfileError( diff --git a/src/glas_harness/reins/rein_aharness.py b/src/glas_harness/reins/rein_aharness.py index 7ac7826..af13f09 100644 --- a/src/glas_harness/reins/rein_aharness.py +++ b/src/glas_harness/reins/rein_aharness.py @@ -64,6 +64,8 @@ class ReinAharness(Rein): self, profile: HarnessProfile, inputs: dict[str, str], sandbox: SandboxHandle ) -> dict[str, Any]: transport = transport_from_sandbox(sandbox) + if inputs.get("task_file") and (profile.limits.max_budget_usd is not None or profile.limits.max_turns is not None): + raise ValueError("native limits require a Glas-generated task file") target_repo = transport.workspace head_before = transport.git_head() generated_task_file = not bool(inputs.get("task_file")) @@ -74,6 +76,8 @@ class ReinAharness(Rein): "target_repo": target_repo, "agent": inputs.get("agent", "coach"), "timeout_seconds": profile.limits.timeout_seconds or 600, + "max_budget_usd": profile.limits.max_budget_usd, + "max_turns": profile.limits.max_turns, } ) @@ -116,6 +120,7 @@ class ReinAharness(Rein): events=events, events_completeness="complete" if self.stream_tool_events else "unavailable", tokens_spent=last_result.get("tokens_spent"), + cost_usd=last_result.get("cost_usd"), duration_s=last_result.get("execution_time_s"), resolved_model=last_result.get("model") or self.model, metadata={ @@ -160,6 +165,7 @@ class ReinAharness(Rein): outcome=outcome, reason=reason, tokens_spent=last_result.get("tokens_spent"), + cost_usd=last_result.get("cost_usd"), duration_s=last_result.get("execution_time_s"), resolved_model=last_result.get("model") or self.model, artifacts=[head_after] if committed and head_after else [], diff --git a/tests/test_gateway.py b/tests/test_gateway.py index ca28b08..d0987f3 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -553,3 +553,47 @@ def test_reachability_preparation_failure_is_reported_and_destroyed(): assert 'private owner detail' not in str(post.call_args) assert rein.calls == [] manager.destroy.assert_called_once_with('sbx1') + + +def test_artifact_capture_occurs_after_cleanup_before_destroy(): + manager = MagicMock() + manager.create.return_value = _fake_status() + rein = _FakeRein() + order = [] + rein.cleanup_session = lambda session: order.append("cleanup") + manager.destroy.side_effect = lambda sandbox: order.append("destroy") + def capture(sandbox, summary): + assert summary.commit_sha == "deadbeef" + assert sandbox._owner_execute is not None + order.append("capture") + result = run_execution(_request(), catalog=_catalog_with_readiness(), rein=rein, + manager=manager, artifact_capture=capture) + assert result.ok + assert order == ["cleanup", "capture", "destroy"] + assert "bundle" not in result.model_dump_json() + + +def test_artifact_capture_failure_still_destroys_sandbox(): + manager = MagicMock() + manager.create.return_value = _fake_status() + def capture(*args): + raise ValueError("invalid artifact") + result = run_execution(_request(), catalog=_catalog_with_readiness(), rein=_FakeRein(), + manager=manager, artifact_capture=capture) + assert not result.ok + assert result.evidence.failure_stage == "artifact_capture" + assert result.evidence.sandbox_destroy == "succeeded" + manager.destroy.assert_called_once_with("sbx1") + + +def test_cleanup_failure_never_captures_artifact(): + manager = MagicMock() + manager.create.return_value = _fake_status() + rein = _FakeRein() + rein.cleanup_session = MagicMock(side_effect=ValueError("cleanup")) + capture = MagicMock() + result = run_execution(_request(), catalog=_catalog_with_readiness(), rein=rein, + manager=manager, artifact_capture=capture) + assert not result.ok + capture.assert_not_called() + manager.destroy.assert_called_once() diff --git a/tests/test_rein_aharness.py b/tests/test_rein_aharness.py index 37eacdd..3fb447a 100644 --- a/tests/test_rein_aharness.py +++ b/tests/test_rein_aharness.py @@ -210,3 +210,19 @@ def test_cleanup_session_removes_only_generated_task_file() -> None: {"transport": transport, "task_file": "/sandbox/task.json", "generated_task_file": False} ) transport.remove_file.assert_not_called() + + +def test_profile_native_limits_are_carried_in_owned_task_file(): + from glas_harness.contract import ExecutionLimits + profile = ProfileCatalog().resolve("harness.agent-dev-local@1.0.0")[0].model_copy(update={ + "limits":ExecutionLimits(max_budget_usd=.25,max_turns=3,timeout_seconds=30)}) + transport=MagicMock(spec=ExecutionTransport) + transport.workspace="/sandbox" + transport.git_head.return_value="a"*40 + rein=ReinAharness() + with patch("glas_harness.reins.rein_aharness.transport_from_sandbox",return_value=transport): + rein.start_session(profile,{"title":"task","description":"prompt asks for unlimited money"},SandboxHandle(sandbox_id="fixture",host="localhost")) + assert transport.write_task_file.call_args.args[0]["max_budget_usd"] == .25 + assert transport.write_task_file.call_args.args[0]["max_turns"] == 3 + with pytest.raises(ValueError,match="Glas-generated"): + rein.start_session(profile,{"task_file":"/untrusted.json"},object()) diff --git a/workplans/GLAS-WP-0015-production-dependency-coordination.md b/workplans/GLAS-WP-0015-production-dependency-coordination.md index 793beb9..df8130f 100644 --- a/workplans/GLAS-WP-0015-production-dependency-coordination.md +++ b/workplans/GLAS-WP-0015-production-dependency-coordination.md @@ -8,7 +8,7 @@ status: active owner: codex topic_slug: production-dependency-coordination created: "2026-09-06" -updated: "2026-09-06" +updated: "2026-09-09" state_hub_workstream_id: "94c02b1f-66ed-588d-bdd7-7158107b85fb" --- @@ -65,6 +65,18 @@ Check revisions and positive/negative evidence; feed verified returns into GLAS-WP-0012-T02. Keep unfulfilled owner work live, and do not close this plan while actionable requests lack an owning record or acknowledged disposition. +2026-09-09 implementation return: the worker now captures a bounded Git bundle +through sandbox owner execution after rein cleanup, then validates and imports +the exact one-commit result under its original baseline, grant and lease after +successful teardown. The actual bwrap/Glas/worker test passed with a deterministic +authoring fixture and response-lost close replay, without duplicate execution. +Native profile USD/turn limits now reach Claude CLI controls and require valid +terminal accounting; missing/exhausted accounting refuses success. Daily/total +reservation, EUR treatment and live provider semantics remain HFACT-WP-0001-T01. +The matching rein/Glas code must be rebuilt and admitted in the protected runtime; +this local proof does not close live G1/G2 or authorize a model request. See the +2026-09-09 runtime-transfer evidence and the owning runtime documentation. + ## Delivery receipts | Owner | Root message / reply thread |