From 984b9056a7a2b1a950d0aac3fc756fca17510749 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 20 Oct 2026 12:14:30 -0700 Subject: [PATCH 1/6] Reserve one explicit final provider recovery allowance --- docs/api.md | 16 + docs/architecture.md | 13 + docs/recovery.md | 20 +- src/firefly_forge/agent_roles.py | 26 ++ src/firefly_forge/api.py | 7 + src/firefly_forge/client.py | 29 ++ src/firefly_forge/engine.py | 27 +- src/firefly_forge/journal.py | 41 ++- src/firefly_forge/provider_recoveries.py | 53 ++- ...provider_recovery_allowance_controllers.py | 30 ++ .../provider_recovery_allowances.py | 300 ++++++++++++++++ studio/src/WorkerDialogueData.test.ts | 38 +++ studio/src/WorkerDialogueData.ts | 19 +- .../test_provider_recovery_allowance_host.py | 322 +++++++++++++++++ ...test_provider_recovery_allowance_native.py | 323 ++++++++++++++++++ tests/test_provider_recovery_allowance_sdk.py | 52 +++ 16 files changed, 1299 insertions(+), 17 deletions(-) create mode 100644 src/firefly_forge/provider_recovery_allowance_controllers.py create mode 100644 src/firefly_forge/provider_recovery_allowances.py create mode 100644 tests/test_provider_recovery_allowance_host.py create mode 100644 tests/test_provider_recovery_allowance_native.py create mode 100644 tests/test_provider_recovery_allowance_sdk.py diff --git a/docs/api.md b/docs/api.md index d8105957..73956c8b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -80,6 +80,22 @@ A native factory administrator can review `GET /runs/{id}/provider-recoveries/ca The strict body requires exactly one native process digest or explicit historical operator attestation. New-protocol unknown outcomes stay held. Current authority, source, provider and policy are checked again before launch, and all implementation intents count toward the four-dispatch limit. See [failed official CLI recovery](recovery.md#recover-a-known-failed-official-cli-attempt) for the proof fields, provenance and lost-response procedure. This change exposes HTTP endpoints only. +### Final native recovery allowance + +The separate `/runs/{id}/provider-recoveries/allowances` surface reserves one fifth dispatch after a known native fourth-attempt failure. It requires a current native administrator with current execution authority. It accepts no historical process attestation or budget override. + +| Method | Path | Purpose | +| --- | --- | --- | +| GET | `/runs/{id}/provider-recoveries/allowances/candidate?agent=codex&adopt_current_executable=true` | Review the exact eligible failure and selected executable | +| POST | `/runs/{id}/provider-recoveries/allowances` | Reserve and consume the one lifetime final allowance | +| GET | `/runs/{id}/provider-recoveries/allowances/{request_id}` | Read the exact receipt as the original current operator | + +The strict POST body contains `request_id` (32 lowercase hexadecimal characters), `candidate_digest`, `native_process_digest`, `mitigation_evidence_digest` (each 64 lowercase hexadecimal characters), `agent` (`codex` or `claude`), `adopt_current_executable` (required literal `true`), and a nonblank `reason` of at most 2,000 characters. The mitigation digest is an administrator attestation, not server-generated QA evidence. HTTP201 retains a version-2 receipt with protocol `native-provider-recovery-allowance-v1`; the HTTP request does not execute inference. + +Same ID, body and current operator return the existing receipt. A stale candidate, different request, revoked authority or spent allowance refuses without another reservation. A missing response never renews eligibility. Legacy recovery routes retain their existing schemas and four-dispatch limit; reading a final allowance through the legacy receipt route returns HTTP409 identifying the new endpoint. See [the recovery procedure](recovery.md#reserve-one-final-native-provider-attempt) for launch checks, executable limits and lost-response handling. + +Fifth-attempt worker feedback uses `protocol: "native-provider-recovery-allowance-v1"`, literal `attempt: 5`, `role_id: "implementer"`, `task_id: "implement"`, `allowance_id` and `allowance_digest`, alongside the usual feedback fields. The native journal checks the actual consumed reservation and current authority before accepting it. Legacy `WorkerFeedback` remains limited to attempts 1–4. + | Method | Path | Operation | | --- | --- | --- | | GET | `/health` | Inspect server health | diff --git a/docs/architecture.md b/docs/architecture.md index f9e8c6b9..7588b1b5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -254,6 +254,19 @@ An unknown remote publication, deployment or memory write cannot be generically Known official-CLI process failures have a separate same-run recovery admission. Its candidate binds current administrator authority, source, Git identity, provider settings and the original failed task. An optional executable adoption adds a server-derived file identity and SHA-256 to that cut. The admission, reservation and dispatch WAL retain the reviewed descriptor; each execution boundary rechecks it. The Engine builds a separate provider for that task with only the executable setting overlaid, so later normal repairs retain the original model configuration and CLI. See [updated CLI recovery](recovery.md#adopt-an-updated-cli-for-one-recovery) for the HTTP contract and installation limits. +The final provider allowance is a separate administrator reservation after a known native fourth-attempt failure. Under the existing PostgreSQL identity, membership and run locks, it appends `provider_recovery_allowance`, its linked version-2 admission, the closed failure and one fifth implementation intent. The allowance is spent at reservation, including an unlaunched or failed dispatch. Candidate review binds the existing source/configuration cut and explicitly adopted CLI identity; no repair or verification grant is reset. The Engine and native feedback writer require the same sealed WAL link and current authority. The legacy recovery and worker-feedback contracts keep their four-attempt bound. See [the final allowance contract](api.md#final-native-recovery-allowance). + +```mermaid +flowchart LR + F["Fourth native process failure"] --> C["Administrator reviews exact candidate"] + C --> R["Atomic final reservation and linked WAL"] + R --> G["Recheck authority, source and executable"] + G --> D["One fifth dispatch"] + D --> V["Independent verification"] + R --> H["Uncertain or refused: preserve reservation"] + D --> H +``` + Bridge completion requests follow the same uncertainty rule. A transport failure, unusable completion or cancellation during an unfinished submission retains the original intent and workspace for reconciliation. Local cancellation stops scheduling but cannot establish the remote result. The hold survives cancelled, interrupted or failed run status; resume, handoff and replacement retry check it before dispatch. Recovery of the original Bridge job is not automated. See [provider failure handling](agents.md). Opt-in implementation checkpoints support explicit continuation at the original model boundary. The checkpoint and Bridge intent commit together before the completion POST. A bounded, read-only candidate projection verifies the retained journal, current authority and original ticket bindings before Studio offers a review. The projection makes no Bridge call and writes no ticket or consume receipt. diff --git a/docs/recovery.md b/docs/recovery.md index 961179d6..69bfa8e7 100644 --- a/docs/recovery.md +++ b/docs/recovery.md @@ -112,10 +112,28 @@ The receipt and dispatch WAL bind that executable to one reserved implementation This descriptor identifies the entrypoint file. Administrators remain responsible for its installation, dependencies and filesystem access; the check does not attest every library or make a path-based operating-system launch atomic. Qualify an updated CLI locally before spending another recovery attempt. -The Engine rechecks current authority and the reserved source before launch. Its normal inspection may stage the same retained source; that index-only transition gets a separate native preparation receipt. Source, HEAD, refs and policy changes refuse dispatch. Every implementation intent counts toward a maximum of four dispatch reservations, including failed or unreceipted attempts. Consuming a reservation does not renew the repair or verification budget. +The Engine rechecks current authority and the reserved source before launch. Its normal inspection may stage the same retained source; that index-only transition gets a separate native preparation receipt. Source, HEAD, refs and policy changes refuse dispatch. The ordinary recovery contract counts every implementation intent toward a maximum of four dispatch reservations, including failed or unreceipted attempts. The separate final-allowance contract below is the only native exception. Consuming a reservation does not renew the repair or verification budget. Future official-CLI failures retain typed native process evidence separately from provider output. A known nonzero exit requires the leader to have exited, both streams to have drained and its own process group to be absent. A zero exit without a valid final report, missing receipt or uncertain group remains unresolved. Provider output cannot assert those facts. This HTTP surface has no Studio, CLI or SDK recovery shortcut in this change. +### Reserve one final native provider attempt + +After the fourth dispatch fails, a current native factory administrator can explicitly reserve one final dispatch in the same run. This allowance has a separate HTTP contract. The ordinary recovery API and automatic verification remain limited to four attempts. + +Eligibility requires a native fourth-attempt process receipt with a nonzero exit, a waited leader, drained streams and an absent process group. Historical operator attestation cannot establish eligibility. Unfinished tools, unknown remote effects, cancellation and changed execution authority keep the run held. + +1. Configure the reviewed current CLI executable, then read `GET /api/v1/runs/{id}/provider-recoveries/allowances/candidate?agent=codex&adopt_current_executable=true` (or `claude`). Review the failure, source, Git, policy, configuration and executable bindings. +2. Retain one new 32-character hexadecimal `request_id`. POST `/api/v1/runs/{id}/provider-recoveries/allowances` with that ID, the returned `candidate_digest` and `native_process_digest`, the same `agent`, `adopt_current_executable: true`, a nonblank `reason` of at most 2,000 characters, and `mitigation_evidence_digest` containing a lowercase SHA-256. +3. Keep the exact ID and body. If delivery is uncertain, read `GET /api/v1/runs/{id}/provider-recoveries/allowances/{request_id}` using the original current administrator. An identical POST reads back the reservation. A different ID, body or operator is refused. + +The reason and mitigation digest record an administrator's attestation. The server does not run the referenced mitigation tests or establish that they address the failure. The executable descriptor identifies the selected entrypoint; it does not attest forge's runtime source or every dependency. Qualify the running worker separately before reserving. + +A successful reservation consumes the run's only allowance immediately. It adds a sealed `provider_recovery_allowance`, a linked version-2 native admission and one new implementation intent in the existing transaction. The normal Engine consumer performs the dispatch. A timeout, lost response, cancelled run or failed fifth attempt never restores the allowance. There is no sixth dispatch through this contract. + +The original WAL prefix, partial workspace, model settings, repair budget and verification grants remain intact. Current authority and the exact native allowance link are checked at execution boundaries. Source or executable drift prevents launch. A fifth-attempt worker report uses a distinct typed feedback variant with the allowance ID and digest; the worker cannot create or extend the grant. Its completion report remains separate from verification and product acceptance. Questions that require another coding dispatch remain held after the final allowance is spent. + +The Python SDK offers `provider_recovery_allowance_candidate`, `reserve_provider_recovery_allowance` and `provider_recovery_allowance`. Each method performs one explicit request. This release provides HTTP/SDK admission and compatible Studio feedback reading; a Studio reservation wizard is a separate interface. + ## Inspect a factory verification handoff A pending implementation request with typed `kind: "verification"` can use the run's frozen automatic-verification grant. forge executes all explicit approved commands through the existing authorized runner. It records each intent and known result before supplying protected factory feedback and admitting a bounded continuation. Question text and request IDs never choose commands or expand permissions. diff --git a/src/firefly_forge/agent_roles.py b/src/firefly_forge/agent_roles.py index 03849116..9eeb07f0 100644 --- a/src/firefly_forge/agent_roles.py +++ b/src/firefly_forge/agent_roles.py @@ -393,6 +393,32 @@ def request_contract(self) -> WorkerFeedback: return self +class NativeAllowanceWorkerFeedback(WorkerFeedback): + """Feedback for the one native, administrator-reserved final provider attempt.""" + + protocol: Literal["native-provider-recovery-allowance-v1"] + attempt: Literal[5] + role_id: Literal["implementer"] + task_id: Literal["implement"] + allowance_id: str = Field(pattern=r"^[a-f0-9]{32}$") + allowance_digest: str = Field(pattern=r"^[a-f0-9]{64}$") + + @field_validator("attempt", mode="before") + @classmethod + def exact_attempt(cls, value: Any) -> int: + if type(value) is not int or value != 5: + raise ValueError("Native final allowance feedback must identify exact attempt five") + return value + + +def worker_feedback_model(value: Any) -> type[WorkerFeedback]: + if isinstance(value, NativeAllowanceWorkerFeedback) or ( + isinstance(value, dict) and value.get("protocol") == "native-provider-recovery-allowance-v1" + ): + return NativeAllowanceWorkerFeedback + return WorkerFeedback + + class WorkerResponse(BaseModel): model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) id: str = Field(pattern=r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,79}$") diff --git a/src/firefly_forge/api.py b/src/firefly_forge/api.py index 79e1845b..68c0feb4 100644 --- a/src/firefly_forge/api.py +++ b/src/firefly_forge/api.py @@ -172,6 +172,8 @@ ) from firefly_forge.projects import Projects from firefly_forge.provider_recoveries import ProviderRecoveries +from firefly_forge.provider_recovery_allowance_controllers import ProviderRecoveryAllowanceController +from firefly_forge.provider_recovery_allowances import ProviderRecoveryAllowances from firefly_forge.provider_recovery_controllers import ProviderRecoveryController from firefly_forge.repository_role_controllers import RepositoryRoleController from firefly_forge.request_limits import RequestLimits @@ -638,6 +640,10 @@ def provider_recoveries(self, store: Store, journal: Journal, engine: Engine) -> engine.provider_recoveries = recoveries return recoveries + @bean + def provider_recovery_allowances(self, recoveries: ProviderRecoveries) -> ProviderRecoveryAllowances: + return ProviderRecoveryAllowances(recoveries) + @bean def assurance(self, store: Store, journal: Journal) -> Assurance: return Assurance(store, self.data_dir / "reviews", journal) @@ -1147,6 +1153,7 @@ class Application: JournalController, VerificationHandoffController, ProviderRecoveryController, + ProviderRecoveryAllowanceController, BridgeCompletionController, RunEvidenceController, ActivityContentController, diff --git a/src/firefly_forge/client.py b/src/firefly_forge/client.py index bf21ec1c..7bf90773 100644 --- a/src/firefly_forge/client.py +++ b/src/firefly_forge/client.py @@ -27,6 +27,7 @@ from firefly_forge.product_lifecycle import FeatureChangeInput from firefly_forge.project_campaigns import CampaignAction, CampaignLaunch, CampaignRetry from firefly_forge.project_models import ProjectDefinition + from firefly_forge.provider_recovery_allowances import ProviderRecoveryAllowanceInput from firefly_forge.reverse_engineering import AnalysisReview from firefly_forge.run_creation_models import RunCreationCancelInput, RunCreationInput from firefly_forge.runner_continuations import RunnerContinuationInput @@ -94,6 +95,34 @@ def _recovery_body(model: type[BaseModel], request: BaseModel | dict[str, Any]) except ValueError: raise ValueError("Use the original recovery request with its exact identities, scope and digests") from None + def provider_recovery_allowance_candidate( + self, run_id: str, agent: Literal["codex", "claude"], *, adopt_current_executable: bool = False + ) -> dict[str, Any]: + run_id = self._recovery_identifier(run_id) + if agent not in {"codex", "claude"} or type(adopt_current_executable) is not bool: + raise ValueError("Select an official provider and an explicit CLI adoption choice") + return dict( + self.request( + "GET", + f"runs/{run_id}/provider-recoveries/allowances/candidate", + params={"agent": agent, "adopt_current_executable": str(adopt_current_executable).lower()}, + ) + ) + + def reserve_provider_recovery_allowance( + self, run_id: str, request: ProviderRecoveryAllowanceInput | dict[str, Any] + ) -> dict[str, Any]: + """Reserve once. After uncertain delivery, read the same receipt; never choose another identity.""" + from firefly_forge.provider_recovery_allowances import ProviderRecoveryAllowanceInput + + run_id = self._recovery_identifier(run_id) + body = self._recovery_body(ProviderRecoveryAllowanceInput, request) + return dict(self.request("POST", f"runs/{run_id}/provider-recoveries/allowances", json=body)) + + def provider_recovery_allowance(self, run_id: str, request_id: str) -> dict[str, Any]: + run_id, request_id = self._recovery_identifier(run_id), self._recovery_identifier(request_id) + return dict(self.request("GET", f"runs/{run_id}/provider-recoveries/allowances/{request_id}")) + def request_run_creation(self, request_id: str, request: RunCreationInput | dict[str, Any]) -> dict[str, Any]: """Submit one retained version-1 envelope; uncertain delivery never selects a follow-up action.""" from firefly_forge.run_creation_models import RunCreationInput diff --git a/src/firefly_forge/engine.py b/src/firefly_forge/engine.py index 295a79c6..5cf55416 100644 --- a/src/firefly_forge/engine.py +++ b/src/firefly_forge/engine.py @@ -746,12 +746,22 @@ async def read_run_evidence(query: dict[str, Any]) -> dict[str, Any]: else inject_skills(run["brief"], run.get("skills", DEFAULT_SKILLS)) ) if run.get("role_snapshot"): - from firefly_forge.agent_roles import WorkerFeedback, inject_role_snapshot + from firefly_forge.agent_roles import ( + NativeAllowanceWorkerFeedback, + WorkerFeedback, + inject_role_snapshot, + ) source_brief = inject_role_snapshot(source_brief, run["role_snapshot"]) - source_brief += prompt_text( - "implementation/worker-feedback", schema=json.dumps(WorkerFeedback.model_json_schema()) - ) + feedback_schema = WorkerFeedback.model_json_schema() + if recovery is not None and recovery["attempt"] == 5: + link = await self.provider_recoveries._allowance(run) + if not link: + raise ValueError("Final feedback schema requires a sealed native allowance") + feedback_schema = NativeAllowanceWorkerFeedback.model_json_schema() + for name, value in link.items(): + feedback_schema["properties"][name]["const"] = value + source_brief += prompt_text("implementation/worker-feedback", schema=json.dumps(feedback_schema)) if (run.get("mcp_snapshot") or {}).get("grants"): source_brief += prompt_text( "implementation/external-tools", @@ -874,7 +884,10 @@ async def read_run_evidence(query: dict[str, Any]) -> dict[str, Any]: repair_failures = [] if recovery is not None: start_round = implementation_limit = recovery["attempt"] - if not 1 <= start_round <= 4: + if not 1 <= start_round <= 4 and ( + start_round != 5 + or not await self.provider_recoveries._allowance(await self.store.get_run(identifier)) + ): raise ReconciliationRequired("The reserved provider dispatch exceeds its finite bound") repair_failures = [] if start_round > implementation_limit: @@ -1275,7 +1288,7 @@ async def launch_guard( async def _worker_feedback( self, identifier: str, workspace: Path, result: Any, attempt: int, safe_data: Callable[[Any], Any] ) -> None: - from firefly_forge.agent_roles import WorkerFeedback + from firefly_forge.agent_roles import worker_feedback_model payload = getattr(result, "worker_feedback", None) or getattr(result, "usage", {}).get("worker_feedback") if payload is None and result.summary.strip().startswith("{"): @@ -1287,7 +1300,7 @@ async def _worker_feedback( payload = report.get("feedback") if payload is None: return - feedback = WorkerFeedback.model_validate(safe_data(payload)) + feedback = worker_feedback_model(payload).model_validate(safe_data(payload)) if feedback.role_id != "implementer" or feedback.task_id != "implement" or feedback.attempt != attempt: raise ValueError("Worker feedback must identify its actual implementation role, stage and attempt") run = await self.store.get_run(identifier) diff --git a/src/firefly_forge/journal.py b/src/firefly_forge/journal.py index 656cb901..203b86f1 100644 --- a/src/firefly_forge/journal.py +++ b/src/firefly_forge/journal.py @@ -856,15 +856,42 @@ async def worker_messages(self, run_id: str) -> list[Data]: return [entry for entry in entries if entry["kind"] in {"worker_feedback", "worker_response"}] async def worker_feedback(self, run_id: str, feedback: Any) -> Data: - from firefly_forge.agent_roles import BINDINGS, WorkerFeedback + from firefly_forge.agent_roles import NativeAllowanceWorkerFeedback, worker_feedback_model - feedback = WorkerFeedback.model_validate(feedback) + feedback = worker_feedback_model(feedback).model_validate(feedback) + if isinstance(feedback, NativeAllowanceWorkerFeedback): + from firefly_forge.provider_recoveries import ProviderRecoveries + + run = await self.store.get_run(run_id) + actor = (run.get("provider_recovery") or {}).get("actor") + if not actor: + raise ValueError("Native final feedback requires its original allowance actor") + async with ProviderRecoveries(self.store, self, None)._locked(run_id, actor): + return await self._worker_feedback(run_id, feedback) + return await self._worker_feedback(run_id, feedback) + + async def _worker_feedback(self, run_id: str, feedback: Any) -> Data: + from firefly_forge.agent_roles import BINDINGS, NativeAllowanceWorkerFeedback, worker_feedback_model + + model = worker_feedback_model(feedback) async with self.store._unit(): run = await self._run(run_id) - payload = WorkerFeedback.model_validate_json( - self._worker_payload(run, feedback.model_dump_json()) - ).model_dump(mode="json") + payload = model.model_validate_json(self._worker_payload(run, feedback.model_dump_json())).model_dump( + mode="json" + ) entries = await self.records.find_for_run(run.id) + if isinstance(feedback, NativeAllowanceWorkerFeedback): + from firefly_forge.provider_recovery_allowances import sealed_allowance + + task = await self._task(run_id, "implement") + native_entries = [{**copy.deepcopy(e.data), "digest": e.digest} for e in entries] + if not self.verify_chain(native_entries): + raise ValueError("Native final feedback requires an intact allowance journal") + link = sealed_allowance(run.data, {**task.data, "attempt": task.attempt}, native_entries) + if run.data["provider_recovery"].get("status") not in {"consumed", "completed"} or any( + payload[name] != value for name, value in link.items() + ): + raise ValueError("Native final feedback does not match its consumed allowance") key = _worker_message_key("worker-feedback", feedback.id) existing = next( (entry for entry in entries if entry.kind == "worker_feedback" and entry.idempotency_key == key), None @@ -876,6 +903,10 @@ async def worker_feedback(self, run_id: str, feedback: Any) -> Data: if run.status in TERMINAL: raise ValueError("A terminal run cannot accept late worker feedback") task = await self._task(run_id, feedback.task_id) + if isinstance(feedback, NativeAllowanceWorkerFeedback) and ( + run.status != "developing" or task.status != "running" + ): + raise ValueError("New final allowance feedback requires its active implementation") if ( run.data.get("provider_recovery") and feedback.task_id == "implement" diff --git a/src/firefly_forge/provider_recoveries.py b/src/firefly_forge/provider_recoveries.py index 8fba4e5c..07b92b03 100644 --- a/src/firefly_forge/provider_recoveries.py +++ b/src/firefly_forge/provider_recoveries.py @@ -219,7 +219,9 @@ async def _settled(self, run: Data, *, idle: bool) -> None: ): raise ValueError("An unknown remote effect requires reconciliation") - async def _candidate(self, run: Data, agent: str, *, adopt_current_executable: bool = False) -> Data: + async def _candidate( + self, run: Data, agent: str, *, adopt_current_executable: bool = False, final_allowance: bool = False + ) -> Data: operator = CURRENT_PRINCIPAL.get() if operator is None: raise AccessDenied("A current native recovery operator is required") @@ -236,7 +238,20 @@ async def _candidate(self, run: Data, agent: str, *, adopt_current_executable: b raise ValueError("Recovery must explicitly retain the original official provider") used = len(dispatch_keys(entries)) attempt = max(used, task["attempt"]) - if type(attempt) is not int or not 1 <= attempt < 4 or used >= 4: + if final_allowance: + if ( + type(task["attempt"]) is not int + or task["attempt"] != 4 + or type(run.get("attempt")) is not int + or run["attempt"] != 4 + or used != 4 + or task["status"] != "failed" + or any(e["kind"] == "provider_recovery_allowance" for e in entries) + ): + raise ValueError( + "The final allowance requires the fourth failed native dispatch and no prior reservation" + ) + elif type(attempt) is not int or not 1 <= attempt < 4 or used >= 4: raise ValueError("The four-dispatch provider recovery budget is exhausted") if any( e["kind"] == "provider_recovery_admission" @@ -252,6 +267,18 @@ async def _candidate(self, run: Data, agent: str, *, adopt_current_executable: b ), None, ) + if final_allowance and ( + observed is None + or observed["payload"].get("provenance") != "native_cli_process_observation" + or not any( + e["kind"] == "provider_dispatch_intent" + and e["payload"].get("task_key") == task["idempotency_key"] + and type(e["payload"].get("attempt")) is int + and e["payload"]["attempt"] == 4 + for e in entries + ) + ): + raise ValueError("The final allowance requires an actual native process failure receipt") if not observed and ( task.get("provider_recovery_id") or any( @@ -292,6 +319,10 @@ async def _candidate(self, run: Data, agent: str, *, adopt_current_executable: b "native_process_digest": observed["digest"] if observed else None, "cut": self._cut({**run, "provider_recovery": prospective}), } + if final_allowance: + from firefly_forge.provider_recovery_allowances import PROTOCOL + + payload["protocol"] = PROTOCOL if adoption: payload["executable_adoption"] = adoption return {**payload, "digest": digest(payload)} @@ -325,6 +356,8 @@ async def get(self, run_id: str, request_id: str) -> Data: if found is None: raise KeyError("The original provider recovery receipt was not found") await self._operator(run_id) + if found.get("protocol") == "native-provider-recovery-allowance-v1": + raise ValueError("Read the final allowance through /provider-recoveries/allowances/" + request_id) self.store.reject_credentials(found) return copy.deepcopy(found) @@ -431,6 +464,16 @@ async def admit(self, run_id: str, body: ProviderRecoveryAdmission) -> Data: raise ValueError("The recovery cut changed before commit") return copy.deepcopy(receipt) + async def _allowance(self, run: Data) -> Data | None: + async with self.store._unit(): + task = self._task_view(await self.journal._task(run["id"], "implement")) + state = run.get("provider_recovery") or {} + if task["attempt"] <= 4 and not state.get("allowance_id") and not state.get("allowance_digest"): + return None + from firefly_forge.provider_recovery_allowances import sealed_allowance + + return sealed_allowance(run, task, await self._entries(run["id"])) + async def begin(self, run_id: str) -> Data | None: run = await self.store.get_run(run_id) state = run.get("provider_recovery") or {} @@ -438,16 +481,19 @@ async def begin(self, run_id: str) -> Data | None: return None if state.get("status") != "reserved": raise ValueError("The consumed provider dispatch requires reconciliation") + await self._allowance(run) return {"idempotency_key": state["task_key"], "disposition": "execute", "attempt": state["attempt"]} async def prepare_workspace(self, run_id: str, inspect: Any) -> Data: initial = await self.store.get_run(run_id) state = initial.get("provider_recovery") or {} + await self._allowance(initial) if state.get("status") != "reserved" or self._cut(initial) != state["cut"]: raise ValueError("The reserved preparation cut changed") observed: Data = await inspect() async with self._locked(run_id, state["actor"]) as row: current = row.data.get("provider_recovery") or {} + await self._allowance(row.data) if current != state or current.get("workspace_prepared"): raise ValueError("The original reserved preparation is unavailable") after = self._cut(row.data) @@ -479,6 +525,7 @@ async def dispatch(self, run_id: str, key: str) -> None: state = initial.get("provider_recovery") or {} async with self._locked(run_id, state["actor"]) as row: current = row.data.get("provider_recovery") or {} + await self._allowance(row.data) if current.get("status") != "reserved" or current.get("task_key") != key: raise ValueError("The provider dispatch reservation was consumed or changed") task = await self.journal._task(run_id, "implement") @@ -516,6 +563,7 @@ async def assert_dispatch(self, run_id: str, key: str) -> None: state = run.get("provider_recovery") or {} async with self._locked(run_id, state["actor"]) as row: current = row.data.get("provider_recovery") or {} + await self._allowance(row.data) if current.get("status") != "consumed" or current.get("task_key") != key: raise ValueError("The original provider dispatch is unavailable") if row.status in {"failed", "cancelled", "succeeded", "needs_reconciliation"}: @@ -614,6 +662,7 @@ async def assert_result(self, run_id: str, key: str) -> None: raise ValueError("The local result context changed during native commit") async def _result_cut(self, row: Any, key: str, state: Data) -> None: + await self._allowance(row.data) task = await self.journal._task(row.data["id"], "implement") if task.data.get("idempotency_key") != key: raise ProviderAttemptClosed("The provider result belongs to a closed old attempt") diff --git a/src/firefly_forge/provider_recovery_allowance_controllers.py b/src/firefly_forge/provider_recovery_allowance_controllers.py new file mode 100644 index 00000000..b96a9683 --- /dev/null +++ b/src/firefly_forge/provider_recovery_allowance_controllers.py @@ -0,0 +1,30 @@ +# Copyright 2026 Firefly Software Foundation. SPDX-License-Identifier: Apache-2.0 +"""Explicit administrator review, reservation and exact final-allowance readback.""" + +from typing import Any + +from pyfly.container import rest_controller +from pyfly.web import Body, PathVar, QueryParam, get_mapping, post_mapping, request_mapping + +from firefly_forge.provider_recovery_allowances import ProviderRecoveryAllowanceInput, ProviderRecoveryAllowances + + +@rest_controller +@request_mapping("/api/v1/runs/{run_id}/provider-recoveries/allowances") +class ProviderRecoveryAllowanceController: + def __init__(self, allowances: ProviderRecoveryAllowances) -> None: + self.allowances = allowances + + @get_mapping("/candidate") + async def candidate( + self, run_id: PathVar[str], agent: QueryParam[str], adopt_current_executable: QueryParam[bool] = False + ) -> dict[str, Any]: + return await self.allowances.candidate(run_id, agent, adopt_current_executable=adopt_current_executable) + + @post_mapping("", status_code=201) + async def reserve(self, run_id: PathVar[str], body: Body[ProviderRecoveryAllowanceInput]) -> dict[str, Any]: + return await self.allowances.reserve(run_id, body) + + @get_mapping("/{request_id}") + async def receipt(self, run_id: PathVar[str], request_id: PathVar[str]) -> dict[str, Any]: + return await self.allowances.get(run_id, request_id) diff --git a/src/firefly_forge/provider_recovery_allowances.py b/src/firefly_forge/provider_recovery_allowances.py new file mode 100644 index 00000000..86e20933 --- /dev/null +++ b/src/firefly_forge/provider_recovery_allowances.py @@ -0,0 +1,300 @@ +# Copyright 2026 Firefly Software Foundation. SPDX-License-Identifier: Apache-2.0 +"""Native administrator reservation for one final, fifth provider dispatch.""" + +from __future__ import annotations + +import copy +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator +from pyfly.container import service + +from firefly_forge.journal import digest +from firefly_forge.project_access import CURRENT_PRINCIPAL, AccessDenied, ProjectAccess +from firefly_forge.provider_recoveries import ProviderRecoveries +from firefly_forge.provider_recovery_models import ProviderProcessOutcome, dispatch_keys +from firefly_forge.store import identifier, now + +Data = dict[str, Any] +PROTOCOL = "native-provider-recovery-allowance-v1" + + +class ProviderRecoveryAllowanceInput(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + request_id: str = Field(pattern=r"^[a-f0-9]{32}$") + candidate_digest: str = Field(pattern=r"^[a-f0-9]{64}$") + native_process_digest: str = Field(pattern=r"^[a-f0-9]{64}$") + agent: Literal["codex", "claude"] + reason: str = Field(min_length=1, max_length=2000) + mitigation_evidence_digest: str = Field(pattern=r"^[a-f0-9]{64}$") + adopt_current_executable: Literal[True] + + @field_validator("adopt_current_executable", mode="before") + @classmethod + def explicit_executable(cls, value: Any) -> bool: + if value is not True: + raise ValueError("The final allowance requires explicit adoption of the reviewed current CLI") + return value + + @field_validator("reason") + @classmethod + def nonempty_reason(cls, value: str) -> str: + if not value.strip(): + raise ValueError("Explain the reviewed mitigation before reserving the final allowance") + return value + + +def allowance_key(run_id: str, request_id: str) -> str: + return digest({"protocol": PROTOCOL, "run": run_id, "request": request_id})[:32] + + +def sealed_allowance(run: Data, task: Data, entries: list[Data]) -> Data: + """Read the actual native WAL link; run JSON alone never grants a fifth dispatch.""" + try: + return _sealed_allowance(run, task, entries) + except (KeyError, TypeError, AttributeError) as exc: + raise ValueError("The final provider allowance record is incomplete") from exc + + +def _sealed_allowance(run: Data, task: Data, entries: list[Data]) -> Data: + state = run.get("provider_recovery") or {} + grants = [entry for entry in entries if entry["kind"] == "provider_recovery_allowance"] + if len(grants) != 1: + raise ValueError("The final provider allowance requires exactly one native reservation") + event = grants[0] + receipt = event["payload"] + body = ProviderRecoveryAllowanceInput.model_validate(receipt["request"]) + candidate = receipt["candidate"] + key = allowance_key(run["id"], body.request_id) + admission = [ + e + for e in entries + if e["kind"] == "provider_recovery_admission" and e["payload"]["request"]["request_id"] == body.request_id + ] + failures = [ + e for e in entries if e["digest"] == body.native_process_digest and e["kind"] == "provider_failure_observation" + ] + candidate_body = {k: v for k, v in candidate.items() if k != "digest"} + intents = [ + e for e in entries if e["kind"] == "intent" and e["task_id"] == "implement" and e["idempotency_key"] == key + ] + original = [ + e + for e in entries + if e["kind"] == "intent" + and e["task_id"] == "implement" + and e["idempotency_key"] == candidate["failed_task_key"] + and e["digest"] == candidate["failed_intent_digest"] + ] + frozen_cut = {k: v for k, v in candidate["cut"].items() if k != "index_digest"} + state_cut = {k: v for k, v in state["cut"].items() if k != "index_digest"} + if ( + receipt.get("protocol") != PROTOCOL + or receipt.get("version") != 2 + or event["idempotency_key"] != body.request_id + or event["task_id"] != "implement" + or receipt.get("task_key") != key + or candidate.get("protocol") != PROTOCOL + or candidate.get("digest") != digest(candidate_body) + or candidate.get("operator_digest") != digest(receipt["actor"]) + or state_cut != frozen_cut + or len(original) != 1 + or len(intents) != 1 + or intents[0]["payload"] + != { + "reason": "explicit_final_provider_allowance", + "admission_id": body.request_id, + "allowance_digest": event["digest"], + } + or body.candidate_digest != candidate["digest"] + or candidate.get("run_id") != run["id"] + or candidate.get("agent") != body.agent + or body.agent != run["agent"] + or candidate.get("next_attempt") != 5 + or candidate.get("dispatches_used") != 4 + or candidate.get("journal_digest") != event["previous_hash"] + or candidate.get("native_process_digest") != body.native_process_digest + or candidate.get("process_evidence") != "native_cli_process_receipt" + or receipt.get("previous_task", {}).get("attempt") != 4 + or receipt.get("previous_task", {}).get("status") != "failed" + or receipt.get("previous_task", {}).get("idempotency_key") != candidate.get("failed_task_key") + or len(failures) != 1 + or failures[0]["payload"].get("provenance") != "native_cli_process_observation" + or failures[0]["payload"].get("task_key") != candidate.get("failed_task_key") + or not ProviderProcessOutcome.model_validate(failures[0]["payload"]["process"]).known_failure + or len(admission) != 1 + or admission[0]["payload"] != {**receipt, "allowance_digest": event["digest"]} + or state.get("request_id") != body.request_id + or state.get("allowance_id") != body.request_id + or state.get("allowance_digest") != event["digest"] + or state.get("actor") != receipt.get("actor") + or state.get("task_key") != key + or state.get("attempt") != 5 + or task.get("attempt") != 5 + or task.get("idempotency_key") != key + or task.get("provider_recovery_id") != body.request_id + or state.get("executable_adoption") != candidate.get("executable_adoption") + or len(dispatch_keys(entries)) != 5 + ): + raise ValueError("The final provider allowance linkage changed") + return {"allowance_id": body.request_id, "allowance_digest": event["digest"], "protocol": PROTOCOL} + + +@service +class ProviderRecoveryAllowances: + def __init__(self, recoveries: ProviderRecoveries) -> None: + self.recoveries = recoveries + + async def candidate(self, run_id: str, agent: str, *, adopt_current_executable: bool = False) -> Data: + identifier(run_id) + if adopt_current_executable is not True: + raise ValueError("The final allowance requires explicit current CLI adoption") + r = self.recoveries + await r._operator(run_id) + async with r.store._unit(): + value = await r._candidate( + await r.store.get_run(run_id), + agent, + adopt_current_executable=adopt_current_executable, + final_allowance=True, + ) + await r._operator(run_id) + r.store.reject_credentials(value) + return value + + async def get(self, run_id: str, request_id: str) -> Data: + identifier(request_id) + r = self.recoveries + actor = await r._operator(run_id) + snapshot = { + **ProjectAccess.actor_snapshot(actor), + "identity_digest": digest({"issuer": actor.issuer, "subject": actor.subject}), + } + async with r._locked(run_id, snapshot): + entries = await r._entries(run_id) + found = next( + ( + e["payload"] + for e in entries + if e["kind"] == "provider_recovery_allowance" and e["idempotency_key"] == request_id + ), + None, + ) + if found is None: + raise KeyError("The original final provider allowance was not found") + if found["actor"] != snapshot: + raise AccessDenied("Only the original current operator can read this allowance receipt") + r.store.reject_credentials(found) + return copy.deepcopy(found) + + async def reserve(self, run_id: str, body: ProviderRecoveryAllowanceInput) -> Data: + r = self.recoveries + actor = await r._operator(run_id) + snapshot = { + **ProjectAccess.actor_snapshot(actor), + "identity_digest": digest({"issuer": actor.issuer, "subject": actor.subject}), + } + request = body.model_dump() + r.store.reject_credentials(request) + async with r._locked(run_id, snapshot) as row: + entries = await r._entries(run_id) + grants = [e for e in entries if e["kind"] == "provider_recovery_allowance"] + if grants: + prior = grants[0]["payload"] + if len(grants) != 1 or prior["request"] != request or prior["actor"] != snapshot: + raise ValueError("The one lifetime final allowance is already reserved") + return copy.deepcopy(prior) + candidate = await r._candidate( + row.data, body.agent, adopt_current_executable=body.adopt_current_executable, final_allowance=True + ) + if ( + candidate["digest"] != body.candidate_digest + or candidate["native_process_digest"] != body.native_process_digest + ): + raise ValueError("The reviewed final allowance candidate changed") + task = await r.journal._task(run_id, "implement") + if CURRENT_PRINCIPAL.get() != actor: + raise AccessDenied("The final allowance operator changed before reservation") + prospective = {"status": "reserved", "executable_adoption": candidate.get("executable_adoption")} + if r._cut({**row.data, "provider_recovery": prospective}) != candidate["cut"]: + raise ValueError("The final allowance execution cut changed") + key = allowance_key(run_id, body.request_id) + receipt = dict( + version=2, + protocol=PROTOCOL, + request=request, + candidate=candidate, + actor=snapshot, + task_key=key, + previous_task=r._task_view(task), + process_evidence={"provenance": "native_cli_process_receipt", "digest": body.native_process_digest}, + mitigation_evidence={ + "provenance": "administrator_attestation", + "digest": body.mitigation_evidence_digest, + }, + ) + r.store.reject_credentials(receipt) + event = await r.journal._append( + run_id, "implement", body.request_id, "provider_recovery_allowance", receipt + ) + seal = event["digest"] + await r.journal._append( + run_id, + "implement", + body.request_id, + "provider_recovery_admission", + {**receipt, "allowance_digest": seal}, + ) + await r.journal._append( + run_id, + "implement", + candidate["failed_task_key"], + "provider_failure_closed", + { + "provenance": "native_cli_process_receipt", + "admission_id": body.request_id, + "failed_intent_digest": candidate["failed_intent_digest"], + "allowance_digest": seal, + }, + ) + await r.journal._append( + run_id, + "implement", + key, + "intent", + { + "reason": "explicit_final_provider_allowance", + "admission_id": body.request_id, + "allowance_digest": seal, + }, + ) + task.status, task.attempt = "queued", 5 + task.data = {"idempotency_key": key, "provider_recovery_id": body.request_id} + await r.journal.tasks.save(task) + row.status = "queued" + row.data = { + **row.data, + "status": "queued", + "resume_existing": True, + "updated_at": now(), + "provider_recovery": dict( + request_id=body.request_id, + task_key=key, + status="reserved", + attempt=5, + cut=candidate["cut"], + actor=snapshot, + allowance_id=body.request_id, + allowance_digest=seal, + **( + {"executable_adoption": candidate["executable_adoption"]} + if body.adopt_current_executable + else {} + ), + ), + } + await r.store.run_records.save(row) + if CURRENT_PRINCIPAL.get() != actor or r._cut(row.data) != candidate["cut"]: + raise ValueError("The final allowance authority or execution cut changed before commit") + sealed_allowance(row.data, r._task_view(task), await r._entries(run_id)) + return copy.deepcopy(receipt) diff --git a/studio/src/WorkerDialogueData.test.ts b/studio/src/WorkerDialogueData.test.ts index 5e8a7843..b1571e01 100644 --- a/studio/src/WorkerDialogueData.test.ts +++ b/studio/src/WorkerDialogueData.test.ts @@ -759,3 +759,41 @@ describe("exclusive worker reply claims", () => { }, ); }); + +it("reads a distinct native final allowance feedback without widening legacy attempts", async () => { + const original = await question(); + const { digest: _digest, ...body } = original; + void _digest; + const payload = { + ...body.payload, + attempt: 5, + protocol: "native-provider-recovery-allowance-v1", + allowance_id: "c".repeat(32), + allowance_digest: "d".repeat(64), + }; + const final = await seal({ ...body, payload }); + const threads = await workerThreads([final], run); + expect(threads).toHaveLength(1); + expect(threads[0].event.payload.attempt).toBe(5); + for (const change of [ + { protocol: "legacy" }, + { attempt: 6 }, + { allowance_digest: "bad" }, + { allowance_id: "bad" }, + { task_id: "verify" }, + { role_id: "architect" }, + ]) { + await expect( + workerThreads( + [await seal({ ...body, payload: { ...payload, ...change } })], + run, + ), + ).rejects.toThrow(); + } + await expect( + workerThreads( + [await seal({ ...body, payload: { ...body.payload, attempt: 5 } })], + run, + ), + ).rejects.toThrow(); +}); diff --git a/studio/src/WorkerDialogueData.ts b/studio/src/WorkerDialogueData.ts index b7cca035..c4bcdf8e 100644 --- a/studio/src/WorkerDialogueData.ts +++ b/studio/src/WorkerDialogueData.ts @@ -195,10 +195,23 @@ export async function workerThreads( for (const event of events) { const payload = event.payload; if (event.kind === "worker_feedback") { + const nativeAllowance = + payload.protocol === "native-provider-recovery-allowance-v1"; assert( Object.keys(payload).sort().join(",") === - "attempt,disposition,evidence_paths,id,request,role_id,summary,task_id", + (nativeAllowance + ? "allowance_digest,allowance_id,attempt,disposition,evidence_paths,id,protocol,request,role_id,summary,task_id" + : "attempt,disposition,evidence_paths,id,request,role_id,summary,task_id"), ); + if (nativeAllowance) { + matches(payload.allowance_id, id); + matches(payload.allowance_digest, hash); + assert( + payload.attempt === 5 && + payload.task_id === "implement" && + payload.role_id === "implementer", + ); + } assert(payload.task_id === event.task_id); assert( [ @@ -225,7 +238,9 @@ export async function workerThreads( assert( Number.isInteger(payload.attempt) && (payload.attempt as number) >= 1 && - (payload.attempt as number) <= 4, + (nativeAllowance + ? payload.attempt === 5 + : (payload.attempt as number) <= 4), ); if (payload.request == null) continue; assert( diff --git a/tests/test_provider_recovery_allowance_host.py b/tests/test_provider_recovery_allowance_host.py new file mode 100644 index 00000000..44bf410c --- /dev/null +++ b/tests/test_provider_recovery_allowance_host.py @@ -0,0 +1,322 @@ +# Copyright 2026 Firefly Software Foundation. SPDX-License-Identifier: Apache-2.0 +"""Explicit final allowance over the real recovery service and journal (host storage only).""" + +import copy +from uuid import uuid4 + +import pytest +from pydantic import ValidationError +from test_provider_recovery_service import configure_executable +from test_provider_recovery_service import recovery as recovery + +from firefly_forge.provider_recovery_models import ProviderProcessOutcome + + +async def fourth_failure(e, *, known=True): + for key in ("3" * 32, "4" * 32): + await e.journal._append(e.memory.id.hex, "implement", key, "intent", {}) + e.memory.task.attempt = 4 + e.memory.task.data = {"idempotency_key": "4" * 32} + e.memory.run.data["attempt"] = 4 + await e.journal._append( + e.memory.id.hex, + "implement", + "4" * 32 + ":provider", + "provider_dispatch_intent", + {"version": 1, "task_key": "4" * 32, "attempt": 4}, + ) + await e.service.record_process( + e.memory.id.hex, + "4" * 32, + ProviderProcessOutcome(exit_code=-25, leader_waited=True, streams_drained=True, group_absent=known), + ) + + +def request(candidate, **changes): + from firefly_forge.provider_recovery_allowances import ProviderRecoveryAllowanceInput + + return ProviderRecoveryAllowanceInput.model_validate( + dict( + request_id="a" * 32, + candidate_digest=candidate["digest"], + native_process_digest=candidate["native_process_digest"], + agent="codex", + reason="Reviewed a finite file-limit signal mitigation in controlled local tests.", + mitigation_evidence_digest="e" * 64, + adopt_current_executable=True, + ) + | changes + ) + + +def allowances(e): + from firefly_forge.provider_recovery_allowances import ProviderRecoveryAllowances + + configure_executable(e, e.workspace.parent.parent) + return ProviderRecoveryAllowances(e.service) + + +async def test_final_allowance_reserves_once_preserves_prefix_and_legacy_budget(recovery): + e = recovery + await fourth_failure(e) + before = copy.deepcopy(e.memory.run.data) + prefix = await e.service._entries(e.memory.id.hex) + with pytest.raises(ValueError, match="four-dispatch"): + await e.service.candidate(e.memory.id.hex, "codex") + service = allowances(e) + candidate = await service.candidate(e.memory.id.hex, "codex", adopt_current_executable=True) + assert candidate["next_attempt"] == 5 and candidate["dispatches_used"] == 4 + body = request(candidate) + receipt = await service.reserve(e.memory.id.hex, body) + assert receipt == await service.reserve(e.memory.id.hex, body) + assert receipt == await service.get(e.memory.id.hex, body.request_id) + entries = await e.service._entries(e.memory.id.hex) + assert entries[: len(prefix)] == prefix + assert sum(v["kind"] == "provider_recovery_allowance" for v in entries) == 1 + assert sum(v["kind"] == "provider_recovery_admission" for v in entries) == 1 + assert e.memory.task.attempt == 5 and e.memory.task.status == "queued" + for key in before: + if key not in {"status", "updated_at", "provider_recovery", "resume_existing"}: + assert e.memory.run.data[key] == before[key] + for changed in (dict(request_id=uuid4().hex), dict(reason="Different mitigation")): + with pytest.raises(ValueError): + await service.reserve(e.memory.id.hex, request(candidate, **changed)) + with pytest.raises(ValueError): + await service.candidate(e.memory.id.hex, "codex", adopt_current_executable=True) + await e.service.begin(e.memory.id.hex) + key = e.memory.task.data["idempotency_key"] + await e.service.dispatch(e.memory.id.hex, key) + with pytest.raises(ValueError): + await e.service.dispatch(e.memory.id.hex, key) + e.memory.run.status = e.memory.run.data["status"] = "developing" + await e.service.assert_launch(e.memory.id.hex, key) + await e.service.assert_result(e.memory.id.hex, key) + await e.service.record_process( + e.memory.id.hex, + key, + ProviderProcessOutcome(exit_code=1, leader_waited=True, streams_drained=True, group_absent=True), + ) + e.memory.run.status = e.memory.run.data["status"] = "failed" + with pytest.raises(ValueError): + await service.candidate(e.memory.id.hex, "codex", adopt_current_executable=True) + assert receipt == await service.reserve(e.memory.id.hex, body) + assert sum(v["kind"] == "provider_recovery_allowance" for v in await e.service._entries(e.memory.id.hex)) == 1 + + +@pytest.mark.parametrize("mutation", ["unknown", "missing_native", "third_attempt", "source", "tail"]) +async def test_allowance_denies_unknown_legacy_stale_and_nonfinal_failures(recovery, mutation): + e = recovery + await fourth_failure(e, known=mutation != "unknown") + if mutation == "missing_native": + e.memory.rows.pop() + if mutation == "third_attempt": + e.memory.task.attempt = 3 + service = allowances(e) + if mutation in {"source", "tail"}: + candidate = await service.candidate(e.memory.id.hex, "codex", adopt_current_executable=True) + if mutation == "source": + (e.workspace / "partial.py").write_text("CHANGED = True\n") + else: + await e.journal._append(e.memory.id.hex, "implement", "new", "note", {}) + with pytest.raises(ValueError): + await service.reserve(e.memory.id.hex, request(candidate)) + else: + with pytest.raises(ValueError): + await service.candidate(e.memory.id.hex, "codex", adopt_current_executable=True) + assert not any(v.kind == "provider_recovery_allowance" for v in e.memory.rows) + + +@pytest.mark.parametrize("boundary", ["begin", "dispatch", "launch", "result"]) +async def test_fifth_attempt_requires_its_exact_sealed_native_allowance_at_every_boundary(recovery, boundary): + e = recovery + await fourth_failure(e) + service = allowances(e) + await service.reserve( + e.memory.id.hex, request(await service.candidate(e.memory.id.hex, "codex", adopt_current_executable=True)) + ) + key = e.memory.task.data["idempotency_key"] + if boundary in {"launch", "result"}: + await e.service.dispatch(e.memory.id.hex, key) + e.memory.run.status = e.memory.run.data["status"] = "developing" + e.memory.run.data["provider_recovery"]["allowance_digest"] = "0" * 64 + with pytest.raises(ValueError, match="allowance"): + if boundary == "begin": + await e.service.begin(e.memory.id.hex) + elif boundary == "dispatch": + await e.service.dispatch(e.memory.id.hex, key) + elif boundary == "launch": + await e.service.assert_launch(e.memory.id.hex, key) + else: + await e.service.assert_result(e.memory.id.hex, key) + + +async def test_allowance_input_never_accepts_legacy_proof_or_budget_overrides(recovery): + await fourth_failure(recovery) + candidate = await allowances(recovery).candidate(recovery.memory.id.hex, "codex", adopt_current_executable=True) + for change in ( + dict(reason=" "), + dict(mitigation_evidence_digest="bad"), + dict(legacy_attestation={}), + dict(next_attempt=6), + dict(repair_budget=1), + dict(agent="bridge"), + ): + with pytest.raises(ValidationError): + request(candidate, **change) + + +def feedback_payload(link): + return dict( + id="final-feedback", + role_id="implementer", + task_id="implement", + attempt=5, + disposition="completed", + summary="Implementation reported; verification is a separate stage.", + evidence_paths=[], + request=None, + **link, + ) + + +async def test_native_fifth_feedback_requires_real_consumed_allowance_and_legacy_stays_strict(recovery): + from firefly_forge.agent_roles import WorkerFeedback + + e = recovery + await fourth_failure(e) + service = allowances(e) + body = request(await service.candidate(e.memory.id.hex, "codex", adopt_current_executable=True)) + await service.reserve(e.memory.id.hex, body) + state = e.memory.run.data["provider_recovery"] + value = feedback_payload( + dict( + protocol="native-provider-recovery-allowance-v1", + allowance_id=body.request_id, + allowance_digest=state["allowance_digest"], + ) + ) + with pytest.raises(ValidationError): + WorkerFeedback.model_validate(value) + with pytest.raises(ValueError): + await e.journal.worker_feedback(e.memory.id.hex, value) + await e.service.dispatch(e.memory.id.hex, state["task_key"]) + e.memory.run.status = e.memory.run.data["status"] = "developing" + for change in (dict(allowance_digest="0" * 64), dict(allowance_id="f" * 32), dict(attempt=6)): + with pytest.raises(ValueError): + await e.journal.worker_feedback(e.memory.id.hex, value | change) + recorded = await e.journal.worker_feedback(e.memory.id.hex, value) + assert recorded["payload"] == value + assert await e.journal.worker_feedback(e.memory.id.hex, value) == recorded + assert sum(v.kind == "worker_feedback" for v in e.memory.rows) == 1 + + +async def test_legacy_receipt_route_refuses_new_protocol_without_widening(recovery): + e = recovery + await fourth_failure(e) + service = allowances(e) + body = request(await service.candidate(e.memory.id.hex, "codex", adopt_current_executable=True)) + await service.reserve(e.memory.id.hex, body) + with pytest.raises(ValueError, match="/provider-recoveries/allowances/"): + await e.service.get(e.memory.id.hex, body.request_id) + + +async def test_final_allowance_requires_explicit_current_cli_and_rejects_binary_replacement(recovery): + e = recovery + await fourth_failure(e) + service = allowances(e) + for choice in (False, 1, None): + with pytest.raises(ValueError): + await service.candidate(e.memory.id.hex, "codex", adopt_current_executable=choice) + candidate = await service.candidate(e.memory.id.hex, "codex", adopt_current_executable=True) + for change in (dict(adopt_current_executable=False), dict(adopt_current_executable=1)): + with pytest.raises(ValidationError): + request(candidate, **change) + body = request(candidate) + with pytest.raises(ValidationError): + type(body).model_validate(body.model_dump(exclude={"adopt_current_executable"})) + await service.reserve(e.memory.id.hex, body) + key = e.memory.task.data["idempotency_key"] + await e.service.dispatch(e.memory.id.hex, key) + e.memory.run.status = e.memory.run.data["status"] = "developing" + (e.workspace.parent.parent / "official-cli").write_text("#!/bin/sh\nexit 7\n") + with pytest.raises(ValueError, match="executable changed"): + await e.service.assert_launch(e.memory.id.hex, key) + + +async def test_final_allowance_current_revocation_blocks_dispatch_and_receipt_readback(recovery, monkeypatch): + from firefly_forge.project_access import AccessDenied, ProjectAccess + + e = recovery + await fourth_failure(e) + service = allowances(e) + body = request(await service.candidate(e.memory.id.hex, "codex", adopt_current_executable=True)) + await service.reserve(e.memory.id.hex, body) + prefix = await e.service._entries(e.memory.id.hex) + + async def denied(*args, **kwargs): + return False + + monkeypatch.setattr(ProjectAccess, "authorize_actor", denied) + with pytest.raises(AccessDenied): + await e.service.dispatch(e.memory.id.hex, e.memory.task.data["idempotency_key"]) + with pytest.raises(AccessDenied): + await service.get(e.memory.id.hex, body.request_id) + assert await e.service._entries(e.memory.id.hex) == prefix + + +async def test_final_feedback_replays_exact_receipt_after_completion_but_refuses_new_late_message(recovery): + e = recovery + await fourth_failure(e) + service = allowances(e) + body = request(await service.candidate(e.memory.id.hex, "codex", adopt_current_executable=True)) + await service.reserve(e.memory.id.hex, body) + state = e.memory.run.data["provider_recovery"] + await e.service.dispatch(e.memory.id.hex, state["task_key"]) + e.memory.run.status = e.memory.run.data["status"] = "developing" + value = feedback_payload( + dict( + protocol="native-provider-recovery-allowance-v1", + allowance_id=body.request_id, + allowance_digest=state["allowance_digest"], + ) + ) + first = await e.journal.worker_feedback(e.memory.id.hex, value) + e.memory.task.status = "done" + e.memory.run.status = e.memory.run.data["status"] = "validating" + assert await e.journal.worker_feedback(e.memory.id.hex, value) == first + with pytest.raises(ValueError, match="active implementation"): + await e.journal.worker_feedback(e.memory.id.hex, value | {"id": "late-final-feedback"}) + assert sum(v.kind == "worker_feedback" for v in e.memory.rows) == 1 + + +async def test_final_allowance_native_repository_reads_stay_inside_the_store_transaction(recovery): + from contextlib import asynccontextmanager + + e = recovery + await fourth_failure(e) + service = allowances(e) + await service.reserve( + e.memory.id.hex, request(await service.candidate(e.memory.id.hex, "codex", adopt_current_executable=True)) + ) + original_unit, original_task = e.memory._unit, e.journal._task + depth = 0 + + @asynccontextmanager + async def unit(): + nonlocal depth + depth += 1 + try: + async with original_unit() as session: + yield session + finally: + depth -= 1 + + async def native_task(*args): + if not depth: + raise AssertionError("Native repository access requires a transaction") + return await original_task(*args) + + e.memory._unit, e.journal._task = unit, native_task + dispatch = await e.service.begin(e.memory.id.hex) + assert dispatch["attempt"] == 5 and dispatch["disposition"] == "execute" + assert depth == 0 diff --git a/tests/test_provider_recovery_allowance_native.py b/tests/test_provider_recovery_allowance_native.py new file mode 100644 index 00000000..939dcfaf --- /dev/null +++ b/tests/test_provider_recovery_allowance_native.py @@ -0,0 +1,323 @@ +# Copyright 2026 Firefly Software Foundation. SPDX-License-Identifier: Apache-2.0 +"""Real PostgreSQL/WAL/Engine; coding providers and command runners are controlled fixtures.""" + +import asyncio +import copy +from uuid import UUID, uuid4 + +import pytest +from test_provider_recovery_native import legacy_failure +from worker_reply_fixtures import authenticated_operator + +from firefly_forge.configuration import ProviderConfiguration +from firefly_forge.identity_persistence import ForgeIdentityUserRepository +from firefly_forge.journal import Journal +from firefly_forge.project_access import AccessDenied +from firefly_forge.provider_recovery_allowances import ProviderRecoveryAllowanceInput, ProviderRecoveryAllowances +from firefly_forge.provider_recovery_models import ProviderProcessOutcome, ProviderRecoveryAdmission +from firefly_forge.providers import AgentResult, ProviderError + +OUTCOME = ProviderProcessOutcome(exit_code=-25, leader_waited=True, streams_drained=True, group_absent=True) + + +def allowance_request(candidate, **changes): + return ProviderRecoveryAllowanceInput.model_validate( + dict( + request_id=uuid4().hex, + candidate_digest=candidate["digest"], + native_process_digest=candidate["native_process_digest"], + agent="codex", + reason="Reviewed the finite CLI signal mitigation against controlled local fixtures.", + mitigation_evidence_digest="e" * 64, + adopt_current_executable=True, + ) + | changes + ) + + +async def failed_fourth(store, tmp_path, monkeypatch, *, unknown_fourth=False): + env = await legacy_failure(store, tmp_path, monkeypatch) + run_id = env.run["id"] + async with store._unit(): + task = await env.journal._task(run_id, "implement") + await env.recovery.record_process(run_id, task.data["idempotency_key"], OUTCOME) + + class FailedNativeProvider: + async def execute(self, workspace, *args): + (workspace / "retained-note.txt").write_text("Native controlled failure keeps partial source\n") + error = ProviderError("Controlled native CLI failure; no inference was performed") + attempt = (await store.get_run(run_id))["attempt"] + error.process_outcome = ( + OUTCOME.model_copy(update={"group_absent": False}) if unknown_fourth and attempt == 4 else OUTCOME + ) + raise error + + env.engine.provider_factory = lambda _: FailedNativeProvider() + for attempt in (3, 4): + candidate = await env.recovery.candidate(run_id, "codex") + assert candidate["next_attempt"] == attempt + await env.recovery.admit( + run_id, + ProviderRecoveryAdmission( + request_id=uuid4().hex, + candidate_digest=candidate["digest"], + agent="codex", + native_process_digest=candidate["native_process_digest"], + ), + ) + await env.engine.execute_run(run_id) + expected = "needs_reconciliation" if unknown_fourth and attempt == 4 else "failed" + assert (await store.get_run(run_id))["status"] == expected + executable = tmp_path / "reviewed-fixture-cli" + executable.write_text("#!/bin/sh\nexit 0\n") + executable.chmod(0o755) + store.configuration.update("codex", ProviderConfiguration(values={"executable": str(executable)})) + env.allowances = ProviderRecoveryAllowances(env.recovery) + return env + + +async def test_native_final_allowance_engine_executes_once_preserves_grants_and_original_wal( + store, tmp_path, monkeypatch +): + async with authenticated_operator(store): + env = await failed_fourth(store, tmp_path, monkeypatch) + run_id = env.run["id"] + before = await store.get_run(run_id) + prefix = await env.journal.entries(run_id) + with pytest.raises(ValueError, match="four-dispatch"): + await env.recovery.candidate(run_id, "codex") + body = allowance_request(await env.allowances.candidate(run_id, "codex", adopt_current_executable=True)) + first, repeated = await asyncio.wait_for( + asyncio.gather(env.allowances.reserve(run_id, body), env.allowances.reserve(run_id, body)), 10 + ) + assert first == repeated == await env.allowances.get(run_id, body.request_id) + calls = [] + + class FinalProvider: + async def execute(self, workspace, *args): + calls.append(workspace) + assert ( + workspace / "retained-note.txt" + ).read_text() == "Native controlled failure keeps partial source\n" + (workspace / "partial.py").write_text("VALUE = 2\n") + state = (await store.get_run(run_id))["provider_recovery"] + return AgentResult( + "Controlled retained implementation", + "fixture", + { + "worker_feedback": dict( + id="final-native-feedback", + role_id="implementer", + task_id="implement", + attempt=5, + disposition="completed", + summary="Implementation reported; checks remain independent.", + evidence_paths=["partial.py"], + request=None, + protocol="native-provider-recovery-allowance-v1", + allowance_id=body.request_id, + allowance_digest=state["allowance_digest"], + ) + }, + ) + + from firefly_forge import providers + + env.engine.configured_provider = True + monkeypatch.setattr(providers, "get_provider", lambda agent, settings: FinalProvider()) + await env.engine.execute_run(run_id) + final = await store.get_run(run_id) + assert final["status"] == "succeeded", final.get("error") + assert len(calls) == 1 and final["attempt"] == 5 + assert final["provider_recovery"]["status"] == "completed" + assert final["worker_feedback"]["attempt"] == 5 + for name in ("repair_budget", "verification_handoff", "config_revision", "worker_response"): + assert final[name] == before[name] + entries = await env.journal.entries(run_id) + assert entries[: len(prefix)] == prefix and Journal.verify_chain(entries) + assert sum(e["kind"] == "provider_recovery_allowance" for e in entries) == 1 + assert sum(e["kind"] == "provider_dispatch_intent" and e["payload"]["attempt"] == 5 for e in entries) == 1 + assert len(env.backend_calls) == 14 + assert first == await env.allowances.reserve(run_id, body) + + +async def test_native_competing_allowances_reserve_one_even_when_final_dispatch_fails(store, tmp_path, monkeypatch): + async with authenticated_operator(store): + env = await failed_fourth(store, tmp_path, monkeypatch) + run_id = env.run["id"] + candidate = await env.allowances.candidate(run_id, "codex", adopt_current_executable=True) + bodies = [allowance_request(candidate), allowance_request(candidate)] + results = await asyncio.wait_for( + asyncio.gather(*(env.allowances.reserve(run_id, b) for b in bodies), return_exceptions=True), 10 + ) + assert sum(isinstance(value, dict) for value in results) == 1 + assert sum(isinstance(value, ValueError) for value in results) == 1 + before = copy.deepcopy(await store.get_run(run_id)) + from firefly_forge import providers + + failed_provider = env.engine.provider_factory("codex") + env.engine.configured_provider = True + monkeypatch.setattr(providers, "get_provider", lambda agent, settings: failed_provider) + await env.engine.execute_run(run_id) + assert (await store.get_run(run_id))["status"] == "failed" + with pytest.raises(ValueError): + await env.allowances.candidate(run_id, "codex", adopt_current_executable=True) + with pytest.raises(ValueError): + await env.recovery.candidate(run_id, "codex") + entries = await env.journal.entries(run_id) + assert sum(e["kind"] == "provider_recovery_allowance" for e in entries) == 1 + assert sum(e["kind"] == "provider_dispatch_intent" and e["payload"]["attempt"] == 5 for e in entries) == 1 + assert (await store.get_run(run_id))["repair_budget"] == before["repair_budget"] == 0 + + +@pytest.mark.parametrize("mutation", ["source", "tail", "revocation"]) +async def test_native_stale_or_revoked_allowance_rolls_back_without_new_intent(store, tmp_path, monkeypatch, mutation): + async with authenticated_operator(store) as actor: + env = await failed_fourth(store, tmp_path, monkeypatch) + run_id = env.run["id"] + body = allowance_request(await env.allowances.candidate(run_id, "codex", adopt_current_executable=True)) + if mutation == "source": + (env.engine.workspaces / run_id / "partial.py").write_text("CHANGED = True\n") + elif mutation == "tail": + async with store._unit(): + await env.journal._append(run_id, "implement", "diagnostic", "note", {}) + else: + async with store._unit(): + user = await ForgeIdentityUserRepository().find_by_id(UUID(str(actor.id))) + user.enabled = False + await ForgeIdentityUserRepository().save(user) + async with store._unit(): + prefix = await env.recovery._entries(run_id) + with pytest.raises((ValueError, AccessDenied)): + await env.allowances.reserve(run_id, body) + async with store._unit(): + assert await env.recovery._entries(run_id) == prefix + assert not any(e["kind"] == "provider_recovery_allowance" for e in prefix) + assert (await store.get_run(run_id))["status"] == "failed" + async with store._unit(): + assert (await env.journal._task(run_id, "implement")).attempt == 4 + + +def test_native_allowance_http_di_strict_contract_and_exact_readback(tmp_path, postgres_schema): + from starlette.testclient import TestClient + from test_execution import seed_repository + + from firefly_forge.api import create_application + from firefly_forge.engine import Engine + from firefly_forge.git_workspace import metadata_digest + from firefly_forge.models import Command, RepositoryInput, RunInput + from firefly_forge.provider_recoveries import ProviderRecoveries + + token = "owned-final-allowance-api-fixture" + with TestClient( + create_application(tmp_path / "api", token, role="api"), + base_url="http://testserver", + headers={"Authorization": "Bearer " + token, "Origin": "http://testserver"}, + client=("127.0.0.1", 52551), + ) as http: + assert http.post("/api/v1/auth/login", json={"token": token}).status_code == 200 + store = http.app.state.store + context = http.app.state.runtime.context + engine = context.get_bean(Engine) + recoveries = context.get_bean(ProviderRecoveries) + assert context.get_bean(ProviderRecoveryAllowances).recoveries is recoveries + assert engine.provider_recoveries is recoveries + executable = tmp_path / "reviewed-api-cli" + executable.write_text("#!/bin/sh\nexit 0\n") + executable.chmod(0o755) + store.configuration.update("codex", ProviderConfiguration(values={"executable": str(executable)})) + + async def seed(): + async with authenticated_operator(store): + repository = await store.add_repository( + RepositoryInput( + name="Final allowance controlled API fixture", + url=str(await seed_repository(tmp_path / "source")), + ) + ) + run = await store.create_run( + RunInput( + repository_id=repository["id"], + brief="Review an existing fourth failed native process without running a model in HTTP", + frontend="none", + skills=[], + commands=[Command(argv=["approved-check"])], + ) + ) + journal = context.get_bean(Journal) + await journal.ensure_plan(run["id"]) + engine.workspaces.mkdir(parents=True, exist_ok=True) + workspace = await seed_repository(engine.workspaces / run["id"]) + (workspace / "partial.py").write_text("VALUE = 1\n") + await store.update(run["id"], git_metadata_digest=metadata_digest(workspace), active_task="implement") + for attempt in range(1, 5): + intent = await journal.intent(run["id"], "implement", attempt) + await store.update(run["id"], status="developing", attempt=attempt) + await recoveries.normal_dispatch(run["id"], intent["idempotency_key"]) + await recoveries.record_process(run["id"], intent["idempotency_key"], OUTCOME) + await store.update(run["id"], status="failed", error="Controlled native process failure") + return run["id"] + + run_id = http.portal.call(seed) + endpoint = f"/api/v1/runs/{run_id}/provider-recoveries/allowances" + assert http.get(endpoint + "/candidate?agent=codex").status_code == 409 + candidate = http.get(endpoint + "/candidate?agent=codex&adopt_current_executable=true") + assert candidate.status_code == 200, candidate.text + body = allowance_request(candidate.json()).model_dump() + response = http.post(endpoint, json=body) + assert response.status_code == 201, response.text + assert http.post(endpoint, json=body).json() == response.json() + assert http.get(endpoint + "/" + body["request_id"]).json() == response.json() + assert http.post(endpoint, json=body | {"request_id": uuid4().hex}).status_code == 409 + assert http.post(endpoint, json=body | {"adopt_current_executable": False}).status_code == 422 + assert http.post(endpoint, json=body | {"legacy_attestation": {}}).status_code == 422 + legacy = http.get(f"/api/v1/runs/{run_id}/provider-recoveries/" + body["request_id"]) + assert legacy.status_code == 409 and "/allowances/" in legacy.json()["detail"] + + +@pytest.mark.parametrize("mutation", ["source", "binary", "revocation"]) +async def test_native_reserved_allowance_drift_never_executes_provider(store, tmp_path, monkeypatch, mutation): + from firefly_forge import providers + + async with authenticated_operator(store) as actor: + env = await failed_fourth(store, tmp_path, monkeypatch) + run_id = env.run["id"] + body = allowance_request(await env.allowances.candidate(run_id, "codex", adopt_current_executable=True)) + await env.allowances.reserve(run_id, body) + if mutation == "source": + (env.engine.workspaces / run_id / "partial.py").write_text("CHANGED = True\n") + elif mutation == "binary": + (tmp_path / "reviewed-fixture-cli").write_text("#!/bin/sh\nexit 7\n") + else: + async with store._unit(): + user = await ForgeIdentityUserRepository().find_by_id(actor.id) + user.enabled = False + await ForgeIdentityUserRepository().save(user) + calls = [] + + class NeverProvider: + async def execute(self, *args): + calls.append(True) + raise AssertionError("Drift must prevent actual provider execution") + + env.engine.configured_provider = True + monkeypatch.setattr(providers, "get_provider", lambda agent, settings: NeverProvider()) + await env.engine.execute_run(run_id) + assert calls == [] and len(env.backend_calls) == 7 + async with store._unit(): + entries = await env.recovery._entries(run_id) + assert sum(e["kind"] == "provider_recovery_allowance" for e in entries) == 1 + assert not any(e["kind"] == "provider_dispatch_intent" and e["payload"]["attempt"] == 5 for e in entries) + with pytest.raises((ValueError, AccessDenied)): + await env.allowances.reserve(run_id, body.model_copy(update={"request_id": uuid4().hex})) + + +async def test_native_unknown_fourth_outcome_cannot_use_final_allowance(store, tmp_path, monkeypatch): + async with authenticated_operator(store): + env = await failed_fourth(store, tmp_path, monkeypatch, unknown_fourth=True) + run_id = env.run["id"] + with pytest.raises(ValueError): + await env.allowances.candidate(run_id, "codex", adopt_current_executable=True) + entries = await env.journal.entries(run_id) + assert not any(e["kind"] == "provider_recovery_allowance" for e in entries) + assert (await store.get_run(run_id))["status"] == "needs_reconciliation" diff --git a/tests/test_provider_recovery_allowance_sdk.py b/tests/test_provider_recovery_allowance_sdk.py new file mode 100644 index 00000000..3251eaaf --- /dev/null +++ b/tests/test_provider_recovery_allowance_sdk.py @@ -0,0 +1,52 @@ +# Copyright 2026 Firefly Software Foundation. SPDX-License-Identifier: Apache-2.0 +"""Final-allowance SDK preserves caller identity and never retries uncertain writes.""" + +import json + +import httpx +import pytest +from test_original_recovery_sdk import connection + +RUN, REQUEST = "a" * 32, "b" * 32 +BODY = dict( + request_id=REQUEST, + candidate_digest="c" * 64, + native_process_digest="d" * 64, + agent="codex", + reason="Reviewed controlled mitigation", + mitigation_evidence_digest="e" * 64, + adopt_current_executable=True, +) + + +def test_sdk_reserves_exact_identity_then_reads_original_without_automatic_dispatch(): + calls = [] + + def handler(request): + calls.append((request.method, request.url.path, request.content)) + return httpx.Response(201 if request.method == "POST" else 200, json={"retained": BODY}) + + with connection(handler) as client: + assert client.reserve_provider_recovery_allowance(RUN, BODY) == {"retained": BODY} + assert client.provider_recovery_allowance(RUN, REQUEST) == {"retained": BODY} + assert [(m, p) for m, p, _ in calls] == [ + ("POST", f"/api/v1/runs/{RUN}/provider-recoveries/allowances"), + ("GET", f"/api/v1/runs/{RUN}/provider-recoveries/allowances/{REQUEST}"), + ] + assert json.loads(calls[0][2]) == BODY and calls[1][2] == b"" + + +def test_lost_response_is_one_write_and_invalid_budget_never_reaches_transport(): + calls = [] + + def handler(request): + calls.append(request) + raise httpx.ReadTimeout("Controlled uncertain response") + + with connection(handler) as client: + with pytest.raises(ValueError): + client.reserve_provider_recovery_allowance(RUN, BODY | {"next_attempt": 6}) + assert calls == [] + with pytest.raises(httpx.ReadTimeout): + client.reserve_provider_recovery_allowance(RUN, BODY) + assert len(calls) == 1 and json.loads(calls[0].content) == BODY From 1085991f705a923629e5a8d8cb896c822298fb8c Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 20 Oct 2026 12:46:56 -0700 Subject: [PATCH 2/6] Add a guarded administrator wizard for final provider recovery --- docs/recovery.md | 4 +- scripts/docs_scene_hook.py | 18 +- scripts/test_docs_scene_hook.py | 33 + studio/playwright.docs.config.ts | 8 +- studio/src/ExecutionPresentation.test.tsx | 19 + studio/src/ExecutionRecord.tsx | 28 + studio/src/ProviderRecoveryAllowance.css | 203 +++++ studio/src/ProviderRecoveryAllowance.test.tsx | 54 ++ studio/src/ProviderRecoveryAllowance.tsx | 698 ++++++++++++++++++ .../src/ProviderRecoveryAllowanceData.test.ts | 207 ++++++ studio/src/ProviderRecoveryAllowanceData.ts | 459 ++++++++++++ studio/src/api.ts | 2 + studio/tests/docs-provider-allowance.spec.ts | 61 ++ ...ider-recovery-allowance-browser-fixture.ts | 203 +++++ .../provider-recovery-allowance-fixture.ts | 96 +++ .../tests/provider-recovery-allowance.spec.ts | 434 +++++++++++ studio/tests/walkthrough-capture.ts | 76 +- 17 files changed, 2590 insertions(+), 13 deletions(-) create mode 100644 studio/src/ProviderRecoveryAllowance.css create mode 100644 studio/src/ProviderRecoveryAllowance.test.tsx create mode 100644 studio/src/ProviderRecoveryAllowance.tsx create mode 100644 studio/src/ProviderRecoveryAllowanceData.test.ts create mode 100644 studio/src/ProviderRecoveryAllowanceData.ts create mode 100644 studio/tests/docs-provider-allowance.spec.ts create mode 100644 studio/tests/provider-recovery-allowance-browser-fixture.ts create mode 100644 studio/tests/provider-recovery-allowance-fixture.ts create mode 100644 studio/tests/provider-recovery-allowance.spec.ts diff --git a/docs/recovery.md b/docs/recovery.md index 69bfa8e7..890181c8 100644 --- a/docs/recovery.md +++ b/docs/recovery.md @@ -132,7 +132,9 @@ A successful reservation consumes the run's only allowance immediately. It adds The original WAL prefix, partial workspace, model settings, repair budget and verification grants remain intact. Current authority and the exact native allowance link are checked at execution boundaries. Source or executable drift prevents launch. A fifth-attempt worker report uses a distinct typed feedback variant with the allowance ID and digest; the worker cannot create or extend the grant. Its completion report remains separate from verification and product acceptance. Questions that require another coding dispatch remain held after the final allowance is spent. -The Python SDK offers `provider_recovery_allowance_candidate`, `reserve_provider_recovery_allowance` and `provider_recovery_allowance`. Each method performs one explicit request. This release provides HTTP/SDK admission and compatible Studio feedback reading; a Studio reservation wizard is a separate interface. +The Python SDK offers `provider_recovery_allowance_candidate`, `reserve_provider_recovery_allowance` and `provider_recovery_allowance`. Each method performs one explicit request. In Studio, open the run's Plan tab and choose **Review final allowance** under **Final provider allowance**. The three steps review the failed attempt and selected CLI, record your mitigation reason and evidence fingerprint, then confirm the final reservation. This action is available to native factory administrators for an eligible failed fourth attempt; the server rechecks eligibility before reservation. Studio saves the exact request in the browser before sending it and offers **Check saved reservation** for readback. Closing the review or losing a response never creates a replacement request. + +If the browser's saved request is lost, the run's native allowance identity still provides **Read existing reservation**. This performs only an exact receipt read under the original administrator's current authority. Studio validates the returned operator, protocol, run and task bindings without claiming that the request body was previously saved locally. A missing or refused receipt stays unresolved; this path cannot reserve another attempt. ## Inspect a factory verification handoff diff --git a/scripts/docs_scene_hook.py b/scripts/docs_scene_hook.py index a78fa655..fdc10bff 100644 --- a/scripts/docs_scene_hook.py +++ b/scripts/docs_scene_hook.py @@ -34,8 +34,14 @@ "studio/tests/scm-delegation-fixture.ts", "studio/tests/scm-delegation-contract-fixture.json", "studio/tests/scm-poll-resume-contract-fixture.json", + "studio/tests/provider-recovery-allowance-fixture.ts", + "studio/tests/provider-recovery-allowance-browser-fixture.ts", +} +CAPTURE_PRODUCERS = { + "studio/tests/docs-walkthrough.spec.ts", + "studio/tests/external-tools.spec.ts", + "studio/tests/docs-provider-allowance.spec.ts", } -CAPTURE_PRODUCERS = {"studio/tests/docs-walkthrough.spec.ts", "studio/tests/external-tools.spec.ts"} def _load_walkthrough(config, root=ROOT): @@ -104,9 +110,19 @@ def _load_walkthrough(config, root=ROOT): or name != f"{stage}-{device}-{theme}.png" ): raise ValueError("Walkthrough screenshot stage differs from its path: " + name) + allowance_stages = { + "provider-allowance-failure", + "provider-allowance-mitigation", + "provider-allowance-review", + "provider-allowance-reserved", + } + if stage.startswith("provider-allowance-") and stage not in allowance_stages: + raise ValueError("Unknown final allowance capture stage: " + name) expected_producer = ( "studio/tests/external-tools.spec.ts" if stage.startswith("external-") + else "studio/tests/docs-provider-allowance.spec.ts" + if stage in allowance_stages else "studio/tests/docs-walkthrough.spec.ts" ) if producer != expected_producer: diff --git a/scripts/test_docs_scene_hook.py b/scripts/test_docs_scene_hook.py index 222d76f5..0a4e14e3 100644 --- a/scripts/test_docs_scene_hook.py +++ b/scripts/test_docs_scene_hook.py @@ -69,8 +69,11 @@ def test_shared_helper_config_and_each_producer_bind_the_exact_capture(self): "studio/tests/scm-delegation-fixture.ts": "fixture SCM routes", "studio/tests/scm-delegation-contract-fixture.json": "fixture SCM mapping", "studio/tests/scm-poll-resume-contract-fixture.json": "fixture polling consent", + "studio/tests/provider-recovery-allowance-fixture.ts": "fixture native allowance", + "studio/tests/provider-recovery-allowance-browser-fixture.ts": "fixture allowance routes", "studio/tests/docs-walkthrough.spec.ts": "fixture console producer", "studio/tests/external-tools.spec.ts": "fixture MCP producer", + "studio/tests/docs-provider-allowance.spec.ts": "fixture allowance producer", "studio/playwright.docs.config.ts": "fixture capture config", } for name, content in paths.items(): @@ -112,6 +115,8 @@ def fingerprint(value): "studio/tests/scm-delegation-fixture.ts", "studio/tests/scm-delegation-contract-fixture.json", "studio/tests/scm-poll-resume-contract-fixture.json", + "studio/tests/provider-recovery-allowance-fixture.ts", + "studio/tests/provider-recovery-allowance-browser-fixture.ts", ) } producers = {name: sha(root / name) for name in paths if name.endswith(".spec.ts")} @@ -161,6 +166,9 @@ def save(): ("studio/tests/agent-checkpoint-recovery-fixture.ts", "capture helper"), ("studio/tests/run-creation-cancellation-fixture.ts", "capture helper"), ("studio/tests/docs-walkthrough.spec.ts", "capture producer"), + ("studio/tests/docs-provider-allowance.spec.ts", "capture producer"), + ("studio/tests/provider-recovery-allowance-fixture.ts", "capture helper"), + ("studio/tests/provider-recovery-allowance-browser-fixture.ts", "capture helper"), ("studio/playwright.docs.config.ts", "capture config"), ]: (root / name).write_text("changed input") @@ -226,6 +234,26 @@ def save(): record["producer"] = "studio/tests/external-tools.spec.ts" record["producer_sha256"] = producers[record["producer"]] save() + allowance_name = "provider-allowance-review-mobile-light.png" + allowance_asset = asset.parent / allowance_name + allowance_asset.write_bytes(asset.read_bytes()) + allowance_record = { + **record, + "stage": "provider-allowance-review", + "path": "assets/screenshots/" + allowance_name, + "producer": "studio/tests/docs-provider-allowance.spec.ts", + } + allowance_record["producer_sha256"] = producers[allowance_record["producer"]] + manifest["captures"][allowance_name] = allowance_record + save() + hook._load_walkthrough({"docs_dir": str(docs)}, root) + allowance_record["producer"] = "studio/tests/docs-walkthrough.spec.ts" + allowance_record["producer_sha256"] = producers[allowance_record["producer"]] + save() + with self.assertRaisesRegex(ValueError, "stage.*producer"): + hook._load_walkthrough({"docs_dir": str(docs)}, root) + del manifest["captures"][allowance_name] + save() extra = root / "studio/src/New.tsx" extra.write_text("new frontend input") with self.assertRaisesRegex(ValueError, "frontend inventory"): @@ -328,12 +356,16 @@ def test_rejects_a_changed_capture_or_frontend_source(self): "studio/tests/scm-delegation-fixture.ts", "studio/tests/scm-delegation-contract-fixture.json", "studio/tests/scm-poll-resume-contract-fixture.json", + "studio/tests/provider-recovery-allowance-fixture.ts", + "studio/tests/provider-recovery-allowance-browser-fixture.ts", ] for name in operator_inputs: (root / name).parent.mkdir(parents=True, exist_ok=True) (root / name).write_text("fixture operator guide input") mcp_capture = root / "studio/tests/external-tools.spec.ts" mcp_capture.write_text("fixture MCP producer") + allowance_capture = root / "studio/tests/docs-provider-allowance.spec.ts" + allowance_capture.write_text("fixture allowance producer") capture_config = root / "studio/playwright.docs.config.ts" capture_config.write_text("fixture capture config") @@ -352,6 +384,7 @@ def sha(path): "capture_producers": { "studio/tests/docs-walkthrough.spec.ts": sha(capture), "studio/tests/external-tools.spec.ts": sha(mcp_capture), + "studio/tests/docs-provider-allowance.spec.ts": sha(allowance_capture), }, "capture_helpers": { "studio/tests/walkthrough-capture.ts": sha(helper), diff --git a/studio/playwright.docs.config.ts b/studio/playwright.docs.config.ts index 302dc590..8930b60f 100644 --- a/studio/playwright.docs.config.ts +++ b/studio/playwright.docs.config.ts @@ -3,8 +3,12 @@ import { defineConfig } from "@playwright/test"; const port = Number(process.env.FORGE_DOCS_PORT ?? 5227); export default defineConfig({ testDir: "./tests", - testMatch: ["docs-walkthrough.spec.ts", "external-tools.spec.ts"], - grep: /guided fixture journey|actual fixture review frames/, + testMatch: [ + "docs-walkthrough.spec.ts", + "external-tools.spec.ts", + "docs-provider-allowance.spec.ts", + ], + grep: /guided fixture journey|actual fixture review frames|actual fixture final allowance frames/, workers: 1, timeout: 90000, use: { diff --git a/studio/src/ExecutionPresentation.test.tsx b/studio/src/ExecutionPresentation.test.tsx index b7de69af..724ee0f4 100644 --- a/studio/src/ExecutionPresentation.test.tsx +++ b/studio/src/ExecutionPresentation.test.tsx @@ -10,8 +10,27 @@ import { TimelineEventView, } from "./ExecutionPresentation"; import { parseDiff, boundedText, tokenizeCode } from "./ExecutionFormat"; +import { receipt } from "../tests/provider-recovery-allowance-fixture"; describe("execution evidence presentation", () => { + it("presents the final allowance WAL as labeled evidence with raw JSON closed and no verification claim", async () => { + const value = await receipt({ + request_id: "a".repeat(32), + mitigation_evidence_digest: "e".repeat(64), + }); + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("
"); + expect(html).toContain("Mitigation evidence"); + expect(html).toContain("Provenance"); + expect(html).toContain("administrator_attestation"); + expect(html).toContain("Native process digest"); + expect(html).toContain("Advanced record"); + expect(html).not.toContain("
Passed<"); + expect(html).not.toContain(">Verified<"); + }); it("projects provider command events into a bounded command result instead of a JSON heading", () => { const html = renderToStaticMarkup( )} + {session && ( + + )} {error && (
{error} diff --git a/studio/src/ProviderRecoveryAllowance.css b/studio/src/ProviderRecoveryAllowance.css new file mode 100644 index 00000000..a134d7b3 --- /dev/null +++ b/studio/src/ProviderRecoveryAllowance.css @@ -0,0 +1,203 @@ +/* Copyright 2026 Firefly Software Foundation. SPDX-License-Identifier: Apache-2.0 */ +.provider-allowance-panel { + border: 1px solid var(--line); + border-radius: 8px; + padding: 24px; + margin-bottom: 24px; + background: var(--surface); + text-align: left; +} +.provider-allowance-intro { + display: flex; + align-items: flex-start; + gap: 16px; + margin-bottom: 16px; +} +.provider-allowance-intro > svg { + color: var(--accent-ink); + flex: 0 0 auto; + margin-top: 3px; +} +.provider-allowance-intro h2 { + margin: 0 0 8px; +} +.provider-allowance-intro p { + margin: 0; + max-width: 74ch; +} +.provider-allowance-dialog { + height: max-content; +} +.provider-allowance-dialog .wizard-content { + padding: 24px; +} +.access-member-dialog.provider-allowance-dialog .wizard-field-scroll .field { + margin-bottom: 20px; +} +.provider-allowance-dialog textarea, +.provider-allowance-dialog input[type="text"] { + width: 100%; + min-width: 0; +} +.provider-allowance-dialog small { + display: block; + line-height: 1.6; + margin-top: 8px; + color: var(--muted); +} +.provider-allowance-facts { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + padding-bottom: 20px; + margin: 0 0 20px; + border-bottom: 1px solid var(--line); +} +.provider-allowance-facts dt { + font-size: 12px; + color: var(--muted); + margin-bottom: 6px; +} +.provider-allowance-facts dd { + margin: 0; + font-size: 18px; + font-weight: 700; +} +.provider-allowance-executable { + display: flex; + gap: 12px; + align-items: flex-start; + padding: 16px; + border: 1px solid var(--line); + border-radius: 6px; + margin: 20px 0; +} +.provider-allowance-executable > svg { + color: var(--accent-ink); + flex-shrink: 0; +} +.provider-allowance-executable > div { + min-width: 0; +} +.provider-allowance-executable code { + display: block; + margin-top: 6px; + overflow-wrap: anywhere; +} +.provider-allowance-dialog details { + margin: 20px 0; +} +.provider-allowance-dialog summary { + cursor: pointer; +} +.provider-allowance-digests > div, +.provider-allowance-review > div { + padding: 12px 0; + border-bottom: 1px solid var(--line); +} +.provider-allowance-digests dt, +.provider-allowance-review dt { + font-size: 12px; + color: var(--muted); + margin-bottom: 6px; +} +.provider-allowance-digests dd, +.provider-allowance-review dd { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.provider-allowance-choice { + align-items: flex-start; + gap: 12px; + margin: 20px 0; + line-height: 1.6; +} +.provider-allowance-choice input { + margin-top: 5px; + flex: 0 0 auto; +} +.provider-allowance-result h3 { + display: flex; + align-items: center; + gap: 10px; + margin: 0 0 12px; +} +.provider-allowance-result p { + margin: 0 0 12px; + line-height: 1.6; +} +.provider-allowance-panel > .alert { + margin-top: 16px; +} +.provider-allowance-identity { + overflow-wrap: anywhere; + font-size: 13px; +} +.provider-allowance-panel :focus-visible { + outline: 2px solid var(--accent-ink); + outline-offset: 3px; +} +@media (max-width: 600px) { + .provider-allowance-panel { + padding: 18px; + } + .provider-allowance-dialog .wizard-content { + padding: 16px; + } + .provider-allowance-facts { + gap: 10px; + } + .provider-allowance-facts dd { + font-size: 16px; + } +} + +/* A single scroll owner preserves the form flow on short or zoomed viewports. */ +.access-member-dialog.provider-allowance-dialog { + display: block; + overflow: auto; + width: min(760px, calc(100% - 48px)); + max-height: calc(100% - 48px); + overscroll-behavior: contain; + scroll-padding-block: 16px; +} +.access-member-dialog.provider-allowance-dialog .wizard, +.access-member-dialog.provider-allowance-dialog .wizard-body, +.access-member-dialog.provider-allowance-dialog .wizard-content, +.access-member-dialog.provider-allowance-dialog .wizard-form { + display: block; + height: auto; + min-height: 0; + overflow: visible; +} +.access-member-dialog.provider-allowance-dialog .wizard-field-scroll { + display: block; + overflow: visible; + min-height: 0; + max-height: none; +} +.access-member-dialog.provider-allowance-dialog .wizard-actions { + position: static; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} +.access-member-dialog.provider-allowance-dialog .wizard-actions button { + width: 100%; + min-width: 0; + min-height: 44px; + white-space: normal; + justify-content: center; +} +@media (max-width: 600px) { + .access-member-dialog.provider-allowance-dialog .wizard-actions { + grid-template-columns: minmax(0, 1fr); + } +} +@media (max-width: 600px) { + .access-member-dialog.provider-allowance-dialog { + width: calc(100% - 24px); + max-height: calc(100% - 24px); + } +} diff --git a/studio/src/ProviderRecoveryAllowance.test.tsx b/studio/src/ProviderRecoveryAllowance.test.tsx new file mode 100644 index 00000000..ff0f3b38 --- /dev/null +++ b/studio/src/ProviderRecoveryAllowance.test.tsx @@ -0,0 +1,54 @@ +/* Copyright 2026 Firefly Software Foundation. SPDX-License-Identifier: Apache-2.0 */ +import { expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { ProviderRecoveryAllowance } from "./ProviderRecoveryAllowance"; +import type { IdentitySession } from "./Identity"; +import type { ForgeRun } from "./api"; +const session = { + authenticated: true, + user: { id: "f".repeat(32), authentication_method: "local_bootstrap" }, + global_admin: true, + memberships: [], +} as unknown as IdentitySession; +const run = { + id: "2".repeat(32), + repository_id: "3".repeat(32), + brief: "private brief should stay private", + status: "failed", + active_task: "implement", + attempt: 4, + agent: "codex", +} as ForgeRun; +function html(current = run, actor = session) { + return renderToStaticMarkup( + , + ); +} +it("introduces an advanced native administrator action without claiming eligibility or execution", () => { + expect(html()).toContain("Final provider allowance"); + expect(html()).toContain("Checking saved request"); + expect(html()).not.toContain(run.brief); + expect(html()).not.toContain("not-displayed"); +}); +it("does not expose a reservation action to nonadmins, mapped identities or unrelated runs", () => { + expect(html(run, { ...session, global_admin: false })).toBe(""); + expect( + html(run, { + ...session, + user: { ...session.user, authentication_method: "scm_mapping" }, + }), + ).toBe(""); + for (const change of [ + { agent: "bridge" }, + { attempt: 3 }, + { active_task: "verify" }, + ]) + expect(html({ ...run, ...change })).toBe(""); +}); diff --git a/studio/src/ProviderRecoveryAllowance.tsx b/studio/src/ProviderRecoveryAllowance.tsx new file mode 100644 index 00000000..315cd587 --- /dev/null +++ b/studio/src/ProviderRecoveryAllowance.tsx @@ -0,0 +1,698 @@ +/* Copyright 2026 Firefly Software Foundation. SPDX-License-Identifier: Apache-2.0 */ +import { useEffect, useRef, useState } from "react"; +import { + ArrowRight, + CheckCircle2, + FileCheck2, + RefreshCw, + ShieldCheck, + X, +} from "lucide-react"; +import { + ApiError, + normalizeBase, + request, + type Connection, + type ForgeRun, +} from "./api"; +import type { IdentitySession } from "./Identity"; +import { + allowanceCandidate, + allowanceInput, + allowanceMarkerKey, + allowanceReceipt, + allowanceReadback, + existingAllowanceId, + readAllowanceMarker, + retainAllowance, + type AllowanceContext, + type AllowanceMarker, + type AllowanceReview, +} from "./ProviderRecoveryAllowanceData"; +import { Wizard } from "./Wizard"; +import "./ProjectAccess.css"; +import "./ProviderRecoveryAllowance.css"; + +export function ProviderRecoveryAllowance({ + run, + connection, + session, +}: { + run: ForgeRun; + connection: Connection; + session: IdentitySession; +}) { + const official = run.agent === "codex" || run.agent === "claude"; + const existingId = existingAllowanceId(run.provider_recovery); + const nativeAdmin = + session.global_admin && + session.user.authentication_method !== "scm_mapping"; + const potential = + nativeAdmin && + official && + ((run.attempt === 4 && run.active_task === "implement") || + run.attempt === 5); + const eligible = + potential && + run.attempt === 4 && + run.status === "failed" && + !existingId && + run.cancel_request?.status !== "requested" && + !run.bridge_completion_hold; + const context: AllowanceContext = { + base: new URL( + normalizeBase(connection.base), + typeof window === "undefined" + ? "https://localhost" + : window.location.href, + ).href.replace(/\/$/, ""), + actor: session.user.id, + authentication: session.user.authentication_method, + run: run.id, + repository: run.repository_id, + project: run.project_id ?? null, + agent: run.agent === "claude" ? "claude" : "codex", + }; + const [loaded, setLoaded] = useState(false), + [blocked, setBlocked] = useState(false), + [marker, setMarker] = useState(null), + [confirmed, setConfirmed] = useState(false), + [candidate, setCandidate] = useState(null), + [editing, setEditing] = useState(false), + [step, setStep] = useState(0), + [reason, setReason] = useState(""), + [evidence, setEvidence] = useState(""), + [adopt, setAdopt] = useState(false), + [ack, setAck] = useState(false), + [busy, setBusy] = useState(false), + [error, setError] = useState(""); + const retained = useRef(null); + const live = useRef(true), + inflight = useRef(false), + controller = useRef(null), + dialog = useRef(null), + opener = useRef(null), + heading = useRef(null), + result = useRef(null); + const path = `/runs/${run.id}/provider-recoveries/allowances`; + useEffect(() => { + live.current = true; + if (!potential) + return () => { + live.current = false; + }; + const key = allowanceMarkerKey(context); + async function restore() { + try { + const saved = await readAllowanceMarker( + localStorage.getItem(key), + context, + ); + if (live.current) { + if (saved) { + retained.current = saved; + setMarker(saved); + setEditing(false); + setCandidate(null); + setAck(false); + } else if (retained.current) { + setBlocked(true); + } + setLoaded(true); + setConfirmed(false); + } + } catch { + if (live.current) { + setBlocked(true); + setLoaded(true); + setConfirmed(false); + } + } + } + void restore(); + const storage = (event: StorageEvent) => { + if (event.key === key || event.key === null) void restore(); + }; + window.addEventListener("storage", storage); + return () => { + live.current = false; + controller.current?.abort(); + window.removeEventListener("storage", storage); + }; + // ExecutionRecord remounts on the complete connection and authority context. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + useEffect(() => { + const node = dialog.current; + if (editing) node?.showModal(); + return () => node?.close(); + }, [editing]); + useEffect(() => { + if (marker && !editing) result.current?.focus({ preventScroll: true }); + }, [marker, editing]); + function close() { + controller.current?.abort(); + setEditing(false); + requestAnimationFrame(() => { + if (opener.current && !opener.current.disabled) opener.current.focus(); + else heading.current?.focus(); + }); + } + function failure(cause: unknown, pending: boolean) { + if (!live.current) return; + setConfirmed(false); + if (cause instanceof ApiError && [401, 403].includes(cause.status)) { + setCandidate(null); + setEditing(false); + setError( + "Current administrator access could not be confirmed. Any saved request remains held for authorized readback.", + ); + } else if (pending && cause instanceof ApiError && cause.status === 404) { + setError( + "No matching receipt is available yet. Keep the saved request; a missing receipt does not prove the reservation failed.", + ); + } else if (pending) { + setError( + "The reservation is not confirmed. Its exact request is saved. Check that receipt before taking another action.", + ); + } else { + setCandidate(null); + setError( + "This run does not currently pass the final allowance checks. Review its execution record and refresh the candidate.", + ); + } + } + async function loadCandidate(signal: AbortSignal) { + return allowanceCandidate( + await request( + connection, + path + + `/candidate?agent=${context.agent}&adopt_current_executable=true`, + { signal }, + ), + context, + ); + } + async function refresh() { + if (inflight.current || !eligible || marker || blocked) return; + inflight.current = true; + setBusy(true); + setError(""); + setAck(false); + setAdopt(false); + const c = new AbortController(); + controller.current = c; + try { + const view = await loadCandidate(c.signal); + if (live.current && !c.signal.aborted) setCandidate(view); + } catch (cause) { + if (!c.signal.aborted) failure(cause, false); + } finally { + inflight.current = false; + if (live.current) setBusy(false); + } + } + async function exact(saved: AllowanceMarker, signal: AbortSignal) { + await allowanceReceipt( + await request(connection, path + "/" + saved.body.request_id, { + signal, + }), + saved, + ); + if (live.current && !signal.aborted) { + setConfirmed(true); + setError(""); + } + } + async function check() { + if (inflight.current || (!marker && !existingId)) return; + inflight.current = true; + setBusy(true); + setConfirmed(false); + setError(""); + const c = new AbortController(); + controller.current = c; + try { + if (marker) await exact(marker, c.signal); + else { + await allowanceReadback( + await request(connection, path + "/" + existingId, { + signal: c.signal, + }), + context, + existingId!, + ); + if (live.current && !c.signal.aborted) { + setConfirmed(true); + setError(""); + } + } + } catch (cause) { + if (!c.signal.aborted) failure(cause, true); + } finally { + inflight.current = false; + if (live.current) setBusy(false); + } + } + let validMitigation = false; + if (candidate) { + try { + allowanceInput(candidate, "0".repeat(32), reason, evidence); + validMitigation = adopt; + } catch { + /* Incomplete form. */ + } + } + async function reserve() { + if ( + inflight.current || + !eligible || + !candidate || + !validMitigation || + !ack || + marker || + blocked + ) + return; + inflight.current = true; + setBusy(true); + setError(""); + const c = new AbortController(); + controller.current = c; + let saved: AllowanceMarker | null = null; + try { + const fresh = await loadCandidate(c.signal); + if (!live.current || c.signal.aborted) return; + if (JSON.stringify(fresh) !== JSON.stringify(candidate)) { + setCandidate(null); + setStep(0); + setAdopt(false); + setAck(false); + setError( + "The reviewed source, executable or failure changed. Refresh and review the current candidate before reserving.", + ); + return; + } + const body = allowanceInput( + candidate, + crypto.randomUUID().replaceAll("-", ""), + reason, + evidence, + ); + try { + saved = await retainAllowance( + localStorage, + navigator.locks, + context, + candidate, + body, + ); + } catch { + setBlocked(true); + setError( + "Request storage could not be verified. No reservation was sent. Keep any saved identity for reconciliation.", + ); + return; + } + if (!live.current) return; + retained.current = saved; + setMarker(saved); + setEditing(false); + if (c.signal.aborted) return; + await request(connection, path, { + method: "POST", + body: JSON.stringify(saved.body), + signal: c.signal, + }); + await exact(saved, c.signal); + } catch (cause) { + if (!c.signal.aborted) failure(cause, saved !== null); + } finally { + inflight.current = false; + if (live.current) setBusy(false); + } + } + if (!potential) return null; + const name = context.agent === "codex" ? "Codex" : "Claude Code"; + return ( +
+
+
+ {!loaded ? ( +

Checking saved request…

+ ) : marker || existingId ? ( +
+

+ {confirmed ? ( + <> +

+

+ {confirmed + ? "The server retained the final allowance. It is spent even if dispatch never starts. Follow the plan and execution record for the actual outcome." + : marker + ? "The outcome is not confirmed. Keep this request and check its exact receipt. No reservation will be repeated." + : "The run identifies an existing reservation. Its original request is not saved in this browser. Read the server receipt to confirm it; this action cannot reserve another attempt."} +

+

+ Request {marker?.body.request_id ?? existingId} +

+ +
+ ) : blocked ? ( +

+ Saved request storage cannot be verified. New final allowance + reservations are disabled. +

+ ) : eligible ? ( + + ) : ( +

+ No new final allowance is available for this run. Existing execution + records remain available below. +

+ )} + {!editing && error && ( +

+ {error} +

+ )} + {editing && ( + { + event.preventDefault(); + close(); + }} + > +
+

Final provider allowance

+ +
+ { + setStep(next); + setAck(false); + }} + locked={busy || blocked} + title={ + [ + "Review the failed attempt", + "Record the mitigation", + "Confirm the final reservation", + ][step] + } + description={ + [ + "The server checks current authority, native process evidence and retained source.", + "Explain what you reviewed before spending the final allowance.", + "This reserves one fifth dispatch. The allowance cannot be renewed.", + ][step] + } + > +
{ + event.preventDefault(); + if (step < 2) { + setStep(step + 1); + setAck(false); + } else void reserve(); + }} + > + {error && ( +

+ {error} +

+ )} + {step === 0 ? ( + <> + {busy ? ( +

Checking the current candidate…

+ ) : candidate ? ( + <> +
+
+
Provider
+
{name}
+
+
+
Failed attempt
+
4
+
+
+
Final dispatch
+
5
+
+
+

+ A native failure receipt is retained. Eligibility + requires a nonzero exit, a waited leader, drained + streams and an absent process group. +

+
+
+
+ Source and executable fingerprints +
+
+
Retained source
+
{candidate.sourceDigest}
+
+
+
Git identity
+
{candidate.gitDigest}
+
+
+
CLI SHA-256
+
{candidate.executableSha256}
+
+
+
Native failure receipt
+
{candidate.nativeProcessDigest}
+
+
+
+

+ The existing workspace, model configuration and spent + repair and verification budgets stay attached to this + run. +

+ + ) : ( + + )} + + ) : step === 1 ? ( + <> +
+ +